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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user