932b488bcb
Блок A2: вынос условий выхода из основного промпта в отдельное поле agent_configs.exit_conditions_text. compose_full_system_prompt склеивает system_prompt + rules_text + exit_conditions_text перед отправкой в модель. Одноразовая миграция данных при старте: пытаемся выделить блок «Условия выхода» из хвоста существующих system_prompt-ов и перенести в новое поле (поддерживаются три формы заголовка: «## Условия выхода», «**Условия выхода**», просто «Условия выхода:»). В UI «Настройки» — третья textarea с подсказкой ⓘ на отдельной кнопке. Блок B: защита от петель маршрутизации (v2 §4.3). В thread_state добавлена колонка handoff_count, инкрементируется на каждом hard-handoff: либо когда роутер переключает не-sm-ветку (state reset), либо когда sm-ветка сама выдаёт [INTENT_CHANGE: …] (bouncing). При превышении HANDOFF_CAP=3 диалог автоматически уводится в escalate_human с шаблонным ответом «Уточню детали с администратором клиники, свяжемся с вами в течение ближайшего часа», LLM не вызывается, handoff_count сбрасывается. В Песочнице видны счётчик «переключений ветки в диалоге» и красная плашка при срабатывании защиты. Также пофикшен баг: для не-sm-веток snapshot.current_intent_code теперь финализируется на served_code, иначе на следующей реплике prev_intent_code терялся и handoff_count не считался. Блок C: suspended_intent / resumable_step_code / resumable_slots_json в thread_state (v2 §4.4). При hard-handoff из sm-ветки через [INTENT_CHANGE] текущий сценарий запоминается (если suspended ещё не занят). Когда роутер на следующих репликах возвращает intent = suspended_intent — RESUME: восстанавливаем current_intent_code, current_step_code, slots; suspended_* очищается, handoff_count=0. Возврат имеет приоритет над sticky-логикой. В Песочнице — синяя плашка «📌 отложен сценарий X (шаг Y)» во время detour'а и зелёная «↩️ возврат к отложенному сценарию» в момент resume. Routing-loop guard и роутер-driven handoff не теряют suspended (только при authoritative сценариях вроде эскалации он сбрасывается). Прогон вручную: detour из new_booking/qualify в price_question и обратно восстанавливает name=Алексей, reason=болит ухо на исходном шаге. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
10 KiB
Python
255 lines
10 KiB
Python
"""Версионируемые конфигурации агента, привязанные к ветке (intent).
|
||
|
||
Со Спринта 4 каждая версия относится к конкретной ветке графовой архитектуры.
|
||
Активна одна версия в пределах ветки, не глобально.
|
||
"""
|
||
import logging
|
||
import re
|
||
|
||
from sqlalchemy import func, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from db.models import AgentConfig, Intent
|
||
from services import intent_service
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
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()
|
||
|
||
|
||
async def migrate_legacy_config_to_general_info(session: AsyncSession) -> None:
|
||
"""Одноразовая миграция: старый конфиг без intent_id цепляем к general_info.
|
||
|
||
Он был сохранён как «единый» системный промпт — теперь это стартовый промпт общей
|
||
справочной ветки. Если в 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
|
||
|
||
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("Migrated %d legacy config(s) to general_info", len(orphans))
|
||
|
||
|
||
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())
|
||
|
||
|
||
async def get_config(session: AsyncSession, config_id: int) -> AgentConfig | None:
|
||
return await session.get(AgentConfig, config_id)
|
||
|
||
|
||
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,
|
||
exit_conditions_text: str | None = None,
|
||
) -> AgentConfig:
|
||
"""Создать новую версию в рамках ветки. При 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.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,
|
||
rules_text=rules_text or "",
|
||
exit_conditions_text=(exit_conditions_text or None),
|
||
is_active=activate,
|
||
)
|
||
session.add(cfg)
|
||
await session.commit()
|
||
await session.refresh(cfg)
|
||
return cfg
|
||
|
||
|
||
async def activate_config(session: AsyncSession, config_id: int) -> AgentConfig | None:
|
||
cfg = await session.get(AgentConfig, config_id)
|
||
if cfg is None or cfg.intent_id is None:
|
||
return None
|
||
await session.execute(
|
||
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()
|
||
await session.refresh(cfg)
|
||
return cfg
|
||
|
||
|
||
async def delete_config(session: AsyncSession, config_id: int) -> tuple[bool, str]:
|
||
cfg = await session.get(AgentConfig, config_id)
|
||
if cfg is None:
|
||
return False, "not_found"
|
||
if cfg.is_active:
|
||
return False, "active"
|
||
await session.delete(cfg)
|
||
await session.commit()
|
||
return True, ""
|
||
|
||
|
||
def compose_full_system_prompt(cfg: AgentConfig) -> str:
|
||
"""Склейка системного промпта для модели: каркас + правила + условия выхода.
|
||
|
||
Все три поля редактируются оператором отдельно (с Спринта 6a, блок A2),
|
||
но в LLM улетают одной строкой.
|
||
"""
|
||
base = (cfg.system_prompt or "").strip()
|
||
rules = (cfg.rules_text or "").strip()
|
||
exits = (cfg.exit_conditions_text or "").strip()
|
||
parts = [base] if base else []
|
||
if rules:
|
||
parts.append(f"## Дополнительные правила\n\n{rules}")
|
||
if exits:
|
||
parts.append(f"## Условия выхода (exit conditions)\n\n{exits}")
|
||
return "\n\n".join(parts)
|
||
|
||
|
||
# Регэкспы для одноразовой миграции: ищем заголовок «Условия выхода» в трёх вариантах:
|
||
# (а) markdown-заголовок `## Условия выхода` (с любым уровнем 1–3),
|
||
# (б) жирный `**Условия выхода**` на отдельной строке,
|
||
# (в) просто «Условия выхода:» / «Условия выхода» на отдельной строке.
|
||
# Блок длится до следующего markdown-заголовка или до конца текста.
|
||
_EXITS_HEADER_RE = re.compile(
|
||
r"(?im)^[ \t]*(?:#{1,3}[ \t]*|\*\*\s*)?Условия\s+выхода\b.*?$"
|
||
)
|
||
_NEXT_TOP_HEADER_RE = re.compile(r"(?m)^[ \t]*#{1,3}[ \t]+\S")
|
||
|
||
|
||
def _split_exit_conditions(system_prompt: str) -> tuple[str, str | None]:
|
||
"""Попробовать выделить блок «Условия выхода» из конца промпта.
|
||
|
||
Возвращает (новый_system_prompt, exit_conditions_text_или_None).
|
||
Если блок не нашёлся — возвращает исходный текст и None.
|
||
"""
|
||
if not system_prompt:
|
||
return system_prompt, None
|
||
m = _EXITS_HEADER_RE.search(system_prompt)
|
||
if m is None:
|
||
return system_prompt, None
|
||
|
||
after_header = m.end()
|
||
# Ищем следующий заголовок ПОСЛЕ блока — если есть, обрезаем им; иначе до конца.
|
||
nxt = _NEXT_TOP_HEADER_RE.search(system_prompt, after_header)
|
||
end_of_block = nxt.start() if nxt else len(system_prompt)
|
||
|
||
exits_body = system_prompt[after_header:end_of_block].strip()
|
||
if not exits_body:
|
||
return system_prompt, None
|
||
|
||
new_prompt = (system_prompt[:m.start()] + system_prompt[end_of_block:]).strip()
|
||
return new_prompt, exits_body
|
||
|
||
|
||
async def migrate_exit_conditions_to_field(session: AsyncSession) -> None:
|
||
"""Одноразовая миграция данных: вынуть «Условия выхода» из system_prompt в поле.
|
||
|
||
Идём по всем конфигам, где exit_conditions_text пуст, и пытаемся отрезать блок
|
||
из хвоста system_prompt. Если не нашлось — оставляем как есть.
|
||
"""
|
||
stmt = select(AgentConfig).where(AgentConfig.exit_conditions_text.is_(None))
|
||
cfgs = list((await session.execute(stmt)).scalars().all())
|
||
moved = 0
|
||
for cfg in cfgs:
|
||
new_prompt, exits = _split_exit_conditions(cfg.system_prompt)
|
||
if exits is None:
|
||
continue
|
||
cfg.system_prompt = new_prompt
|
||
cfg.exit_conditions_text = exits
|
||
moved += 1
|
||
if moved:
|
||
await session.commit()
|
||
logger.info("Migrated exit_conditions out of system_prompt for %d config(s)", moved)
|