feat(sprint6b): блоки D+F — pending_guard, guard-индикаторы в UI, race-condition fix
- _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 <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+10
-2
@@ -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
|
||||
📣 пациент несколько раз подряд уходит в боковые вопросы — на этой реплике ветка получила инструкцию вернуть его к шагу.
|
||||
</div>`
|
||||
: "";
|
||||
const pendingGuard = state.pending_guard;
|
||||
const pendingGuardHtml = pendingGuard
|
||||
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
|
||||
🔒 <b>guard активен: ${esc(pendingGuard.guard_name)}</b> — ждём заполнения: ${(pendingGuard.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ")}.<br>
|
||||
<span style="opacity:.75;">${esc(pendingGuard.description || "")}</span>
|
||||
</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>.
|
||||
@@ -858,7 +866,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
<b>${esc(state.current_intent_code)}</b>
|
||||
<span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span>
|
||||
</div>
|
||||
${handoffHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
${handoffHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -869,7 +877,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
<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>
|
||||
${handoffHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
${handoffHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user