Чанкер тащил в базу markdown-мусор: навигационные блоки «Вернуться на:»
со списками ссылок, инлайн-ссылки [текст](url) в теле, служебные
пометки _Источник: .../file.md_, лишние пустые строки. Всё это ело
контекст LLM и засоряло правую панель отладки.
- services/text_cleanup: clean_markdown_text — удаляет навигационные
строки, строки-только-ссылки (обычно это меню), служебные _Источник:_,
раскрывает инлайн-ссылки [x](url) → x, сжимает 3+ переносов до 2.
- services/document_processor: process_document теперь возвращает
(id, raw_text, sections, chunks); чистку применяем к заголовкам и
телам секций; чанки короче 20 символов выбрасываем с пересчётом
индексов. Вспомогательная rechunk_raw_text — для переиндексации.
Чтобы переиндексировать без повторной загрузки файла, нужен исходный
текст. Вводим отдельный слой:
- новая таблица SQLite documents (id, name, file_type, raw_text,
created_at, updated_at) + миграция Alembic 7ee7296ccd6d.
- db/models/Document + регистрация в db.models.__init__.
- services/document_service: save/get/list/delete для raw_text.
- routers/documents.upload: сохраняет raw_text в SQLite перед
индексацией в Chroma; delete убирает и из SQLite, и из Chroma.
- Новые эндпоинты POST /documents/{id}/reindex и
POST /documents/reindex-all — берут raw_text из SQLite, пропускают
через rechunk_raw_text, заменяют чанки в Chroma.
Существующие 4 документа были перезалиты вручную (решение: не делать
одноразовый backfill, проще залить заново). Старая Chroma очищена,
новые чанки прошли через чистку — мусор ушёл.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Три подряд доработки по плану Спринта 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>
Расширяем просмотр документа, чтобы оператор видел не только текстовые
чанки, но и как они лежат в ChromaDB в виде векторов — по паттерну из
work-pcs-dr-cdss.
Backend:
- services/vectorstore.get_document_chunks теперь запрашивает
include=["embeddings"] и отдаёт вектор как list[float]. Chroma
возвращает numpy-массивы, поэтому проверка наличия embeddings
сделана через len(), без or-шортката.
- models.ChunkDetail: поля embedding: list[float] + embedding_dim: int.
- routers/documents прокидывает вектор и размерность в ответ.
Frontend (static/index.html):
- В карточку чанка добавлен блок .chunk-card-actions с кнопкой
«вектор (N dim)»; раскрывается в .embedding-box с полным списком
координат (округление до 6 знаков, моноширинный шрифт, скролл).
- Функция toggleChunkText переписана через .closest + querySelector,
чтобы не ломаться от новой обёртки кнопок.
- Добавлена toggleEmb(embId).
Проверено на загруженных документах — возвращается по 1024 координаты
(E5-large), совпадает с ожиданиями embedding-модели.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>