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
+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;