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,
+106 -36
View File
@@ -1,46 +1,93 @@
"""Версионируемые конфигурации агента: создание, активация, чтение."""
"""Версионируемые конфигурации агента, привязанные к ветке (intent).
Со Спринта 4 каждая версия относится к конкретной ветке графовой архитектуры.
Активна одна версия в пределах ветки, не глобально.
"""
import logging
from pathlib import Path
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import AgentConfig
from db.models import AgentConfig, Intent
from services import intent_service
logger = logging.getLogger(__name__)
SEED_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "system_prompt.md"
async def ensure_seed_configs(session: AsyncSession) -> None:
"""Для каждой ветки без конфигов — создать v1 из prompts/intents/{code}.md и активировать."""
intents = await intent_service.list_intents(session)
for intent in intents:
has_config = (await session.execute(
select(func.count(AgentConfig.id)).where(AgentConfig.intent_id == intent.id)
)).scalar_one()
if has_config > 0:
continue
seed_text = intent_service.load_seed_prompt(intent.code)
session.add(AgentConfig(
intent_id=intent.id,
version=1,
name=f"Исходная версия (из prompts/intents/{intent.code}.md)",
system_prompt=seed_text,
rules_text="",
is_active=True,
))
logger.info("Seeded v1 for intent %r", intent.code)
await session.commit()
def _load_seed_prompt() -> str:
try:
return SEED_PROMPT_PATH.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("Seed prompt file not found at %s — creating empty v1", SEED_PROMPT_PATH)
return ""
async def migrate_legacy_config_to_general_info(session: AsyncSession) -> None:
"""Одноразовая миграция: старый конфиг без intent_id цепляем к general_info.
async def ensure_seed(session: AsyncSession) -> None:
"""Если таблица пустая, создать v1 из prompts/system_prompt.md и активировать."""
count = (await session.execute(select(func.count(AgentConfig.id)))).scalar_one()
if count > 0:
Он был сохранён как «единый» системный промпт — теперь это стартовый промпт общей
справочной ветки. Если в general_info уже есть конфиги (сид отработал раньше) —
не трогаем, чтобы не задвоить.
"""
orphan_stmt = select(AgentConfig).where(AgentConfig.intent_id.is_(None))
orphans = list((await session.execute(orphan_stmt)).scalars().all())
if not orphans:
return
seed_text = _load_seed_prompt()
seed = AgentConfig(
version=1,
name="Исходная версия (из prompts/system_prompt.md)",
system_prompt=seed_text,
rules_text="",
is_active=True,
)
session.add(seed)
general = await intent_service.get_intent_by_code(session, "general_info")
if general is None:
logger.warning("general_info intent not found, can't migrate legacy configs")
return
existing_versions = set((await session.execute(
select(AgentConfig.version).where(AgentConfig.intent_id == general.id)
)).scalars().all())
# Переносим по одному, смещая version при столкновении с сид-версиями.
next_free = max(existing_versions, default=0) + 1
for cfg in sorted(orphans, key=lambda c: c.version):
cfg.intent_id = general.id
if cfg.version in existing_versions:
cfg.version = next_free
next_free += 1
# Если у ветки уже есть активная — ставим новые как неактивные.
has_active = (await session.execute(
select(func.count(AgentConfig.id)).where(
AgentConfig.intent_id == general.id,
AgentConfig.is_active.is_(True),
AgentConfig.id != cfg.id,
)
)).scalar_one()
if has_active > 0:
cfg.is_active = False
existing_versions.add(cfg.version)
await session.commit()
logger.info("Seeded agent_configs with v1 from %s", SEED_PROMPT_PATH.name)
logger.info("Migrated %d legacy config(s) to general_info", len(orphans))
async def list_configs(session: AsyncSession) -> list[AgentConfig]:
stmt = select(AgentConfig).order_by(AgentConfig.version.desc())
async def list_configs_for_intent(session: AsyncSession, intent_id: int) -> list[AgentConfig]:
stmt = (
select(AgentConfig)
.where(AgentConfig.intent_id == intent_id)
.order_by(AgentConfig.version.desc())
)
return list((await session.execute(stmt)).scalars().all())
@@ -48,27 +95,50 @@ async def get_config(session: AsyncSession, config_id: int) -> AgentConfig | Non
return await session.get(AgentConfig, config_id)
async def get_active_config(session: AsyncSession) -> AgentConfig | None:
stmt = select(AgentConfig).where(AgentConfig.is_active.is_(True)).limit(1)
async def get_active_config_for_intent(session: AsyncSession, intent_id: int) -> AgentConfig | None:
stmt = (
select(AgentConfig)
.where(AgentConfig.intent_id == intent_id, AgentConfig.is_active.is_(True))
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_active_config_by_intent_code(
session: AsyncSession, intent_code: str
) -> tuple[Intent, AgentConfig] | None:
"""Удобный шорткат для оркестратора: по коду ветки вернуть её + активный конфиг."""
intent = await intent_service.get_intent_by_code(session, intent_code)
if intent is None:
return None
cfg = await get_active_config_for_intent(session, intent.id)
if cfg is None:
return None
return intent, cfg
async def create_config(
session: AsyncSession,
intent_id: int,
system_prompt: str,
rules_text: str,
name: str | None = None,
activate: bool = False,
) -> AgentConfig:
"""Создать новую версию. При activate=True — сразу сделать активной."""
next_version = (await session.execute(select(func.coalesce(func.max(AgentConfig.version), 0)))).scalar_one() + 1
"""Создать новую версию в рамках ветки. При activate=True — сразу активна в этой ветке."""
next_version = (await session.execute(
select(func.coalesce(func.max(AgentConfig.version), 0)).where(AgentConfig.intent_id == intent_id)
)).scalar_one() + 1
if activate:
await session.execute(
update(AgentConfig).where(AgentConfig.is_active.is_(True)).values(is_active=False)
update(AgentConfig)
.where(AgentConfig.intent_id == intent_id, AgentConfig.is_active.is_(True))
.values(is_active=False)
)
cfg = AgentConfig(
intent_id=intent_id,
version=next_version,
name=(name or "").strip() or None,
system_prompt=system_prompt,
@@ -83,10 +153,12 @@ async def create_config(
async def activate_config(session: AsyncSession, config_id: int) -> AgentConfig | None:
cfg = await session.get(AgentConfig, config_id)
if cfg is None:
if cfg is None or cfg.intent_id is None:
return None
await session.execute(
update(AgentConfig).where(AgentConfig.is_active.is_(True)).values(is_active=False)
update(AgentConfig)
.where(AgentConfig.intent_id == cfg.intent_id, AgentConfig.is_active.is_(True))
.values(is_active=False)
)
cfg.is_active = True
await session.commit()
@@ -95,7 +167,6 @@ async def activate_config(session: AsyncSession, config_id: int) -> AgentConfig
async def delete_config(session: AsyncSession, config_id: int) -> tuple[bool, str]:
"""Удалить версию. Нельзя удалить активную. Возвращает (ok, reason_if_fail)."""
cfg = await session.get(AgentConfig, config_id)
if cfg is None:
return False, "not_found"
@@ -107,7 +178,6 @@ async def delete_config(session: AsyncSession, config_id: int) -> tuple[bool, st
def compose_full_system_prompt(cfg: AgentConfig) -> str:
"""Собрать из system_prompt + rules_text единую строку для LLM."""
base = (cfg.system_prompt or "").strip()
rules = (cfg.rules_text or "").strip()
if not rules:
+83
View File
@@ -0,0 +1,83 @@
"""Ветки графовой архитектуры: каталог 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)
+129
View File
@@ -0,0 +1,129 @@
"""LLM-роутер: по последней реплике + короткой истории определяет ветку.
Отдельный класс от LLMClient сознательно — роутер зовётся часто (каждую реплику),
имеет смысл в будущем перевести на более дешёвую модель (gpt-4o-mini, локальная Qwen).
Сейчас оба используют DeepSeek.
Системный промпт роутера лежит в БД как активный конфиг ветки `_router`
(версионируется, редактируется из UI «Настройки»). Если БД недоступна или
ветки нет — используем fallback из prompts/intents/_router.md.
"""
import logging
import re
from pathlib import Path
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from services import config_service, intent_service
logger = logging.getLogger(__name__)
_FALLBACK_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "intents" / "_router.md"
def _load_fallback_prompt() -> str:
try:
return _FALLBACK_PROMPT_PATH.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("Router fallback prompt not found at %s", _FALLBACK_PROMPT_PATH)
return ""
FALLBACK_SYSTEM_PROMPT = _load_fallback_prompt()
VALID_CODES = {
"new_booking",
"reschedule",
"price_question",
"medical_question",
"general_info",
"escalate_human",
}
CODE_RE = re.compile(r"\b(new_booking|reschedule|price_question|medical_question|general_info|escalate_human)\b")
class RouterClient:
def __init__(
self,
api_key: str | None = None,
model: str | None = None,
base_url: str | None = None,
):
self.api_key = api_key or settings.deepseek_api_key
self.model = model or settings.deepseek_model
self.base_url = (base_url or settings.deepseek_base_url).rstrip("/")
def _format_history(self, history: list[dict], last_n: int = 4) -> str:
"""Короткая история последних реплик — для контекста классификации."""
if not history:
return "(предыдущих реплик нет)"
tail = history[-last_n:]
lines = []
for m in tail:
role_ru = "Пациент" if m["role"] == "user" else "Ассистент"
content = m["content"].replace("\n", " ")[:300]
lines.append(f"{role_ru}: {content}")
return "\n".join(lines)
async def _get_system_prompt(self, session: AsyncSession) -> tuple[str, int | None]:
"""Активный промпт роутера из БД (ветка _router). Возвращает (prompt, version_or_None)."""
pair = await config_service.get_active_config_by_intent_code(
session, intent_service.ROUTER_INTENT_CODE
)
if pair is None:
return FALLBACK_SYSTEM_PROMPT, None
_, cfg = pair
return config_service.compose_full_system_prompt(cfg), cfg.version
async def classify(self, session: AsyncSession, history: list[dict], text: str) -> dict:
"""Классифицировать реплику. Возвращает {code, version} — версия роутера для отладки.
При сомнении или парсинг-ошибке — general_info (безопасный fallback).
"""
system_prompt, version = await self._get_system_prompt(session)
user_message = (
f"История последних реплик:\n{self._format_history(history)}\n\n"
f"Новая реплика пациента:\n{text}\n\n"
f"Код ветки:"
)
url = f"{self.base_url}/chat/completions"
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"temperature": 0.0,
"max_tokens": 20,
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
url,
json=payload,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
response.raise_for_status()
data = response.json()
except Exception as e:
logger.warning("Router LLM call failed (%s), falling back to general_info", e)
return {"code": "general_info", "version": version}
raw = (data["choices"][0]["message"]["content"] or "").strip()
match = CODE_RE.search(raw)
if match:
code = match.group(1)
logger.info("Router v%s: %r%s", version, text[:80], code)
return {"code": code, "version": version}
logger.warning("Router returned unrecognized response %r, falling back to general_info", raw)
return {"code": "general_info", "version": version}