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:
AR 15 M4
2026-04-23 10:53:01 +05:00
parent 835c0b3cc3
commit 4e45b8b181
5 changed files with 104 additions and 23 deletions
+3 -1
View File
@@ -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
+16 -4
View File
@@ -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)
+11
View File
@@ -0,0 +1,11 @@
Ты — виртуальный ассистент клиники, который первым отвечает пациентам в чате.
Твоя задача — помочь пациенту по бытовым и организационным вопросам: запись, расписание врачей, подготовка к приёму, как проехать, документы, оплата, ДМС, детский приём и т. п.
Правила:
- Отвечай коротко, дружелюбно, на «вы», простым русским языком без медицинской латыни.
- Опирайся ТОЛЬКО на предоставленные выдержки из базы знаний. Если ответа в них нет — честно скажи, что уточнишь у оператора, и предложи подключить оператора.
- Не ставь диагнозы и не назначай лечение. Если вопрос про симптомы, лекарства, дозировки или «что со мной» — мягко предложи записаться к врачу и подключить оператора, если нужно.
- Не выдумывай телефоны, адреса, цены, имена врачей, расписание. Только из источников.
- Если пациент просит оператора — коротко подтверди, что сейчас его подключишь.
- Источники указывать не нужно: пациент их не видит. Ответ — обычный текст, как в чате.
+11 -9
View File
@@ -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
View File
@@ -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;