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>
This commit is contained in:
AR 15 M4
2026-04-25 12:46:10 +05:00
parent 9eef2dab3a
commit 932b488bcb
16 changed files with 547 additions and 106 deletions
+1
View File
@@ -29,5 +29,6 @@ class AgentConfig(Base):
name: Mapped[str | None] = mapped_column(String(200), nullable=True)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
rules_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
exit_conditions_text: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
+5
View File
@@ -26,6 +26,11 @@ class ThreadState(Base):
current_step: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
current_step_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
slots_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
handoff_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Состояние прерванного сценария — блок C Спринта 6a (v2 §4.4).
suspended_intent: Mapped[str | None] = mapped_column(String(50), nullable=True)
resumable_step_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
resumable_slots_json: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False
)
+1
View File
@@ -66,6 +66,7 @@ async def lifespan(app: FastAPI):
await intent_service.ensure_seed_intents(session)
await config_service.migrate_legacy_config_to_general_info(session)
await config_service.ensure_seed_configs(session)
await config_service.migrate_exit_conditions_to_field(session)
await intent_step_service.ensure_seed_steps(session)
yield
@@ -0,0 +1,30 @@
"""add exit_conditions_text to agent_configs (Спринт 6a, блок A2)
Revision ID: c7f3d18a45e2
Revises: b5e91c2d07f1
Create Date: 2026-04-25 11:30:00.000000
Условия выхода (`[INTENT_CHANGE: ...]`) выделены из основного промпта в отдельное
поле, чтобы оператор мог править их без риска зацепить каркас. Перенос данных
старых конфигов из system_prompt — в `services/config_service.migrate_exit_conditions_to_field`.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'c7f3d18a45e2'
down_revision: Union[str, None] = 'b5e91c2d07f1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('agent_configs', recreate='always') as batch:
batch.add_column(sa.Column('exit_conditions_text', sa.Text(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('agent_configs', recreate='always') as batch:
batch.drop_column('exit_conditions_text')
@@ -0,0 +1,30 @@
"""add handoff_count to thread_state (Спринт 6a, блок B)
Revision ID: d9b612a04e75
Revises: c7f3d18a45e2
Create Date: 2026-04-25 12:00:00.000000
Счётчик жёстких handoff'ов в рамках одного диалога. По достижении cap (=3 в коде)
оркестратор автоматически уводит диалог в `escalate_human` с reason=routing_loop —
защита от петель маршрутизации (v2 §4.3).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'd9b612a04e75'
down_revision: Union[str, None] = 'c7f3d18a45e2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('thread_state', recreate='always') as batch:
batch.add_column(sa.Column('handoff_count', sa.Integer(), nullable=False, server_default='0'))
def downgrade() -> None:
with op.batch_alter_table('thread_state', recreate='always') as batch:
batch.drop_column('handoff_count')
@@ -0,0 +1,35 @@
"""add suspended_intent / resumable_step_code / resumable_slots_json (Спринт 6a, блок C)
Revision ID: e1a4f7c83b29
Revises: d9b612a04e75
Create Date: 2026-04-25 12:30:00.000000
При hard-handoff из ветки со state machine (через `[INTENT_CHANGE: ...]`) текущий
сценарий запоминается в `suspended_intent` + `resumable_step_code` + `resumable_slots_json`.
Если на следующих репликах роутер возвращает intent = suspended_intent — восстанавливаем
state и очищаем эти поля. v2 §4.4.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'e1a4f7c83b29'
down_revision: Union[str, None] = 'd9b612a04e75'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('thread_state', recreate='always') as batch:
batch.add_column(sa.Column('suspended_intent', sa.String(length=50), nullable=True))
batch.add_column(sa.Column('resumable_step_code', sa.String(length=50), nullable=True))
batch.add_column(sa.Column('resumable_slots_json', sa.Text(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('thread_state', recreate='always') as batch:
batch.drop_column('resumable_slots_json')
batch.drop_column('resumable_step_code')
batch.drop_column('suspended_intent')
+1
View File
@@ -25,6 +25,7 @@ class AgentConfigCreateRequest(BaseModel):
intent_id: int = Field(..., description="ID ветки (intent), к которой относится конфиг")
system_prompt: str = Field(..., min_length=1)
rules_text: str = Field("", description="Правила в свободной markdown-форме")
exit_conditions_text: str = Field("", description="Условия выхода в формате [INTENT_CHANGE: <code>]")
name: str | None = Field(None, max_length=200)
activate: bool = Field(False, description="Сразу сделать новую версию активной в рамках ветки")
+7
View File
@@ -109,6 +109,10 @@ class ThreadStateInfo(BaseModel):
current_step: int = 0
current_step_code: str | None = None
slots: dict = Field(default_factory=dict)
handoff_count: int = 0
suspended_intent: str | None = None
resumable_step_code: str | None = None
resumable_slots: dict = Field(default_factory=dict)
class BounceInfo(BaseModel):
@@ -151,6 +155,8 @@ class ChatResponse(BaseModel):
bounces: list[BounceInfo] = Field(default_factory=list)
validation_events: list[ValidationEventInfo] = Field(default_factory=list)
parse_error: str | None = None
routing_loop_triggered: bool = False
resumed_from_suspended: bool = False
class ThreadDeleteResponse(BaseModel):
@@ -167,6 +173,7 @@ class AgentConfigInfo(BaseModel):
name: str | None = None
system_prompt: str
rules_text: str = ""
exit_conditions_text: str = ""
is_active: bool
created_at: str
+2
View File
@@ -73,4 +73,6 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
bounces=[BounceInfo(**b) for b in result.get("bounces", [])],
validation_events=[ValidationEventInfo(**v) for v in result.get("validation_events", [])],
parse_error=result.get("parse_error"),
routing_loop_triggered=result.get("routing_loop_triggered", False),
resumed_from_suspended=result.get("resumed_from_suspended", False),
)
+2
View File
@@ -28,6 +28,7 @@ def _to_info(cfg: AgentConfig, intent_code: str = "", intent_name: str = "") ->
name=cfg.name,
system_prompt=cfg.system_prompt,
rules_text=cfg.rules_text or "",
exit_conditions_text=cfg.exit_conditions_text or "",
is_active=cfg.is_active,
created_at=cfg.created_at.isoformat(),
)
@@ -97,6 +98,7 @@ async def create_config(
intent_id=req.intent_id,
system_prompt=req.system_prompt,
rules_text=req.rules_text,
exit_conditions_text=req.exit_conditions_text,
name=req.name,
activate=req.activate,
)
+4
View File
@@ -59,6 +59,10 @@ async def get_thread(thread_id: int, session: AsyncSession = Depends(get_session
current_step=state.get("current_step", 0),
current_step_code=state.get("current_step_code"),
slots=state.get("slots", {}),
handoff_count=state.get("handoff_count", 0),
suspended_intent=state.get("suspended_intent"),
resumable_step_code=state.get("resumable_step_code"),
resumable_slots=state.get("resumable_slots", {}),
),
)
+179 -14
View File
@@ -16,7 +16,13 @@ logger = logging.getLogger(__name__)
HISTORY_LIMIT = 20
FALLBACK_INTENT_CODE = "general_info"
ESCALATE_INTENT_CODE = "escalate_human"
MAX_BOUNCES = 1
HANDOFF_CAP = 3 # столько hard-handoff'ов разрешено за диалог; четвёртое — авто-эскалация
ROUTING_LOOP_REPLY = (
"Уточню детали с администратором клиники, свяжемся с вами "
"в течение ближайшего часа."
)
def _auto_thread_name(first_user_text: str) -> str:
@@ -138,17 +144,46 @@ async def send_message(
router_code = routing["code"]
router_version = routing.get("version")
# 2. Снимок состояния. Важное правило (sticky state machine, мини-G из Спринта 6b):
# если тред уже идёт по state-machine-ветке и роутер предлагает другую —
# НЕ сбрасываем state. Передадим LLM подсказку «роутер думает так», и пусть
# она сама решает: выдать `[INTENT_CHANGE: ...]` или удержать сценарий.
# Это нужно, чтобы фраза-повод («болит ухо») внутри записи не сбрасывала слоты.
# 2. Снимок состояния. Логика выбора effective_code:
# 2.1. Если есть suspended_intent и роутер вернулся в него — RESUME: восстанавливаем
# прерванный сценарий, очищаем suspended_*, handoff_count=0.
# 2.2. Иначе если диалог идёт по sm-ветке и роутер предлагает другую — sticky:
# НЕ сбрасываем state, передаём LLM [ПОДСКАЗКА РОУТЕРА].
# 2.3. Иначе если prev — не-sm и роутер ведёт в другую ветку — hard-handoff.
snapshot = await thread_state_service.load_snapshot(session, thread.id)
prev_intent_code = snapshot["current_intent_code"]
handoff_count = snapshot.get("handoff_count", 0)
suspended_intent = snapshot.get("suspended_intent")
resumable_step_code = snapshot.get("resumable_step_code")
resumable_slots = snapshot.get("resumable_slots", {}) or {}
router_hint: str | None = None
effective_code = router_code
routing_loop_triggered = False
resumed_from_suspended = False
if prev_intent_code and prev_intent_code != router_code:
if suspended_intent and suspended_intent == router_code and prev_intent_code != suspended_intent:
logger.info(
"Resume from suspended in thread %d: %s (step=%s, %d slots)",
thread.id, suspended_intent, resumable_step_code, len(resumable_slots),
)
snapshot = {
"current_intent_code": suspended_intent,
"current_step": 0,
"current_step_code": resumable_step_code,
"slots": dict(resumable_slots),
"handoff_count": 0,
"suspended_intent": None,
"resumable_step_code": None,
"resumable_slots": {},
}
prev_intent_code = suspended_intent
handoff_count = 0
suspended_intent = None
resumable_step_code = None
resumable_slots = {}
effective_code = snapshot["current_intent_code"]
resumed_from_suspended = True
elif prev_intent_code and prev_intent_code != router_code:
if intent_step_service.has_state_machine(prev_intent_code):
logger.info(
"Router suggested %s but thread %d is in sm %s — sticky, hint only",
@@ -164,17 +199,51 @@ async def send_message(
)
effective_code = prev_intent_code
else:
# Реальный hard-handoff: prev — не sm-ветка, роутер ведёт.
logger.info(
"Router switched intent for thread %d: %s%s (state reset)",
thread.id, prev_intent_code, router_code,
)
handoff_count += 1
snapshot = {
"current_intent_code": router_code,
"current_step": 0,
"current_step_code": None,
"slots": {},
"handoff_count": handoff_count,
# suspended_* не трогаем — там может лежать прерванная sm-ветка,
# к которой пациент ещё захочет вернуться.
"suspended_intent": suspended_intent,
"resumable_step_code": resumable_step_code,
"resumable_slots": resumable_slots,
}
# 2b. Защита от петли (v2 §4.3): если за диалог накопилось много handoff'ов и
# сейчас ещё одно переключение — забираем диалог в escalate_human с заглушкой,
# без вызова LLM. После авто-эскалации сбрасываем handoff_count и suspended_*
# (диалог переходит к оператору, прерванный сценарий не продолжаем).
if handoff_count > HANDOFF_CAP and effective_code != ESCALATE_INTENT_CODE:
logger.warning(
"Routing loop guard tripped for thread %d (handoff_count=%d), forcing %s",
thread.id, handoff_count, ESCALATE_INTENT_CODE,
)
effective_code = ESCALATE_INTENT_CODE
snapshot = {
"current_intent_code": ESCALATE_INTENT_CODE,
"current_step": 0,
"current_step_code": None,
"slots": {},
"handoff_count": 0,
"suspended_intent": None,
"resumable_step_code": None,
"resumable_slots": {},
}
handoff_count = 0
suspended_intent = None
resumable_step_code = None
resumable_slots = {}
routing_loop_triggered = True
# 3. Разрешаем ветку (с fallback) и шаг.
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, effective_code)
if served_code != effective_code:
@@ -183,8 +252,18 @@ async def send_message(
"current_step": 0,
"current_step_code": None,
"slots": {},
"handoff_count": handoff_count,
"suspended_intent": suspended_intent,
"resumable_step_code": resumable_step_code,
"resumable_slots": resumable_slots,
}
router_hint = None
# Финализируем snapshot.current_intent_code на served_code: для не-sm-веток
# (general_info / price_question / ...) state_update от LLM не приходит, и без
# этого snapshot["current_intent_code"] осталось бы None для нового треда —
# тогда на следующей реплике prev_intent_code не определится и handoff_count
# не инкрементится.
snapshot["current_intent_code"] = served_code
retrieved = vectorstore.query(query_text=text, top_k=top_k)
sources = _retrieved_to_sources(retrieved)
@@ -196,6 +275,15 @@ async def send_message(
parse_error: str | None = None
is_state_machine = False
# Если уже сработала защита от петли — не зовём LLM, формируем заглушку.
if routing_loop_triggered:
visible_text = ROUTING_LOOP_REPLY
last_assembled_prompt = (
"[ROUTING LOOP GUARD]\n"
f"handoff_count превысил {HANDOFF_CAP}, диалог автоматически уведён в "
f"{ESCALATE_INTENT_CODE}. LLM не вызывался."
)
else:
for attempt in range(MAX_BOUNCES + 1):
current_step = await _resolve_current_step(
session, intent.id, served_code, snapshot.get("current_step_code"),
@@ -220,8 +308,8 @@ async def send_message(
last_assembled_prompt = llm_result["assembled_prompt"]
parsed = parse_branch_response(llm_result["text"])
visible_text = parsed["visible_text"] or llm_result["text"]
# STATE_JSON-блок ждём только от state-machine-веток. У остальных (general_info,
# price_question и т.п.) «no STATE_JSON» — ожидаемое состояние, не ошибка.
# STATE_JSON-блок ждём только от state-machine-веток. У остальных
# «no STATE_JSON» — ожидаемое состояние, не ошибка.
parse_error = parsed["parse_error"] if is_state_machine else None
if parsed["intent_change"] and attempt < MAX_BOUNCES:
@@ -231,13 +319,71 @@ async def send_message(
"to": new_code,
"preface": parsed["visible_text"],
})
logger.info("Intent bounce in thread %d: %s%s", thread.id, served_code, new_code)
logger.info(
"Intent bounce in thread %d: %s%s", thread.id, served_code, new_code,
)
# Если уходим из sm-ветки и suspended_* ещё свободно — запоминаем,
# чтобы вернуться к прерванному сценарию, когда роутер увидит,
# что пациент возвращается к теме (см. блок 2.1 в начале send_message).
if (
is_state_machine
and current_step is not None
and not suspended_intent
and new_code != served_code
):
suspended_intent = served_code
resumable_step_code = current_step.code
resumable_slots = dict(snapshot.get("slots", {}))
logger.info(
"Suspending sm scenario for thread %d: %s (step=%s, %d slots)",
thread.id, suspended_intent, resumable_step_code, len(resumable_slots),
)
handoff_count += 1
# Защита от петли работает и здесь — на bouncing'е.
if handoff_count > HANDOFF_CAP and new_code != ESCALATE_INTENT_CODE:
logger.warning(
"Routing loop guard tripped on bounce in thread %d (handoff_count=%d)",
thread.id, handoff_count,
)
served_code, intent, active_cfg = await _resolve_intent_with_fallback(
session, ESCALATE_INTENT_CODE,
)
snapshot = {
"current_intent_code": served_code,
"current_step": 0,
"current_step_code": None,
"slots": {},
"handoff_count": 0,
"suspended_intent": None,
"resumable_step_code": None,
"resumable_slots": {},
}
handoff_count = 0
suspended_intent = None
resumable_step_code = None
resumable_slots = {}
visible_text = ROUTING_LOOP_REPLY
last_assembled_prompt = (
"[ROUTING LOOP GUARD]\n"
f"handoff_count превысил {HANDOFF_CAP} на bouncing'е, "
f"диалог автоматически уведён в {ESCALATE_INTENT_CODE}."
)
routing_loop_triggered = True
parse_error = None
is_state_machine = False
parsed = {"visible_text": visible_text, "intent_change": None, "state_update": None, "parse_error": None}
break
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, new_code)
snapshot = {
"current_intent_code": served_code,
"current_step": 0,
"current_step_code": None,
"slots": {},
"handoff_count": handoff_count,
"suspended_intent": suspended_intent,
"resumable_step_code": resumable_step_code,
"resumable_slots": resumable_slots,
}
router_hint = None # новая ветка — подсказка больше неактуальна
continue
@@ -252,12 +398,19 @@ async def send_message(
)
slots_updated = parsed["state_update"]["slots_updated"]
merged_slots = {**snapshot.get("slots", {}), **slots_updated}
base_state = {
"current_intent_code": served_code,
"slots": merged_slots,
"handoff_count": handoff_count,
"suspended_intent": suspended_intent,
"resumable_step_code": resumable_step_code,
"resumable_slots": resumable_slots,
}
if ok:
snapshot = {
"current_intent_code": served_code,
**base_state,
"current_step": snapshot["current_step"] + (1 if requested != current_step.code else 0),
"current_step_code": requested,
"slots": merged_slots,
}
else:
logger.warning(
@@ -270,10 +423,9 @@ async def send_message(
})
# Слоты всё равно мёржим (информация полезная), шаг не двигаем.
snapshot = {
"current_intent_code": served_code,
**base_state,
"current_step": snapshot["current_step"],
"current_step_code": current_step.code,
"slots": merged_slots,
}
elif parsed["state_update"] is None and current_step is not None and parse_error:
logger.warning(
@@ -289,6 +441,10 @@ async def send_message(
step=snapshot["current_step"],
step_code=snapshot.get("current_step_code"),
slots=snapshot["slots"],
handoff_count=snapshot.get("handoff_count", handoff_count),
suspended_intent=snapshot.get("suspended_intent"),
resumable_step_code=snapshot.get("resumable_step_code"),
resumable_slots=snapshot.get("resumable_slots"),
)
user_msg.intent_id = intent.id
@@ -312,12 +468,15 @@ async def send_message(
await session.refresh(thread)
logger.info(
"Chat: thread=%d, router=%s, served=%s (v%d), step=%s, slots=%d keys, bounces=%d, validation_events=%d",
"Chat: thread=%d, router=%s, served=%s (v%d), step=%s, slots=%d keys, "
"bounces=%d, validation=%d, handoff=%d, routing_loop=%s",
thread.id, router_code, served_code, active_cfg.version,
snapshot.get("current_step_code") or "-",
len(snapshot["slots"]),
len(bounce_log),
len(validation_events),
snapshot.get("handoff_count", 0),
routing_loop_triggered,
)
return {
@@ -338,10 +497,16 @@ async def send_message(
"current_step": snapshot["current_step"],
"current_step_code": snapshot.get("current_step_code"),
"slots": snapshot["slots"],
"handoff_count": snapshot.get("handoff_count", handoff_count),
"suspended_intent": snapshot.get("suspended_intent"),
"resumable_step_code": snapshot.get("resumable_step_code"),
"resumable_slots": snapshot.get("resumable_slots", {}),
},
"bounces": bounce_log,
"validation_events": validation_events,
"parse_error": parse_error,
"routing_loop_triggered": routing_loop_triggered,
"resumed_from_suspended": resumed_from_suspended,
}
+72 -3
View File
@@ -4,6 +4,7 @@
Активна одна версия в пределах ветки, не глобально.
"""
import logging
import re
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -124,6 +125,7 @@ async def create_config(
rules_text: str,
name: str | None = None,
activate: bool = False,
exit_conditions_text: str | None = None,
) -> AgentConfig:
"""Создать новую версию в рамках ветки. При activate=True — сразу активна в этой ветке."""
next_version = (await session.execute(
@@ -143,6 +145,7 @@ async def create_config(
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)
@@ -178,8 +181,74 @@ async def delete_config(session: AsyncSession, config_id: int) -> tuple[bool, st
def compose_full_system_prompt(cfg: AgentConfig) -> str:
"""Склейка системного промпта для модели: каркас + правила + условия выхода.
Все три поля редактируются оператором отдельно (с Спринта 6a, блок A2),
но в LLM улетают одной строкой.
"""
base = (cfg.system_prompt or "").strip()
rules = (cfg.rules_text or "").strip()
if not rules:
return base
return f"{base}\n\nДополнительные правила:\n{rules}"
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)
+35 -1
View File
@@ -30,7 +30,7 @@ def _parse_slots(raw: str) -> dict:
async def load_snapshot(session: AsyncSession, thread_id: int) -> dict:
"""Удобный снимок состояния для чтения (intent, step_code, step, slots)."""
"""Снимок состояния диалога: текущая ветка/шаг/слоты + handoff_count + suspended_*."""
state = await get_state(session, thread_id)
if state is None:
return {
@@ -38,12 +38,28 @@ async def load_snapshot(session: AsyncSession, thread_id: int) -> dict:
"current_step": 0,
"current_step_code": None,
"slots": {},
"handoff_count": 0,
"suspended_intent": None,
"resumable_step_code": None,
"resumable_slots": {},
}
resumable_slots = {}
if state.resumable_slots_json:
try:
value = json.loads(state.resumable_slots_json)
if isinstance(value, dict):
resumable_slots = value
except json.JSONDecodeError:
logger.warning("Bad resumable_slots_json for thread_state, ignoring")
return {
"current_intent_code": state.current_intent_code,
"current_step": state.current_step,
"current_step_code": state.current_step_code,
"slots": _parse_slots(state.slots_json),
"handoff_count": state.handoff_count,
"suspended_intent": state.suspended_intent,
"resumable_step_code": state.resumable_step_code,
"resumable_slots": resumable_slots,
}
@@ -55,11 +71,20 @@ async def upsert(
step: int,
slots: dict,
step_code: str | None = None,
handoff_count: int = 0,
suspended_intent: str | None = None,
resumable_step_code: str | None = None,
resumable_slots: dict | None = None,
) -> ThreadState:
"""Создать или обновить состояние треда. Коммит — на совести вызывающего."""
state = await get_state(session, thread_id)
now = datetime.now(timezone.utc)
slots_raw = json.dumps(slots or {}, ensure_ascii=False)
resumable_raw = (
json.dumps(resumable_slots, ensure_ascii=False)
if resumable_slots is not None and len(resumable_slots) > 0
else None
)
if state is None:
state = ThreadState(
thread_id=thread_id,
@@ -67,6 +92,10 @@ async def upsert(
current_step=step,
current_step_code=step_code,
slots_json=slots_raw,
handoff_count=handoff_count,
suspended_intent=suspended_intent,
resumable_step_code=resumable_step_code,
resumable_slots_json=resumable_raw,
updated_at=now,
)
session.add(state)
@@ -75,6 +104,10 @@ async def upsert(
state.current_step = step
state.current_step_code = step_code
state.slots_json = slots_raw
state.handoff_count = handoff_count
state.suspended_intent = suspended_intent
state.resumable_step_code = resumable_step_code
state.resumable_slots_json = resumable_raw
state.updated_at = now
return state
@@ -90,4 +123,5 @@ async def reset(
return await upsert(
session, thread_id,
intent_code=new_intent_code, step=0, step_code=new_step_code, slots={},
handoff_count=0,
)
+26 -5
View File
@@ -618,7 +618,7 @@ async function openThread(id) {
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
if (lastAssistant) {
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null, [], d.thread_state && d.thread_state.current_step_code);
renderState(d.thread_state, [], [], null);
renderState(d.thread_state, [], [], null, false, false);
} else {
clearDebug();
}
@@ -673,12 +673,33 @@ function appendMessage(role, text, iso, intentCode, intentName) {
}
/* ---------- отладка ---------- */
function renderState(state, bounces, validationEvents, parseError) {
function renderState(state, bounces, validationEvents, parseError, routingLoopTriggered, resumedFromSuspended) {
const box = $("debug-state");
if (!state || !state.current_intent_code) {
box.innerHTML = '<div class="mini">сценарий ещё не запущен</div>';
return;
}
const handoff = Number(state.handoff_count || 0);
const handoffHtml = `
<div style="margin-top:6px;font-size:11px;color:var(--muted);">
переключений ветки в диалоге: <b style="color:var(--fg);">${handoff}</b>
</div>`;
const loopHtml = routingLoopTriggered
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fee2e2;color:#7f1d1d;font-size:11px;">
🛑 защита от петли сработала: диалог уведён в <code>escalate_human</code>.
</div>`
: "";
const suspendedSlotsCount = Object.keys(state.resumable_slots || {}).length;
const suspendedHtml = state.suspended_intent
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#eff6ff;color:#1e3a8a;font-size:11px;">
📌 отложен сценарий: <code>${esc(state.suspended_intent)}</code>${state.resumable_step_code ? ' (шаг <code>' + esc(state.resumable_step_code) + '</code>)' : ''}, слотов: <b>${suspendedSlotsCount}</b>. Вернёмся, когда пациент возвратится к этой теме.
</div>`
: "";
const resumedHtml = resumedFromSuspended
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#ecfdf5;color:#065f46;font-size:11px;">
↩️ возврат к отложенному сценарию: восстановили шаг и слоты.
</div>`
: "";
const bounceHtml = (bounces && bounces.length)
? `<div style="margin-top:8px;font-size:11px;">
<div style="color:var(--muted);margin-bottom:3px;">переходы в этой реплике:</div>
@@ -705,7 +726,7 @@ function renderState(state, bounces, validationEvents, parseError) {
<b>${esc(state.current_intent_code)}</b>
<span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span>
</div>
${bounceHtml}${validationHtml}${parseErrorHtml}
${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
return;
@@ -716,7 +737,7 @@ function renderState(state, bounces, validationEvents, parseError) {
<div style="font-size:12px;">
<div><b>${esc(state.current_intent_code)}</b> · шаг <code>${esc(state.current_step_code)}</code></div>
<div class="prompt-box" style="margin-top:6px;max-height:200px;">${esc(slotsJson)}</div>
${bounceHtml}${validationHtml}${parseErrorHtml}
${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
}
@@ -827,7 +848,7 @@ async function sendMessage() {
$("chat-title").className = "chat-title";
$("chat-title").textContent = r.thread_name;
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version, r.router_intent_code, r.bounces, r.thread_state && r.thread_state.current_step_code);
renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error);
renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended);
refreshThreads();
} catch (e) {
// Откатываем визуально: убираем пузырь-заглушку ассистента и только что
+46 -12
View File
@@ -677,22 +677,32 @@ function switchTab(tab) {
renderEditor();
}
function toggleRulesHint(force) {
const pop = document.getElementById("rules-hint-popover");
const btn = document.getElementById("rules-hint-btn");
function toggleHint(key, force) {
const pop = document.getElementById(`${key}-hint-popover`);
const btn = document.getElementById(`${key}-hint-btn`);
if (!pop || !btn) return;
const willShow = typeof force === "boolean" ? force : !pop.classList.contains("show");
// Закрываем все остальные открытые подсказки — чтобы не накладывались.
document.querySelectorAll(".hint-popover.show").forEach(p => {
if (p !== pop) p.classList.remove("show");
});
document.querySelectorAll(".hint-btn.active").forEach(b => {
if (b !== btn) b.classList.remove("active");
});
pop.classList.toggle("show", willShow);
btn.classList.toggle("active", willShow);
}
// Клик вне popover-а — закрываем.
// Клик вне любого popover-а — закрываем все.
document.addEventListener("click", (e) => {
const pop = document.getElementById("rules-hint-popover");
const btn = document.getElementById("rules-hint-btn");
if (!pop || !btn || !pop.classList.contains("show")) return;
if (pop.contains(e.target) || btn.contains(e.target)) return;
toggleRulesHint(false);
const opened = document.querySelectorAll(".hint-popover.show");
if (!opened.length) return;
for (const pop of opened) {
const btn = document.getElementById(pop.id.replace("-popover", "-btn"));
if (pop.contains(e.target) || (btn && btn.contains(e.target))) return;
}
document.querySelectorAll(".hint-popover.show").forEach(p => p.classList.remove("show"));
document.querySelectorAll(".hint-btn.active").forEach(b => b.classList.remove("active"));
});
function renderPromptPanel(intent) {
@@ -708,10 +718,10 @@ function renderPromptPanel(intent) {
<div class="field">
<label for="f-rules" class="with-hint">
<span>Правила (дополнение к промпту; свободная markdown-форма)</span>
<button type="button" class="hint-btn" id="rules-hint-btn" onclick="toggleRulesHint()" aria-label="Подсказка">i</button>
<button type="button" class="hint-btn" id="rules-hint-btn" onclick="toggleHint('rules')" aria-label="Подсказка">i</button>
</label>
<div class="hint-popover" id="rules-hint-popover">
<button type="button" class="hint-close" onclick="toggleRulesHint(false)" aria-label="Закрыть">×</button>
<button type="button" class="hint-close" onclick="toggleHint('rules', false)" aria-label="Закрыть">×</button>
<h4>Что писать в «Правила»</h4>
<p>Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.</p>
<p><b>Что нормально писать:</b></p>
@@ -721,10 +731,29 @@ function renderPromptPanel(intent) {
<li>«После 19:00 предлагай только следующий рабочий день».</li>
<li>«Дети до 14 лет — сразу <code>[INTENT_CHANGE: escalate_human]</code>, у нас нет педиатра».</li>
</ul>
<p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода (<code>[INTENT_CHANGE: ...]</code>) пока тоже в основном промпте, в Спринте 6a выделим в отдельное поле.</p>
<p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода отдельное поле ниже.</p>
</div>
<textarea id="f-rules" class="rules" spellcheck="false"></textarea>
</div>
<div class="field">
<label for="f-exits" class="with-hint">
<span>Условия выхода (когда отдать управление другой ветке)</span>
<button type="button" class="hint-btn" id="exits-hint-btn" onclick="toggleHint('exits')" aria-label="Подсказка">i</button>
</label>
<div class="hint-popover" id="exits-hint-popover">
<button type="button" class="hint-close" onclick="toggleHint('exits', false)" aria-label="Закрыть">×</button>
<h4>Что писать в «Условия выхода»</h4>
<p>Список ситуаций, когда ветка должна вместо обычного ответа выдать служебную строку <code>[INTENT_CHANGE: &lt;код_ветки&gt;]</code> и передать диалог другой ветке. Пишется в свободной markdown-форме, склеивается с системным промптом перед отправкой в модель.</p>
<p><b>Примеры:</b></p>
<ul>
<li>«Пациент описывает острое состояние (сильная боль, кровотечение, одышка) → <code>[INTENT_CHANGE: escalate_human]</code>».</li>
<li>«Спрашивает про цены, ДМС, оплату → <code>[INTENT_CHANGE: price_question]</code>».</li>
<li>«Просит соединить с оператором → <code>[INTENT_CHANGE: escalate_human]</code>».</li>
</ul>
<p><b>Не нужно:</b> правила для штатного хода диалога — это в «Правила». Тут только переключения между ветками.</p>
</div>
<textarea id="f-exits" class="rules" spellcheck="false"></textarea>
</div>
<div class="editor-actions">
<button onclick="saveVersion()">Сохранить как новую версию</button>
<button class="secondary" onclick="loadActiveIntoEditor()">Перезагрузить активную</button>
@@ -840,6 +869,7 @@ async function loadActiveIntoEditor() {
$("f-name").value = "";
$("f-prompt").value = "";
$("f-rules").value = "";
if ($("f-exits")) $("f-exits").value = "";
}
return;
}
@@ -848,6 +878,7 @@ async function loadActiveIntoEditor() {
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt;
$("f-rules").value = c.rules_text || "";
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
} catch (e) {
toast("Не удалось загрузить активную: " + e.message, "err");
}
@@ -859,6 +890,7 @@ function loadIntoEditor(configId) {
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt;
$("f-rules").value = c.rules_text || "";
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
toast(`Загружена v${c.version}`);
window.scrollTo({ top: 0, behavior: "smooth" });
}
@@ -907,6 +939,7 @@ async function saveVersion() {
const name = $("f-name").value.trim();
const system_prompt = $("f-prompt").value.trim();
const rules_text = $("f-rules").value.trim();
const exit_conditions_text = $("f-exits") ? $("f-exits").value.trim() : "";
const activate = $("chk-activate").checked;
if (!system_prompt) {
@@ -923,6 +956,7 @@ async function saveVersion() {
name: name || null,
system_prompt,
rules_text,
exit_conditions_text,
activate,
}),
});