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