Перейти к содержанию

Grounding fix v1 — закрытый список источников + реквизиты из реестра

Фикс уверенной галлюцинации Люми на запросах про конкретные НПА РК. Дата: 17 апреля 2026.

Проблема

Тестовый диалог с Камалом показал три пересекающиеся дыры:

  1. Уверенная фабрикация номеров. На запрос «сан требования к пункту питания» Люми родила «Приказ МЗ РК №26 от 20.02.2023 о водоисточниках» — номер и дата выдуманы, документ реально существует, но с другим номером/датой. Только под пушбэком призналась.
  2. Мид-сентенс суррогаты от citation_verifier. Префиксы вида «(соответствующий приказ РК)» появлялись мусорной вставкой посреди предложения, ломая смысл.
  3. COVID-времён требования всплывали как текущие («разметка 1.5м», «бесконтактные термометры»).

На вопрос «это со всеми моделями или мы что-то не так сделали?» — ответ: это структурный архитектурный пробел, не только LLM. У нас было 90% нужных данных (388 НПА РК + ID V2300031934 у документа id=154), но retrieval их не доставал из-за синонимов (водоснабжение vs водоисточники) и реестр не показывал LLM реквизитов.

Решение (три слоя)

Слой 1 — Closed-world grounding hint

В retriever.py::build_retrieval_sources_hint() заголовок блока с «собрано из...» (открытый мир, рекомендация) перевёрнут в ЗАКРЫТЫЙ СПИСОК ИСТОЧНИКОВ (закрытый мир, жёсткое правило):

[ЗАКРЫТЫЙ СПИСОК ИСТОЧНИКОВ] Это единственные документы, на которые
ты имеешь право ссылаться по номеру, дате или статье в этом ответе:
• «Санитарные правила ...» — Әділет V2300031934, утв. 21.04.2025 [РК, НПА]
• ...

ЖЁСТКОЕ ПРАВИЛО:
— Номера приказов, даты, статьи называй ТОЛЬКО из этого списка или контекста ниже.
— «Әділет V23...» и «утв. DD.MM.YYYY» — официальный идентификатор, можешь
  ссылаться: «... (Әділет V2300031934, утв. 21.04.2025)». Не подменяй выдумкой.
— Если пользователь просит номер, которого нет в списке — честно: «точного
  номера сейчас нет, могу описать требования по сути».
— Внутри списка текст документов у тебя есть целиком — не говори «базы нет».

Слой 2 — Citation stripping вместо substitution

В bot.py::_strip_citation() три стратегии удаления, а не подстановки суррогата:

  1. Parenthetical (Приказ X) → удалить скобку целиком.
  2. Connector before («согласно X», «в соответствии с X») → удалить коннектор + референс.
  3. Fallback _neutral_qualifier() — короткая нейтральная форма без служебных скобок.

Постобработка: _cleanup_after_strip убирает многопробелы, пустые скобки, висящую пунктуацию, двойные запятые. Логируется Citation unverified: ... [stripped|kept].

Слой 3 — Registry enrichment с реквизитами

document_registry.py::parse_file_metadata() — парсит поле file в registry.json:

v2300031934.21-04-2025.rus  →  (adilet_id="V2300031934", date_approved="21.04.2025")

Формат Әділет: <adilet_id>.<dd-mm-yyyy>.<lang>. Regex: ^([\w\-]+)\.(\d{2})-(\d{2})-(\d{4})\.(\w{2,5})$.

DocumentRegistry.__init__ при загрузке итерирует all_docs, вызывает парсер, пишет adilet_id и date_approved в каждый док. Логирует enriched_count/total.

DocumentRegistry._by_title — параллельный O(1) индекс по нормализованному заголовку для быстрого lookup из retriever.

DocumentRegistry.get_by_title(title) — точное совпадение + fallback по prefix (на случай обрезанных заголовков в метаданных ChromaDB).

DocumentRegistry.format_requisites(doc)"Әділет V2300031934, утв. 21.04.2025". Пусто для международных (у них формат file другой).

retriever.py::build_retrieval_sources_hint — для каждого документа из ranked вызывает get_by_title() и, если есть, подставляет реквизиты в строку источника.

Volume mount для registry_v2.json

Отдельная структурная проблема, вскрытая в процессе деплоя.

Симптом: после каждой пересборки Docker реестр «отваливался». В логе циклами WARNING → docker cp руками → INFO → пересборка → снова WARNING.

Причина: registry_v2.json — это производное (сгенерировано сканом ChromaDB), а не исходник. В Dockerfile никогда не было COPY registry_v2.json — файл жил только на хосте в /opt/lyumi/. Подкидывался вручную через docker cp, что переживало только текущий инстанс контейнера.

Фикс: добавили volume mounts в docker run:

-v /opt/lyumi/registry_v2.json:/app/registry_v2.json:ro \
-v /opt/lyumi/registry.json:/app/registry.json:ro \

Эффекты: - Пересборка образа больше не ломает реестр — файл идёт мимо образа. - Обновление реестра (новый скан ChromaDB) = один docker restart, без билда. - Единственный способ снова сломать — запустить docker run без флага.

Стандартная команда деплоя теперь 5 volume'ов, не 3. Рекомендуется добавить алиас lyumi-run в ~/.bashrc на сервере.

Файлы

  • hse_copilot/document_registry.pyparse_file_metadata, enrichment в __init__, _by_title, get_by_title, format_requisites
  • hse_copilot/retriever.pybuild_retrieval_sources_hint с реестром + жёсткое правило
  • hse_copilot/bot.py_strip_citation, _cleanup_after_strip, обновлённая verify_citations
  • hse_copilot/llm.py — блок «Номера документов и нормативка» переписан с «не придумывай» на «только из закрытого списка» + негативный пример

Тесты (smoke)

  1. «сан требования к удалённому пункту питания с шашлыком» — ожидание: либо реальный ҚР ДСМ-16, либо honest fallback «точного номера нет, опишу по сути».
  2. «найди приказ про водоснабжение на производстве» — ожидание: Әділет V2300031934, утв. 21.04.2025 вместо фантомного «№26 от 20.02.2023».

Что дальше

  • Парсинг настоящего номера приказа из первого чанка каждого документа (сейчас title в реестре без номера).
  • Synonym expansion для retrieval (водоснабжение ↔ водоисточники, питание ↔ общепит).
  • Phase 2 — tool calling: lookup_npa(query) как отдельный инструмент для LLM.
  • Phase 3 — structured writing: PLAN → WRITE → VALIDATE → ASSEMBLE с whitelist-проверкой каждой ссылки на НПА.

Связанные

  • [[lyumi/bot_v3]] — общая архитектура
  • [[lyumi/kz_monitor_spider]] — источник обновлений реестра