Files
RAG_helper/services/intent_service.py
T
AR 15 M4 b24e985f82 feat(sprint4): фундамент графа — intents + роутер + переключение веток
Первый шаг графовой архитектуры из GRAPH_ARCHITECTURE.md. Заменили
«один активный промпт на всё» на «свой промпт на каждую ветку +
роутер выбирает ветку на каждой реплике».

Данные:
- Новая таблица intents (code, name, description, is_enabled,
  order_index). Коды с префиксом `_` — системные (не responder).
- В agent_configs добавлен intent_id (nullable, FK SET NULL); убрана
  глобальная уникальность version, вместо неё UniqueConstraint
  (intent_id, version) — у каждой ветки свой счётчик версий.
- В messages добавлен intent_id (nullable, FK) — фиксируем, какую
  ветку выбрал роутер для каждой реплики.
- Миграция cd0a88ef9080 в batch-режиме (SQLite не умеет ALTER для
  constraints напрямую).

Сид:
- Стартовые 7 веток: new_booking, reschedule, price_question,
  medical_question, general_info, escalate_human + `_router` как
  системная ветка для промпта классификатора.
- Для каждой ветки — свой v1-промпт из prompts/intents/{code}.md.
- migrate_legacy_config_to_general_info: старый v1 из Спринта 3
  (без intent_id) переносится на general_info с сохранением версии.
- ensure_seed_intents досиживает недостающие коды, существующие не
  трогает — безопасно при добавлении новых веток.

Оркестрация и роутер:
- services/router_client.RouterClient — отдельный класс от LLMClient
  (под будущую смену модели на более дешёвую). Метод classify(session,
  history, text) возвращает {code, version}. Промпт классификатора
  подтягивается из активного конфига ветки `_router`, fallback —
  prompts/intents/_router.md. При сомнении/ошибке возвращает
  general_info.
- services/chat_service.send_message теперь идёт через router.classify
  → берёт активный конфиг выбранной ветки → llm.chat. В сообщения
  пишется intent_id, в треде фиксируется начальный agent_config_id.
  В ответе — intent_code, intent_name, config_version, router_version.

API:
- GET /intents, GET /intents/{code}, PATCH /intents/{code} —
  список веток со счётчиком версий, получение и переключение
  is_enabled.
- /configs теперь требует intent_code как Query-параметр
  (GET /configs, GET /configs/active) — выборка версий в рамках
  ветки. POST /configs принимает intent_id.
- get_thread_detail JOIN-ит Intent — каждая реплика возвращает
  intent_code + intent_name.

UI:
- settings.html переработан в 3-колоночный макет: слева список веток
  с подгруппой «Системные» для `_router` (пометка «система» вместо
  свитча), в центре редактор промпта/правил активной версии выбранной
  ветки, справа список версий с активировать/удалить/загрузить.
  Каждая ветка редактируется независимо — своя история версий,
  своя активная.
- sandbox.html: у каждой реплики бейдж с intent_code, в отладке новый
  блок «Решение роутера» (подсвеченный зелёным) с названием ветки,
  версией её активного конфига и версией промпта роутера. Старый
  «активная: v1» индикатор убран — он больше не имеет смысла (активная
  у каждой ветки своя).

E2E проверено: разные реплики уходят в корректные ветки, каждая
отвечает по своему узкому промпту, промпт роутера редактируется в UI
как v2/v3 и откатывается — классификация сразу использует новую
версию.

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

84 lines
4.4 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": "Системная ветка: промпт классификатора намерений. Пациенту напрямую не отвечает."},
]
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)