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
+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: