diff --git a/models/responses.py b/models/responses.py index 09e0be6..16c3d79 100644 --- a/models/responses.py +++ b/models/responses.py @@ -115,6 +115,7 @@ class ThreadStateInfo(BaseModel): suspended_intent: str | None = None resumable_step_code: str | None = None resumable_slots: dict = Field(default_factory=dict) + pending_guard: dict | None = None class BounceInfo(BaseModel): @@ -129,6 +130,9 @@ class ValidationEventInfo(BaseModel): current_step: str requested_step: str reason: str + guard_name: str | None = None + missing_slots: list[str] = Field(default_factory=list) + guard_description: str = "" class ThreadDetailResponse(BaseModel): diff --git a/routers/threads.py b/routers/threads.py index dc59f67..f20badf 100644 --- a/routers/threads.py +++ b/routers/threads.py @@ -65,6 +65,7 @@ async def get_thread(thread_id: int, session: AsyncSession = Depends(get_session suspended_intent=state.get("suspended_intent"), resumable_step_code=state.get("resumable_step_code"), resumable_slots=state.get("resumable_slots", {}), + pending_guard=state.get("pending_guard"), ), ) diff --git a/services/chat_service.py b/services/chat_service.py index e073ce9..ec0e441 100644 --- a/services/chat_service.py +++ b/services/chat_service.py @@ -111,6 +111,38 @@ async def _resolve_current_step( return await intent_step_service.get_first_step(session, intent_id) +def _eval_pending_guard( + current_step: "IntentStep | None", + slots: dict, +) -> "dict | None": + """Возвращает описание активного guard'а если триггер сработал, но слоты ещё не заполнены.""" + if current_step is None: + return None + guards = intent_step_service.parse_guards(current_step) + if not guards: + return None + for guard_name, guard_def in guards.items(): + if not isinstance(guard_def, dict): + continue + trigger_slot = guard_def.get("trigger_slot") + trigger_value = guard_def.get("trigger_value") + required_slots: list[str] = guard_def.get("required_slots", []) + slot_val = slots.get(trigger_slot) if trigger_slot else None + if isinstance(slot_val, str) and slot_val.lower() in ("true", "false"): + slot_val = slot_val.lower() == "true" + triggered = (trigger_slot is None) or (slot_val == trigger_value) + if not triggered: + continue + missing = [s for s in required_slots if not slots.get(s)] + if missing: + return { + "guard_name": guard_name, + "description": guard_def.get("description", ""), + "missing_slots": missing, + } + return None + + async def send_message( session: AsyncSession, vectorstore: VectorStoreService, @@ -586,6 +618,7 @@ async def send_message( "suspended_intent": snapshot.get("suspended_intent"), "resumable_step_code": snapshot.get("resumable_step_code"), "resumable_slots": snapshot.get("resumable_slots", {}), + "pending_guard": _eval_pending_guard(current_step, snapshot["slots"]), }, "bounces": bounce_log, "validation_events": validation_events, @@ -690,6 +723,19 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non }) state = await thread_state_service.load_snapshot(session, thread_id) + # Вычисляем pending_guard для текущего состояния треда + pending_guard = None + if state.get("current_step_code") and state.get("current_intent_code"): + from db.models import Intent as _Intent + intent_obj = (await session.execute( + select(_Intent).where(_Intent.code == state["current_intent_code"]) + )).scalar_one_or_none() + if intent_obj: + cur_step = await intent_step_service.get_step_by_code( + session, intent_obj.id, state["current_step_code"] + ) + pending_guard = _eval_pending_guard(cur_step, state.get("slots", {})) + state["pending_guard"] = pending_guard return { "id": thread.id, "name": thread.name, diff --git a/static/sandbox.html b/static/sandbox.html index aba50b8..693947e 100644 --- a/static/sandbox.html +++ b/static/sandbox.html @@ -683,6 +683,7 @@ async function openThread(id) { activeThreadId = id; try { const d = await api(`/threads/${id}`); + if (activeThreadId !== id) return; // пользователь переключился пока шёл запрос $("chat-title").className = "chat-title"; $("chat-title").textContent = d.name; renderMessages(d.messages); @@ -810,6 +811,13 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr 📣 пациент несколько раз подряд уходит в боковые вопросы — на этой реплике ветка получила инструкцию вернуть его к шагу. ` : ""; + const pendingGuard = state.pending_guard; + const pendingGuardHtml = pendingGuard + ? `
${esc(s)}`).join(", ")}.escalate_human.
@@ -858,7 +866,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
${esc(state.current_intent_code)}
— без пошагового сценария
${esc(state.current_step_code)}