b24e985f82
Первый шаг графовой архитектуры из 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>
84 lines
4.4 KiB
Python
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)
|