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