From d7ded5c9f134e76666e8cf2c6499f10251e69783 Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Sun, 26 Apr 2026 19:36:15 +0500 Subject: [PATCH] =?UTF-8?q?feat(sprint6b):=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20D+F=20=E2=80=94=20pending=5Fguard,=20guard-=D0=B8?= =?UTF-8?q?=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=D1=8B=20=D0=B2?= =?UTF-8?q?=20UI,=20race-condition=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _eval_pending_guard() — вычисляет активный guard при незаполненных слотах - pending_guard добавлен в ThreadStateInfo (ответы /chat и /threads/:id) - ValidationEventInfo получил guard_name / missing_slots / guard_description - Sandbox: amber-блок «guard активен», подсветка в validation-событиях - openThread() защищён от race condition: if (activeThreadId !== id) return (исключает отрисовку устаревшего треда после переключения на новый) Co-Authored-By: Claude Sonnet 4.6 --- models/responses.py | 4 ++++ routers/threads.py | 1 + services/chat_service.py | 46 ++++++++++++++++++++++++++++++++++++++++ static/sandbox.html | 12 +++++++++-- 4 files changed, 61 insertions(+), 2 deletions(-) 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 + ? `
+ 🔒 guard активен: ${esc(pendingGuard.guard_name)} — ждём заполнения: ${(pendingGuard.missing_slots || []).map(s => `${esc(s)}`).join(", ")}.
+ ${esc(pendingGuard.description || "")} +
` + : ""; const loopHtml = routingLoopTriggered ? `
🛑 защита от петли сработала: диалог уведён в escalate_human. @@ -858,7 +866,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr ${esc(state.current_intent_code)} — без пошагового сценария
- ${handoffHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml} + ${handoffHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml} `; return; @@ -869,7 +877,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
${esc(state.current_intent_code)} · шаг ${esc(state.current_step_code)}
${esc(slotsJson)}
- ${handoffHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml} + ${handoffHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
`; }