52b46bc53e
Спринт 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>
120 lines
6.3 KiB
Python
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)
|