Files
RAG_helper/services/intent_service.py
T
AR 15 M4 52b46bc53e feat(sprint6c+sprint7): терминология, сверка примеров с кодом, мульти-RAG (часть A)
Спринт 6c — терминология и сверка документации с реальным кодом:
- Словарь терминов в static/docs.html: «маршрутизатор» вместо «роутер»,
  «защитное условие» вместо «guard», «пошаговая ветка» вместо «многошаговая».
  Разделены концепты «намерение» (intent) и «ветка» (branch) с пометкой,
  что в коде они хранятся как одна сущность 1:1.
- Песочница: «Решение маршрутизатора» виден всегда (зелёный/жёлтый),
  счётчик переключений «N из 3» отдельной плашкой, бейджи под словарь.
- Настройки: «Условия перехода» → «Защитные условия (guards, JSON)».
- GRAPH_ARCHITECTURE_v4.md: имена полей thread_state и слоты приведены
  к реальной БД (db/models/thread_state.py) и таксономии промптов шагов
  (prompts/intents/new_booking/steps/). Ссылки на *_v2 примеры. На v3
  поставлена шапка «устарело».
- 4 примера переписаны как *_v2: реальные current_intent_code/
  current_step_code/slots_json, реальные allowed_next без двойных переходов,
  реальная таксономия слотов name/reason/specialist/preferred_time/confirmed.
  Удалены вымышленные CRM tool calls и слоты, которых нет в коде.
- static/example.html — параметризованная страница с навигацией между
  4 примерами; роут GET /api/docs/examples/{name} в main.py отдаёт
  markdown без дублирования файлов.
- Редактирование документов в Отладке: GET/PUT /documents/{id}/raw,
  textarea с переразметкой и обновлением Chroma при сохранении.

Спринт 7, часть A — мульти-RAG через подписку ветка↔документы:
- Миграция: таблица intent_documents (M:N), модель IntentDocument,
  индекс по document_id для обратного поиска.
- API: GET/PUT /intents/{code}/documents и GET/PUT /documents/{id}/intents
  с PUT-семантикой «полный список», атомарно. Сервис
  services/intent_document_service.py.
- Retrieval-фильтр в chat_service: подтягивает document_ids активной
  ветки и передаёт в vectorstore.query(). Дефолт пустой подписки —
  document_ids=[] (= 0 чанков), не «вся коллекция»: пустая подписка
  означает «ветка не настроена», подмешивать случайное хуже, чем
  ничего. vectorstore.query() различает None (нет фильтра) и [] (0).
- UI Настроек: блок «Документы базы знаний» в правом сайдбаре,
  всегда видим независимо от вкладки, сортировка по имени, счётчик
  «N из M», PUT при сохранении.
- UI Отладки: третья кнопка «привязка» рядом с «удалить» —
  раскрывашка со списком веток (галочки), быстрая привязка прямо
  на странице загрузки.
- Песочница: блок «Срез RAG» с подпиской/найдено, ворнинг при пустой
  подписке. Поле rag_subscription в QueryResponse и ChatResponse.
- Системный промпт страницы Отладки переехал в обычную ветку _debug
  («Страница отладки»). Удалён prompts/system_prompt.md и логика
  DEFAULT_SYSTEM_PROMPT в llm_client. routers/query.py подтягивает
  активный конфиг ветки _debug и её подписки. Дефолт пустой подписки
  для _debug — None (вся коллекция), не [] как для пациентских — чтобы
  Отладка работала «из коробки». На странице Отладки info-bar показывает
  активную версию и счётчик подписок, ссылка → Настройки.
- Тест-блок «Тест-вопрос» в центре Настроек: расширил /query
  параметрами intent_code (default _debug), system_prompt (override
  для теста черновика из textarea), disable_rag (для _router).
  Редактор промпта обёрнут в <details open> — можно свернуть до
  одной строки. Под ним — три колонки результата (RAG / промпт /
  ответ). Для _router показывается подсказка про отсутствие RAG.

Документы:
- data/datasets/*.md — наработки по 6 веткам (рабочие материалы оператора).
- docs/BRANCH_MAP_AND_PROMPTS_v1.md, docs/OPTIMIZATION_CONVERSION_v1.md,
  docs/guides/state_machine_and_slots.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:00:44 +05:00

120 lines
6.3 KiB
Python

"""Ветки графовой архитектуры: каталог intents + сид при первом запуске."""
import logging
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent
logger = logging.getLogger(__name__)
PROMPTS_INTENTS_DIR = Path(__file__).resolve().parent.parent / "prompts" / "intents"
ROUTER_INTENT_CODE = "_router" # техническая ветка — промпт классификатора (код с `_` = системная).
# Стартовый набор веток (Спринт 4). Порядок — в каком показывать в UI.
# Ветки с кодом, начинающимся на `_`, — системные: их промпты используются служебными
# частями системы (например, классификатор), а не отвечают пациенту напрямую.
SEED_INTENTS: list[dict] = [
{"code": "new_booking", "name": "Новая запись", "description": "Пациент хочет записаться на приём."},
{"code": "reschedule", "name": "Перенос / отмена", "description": "Пациент хочет перенести или отменить существующую запись."},
{"code": "price_question", "name": "Цены и ДМС", "description": "Вопросы про стоимость услуг, оплату, ДМС."},
{"code": "medical_question", "name": "Медицинский вопрос", "description": "Симптомы, лекарства, диагноз — требует врача."},
{"code": "general_info", "name": "Общая справка", "description": "Адрес, часы работы, как доехать, общие вопросы."},
{"code": "escalate_human", "name": "Перевод на оператора", "description": "Перевод диалога на живого оператора."},
{"code": ROUTER_INTENT_CODE, "name": "Маршрутизатор", "description": "Системная ветка: промпт классификатора намерений. Пациенту напрямую не отвечает."},
{"code": "_debug", "name": "Страница отладки", "description": "Системная ветка: используется на странице «Отладка» для одиночных тест-вопросов. Пациентам в диалогах не отвечает. Дефолт RAG — вся коллекция, если подписки пусты."},
]
def is_system_code(code: str) -> bool:
"""Код, начинающийся с подчёркивания — системная ветка (не responder)."""
return code.startswith("_")
def load_seed_prompt(code: str) -> str:
"""Стартовый промпт ветки из prompts/intents/{code}.md. Если файла нет — пустая строка."""
# Код с точкой или пробелом — явно ошибка, не трогаем файловую систему.
path = PROMPTS_INTENTS_DIR / f"{code}.md"
try:
return path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("Seed prompt for intent %r not found at %s", code, path)
return ""
async def list_intents(session: AsyncSession) -> list[Intent]:
stmt = select(Intent).order_by(Intent.order_index, Intent.id)
return list((await session.execute(stmt)).scalars().all())
async def get_intent_by_code(session: AsyncSession, code: str) -> Intent | None:
stmt = select(Intent).where(Intent.code == code)
return (await session.execute(stmt)).scalar_one_or_none()
async def set_intent_enabled(session: AsyncSession, code: str, is_enabled: bool) -> Intent | None:
intent = await get_intent_by_code(session, code)
if intent is None:
return None
intent.is_enabled = is_enabled
await session.commit()
await session.refresh(intent)
return intent
async def ensure_seed_intents(session: AsyncSession) -> None:
"""Досиживает недостающие ветки из SEED_INTENTS. Существующие не трогаются."""
existing = set((await session.execute(select(Intent.code))).scalars().all())
added = 0
for order, data in enumerate(SEED_INTENTS):
if data["code"] in existing:
continue
session.add(Intent(
code=data["code"],
name=data["name"],
description=data["description"],
is_enabled=True,
order_index=order,
))
added += 1
if added:
await session.commit()
logger.info("Seeded %d missing intents", added)
# Точечные переименования: пользователь правит UI-копию, но в БД уже залит
# старый сид. Применяем мягко — только если поле в точности совпадает со старым
# значением (значит оператор не правил его сам).
_INTENT_NAME_MIGRATIONS: list[dict] = [
{
"code": "escalate_human",
"old_name": "Эскалация на оператора",
"new_name": "Перевод на оператора",
"old_description": "Передача диалога живому оператору.",
"new_description": "Перевод диалога на живого оператора.",
},
]
async def migrate_intent_copy(session: AsyncSession) -> None:
"""Обновляет name/description у системных веток, если в БД лежит старый текст."""
updated = 0
for spec in _INTENT_NAME_MIGRATIONS:
intent = await get_intent_by_code(session, spec["code"])
if intent is None:
continue
changed = False
if intent.name == spec["old_name"]:
intent.name = spec["new_name"]
changed = True
if intent.description == spec["old_description"]:
intent.description = spec["new_description"]
changed = True
if changed:
updated += 1
if updated:
await session.commit()
logger.info("Migrated copy for %d intent(s)", updated)