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:
AR 15 M4
2026-04-26 19:36:15 +05:00
parent c3b874dc37
commit d7ded5c9f1
4 changed files with 61 additions and 2 deletions
+46
View File
@@ -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,