feat(sprint2.5): логи, вынесение системного промпта, markdown-рендер
Три подряд доработки по плану Спринта 2.5. 1) Логи. Проблема: uvicorn ставит handlers на root-logger до того, как отработает наш lifespan, поэтому logging.basicConfig там был no-op, и logger.exception ничего не писал. Переносим basicConfig на уровень импорта main.py с force=True — наш StreamHandler перебивает uvicorn-овский root, остальные логгеры (uvicorn.access, uvicorn.error, alembic, chromadb) остаются со своими форматами. В lifespan basicConfig больше не зовётся. 2) Системный промпт вынесен из services/llm_client.py в prompts/system_prompt.md. LLMClient читает файл при импорте модуля через _load_system_prompt(); если файла нет — пустая строка + warning. Это задел под Спринт 3, где промпт будет редактируемым и версионируемым — физически положить его как файл дешевле, чем держать в исходниках. 3) Markdown в ответах ассистента. Подключены marked и DOMPurify с CDN в sandbox.html. Рендер через renderMd(text): marked.parse + DOMPurify.sanitize — защищает от <script> на случай, если LLM вернёт сырой HTML. Реплики пациента остаются plain text (esc). Добавлены стили для p/ul/ol/code/pre/a/h1-h3/blockquote внутри .msg.assistant, чтобы всё выглядело уместно в пузыре. Обёртка msg-body введена, чтобы разделить контент и msg-meta. План в SPRINTS.md уточнён по переиндексации — будет отдельный endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -97,7 +97,9 @@
|
||||
### Задачи
|
||||
|
||||
**Качество RAG:**
|
||||
- [ ] Почистить чанки: убрать markdown-ссылки `[текст](url)`, блоки навигации `**Вернуться на:**`, дубликаты меню; добавить нормализацию/переиндексацию текущей базы (или documented reindex procedure)
|
||||
- [ ] Почистить чанки: убрать markdown-ссылки `[текст](url)`, блоки навигации `**Вернуться на:**`, дубликаты меню
|
||||
- [ ] Эндпоинт `POST /documents/{id}/reindex` — переразметить существующий документ с новыми правилами чанкера (без повторной загрузки файла — но у нас пока нет хранения исходников, поэтому надо хранить исходный текст в метаданных чанков или сохранять оригинал при `upload`); решение по способу — в рамках задачи
|
||||
- [ ] Эндпоинт `POST /documents/reindex-all` — прогнать переиндексацию по всей базе
|
||||
|
||||
**UI:**
|
||||
- [ ] Markdown-рендер ответов ассистента в «Песочнице» (жирный, курсив, списки, код); реплики пациента оставить plain text
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from alembic import command
|
||||
@@ -10,9 +11,21 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from config import settings
|
||||
from services.embeddings import EmbeddingService
|
||||
from services.llm_client import LLMClient
|
||||
from services.vectorstore import VectorStoreService
|
||||
|
||||
# Настройка логов до импорта приложения: uvicorn ставит свои handlers
|
||||
# на root-logger, поэтому basicConfig в lifespan уже не срабатывает
|
||||
# (handlers есть — basicConfig no-op). force=True перебивает.
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
handlers=[logging.StreamHandler(sys.stderr)],
|
||||
force=True,
|
||||
)
|
||||
|
||||
from services.embeddings import EmbeddingService # noqa: E402
|
||||
from services.llm_client import LLMClient # noqa: E402
|
||||
from services.vectorstore import VectorStoreService # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +44,6 @@ def _run_migrations() -> None:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global embedding_service, vectorstore_service, llm_client
|
||||
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
|
||||
logger.info("Running DB migrations…")
|
||||
await asyncio.to_thread(_run_migrations)
|
||||
logger.info("Loading embedding model: %s", settings.embedding_model)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
Ты — виртуальный ассистент клиники, который первым отвечает пациентам в чате.
|
||||
|
||||
Твоя задача — помочь пациенту по бытовым и организационным вопросам: запись, расписание врачей, подготовка к приёму, как проехать, документы, оплата, ДМС, детский приём и т. п.
|
||||
|
||||
Правила:
|
||||
- Отвечай коротко, дружелюбно, на «вы», простым русским языком без медицинской латыни.
|
||||
- Опирайся ТОЛЬКО на предоставленные выдержки из базы знаний. Если ответа в них нет — честно скажи, что уточнишь у оператора, и предложи подключить оператора.
|
||||
- Не ставь диагнозы и не назначай лечение. Если вопрос про симптомы, лекарства, дозировки или «что со мной» — мягко предложи записаться к врачу и подключить оператора, если нужно.
|
||||
- Не выдумывай телефоны, адреса, цены, имена врачей, расписание. Только из источников.
|
||||
- Если пациент просит оператора — коротко подтверди, что сейчас его подключишь.
|
||||
- Источники указывать не нужно: пациент их не видит. Ответ — обычный текст, как в чате.
|
||||
+11
-9
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -6,17 +7,18 @@ from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = """Ты — виртуальный ассистент клиники, который первым отвечает пациентам в чате.
|
||||
SYSTEM_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "system_prompt.md"
|
||||
|
||||
Твоя задача — помочь пациенту по бытовым и организационным вопросам: запись, расписание врачей, подготовка к приёму, как проехать, документы, оплата, ДМС, детский приём и т. п.
|
||||
|
||||
Правила:
|
||||
- Отвечай коротко, дружелюбно, на «вы», простым русским языком без медицинской латыни.
|
||||
- Опирайся ТОЛЬКО на предоставленные выдержки из базы знаний. Если ответа в них нет — честно скажи, что уточнишь у оператора, и предложи подключить оператора.
|
||||
- Не ставь диагнозы и не назначай лечение. Если вопрос про симптомы, лекарства, дозировки или «что со мной» — мягко предложи записаться к врачу и подключить оператора, если нужно.
|
||||
- Не выдумывай телефоны, адреса, цены, имена врачей, расписание. Только из источников.
|
||||
- Если пациент просит оператора — коротко подтверди, что сейчас его подключишь.
|
||||
- Источники указывать не нужно: пациент их не видит. Ответ — обычный текст, как в чате."""
|
||||
def _load_system_prompt() -> str:
|
||||
try:
|
||||
return SYSTEM_PROMPT_PATH.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
logger.warning("System prompt file not found at %s — using empty prompt", SYSTEM_PROMPT_PATH)
|
||||
return ""
|
||||
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = _load_system_prompt()
|
||||
|
||||
DEFAULT_USER_TEMPLATE = """Вопрос пациента:
|
||||
{question}
|
||||
|
||||
+63
-9
@@ -196,10 +196,46 @@
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.msg.user {
|
||||
background: var(--user-bg);
|
||||
align-self: flex-end;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.msg.user { background: var(--user-bg); align-self: flex-end; }
|
||||
.msg.assistant { background: var(--bot-bg); align-self: flex-start; }
|
||||
.msg.assistant p { margin: 0 0 8px 0; }
|
||||
.msg.assistant p:last-child { margin-bottom: 0; }
|
||||
.msg.assistant ul, .msg.assistant ol { margin: 6px 0; padding-left: 22px; }
|
||||
.msg.assistant li { margin: 2px 0; }
|
||||
.msg.assistant code {
|
||||
background: #e5e7eb;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.msg.assistant pre {
|
||||
background: #e5e7eb;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.msg.assistant pre code { background: none; padding: 0; }
|
||||
.msg.assistant a { color: var(--accent); text-decoration: underline; }
|
||||
.msg.assistant h1, .msg.assistant h2, .msg.assistant h3 {
|
||||
margin: 8px 0 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg.assistant blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
margin: 6px 0;
|
||||
padding-left: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.msg-meta { font-size: 10px; color: var(--muted); margin-top: 4px; }
|
||||
.chat-empty {
|
||||
margin: auto;
|
||||
@@ -329,6 +365,8 @@
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -398,6 +436,16 @@ function toast(msg, kind = "ok") {
|
||||
setTimeout(() => t.className = "toast", 2500);
|
||||
}
|
||||
|
||||
function renderMd(text) {
|
||||
try {
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
const html = marked.parse(text || "");
|
||||
return DOMPurify.sanitize(html);
|
||||
} catch (e) {
|
||||
return esc(text);
|
||||
}
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(API + path, opts);
|
||||
if (!res.ok) {
|
||||
@@ -491,12 +539,16 @@ function renderMessages(messages) {
|
||||
box.innerHTML = '<div class="chat-empty">Пусто. Напишите первую реплику.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = messages.map(m => `
|
||||
<div class="msg ${m.role === "user" ? "user" : "assistant"}">
|
||||
${esc(m.text)}
|
||||
<div class="msg-meta">${esc(fmtDate(m.created_at))}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
box.innerHTML = messages.map(m => {
|
||||
const isUser = m.role === "user";
|
||||
const body = isUser ? esc(m.text) : renderMd(m.text);
|
||||
return `
|
||||
<div class="msg ${isUser ? "user" : "assistant"}">
|
||||
<div class="msg-body">${body}</div>
|
||||
<div class="msg-meta">${esc(fmtDate(m.created_at))}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
@@ -505,8 +557,10 @@ function appendMessage(role, text, iso) {
|
||||
const empty = box.querySelector(".chat-empty");
|
||||
if (empty) empty.remove();
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg " + (role === "user" ? "user" : "assistant");
|
||||
div.innerHTML = esc(text) + `<div class="msg-meta">${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
|
||||
const isUser = role === "user";
|
||||
div.className = "msg " + (isUser ? "user" : "assistant");
|
||||
const body = isUser ? esc(text) : renderMd(text);
|
||||
div.innerHTML = `<div class="msg-body">${body}</div><div class="msg-meta">${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
|
||||
box.appendChild(div);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
return div;
|
||||
|
||||
Reference in New Issue
Block a user