feat(sprint6b-F): guards в new_booking — require_legal_rep

- check_guards() в state_machine.py: проверяет guards_json шага при переходе;
  trigger_slot/trigger_value/required_slots; нормализует "true"/"false"-строки
- qualify step: guard require_legal_rep — блокирует переход в present, если
  is_child=true и не заполнены legal_rep_name / legal_rep_phone
- Промпт qualify обновлён: инструкции по is_child, legal_rep, requested_doctor,
  waitlist_flag, needs_surgologist_first
- ensure_seed_guards() патчит guards_json существующих шагов при старте
- Sandbox: блок валидации показывает guard_name + missing_slots + description
- Settings: обновлён лейбл поля guards с примером формата

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-26 18:27:10 +05:00
parent 45832e2b37
commit 4977199cd4
7 changed files with 171 additions and 13 deletions
+27 -6
View File
@@ -9,7 +9,7 @@ from db.models import IntentStep, Message, Thread
from services import config_service, intent_step_service, thread_state_service
from services.llm_client import LLMClient, LLMUnavailableError
from services.router_client import RouterClient
from services.state_machine import parse_branch_response, validate_transition
from services.state_machine import check_guards, parse_branch_response, validate_transition
from services.vectorstore import VectorStoreService
logger = logging.getLogger(__name__)
@@ -425,9 +425,25 @@ async def send_message(
)
slots_updated = parsed["state_update"]["slots_updated"]
merged_slots = {**snapshot.get("slots", {}), **slots_updated}
guard_name: str | None = None
missing_slots: list[str] = []
guard_desc: str | None = None
if ok:
guards = intent_step_service.parse_guards(current_step)
guard_ok, guard_name, missing_slots, guard_desc = check_guards(
current_step_code=current_step.code,
requested_step_code=requested,
slots=merged_slots,
guards=guards,
)
if not guard_ok:
ok = False
reason = (
f"guard {guard_name!r} не пройден: не хватает слотов {missing_slots}"
)
# Решаем, как изменился soft_insertion_count.
# Soft-insertion засчитываем только если ветка явно отметила его и
# одновременно осталась на том же шаге без новых сценарных слотов.
stayed_on_step = ok and requested == current_step.code
if soft_insertion_flag and stayed_on_step and not slots_updated:
soft_insertion_count += 1
@@ -450,13 +466,18 @@ async def send_message(
}
else:
logger.warning(
"Illegal state_after in thread %d (%s): %s", thread.id, served_code, reason,
"Blocked state_after in thread %d (%s): %s", thread.id, served_code, reason,
)
validation_events.append({
event: dict = {
"current_step": current_step.code,
"requested_step": requested,
"reason": reason,
})
}
if guard_name:
event["guard_name"] = guard_name
event["missing_slots"] = missing_slots
event["guard_description"] = guard_desc or ""
validation_events.append(event)
# Слоты всё равно мёржим (информация полезная), шаг не двигаем.
snapshot = {
**base_state,
+44
View File
@@ -28,11 +28,20 @@ SEED_INTENT_STEPS: dict[str, list[dict]] = {
"code": "intro",
"name": "Приветствие",
"allowed_next": ["intro", "qualify"],
"guards": {},
},
{
"code": "qualify",
"name": "Повод и специалист",
"allowed_next": ["qualify", "present"],
"guards": {
"require_legal_rep": {
"description": "Для записи ребёнка нужны ФИО и телефон законного представителя",
"trigger_slot": "is_child",
"trigger_value": True,
"required_slots": ["legal_rep_name", "legal_rep_phone"],
},
},
},
{
"code": "present",
@@ -171,3 +180,38 @@ async def ensure_seed_steps(session: AsyncSession) -> None:
if added:
await session.commit()
logger.info("Seeded %d missing intent_steps", added)
async def ensure_seed_guards(session: AsyncSession) -> None:
"""Патчит guards_json для существующих шагов, если они остались пустыми '{}'.
Нужно для обратной совместимости: шаги созданы раньше, чем guards появились
в SEED_INTENT_STEPS. Вызывается при старте после ensure_seed_steps.
"""
patched = 0
for intent_code, steps_def in SEED_INTENT_STEPS.items():
intent = (await session.execute(
select(Intent).where(Intent.code == intent_code)
)).scalar_one_or_none()
if intent is None:
continue
for step_data in steps_def:
seed_guards = step_data.get("guards")
if not seed_guards:
continue
step = (await session.execute(
select(IntentStep).where(
IntentStep.intent_id == intent.id,
IntentStep.code == step_data["code"],
)
)).scalar_one_or_none()
if step is None:
continue
if step.guards_json in ("{}", "", None):
step.guards_json = json.dumps(seed_guards, ensure_ascii=False)
patched += 1
if patched:
await session.commit()
logger.info("Patched guards_json for %d intent_steps", patched)
+48
View File
@@ -152,3 +152,51 @@ def validate_transition(
False,
f"requested {requested_step!r} not in allowed_next {allowed_next!r} of {current_step!r}",
)
def check_guards(
*,
current_step_code: str,
requested_step_code: str,
slots: dict,
guards: dict,
) -> tuple[bool, str | None, list[str], str | None]:
"""Проверяет guards шага: можно ли перейти с учётом текущих слотов.
Guards проверяются только при реальном переходе (requested != current).
Возвращает (ok, guard_name, missing_slots, guard_description).
Формат guards:
{
"require_legal_rep": {
"description": "...",
"trigger_slot": "is_child",
"trigger_value": true,
"required_slots": ["legal_rep_name", "legal_rep_phone"]
}
}
"""
if requested_step_code == current_step_code or not guards:
return True, None, [], 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
# Нормализация: модель может вернуть "true"/"false" как строки.
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:
desc = guard_def.get("description", "")
return False, guard_name, missing, desc
return True, None, [], None