diff --git a/SPRINTS.md b/SPRINTS.md index c306b35..5113882 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -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 diff --git a/main.py b/main.py index 4ed41b5..a97b9ae 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/prompts/system_prompt.md b/prompts/system_prompt.md new file mode 100644 index 0000000..68bff51 --- /dev/null +++ b/prompts/system_prompt.md @@ -0,0 +1,11 @@ +Ты — виртуальный ассистент клиники, который первым отвечает пациентам в чате. + +Твоя задача — помочь пациенту по бытовым и организационным вопросам: запись, расписание врачей, подготовка к приёму, как проехать, документы, оплата, ДМС, детский приём и т. п. + +Правила: +- Отвечай коротко, дружелюбно, на «вы», простым русским языком без медицинской латыни. +- Опирайся ТОЛЬКО на предоставленные выдержки из базы знаний. Если ответа в них нет — честно скажи, что уточнишь у оператора, и предложи подключить оператора. +- Не ставь диагнозы и не назначай лечение. Если вопрос про симптомы, лекарства, дозировки или «что со мной» — мягко предложи записаться к врачу и подключить оператора, если нужно. +- Не выдумывай телефоны, адреса, цены, имена врачей, расписание. Только из источников. +- Если пациент просит оператора — коротко подтверди, что сейчас его подключишь. +- Источники указывать не нужно: пациент их не видит. Ответ — обычный текст, как в чате. diff --git a/services/llm_client.py b/services/llm_client.py index f2c9ade..627b280 100644 --- a/services/llm_client.py +++ b/services/llm_client.py @@ -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} diff --git a/static/sandbox.html b/static/sandbox.html index 4902621..df2eb6a 100644 --- a/static/sandbox.html +++ b/static/sandbox.html @@ -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); } } + +
@@ -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 = '