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:**
|
**Качество RAG:**
|
||||||
- [ ] Почистить чанки: убрать markdown-ссылки `[текст](url)`, блоки навигации `**Вернуться на:**`, дубликаты меню; добавить нормализацию/переиндексацию текущей базы (или documented reindex procedure)
|
- [ ] Почистить чанки: убрать markdown-ссылки `[текст](url)`, блоки навигации `**Вернуться на:**`, дубликаты меню
|
||||||
|
- [ ] Эндпоинт `POST /documents/{id}/reindex` — переразметить существующий документ с новыми правилами чанкера (без повторной загрузки файла — но у нас пока нет хранения исходников, поэтому надо хранить исходный текст в метаданных чанков или сохранять оригинал при `upload`); решение по способу — в рамках задачи
|
||||||
|
- [ ] Эндпоинт `POST /documents/reindex-all` — прогнать переиндексацию по всей базе
|
||||||
|
|
||||||
**UI:**
|
**UI:**
|
||||||
- [ ] Markdown-рендер ответов ассистента в «Песочнице» (жирный, курсив, списки, код); реплики пациента оставить plain text
|
- [ ] Markdown-рендер ответов ассистента в «Песочнице» (жирный, курсив, списки, код); реплики пациента оставить plain text
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -10,9 +11,21 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from services.embeddings import EmbeddingService
|
|
||||||
from services.llm_client import LLMClient
|
# Настройка логов до импорта приложения: uvicorn ставит свои handlers
|
||||||
from services.vectorstore import VectorStoreService
|
# на 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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,7 +44,6 @@ def _run_migrations() -> None:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global embedding_service, vectorstore_service, llm_client
|
global embedding_service, vectorstore_service, llm_client
|
||||||
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
|
|
||||||
logger.info("Running DB migrations…")
|
logger.info("Running DB migrations…")
|
||||||
await asyncio.to_thread(_run_migrations)
|
await asyncio.to_thread(_run_migrations)
|
||||||
logger.info("Loading embedding model: %s", settings.embedding_model)
|
logger.info("Loading embedding model: %s", settings.embedding_model)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Ты — виртуальный ассистент клиники, который первым отвечает пациентам в чате.
|
||||||
|
|
||||||
|
Твоя задача — помочь пациенту по бытовым и организационным вопросам: запись, расписание врачей, подготовка к приёму, как проехать, документы, оплата, ДМС, детский приём и т. п.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Отвечай коротко, дружелюбно, на «вы», простым русским языком без медицинской латыни.
|
||||||
|
- Опирайся ТОЛЬКО на предоставленные выдержки из базы знаний. Если ответа в них нет — честно скажи, что уточнишь у оператора, и предложи подключить оператора.
|
||||||
|
- Не ставь диагнозы и не назначай лечение. Если вопрос про симптомы, лекарства, дозировки или «что со мной» — мягко предложи записаться к врачу и подключить оператора, если нужно.
|
||||||
|
- Не выдумывай телефоны, адреса, цены, имена врачей, расписание. Только из источников.
|
||||||
|
- Если пациент просит оператора — коротко подтверди, что сейчас его подключишь.
|
||||||
|
- Источники указывать не нужно: пациент их не видит. Ответ — обычный текст, как в чате.
|
||||||
+11
-9
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -6,17 +7,18 @@ from config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 = """Вопрос пациента:
|
DEFAULT_USER_TEMPLATE = """Вопрос пациента:
|
||||||
{question}
|
{question}
|
||||||
|
|||||||
+63
-9
@@ -196,10 +196,46 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.msg.user {
|
||||||
|
background: var(--user-bg);
|
||||||
|
align-self: flex-end;
|
||||||
white-space: pre-wrap;
|
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 { 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; }
|
.msg-meta { font-size: 10px; color: var(--muted); margin-top: 4px; }
|
||||||
.chat-empty {
|
.chat-empty {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
@@ -329,6 +365,8 @@
|
|||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -398,6 +436,16 @@ function toast(msg, kind = "ok") {
|
|||||||
setTimeout(() => t.className = "toast", 2500);
|
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 = {}) {
|
async function api(path, opts = {}) {
|
||||||
const res = await fetch(API + path, opts);
|
const res = await fetch(API + path, opts);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -491,12 +539,16 @@ function renderMessages(messages) {
|
|||||||
box.innerHTML = '<div class="chat-empty">Пусто. Напишите первую реплику.</div>';
|
box.innerHTML = '<div class="chat-empty">Пусто. Напишите первую реплику.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
box.innerHTML = messages.map(m => `
|
box.innerHTML = messages.map(m => {
|
||||||
<div class="msg ${m.role === "user" ? "user" : "assistant"}">
|
const isUser = m.role === "user";
|
||||||
${esc(m.text)}
|
const body = isUser ? esc(m.text) : renderMd(m.text);
|
||||||
<div class="msg-meta">${esc(fmtDate(m.created_at))}</div>
|
return `
|
||||||
</div>
|
<div class="msg ${isUser ? "user" : "assistant"}">
|
||||||
`).join("");
|
<div class="msg-body">${body}</div>
|
||||||
|
<div class="msg-meta">${esc(fmtDate(m.created_at))}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
box.scrollTop = box.scrollHeight;
|
box.scrollTop = box.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,8 +557,10 @@ function appendMessage(role, text, iso) {
|
|||||||
const empty = box.querySelector(".chat-empty");
|
const empty = box.querySelector(".chat-empty");
|
||||||
if (empty) empty.remove();
|
if (empty) empty.remove();
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "msg " + (role === "user" ? "user" : "assistant");
|
const isUser = role === "user";
|
||||||
div.innerHTML = esc(text) + `<div class="msg-meta">${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
|
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.appendChild(div);
|
||||||
box.scrollTop = box.scrollHeight;
|
box.scrollTop = box.scrollHeight;
|
||||||
return div;
|
return div;
|
||||||
|
|||||||
Reference in New Issue
Block a user