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,