Три подряд доработки по плану Спринта 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>
Второй кусок Спринта 2: агент теперь помнит контекст. RAG-retrieval
делается по последней реплике пациента, в LLM уходит системный промпт +
последние 20 сообщений треда + новая реплика + найденные фрагменты.
Backend:
- services/chat_service: send_message — создаёт тред при необходимости
(auto-имя из первой реплики + UTC-дата), сохраняет user-реплику до
вызова LLM (чтобы не потерять при сбое), делает retrieval, грузит
историю треда (desc/limit 20 → reversed для хронологии), зовёт
llm.chat, сохраняет ответ ассистента вместе с sources_json и
assembled_prompt, обновляет thread.updated_at. Плюс list_threads с
JOIN-выборкой превью первой реплики и счётчика сообщений,
get_thread_detail через selectinload, rename_thread, delete_thread
(CASCADE на FK делает уборку сообщений автоматически, но
explicit delete оставлен для подсчёта удалённых).
- services/llm_client.chat: принимает history=[{role, content}, ...],
собирает messages = [system, ...history, user-с-RAG]; assembled_prompt
дампит всю цепочку в виде [SYSTEM]/[USER]/[ASSISTANT]-блоков для
отображения в Debug UI.
- routers/chat: POST /chat, обрабатывает LookupError → 404.
- routers/threads: GET /threads, GET /threads/{id}, PATCH /threads/{id}
(переименовать), DELETE /threads/{id}.
- models: ChatRequest, ThreadRenameRequest; ChatResponse, ThreadInfo,
ThreadListResponse, ThreadDetailResponse, MessageInfo,
ThreadDeleteResponse.
Запуск:
- В lifespan main.py: автоматический alembic upgrade head через
asyncio.to_thread (сам alembic делает asyncio.run внутри, его нельзя
звать из уже работающего event loop). LLMClient инициализируется
один раз при старте — вместо создания на каждый запрос.
E2E проверено: новый тред → агент отвечает и просит представиться;
вторая реплика в том же треде — агент помнит контекст; PATCH
переименовывает; DELETE удаляет тред с каскадом на сообщения.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>