From 932b488bcbe0c8f4fc4d3fc6cb3d6397381e181f Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Sat, 25 Apr 2026 12:46:10 +0500 Subject: [PATCH] =?UTF-8?q?feat(sprint6a):=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20A2,=20B,=20C=20=E2=80=94=20exit=5Fconditions,=20handof?= =?UTF-8?q?f=5Fcount,=20suspended/resume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Блок 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) --- db/models/agent_config.py | 1 + db/models/thread_state.py | 5 + main.py | 1 + .../c7f3d18a45e2_add_exit_conditions_text.py | 30 ++ .../d9b612a04e75_add_handoff_count.py | 30 ++ .../e1a4f7c83b29_add_suspended_state.py | 35 ++ models/requests.py | 1 + models/responses.py | 7 + routers/chat.py | 2 + routers/configs.py | 2 + routers/threads.py | 4 + services/chat_service.py | 335 +++++++++++++----- services/config_service.py | 75 +++- services/thread_state_service.py | 36 +- static/sandbox.html | 31 +- static/settings.html | 58 ++- 16 files changed, 547 insertions(+), 106 deletions(-) create mode 100644 migrations/versions/c7f3d18a45e2_add_exit_conditions_text.py create mode 100644 migrations/versions/d9b612a04e75_add_handoff_count.py create mode 100644 migrations/versions/e1a4f7c83b29_add_suspended_state.py diff --git a/db/models/agent_config.py b/db/models/agent_config.py index a744596..5a8b71d 100644 --- a/db/models/agent_config.py +++ b/db/models/agent_config.py @@ -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) diff --git a/db/models/thread_state.py b/db/models/thread_state.py index 4c936d2..315f922 100644 --- a/db/models/thread_state.py +++ b/db/models/thread_state.py @@ -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 ) diff --git a/main.py b/main.py index 7f6c91a..b233b2f 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/migrations/versions/c7f3d18a45e2_add_exit_conditions_text.py b/migrations/versions/c7f3d18a45e2_add_exit_conditions_text.py new file mode 100644 index 0000000..8e1ac52 --- /dev/null +++ b/migrations/versions/c7f3d18a45e2_add_exit_conditions_text.py @@ -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') diff --git a/migrations/versions/d9b612a04e75_add_handoff_count.py b/migrations/versions/d9b612a04e75_add_handoff_count.py new file mode 100644 index 0000000..55e6a10 --- /dev/null +++ b/migrations/versions/d9b612a04e75_add_handoff_count.py @@ -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') diff --git a/migrations/versions/e1a4f7c83b29_add_suspended_state.py b/migrations/versions/e1a4f7c83b29_add_suspended_state.py new file mode 100644 index 0000000..24dffe1 --- /dev/null +++ b/migrations/versions/e1a4f7c83b29_add_suspended_state.py @@ -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') diff --git a/models/requests.py b/models/requests.py index addd217..ff426c8 100644 --- a/models/requests.py +++ b/models/requests.py @@ -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: ]") name: str | None = Field(None, max_length=200) activate: bool = Field(False, description="Сразу сделать новую версию активной в рамках ветки") diff --git a/models/responses.py b/models/responses.py index e185be6..d928cbe 100644 --- a/models/responses.py +++ b/models/responses.py @@ -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 diff --git a/routers/chat.py b/routers/chat.py index 0e4d1ff..177609e 100644 --- a/routers/chat.py +++ b/routers/chat.py @@ -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), ) diff --git a/routers/configs.py b/routers/configs.py index 9c61123..51b0e16 100644 --- a/routers/configs.py +++ b/routers/configs.py @@ -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, ) diff --git a/routers/threads.py b/routers/threads.py index 0fd90d4..ac10fbe 100644 --- a/routers/threads.py +++ b/routers/threads.py @@ -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", {}), ), ) diff --git a/services/chat_service.py b/services/chat_service.py index 07dd0f8..0a6e7cf 100644 --- a/services/chat_service.py +++ b/services/chat_service.py @@ -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,91 +275,164 @@ async def send_message( parse_error: str | None = None is_state_machine = False - for attempt in range(MAX_BOUNCES + 1): - current_step = await _resolve_current_step( - session, intent.id, served_code, snapshot.get("current_step_code"), + # Если уже сработала защита от петли — не зовём 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 не вызывался." ) - is_state_machine = current_step is not None - if current_step is not None and snapshot.get("current_step_code") != current_step.code: - snapshot["current_step_code"] = current_step.code - - base_prompt = config_service.compose_full_system_prompt(active_cfg) - step_prompt = f"\n\n{current_step.system_prompt}" if current_step else "" - state_context = _format_state_context(snapshot, current_step, router_hint) - system_prompt = base_prompt + step_prompt + state_context - - llm_result = await llm.chat( - question=text, - sources=retrieved, - history=history, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=max_tokens, - ) - 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» — ожидаемое состояние, не ошибка. - parse_error = parsed["parse_error"] if is_state_machine else None - - if parsed["intent_change"] and attempt < MAX_BOUNCES: - new_code = parsed["intent_change"] - bounce_log.append({ - "from": served_code, - "to": new_code, - "preface": parsed["visible_text"], - }) - logger.info("Intent bounce in thread %d: %s → %s", thread.id, served_code, new_code) - 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": {}, - } - router_hint = None # новая ветка — подсказка больше неактуальна - continue - - if parsed["state_update"] is not None and current_step is not None: - requested = parsed["state_update"]["state_after"] - allowed = intent_step_service.parse_allowed_next(current_step) - ok, reason = validate_transition( - current_step=current_step.code, - requested_step=requested, - allowed_next=allowed, + else: + for attempt in range(MAX_BOUNCES + 1): + current_step = await _resolve_current_step( + session, intent.id, served_code, snapshot.get("current_step_code"), ) - slots_updated = parsed["state_update"]["slots_updated"] - merged_slots = {**snapshot.get("slots", {}), **slots_updated} - if ok: - snapshot = { - "current_intent_code": served_code, - "current_step": snapshot["current_step"] + (1 if requested != current_step.code else 0), - "current_step_code": requested, - "slots": merged_slots, - } - else: - logger.warning( - "Illegal state_after in thread %d (%s): %s", thread.id, served_code, reason, - ) - validation_events.append({ - "current_step": current_step.code, - "requested_step": requested, - "reason": reason, + is_state_machine = current_step is not None + if current_step is not None and snapshot.get("current_step_code") != current_step.code: + snapshot["current_step_code"] = current_step.code + + base_prompt = config_service.compose_full_system_prompt(active_cfg) + step_prompt = f"\n\n{current_step.system_prompt}" if current_step else "" + state_context = _format_state_context(snapshot, current_step, router_hint) + system_prompt = base_prompt + step_prompt + state_context + + llm_result = await llm.chat( + question=text, + sources=retrieved, + history=history, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens, + ) + 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-веток. У остальных + # «no STATE_JSON» — ожидаемое состояние, не ошибка. + parse_error = parsed["parse_error"] if is_state_machine else None + + if parsed["intent_change"] and attempt < MAX_BOUNCES: + new_code = parsed["intent_change"] + bounce_log.append({ + "from": served_code, + "to": new_code, + "preface": parsed["visible_text"], }) - # Слоты всё равно мёржим (информация полезная), шаг не двигаем. + 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": snapshot["current_step"], - "current_step_code": current_step.code, - "slots": merged_slots, + "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, } - elif parsed["state_update"] is None and current_step is not None and parse_error: - logger.warning( - "State machine branch %s returned no STATE_JSON: %s", served_code, parse_error, - ) + router_hint = None # новая ветка — подсказка больше неактуальна + continue - break + if parsed["state_update"] is not None and current_step is not None: + requested = parsed["state_update"]["state_after"] + allowed = intent_step_service.parse_allowed_next(current_step) + ok, reason = validate_transition( + current_step=current_step.code, + requested_step=requested, + allowed_next=allowed, + ) + 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 = { + **base_state, + "current_step": snapshot["current_step"] + (1 if requested != current_step.code else 0), + "current_step_code": requested, + } + else: + logger.warning( + "Illegal state_after in thread %d (%s): %s", thread.id, served_code, reason, + ) + validation_events.append({ + "current_step": current_step.code, + "requested_step": requested, + "reason": reason, + }) + # Слоты всё равно мёржим (информация полезная), шаг не двигаем. + snapshot = { + **base_state, + "current_step": snapshot["current_step"], + "current_step_code": current_step.code, + } + elif parsed["state_update"] is None and current_step is not None and parse_error: + logger.warning( + "State machine branch %s returned no STATE_JSON: %s", served_code, parse_error, + ) + + break # 4. Сохраняем: thread_state пишется ПОСЛЕ всей логики, коммит — единой транзакцией. await thread_state_service.upsert( @@ -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, } diff --git a/services/config_service.py b/services/config_service.py index 5ba847e..1387e6b 100644 --- a/services/config_service.py +++ b/services/config_service.py @@ -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) diff --git a/services/thread_state_service.py b/services/thread_state_service.py index 1b2e888..1269f5b 100644 --- a/services/thread_state_service.py +++ b/services/thread_state_service.py @@ -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, ) diff --git a/static/sandbox.html b/static/sandbox.html index 020824a..4f25784 100644 --- a/static/sandbox.html +++ b/static/sandbox.html @@ -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 = '
сценарий ещё не запущен
'; return; } + const handoff = Number(state.handoff_count || 0); + const handoffHtml = ` +
+ переключений ветки в диалоге: ${handoff} +
`; + const loopHtml = routingLoopTriggered + ? `
+ 🛑 защита от петли сработала: диалог уведён в escalate_human. +
` + : ""; + const suspendedSlotsCount = Object.keys(state.resumable_slots || {}).length; + const suspendedHtml = state.suspended_intent + ? `
+ 📌 отложен сценарий: ${esc(state.suspended_intent)}${state.resumable_step_code ? ' (шаг ' + esc(state.resumable_step_code) + ')' : ''}, слотов: ${suspendedSlotsCount}. Вернёмся, когда пациент возвратится к этой теме. +
` + : ""; + const resumedHtml = resumedFromSuspended + ? `
+ ↩️ возврат к отложенному сценарию: восстановили шаг и слоты. +
` + : ""; const bounceHtml = (bounces && bounces.length) ? `
переходы в этой реплике:
@@ -705,7 +726,7 @@ function renderState(state, bounces, validationEvents, parseError) { ${esc(state.current_intent_code)} — без пошагового сценария
- ${bounceHtml}${validationHtml}${parseErrorHtml} + ${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml} `; return; @@ -716,7 +737,7 @@ function renderState(state, bounces, validationEvents, parseError) {
${esc(state.current_intent_code)} · шаг ${esc(state.current_step_code)}
${esc(slotsJson)}
- ${bounceHtml}${validationHtml}${parseErrorHtml} + ${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
`; } @@ -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) { // Откатываем визуально: убираем пузырь-заглушку ассистента и только что diff --git a/static/settings.html b/static/settings.html index e01f8e4..ee6aba3 100644 --- a/static/settings.html +++ b/static/settings.html @@ -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) {
- +

Что писать в «Правила»

Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.

Что нормально писать:

@@ -721,10 +731,29 @@ function renderPromptPanel(intent) {
  • «После 19:00 предлагай только следующий рабочий день».
  • «Дети до 14 лет — сразу [INTENT_CHANGE: escalate_human], у нас нет педиатра».
  • -

    Что сюда не стоит: изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода ([INTENT_CHANGE: ...]) пока тоже в основном промпте, в Спринте 6a выделим в отдельное поле.

    +

    Что сюда не стоит: изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода — отдельное поле ниже.

    +
    + +
    + +

    Что писать в «Условия выхода»

    +

    Список ситуаций, когда ветка должна вместо обычного ответа выдать служебную строку [INTENT_CHANGE: <код_ветки>] и передать диалог другой ветке. Пишется в свободной markdown-форме, склеивается с системным промптом перед отправкой в модель.

    +

    Примеры:

    +
      +
    • «Пациент описывает острое состояние (сильная боль, кровотечение, одышка) → [INTENT_CHANGE: escalate_human]».
    • +
    • «Спрашивает про цены, ДМС, оплату → [INTENT_CHANGE: price_question]».
    • +
    • «Просит соединить с оператором → [INTENT_CHANGE: escalate_human]».
    • +
    +

    Не нужно: правила для штатного хода диалога — это в «Правила». Тут только переключения между ветками.

    +
    + +
    @@ -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, }), });