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>
This commit is contained in:
AR 15 M4
2026-04-23 21:20:23 +05:00
parent 2e2f2321c3
commit b24e985f82
25 changed files with 1135 additions and 261 deletions
+56 -19
View File
@@ -4,16 +4,17 @@ from datetime import datetime, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from db.models import Message, Thread
from services import config_service
from services import config_service, intent_service
from services.llm_client import LLMClient
from services.router_client import RouterClient
from services.vectorstore import VectorStoreService
logger = logging.getLogger(__name__)
HISTORY_LIMIT = 20 # последние N сообщений треда, которые улетают в LLM
FALLBACK_INTENT_CODE = "general_info"
def _auto_thread_name(first_user_text: str) -> str:
@@ -44,21 +45,16 @@ async def send_message(
session: AsyncSession,
vectorstore: VectorStoreService,
llm: LLMClient,
router: RouterClient,
text: str,
thread_id: int | None = None,
top_k: int = 5,
temperature: float | None = None,
max_tokens: int | None = None,
) -> dict:
"""Добавить реплику пациента в тред, получить ответ ассистента, сохранить оба сообщения."""
active_cfg = await config_service.get_active_config(session)
system_prompt = config_service.compose_full_system_prompt(active_cfg) if active_cfg else None
"""Добавить реплику пациента в тред, прогнать через роутер, получить ответ ассистента."""
if thread_id is None:
thread = Thread(
name=_auto_thread_name(text),
agent_config_id=active_cfg.id if active_cfg else None,
)
thread = Thread(name=_auto_thread_name(text))
session.add(thread)
await session.flush()
else:
@@ -71,10 +67,7 @@ async def send_message(
session.add(user_msg)
await session.flush()
retrieved = vectorstore.query(query_text=text, top_k=top_k)
sources = _retrieved_to_sources(retrieved)
# История для LLM: все сообщения треда, кроме только что добавленной user-реплики.
# История для классификации и для LLM: все сообщения треда до новой реплики.
stmt = (
select(Message)
.where(Message.thread_id == thread.id, Message.id != user_msg.id)
@@ -84,6 +77,32 @@ async def send_message(
rows = (await session.execute(stmt)).scalars().all()
history = [{"role": m.role, "content": m.text} for m in reversed(rows)]
# 1. Роутер определяет ветку.
routing = await router.classify(session=session, history=history, text=text)
intent_code = routing["code"]
router_version = routing.get("version")
pair = await config_service.get_active_config_by_intent_code(session, intent_code)
if pair is None:
# Ветка выключена или без активного конфига — подстраховываемся общей справкой.
logger.warning("Intent %r has no active config, falling back to %s", intent_code, FALLBACK_INTENT_CODE)
intent_code = FALLBACK_INTENT_CODE
pair = await config_service.get_active_config_by_intent_code(session, intent_code)
if pair is None:
# Даже fallback не нашёлся — критическая ошибка конфигурации.
raise RuntimeError(f"No active config for fallback intent {FALLBACK_INTENT_CODE!r}")
intent, active_cfg = pair
system_prompt = config_service.compose_full_system_prompt(active_cfg)
user_msg.intent_id = intent.id
if thread.agent_config_id is None:
thread.agent_config_id = active_cfg.id
# 2. Retrieval + запрос к ветке.
retrieved = vectorstore.query(query_text=text, top_k=top_k)
sources = _retrieved_to_sources(retrieved)
llm_result = await llm.chat(
question=text,
sources=retrieved,
@@ -99,6 +118,7 @@ async def send_message(
text=llm_result["text"],
sources_json=json.dumps(sources, ensure_ascii=False),
assembled_prompt=llm_result["assembled_prompt"],
intent_id=intent.id,
)
session.add(assistant_msg)
@@ -108,13 +128,19 @@ async def send_message(
await session.refresh(assistant_msg)
await session.refresh(thread)
logger.info("Chat: thread=%d, user_msg=%d, assistant_msg=%d, sources=%d",
thread.id, user_msg.id, assistant_msg.id, len(sources))
logger.info(
"Chat: thread=%d, intent=%s (v%d), user_msg=%d, assistant_msg=%d, sources=%d",
thread.id, intent.code, active_cfg.version, user_msg.id, assistant_msg.id, len(sources),
)
return {
"thread_id": thread.id,
"thread_name": thread.name,
"message_id": assistant_msg.id,
"intent_code": intent.code,
"intent_name": intent.name,
"config_version": active_cfg.version,
"router_version": router_version,
"answer": llm_result["text"],
"sources": sources,
"model_used": llm.model,
@@ -166,13 +192,22 @@ async def list_threads(session: AsyncSession) -> list[dict]:
async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | None:
stmt = select(Thread).where(Thread.id == thread_id).options(selectinload(Thread.messages))
thread = (await session.execute(stmt)).scalar_one_or_none()
from db.models import Intent
thread = await session.get(Thread, thread_id)
if thread is None:
return None
stmt = (
select(Message, Intent.code, Intent.name)
.outerjoin(Intent, Intent.id == Message.intent_id)
.where(Message.thread_id == thread_id)
.order_by(Message.created_at)
)
rows = (await session.execute(stmt)).all()
messages = []
for m in thread.messages:
for m, intent_code, intent_name in rows:
sources = []
if m.sources_json:
try:
@@ -186,6 +221,8 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non
"created_at": m.created_at.isoformat(),
"sources": sources,
"assembled_prompt": m.assembled_prompt or "",
"intent_code": intent_code or "",
"intent_name": intent_name or "",
})
return {
"id": thread.id,