Files
AR 15 M4 932b488bcb feat(sprint6a): блоки A2, B, C — exit_conditions, handoff_count, suspended/resume
Блок 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>
2026-04-25 12:46:10 +05:00

255 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Версионируемые конфигурации агента, привязанные к ветке (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)