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) name: Mapped[str | None] = mapped_column(String(200), nullable=True)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False) system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
rules_text: Mapped[str] = mapped_column(Text, nullable=False, default="") 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) 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) 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: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
current_step_code: Mapped[str | None] = mapped_column(String(50), nullable=True) current_step_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
slots_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") 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( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False 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 intent_service.ensure_seed_intents(session)
await config_service.migrate_legacy_config_to_general_info(session) await config_service.migrate_legacy_config_to_general_info(session)
await config_service.ensure_seed_configs(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) await intent_step_service.ensure_seed_steps(session)
yield 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), к которой относится конфиг") intent_id: int = Field(..., description="ID ветки (intent), к которой относится конфиг")
system_prompt: str = Field(..., min_length=1) system_prompt: str = Field(..., min_length=1)
rules_text: str = Field("", description="Правила в свободной markdown-форме") rules_text: str = Field("", description="Правила в свободной markdown-форме")
exit_conditions_text: str = Field("", description="Условия выхода в формате [INTENT_CHANGE: <code>]")
name: str | None = Field(None, max_length=200) name: str | None = Field(None, max_length=200)
activate: bool = Field(False, description="Сразу сделать новую версию активной в рамках ветки") activate: bool = Field(False, description="Сразу сделать новую версию активной в рамках ветки")
+7
View File
@@ -109,6 +109,10 @@ class ThreadStateInfo(BaseModel):
current_step: int = 0 current_step: int = 0
current_step_code: str | None = None current_step_code: str | None = None
slots: dict = Field(default_factory=dict) 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): class BounceInfo(BaseModel):
@@ -151,6 +155,8 @@ class ChatResponse(BaseModel):
bounces: list[BounceInfo] = Field(default_factory=list) bounces: list[BounceInfo] = Field(default_factory=list)
validation_events: list[ValidationEventInfo] = Field(default_factory=list) validation_events: list[ValidationEventInfo] = Field(default_factory=list)
parse_error: str | None = None parse_error: str | None = None
routing_loop_triggered: bool = False
resumed_from_suspended: bool = False
class ThreadDeleteResponse(BaseModel): class ThreadDeleteResponse(BaseModel):
@@ -167,6 +173,7 @@ class AgentConfigInfo(BaseModel):
name: str | None = None name: str | None = None
system_prompt: str system_prompt: str
rules_text: str = "" rules_text: str = ""
exit_conditions_text: str = ""
is_active: bool is_active: bool
created_at: str 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", [])], bounces=[BounceInfo(**b) for b in result.get("bounces", [])],
validation_events=[ValidationEventInfo(**v) for v in result.get("validation_events", [])], validation_events=[ValidationEventInfo(**v) for v in result.get("validation_events", [])],
parse_error=result.get("parse_error"), 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, name=cfg.name,
system_prompt=cfg.system_prompt, system_prompt=cfg.system_prompt,
rules_text=cfg.rules_text or "", rules_text=cfg.rules_text or "",
exit_conditions_text=cfg.exit_conditions_text or "",
is_active=cfg.is_active, is_active=cfg.is_active,
created_at=cfg.created_at.isoformat(), created_at=cfg.created_at.isoformat(),
) )
@@ -97,6 +98,7 @@ async def create_config(
intent_id=req.intent_id, intent_id=req.intent_id,
system_prompt=req.system_prompt, system_prompt=req.system_prompt,
rules_text=req.rules_text, rules_text=req.rules_text,
exit_conditions_text=req.exit_conditions_text,
name=req.name, name=req.name,
activate=req.activate, 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=state.get("current_step", 0),
current_step_code=state.get("current_step_code"), current_step_code=state.get("current_step_code"),
slots=state.get("slots", {}), 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", {}),
), ),
) )
+250 -85
View File
@@ -16,7 +16,13 @@ logger = logging.getLogger(__name__)
HISTORY_LIMIT = 20 HISTORY_LIMIT = 20
FALLBACK_INTENT_CODE = "general_info" FALLBACK_INTENT_CODE = "general_info"
ESCALATE_INTENT_CODE = "escalate_human"
MAX_BOUNCES = 1 MAX_BOUNCES = 1
HANDOFF_CAP = 3 # столько hard-handoff'ов разрешено за диалог; четвёртое — авто-эскалация
ROUTING_LOOP_REPLY = (
"Уточню детали с администратором клиники, свяжемся с вами "
"в течение ближайшего часа."
)
def _auto_thread_name(first_user_text: str) -> str: def _auto_thread_name(first_user_text: str) -> str:
@@ -138,17 +144,46 @@ async def send_message(
router_code = routing["code"] router_code = routing["code"]
router_version = routing.get("version") router_version = routing.get("version")
# 2. Снимок состояния. Важное правило (sticky state machine, мини-G из Спринта 6b): # 2. Снимок состояния. Логика выбора effective_code:
# если тред уже идёт по state-machine-ветке и роутер предлагает другую — # 2.1. Если есть suspended_intent и роутер вернулся в него — RESUME: восстанавливаем
# НЕ сбрасываем state. Передадим LLM подсказку «роутер думает так», и пусть # прерванный сценарий, очищаем suspended_*, handoff_count=0.
# она сама решает: выдать `[INTENT_CHANGE: ...]` или удержать сценарий. # 2.2. Иначе если диалог идёт по sm-ветке и роутер предлагает другую — sticky:
# Это нужно, чтобы фраза-повод («болит ухо») внутри записи не сбрасывала слоты. # НЕ сбрасываем state, передаём LLM [ПОДСКАЗКА РОУТЕРА].
# 2.3. Иначе если prev — не-sm и роутер ведёт в другую ветку — hard-handoff.
snapshot = await thread_state_service.load_snapshot(session, thread.id) snapshot = await thread_state_service.load_snapshot(session, thread.id)
prev_intent_code = snapshot["current_intent_code"] 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 router_hint: str | None = None
effective_code = router_code 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): if intent_step_service.has_state_machine(prev_intent_code):
logger.info( logger.info(
"Router suggested %s but thread %d is in sm %s — sticky, hint only", "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 effective_code = prev_intent_code
else: else:
# Реальный hard-handoff: prev — не sm-ветка, роутер ведёт.
logger.info( logger.info(
"Router switched intent for thread %d: %s%s (state reset)", "Router switched intent for thread %d: %s%s (state reset)",
thread.id, prev_intent_code, router_code, thread.id, prev_intent_code, router_code,
) )
handoff_count += 1
snapshot = { snapshot = {
"current_intent_code": router_code, "current_intent_code": router_code,
"current_step": 0, "current_step": 0,
"current_step_code": None, "current_step_code": None,
"slots": {}, "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) и шаг. # 3. Разрешаем ветку (с fallback) и шаг.
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, effective_code) served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, effective_code)
if served_code != effective_code: if served_code != effective_code:
@@ -183,8 +252,18 @@ async def send_message(
"current_step": 0, "current_step": 0,
"current_step_code": None, "current_step_code": None,
"slots": {}, "slots": {},
"handoff_count": handoff_count,
"suspended_intent": suspended_intent,
"resumable_step_code": resumable_step_code,
"resumable_slots": resumable_slots,
} }
router_hint = None 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) retrieved = vectorstore.query(query_text=text, top_k=top_k)
sources = _retrieved_to_sources(retrieved) sources = _retrieved_to_sources(retrieved)
@@ -196,91 +275,164 @@ async def send_message(
parse_error: str | None = None parse_error: str | None = None
is_state_machine = False is_state_machine = False
for attempt in range(MAX_BOUNCES + 1): # Если уже сработала защита от петли — не зовём LLM, формируем заглушку.
current_step = await _resolve_current_step( if routing_loop_triggered:
session, intent.id, served_code, snapshot.get("current_step_code"), 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 else:
if current_step is not None and snapshot.get("current_step_code") != current_step.code: for attempt in range(MAX_BOUNCES + 1):
snapshot["current_step_code"] = current_step.code current_step = await _resolve_current_step(
session, intent.id, served_code, snapshot.get("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,
) )
slots_updated = parsed["state_update"]["slots_updated"] is_state_machine = current_step is not None
merged_slots = {**snapshot.get("slots", {}), **slots_updated} if current_step is not None and snapshot.get("current_step_code") != current_step.code:
if ok: snapshot["current_step_code"] = current_step.code
snapshot = {
"current_intent_code": served_code, base_prompt = config_service.compose_full_system_prompt(active_cfg)
"current_step": snapshot["current_step"] + (1 if requested != current_step.code else 0), step_prompt = f"\n\n{current_step.system_prompt}" if current_step else ""
"current_step_code": requested, state_context = _format_state_context(snapshot, current_step, router_hint)
"slots": merged_slots, system_prompt = base_prompt + step_prompt + state_context
}
else: llm_result = await llm.chat(
logger.warning( question=text,
"Illegal state_after in thread %d (%s): %s", thread.id, served_code, reason, sources=retrieved,
) history=history,
validation_events.append({ system_prompt=system_prompt,
"current_step": current_step.code, temperature=temperature,
"requested_step": requested, max_tokens=max_tokens,
"reason": reason, )
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 = { snapshot = {
"current_intent_code": served_code, "current_intent_code": served_code,
"current_step": snapshot["current_step"], "current_step": 0,
"current_step_code": current_step.code, "current_step_code": None,
"slots": merged_slots, "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: router_hint = None # новая ветка — подсказка больше неактуальна
logger.warning( continue
"State machine branch %s returned no STATE_JSON: %s", served_code, parse_error,
)
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 пишется ПОСЛЕ всей логики, коммит — единой транзакцией. # 4. Сохраняем: thread_state пишется ПОСЛЕ всей логики, коммит — единой транзакцией.
await thread_state_service.upsert( await thread_state_service.upsert(
@@ -289,6 +441,10 @@ async def send_message(
step=snapshot["current_step"], step=snapshot["current_step"],
step_code=snapshot.get("current_step_code"), step_code=snapshot.get("current_step_code"),
slots=snapshot["slots"], 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 user_msg.intent_id = intent.id
@@ -312,12 +468,15 @@ async def send_message(
await session.refresh(thread) await session.refresh(thread)
logger.info( 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, thread.id, router_code, served_code, active_cfg.version,
snapshot.get("current_step_code") or "-", snapshot.get("current_step_code") or "-",
len(snapshot["slots"]), len(snapshot["slots"]),
len(bounce_log), len(bounce_log),
len(validation_events), len(validation_events),
snapshot.get("handoff_count", 0),
routing_loop_triggered,
) )
return { return {
@@ -338,10 +497,16 @@ async def send_message(
"current_step": snapshot["current_step"], "current_step": snapshot["current_step"],
"current_step_code": snapshot.get("current_step_code"), "current_step_code": snapshot.get("current_step_code"),
"slots": snapshot["slots"], "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, "bounces": bounce_log,
"validation_events": validation_events, "validation_events": validation_events,
"parse_error": parse_error, "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 logging
import re
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -124,6 +125,7 @@ async def create_config(
rules_text: str, rules_text: str,
name: str | None = None, name: str | None = None,
activate: bool = False, activate: bool = False,
exit_conditions_text: str | None = None,
) -> AgentConfig: ) -> AgentConfig:
"""Создать новую версию в рамках ветки. При activate=True — сразу активна в этой ветке.""" """Создать новую версию в рамках ветки. При activate=True — сразу активна в этой ветке."""
next_version = (await session.execute( next_version = (await session.execute(
@@ -143,6 +145,7 @@ async def create_config(
name=(name or "").strip() or None, name=(name or "").strip() or None,
system_prompt=system_prompt, system_prompt=system_prompt,
rules_text=rules_text or "", rules_text=rules_text or "",
exit_conditions_text=(exit_conditions_text or None),
is_active=activate, is_active=activate,
) )
session.add(cfg) 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: def compose_full_system_prompt(cfg: AgentConfig) -> str:
"""Склейка системного промпта для модели: каркас + правила + условия выхода.
Все три поля редактируются оператором отдельно (с Спринта 6a, блок A2),
но в LLM улетают одной строкой.
"""
base = (cfg.system_prompt or "").strip() base = (cfg.system_prompt or "").strip()
rules = (cfg.rules_text or "").strip() rules = (cfg.rules_text or "").strip()
if not rules: exits = (cfg.exit_conditions_text or "").strip()
return base parts = [base] if base else []
return f"{base}\n\nДополнительные правила:\n{rules}" 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: 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) state = await get_state(session, thread_id)
if state is None: if state is None:
return { return {
@@ -38,12 +38,28 @@ async def load_snapshot(session: AsyncSession, thread_id: int) -> dict:
"current_step": 0, "current_step": 0,
"current_step_code": None, "current_step_code": None,
"slots": {}, "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 { return {
"current_intent_code": state.current_intent_code, "current_intent_code": state.current_intent_code,
"current_step": state.current_step, "current_step": state.current_step,
"current_step_code": state.current_step_code, "current_step_code": state.current_step_code,
"slots": _parse_slots(state.slots_json), "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, step: int,
slots: dict, slots: dict,
step_code: str | None = None, 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: ) -> ThreadState:
"""Создать или обновить состояние треда. Коммит — на совести вызывающего.""" """Создать или обновить состояние треда. Коммит — на совести вызывающего."""
state = await get_state(session, thread_id) state = await get_state(session, thread_id)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
slots_raw = json.dumps(slots or {}, ensure_ascii=False) 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: if state is None:
state = ThreadState( state = ThreadState(
thread_id=thread_id, thread_id=thread_id,
@@ -67,6 +92,10 @@ async def upsert(
current_step=step, current_step=step,
current_step_code=step_code, current_step_code=step_code,
slots_json=slots_raw, 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, updated_at=now,
) )
session.add(state) session.add(state)
@@ -75,6 +104,10 @@ async def upsert(
state.current_step = step state.current_step = step
state.current_step_code = step_code state.current_step_code = step_code
state.slots_json = slots_raw 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 state.updated_at = now
return state return state
@@ -90,4 +123,5 @@ async def reset(
return await upsert( return await upsert(
session, thread_id, session, thread_id,
intent_code=new_intent_code, step=0, step_code=new_step_code, slots={}, 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"); const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
if (lastAssistant) { 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); 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 { } else {
clearDebug(); 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"); const box = $("debug-state");
if (!state || !state.current_intent_code) { if (!state || !state.current_intent_code) {
box.innerHTML = '<div class="mini">сценарий ещё не запущен</div>'; box.innerHTML = '<div class="mini">сценарий ещё не запущен</div>';
return; 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) const bounceHtml = (bounces && bounces.length)
? `<div style="margin-top:8px;font-size:11px;"> ? `<div style="margin-top:8px;font-size:11px;">
<div style="color:var(--muted);margin-bottom:3px;">переходы в этой реплике:</div> <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> <b>${esc(state.current_intent_code)}</b>
<span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span> <span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span>
</div> </div>
${bounceHtml}${validationHtml}${parseErrorHtml} ${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div> </div>
`; `;
return; return;
@@ -716,7 +737,7 @@ function renderState(state, bounces, validationEvents, parseError) {
<div style="font-size:12px;"> <div style="font-size:12px;">
<div><b>${esc(state.current_intent_code)}</b> · шаг <code>${esc(state.current_step_code)}</code></div> <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> <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> </div>
`; `;
} }
@@ -827,7 +848,7 @@ async function sendMessage() {
$("chat-title").className = "chat-title"; $("chat-title").className = "chat-title";
$("chat-title").textContent = r.thread_name; $("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); 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(); refreshThreads();
} catch (e) { } catch (e) {
// Откатываем визуально: убираем пузырь-заглушку ассистента и только что // Откатываем визуально: убираем пузырь-заглушку ассистента и только что
+46 -12
View File
@@ -677,22 +677,32 @@ function switchTab(tab) {
renderEditor(); renderEditor();
} }
function toggleRulesHint(force) { function toggleHint(key, force) {
const pop = document.getElementById("rules-hint-popover"); const pop = document.getElementById(`${key}-hint-popover`);
const btn = document.getElementById("rules-hint-btn"); const btn = document.getElementById(`${key}-hint-btn`);
if (!pop || !btn) return; if (!pop || !btn) return;
const willShow = typeof force === "boolean" ? force : !pop.classList.contains("show"); 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); pop.classList.toggle("show", willShow);
btn.classList.toggle("active", willShow); btn.classList.toggle("active", willShow);
} }
// Клик вне popover-а — закрываем. // Клик вне любого popover-а — закрываем все.
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const pop = document.getElementById("rules-hint-popover"); const opened = document.querySelectorAll(".hint-popover.show");
const btn = document.getElementById("rules-hint-btn"); if (!opened.length) return;
if (!pop || !btn || !pop.classList.contains("show")) return; for (const pop of opened) {
if (pop.contains(e.target) || btn.contains(e.target)) return; const btn = document.getElementById(pop.id.replace("-popover", "-btn"));
toggleRulesHint(false); 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) { function renderPromptPanel(intent) {
@@ -708,10 +718,10 @@ function renderPromptPanel(intent) {
<div class="field"> <div class="field">
<label for="f-rules" class="with-hint"> <label for="f-rules" class="with-hint">
<span>Правила (дополнение к промпту; свободная markdown-форма)</span> <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> </label>
<div class="hint-popover" id="rules-hint-popover"> <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> <h4>Что писать в «Правила»</h4>
<p>Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.</p> <p>Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.</p>
<p><b>Что нормально писать:</b></p> <p><b>Что нормально писать:</b></p>
@@ -721,10 +731,29 @@ function renderPromptPanel(intent) {
<li>«После 19:00 предлагай только следующий рабочий день».</li> <li>«После 19:00 предлагай только следующий рабочий день».</li>
<li>«Дети до 14 лет — сразу <code>[INTENT_CHANGE: escalate_human]</code>, у нас нет педиатра».</li> <li>«Дети до 14 лет — сразу <code>[INTENT_CHANGE: escalate_human]</code>, у нас нет педиатра».</li>
</ul> </ul>
<p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода (<code>[INTENT_CHANGE: ...]</code>) пока тоже в основном промпте, в Спринте 6a выделим в отдельное поле.</p> <p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода отдельное поле ниже.</p>
</div> </div>
<textarea id="f-rules" class="rules" spellcheck="false"></textarea> <textarea id="f-rules" class="rules" spellcheck="false"></textarea>
</div> </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"> <div class="editor-actions">
<button onclick="saveVersion()">Сохранить как новую версию</button> <button onclick="saveVersion()">Сохранить как новую версию</button>
<button class="secondary" onclick="loadActiveIntoEditor()">Перезагрузить активную</button> <button class="secondary" onclick="loadActiveIntoEditor()">Перезагрузить активную</button>
@@ -840,6 +869,7 @@ async function loadActiveIntoEditor() {
$("f-name").value = ""; $("f-name").value = "";
$("f-prompt").value = ""; $("f-prompt").value = "";
$("f-rules").value = ""; $("f-rules").value = "";
if ($("f-exits")) $("f-exits").value = "";
} }
return; return;
} }
@@ -848,6 +878,7 @@ async function loadActiveIntoEditor() {
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`; $("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt; $("f-prompt").value = c.system_prompt;
$("f-rules").value = c.rules_text || ""; $("f-rules").value = c.rules_text || "";
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
} catch (e) { } catch (e) {
toast("Не удалось загрузить активную: " + e.message, "err"); 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-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt; $("f-prompt").value = c.system_prompt;
$("f-rules").value = c.rules_text || ""; $("f-rules").value = c.rules_text || "";
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
toast(`Загружена v${c.version}`); toast(`Загружена v${c.version}`);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
@@ -907,6 +939,7 @@ async function saveVersion() {
const name = $("f-name").value.trim(); const name = $("f-name").value.trim();
const system_prompt = $("f-prompt").value.trim(); const system_prompt = $("f-prompt").value.trim();
const rules_text = $("f-rules").value.trim(); const rules_text = $("f-rules").value.trim();
const exit_conditions_text = $("f-exits") ? $("f-exits").value.trim() : "";
const activate = $("chk-activate").checked; const activate = $("chk-activate").checked;
if (!system_prompt) { if (!system_prompt) {
@@ -923,6 +956,7 @@ async function saveVersion() {
name: name || null, name: name || null,
system_prompt, system_prompt,
rules_text, rules_text,
exit_conditions_text,
activate, activate,
}), }),
}); });