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
+1
View File
@@ -69,6 +69,7 @@ async def lifespan(app: FastAPI):
await config_service.ensure_seed_configs(session) await config_service.ensure_seed_configs(session)
await config_service.migrate_exit_conditions_to_field(session) await config_service.migrate_exit_conditions_to_field(session)
await intent_step_service.ensure_seed_steps(session) await intent_step_service.ensure_seed_steps(session)
await intent_step_service.ensure_seed_guards(session)
yield yield
logger.info("Shutting down") logger.info("Shutting down")
+43 -5
View File
@@ -1,14 +1,52 @@
## Шаг «Повод и специалист» (qualify) ## Шаг «Повод и специалист» (qualify)
Задача: узнать коротко повод обращения и к какому специалисту записывать. Задача: узнать повод обращения и к какому специалисту записывать. Также на этом шаге нужно выявить три особых ситуации (см. ниже), которые меняют дальнейший сбор данных.
- Спроси про повод без сбора медицинской истории. Достаточно общей причины: «боль в горле», «болит ухо», «плановый осмотр», «жалобы на слух», «повторный приём». - Спроси про повод без сбора медицинской истории. Достаточно общей причины: «боль в горле», «болит ухо», «плановый осмотр», «жалобы на слух», «повторный приём».
- **Если пациент описал жалобу** — обязательно вырази короткое сочувствие («понимаю, боль в ухе — это неприятно», «понимаю, с горлом неприятно») и запиши жалобу в слот `reason` одной фразой так, как описал пациент («болит ухо», «боль в горле»). Не уточняй степень боли, длительность, выделения — это вопросы для врача на приёме. - **Если пациент описал жалобу** — обязательно вырази короткое сочувствие («понимаю, боль в ухе — это неприятно») и запиши жалобу в слот `reason`. Не уточняй степень боли, длительность, выделения — это вопросы для врача.
- Если пациент сам назвал специалиста — зафиксируй в `specialist`. - Если пациент сам назвал специалиста — зафиксируй в `specialist`.
- Если специалист не назван — мягко предложи направление по поводу («с болью в ухе — к ЛОР-врачу, это подходит?»). - Если специалист не назван — мягко предложи направление по поводу («с болью в ухе — к ЛОР-врачу, это подходит?»).
- **Не уходи в `medical_question`** по одному лишь факту жалобы. Это повод для записи, а не повод обсуждать симптомы. - **Не уходи в `medical_question`** по одному лишь факту жалобы. Это повод для записи, а не повод обсуждать симптомы.
- Только если пациент просит тебя именно поставить диагноз, назвать лекарство / дозировку или описывает острое состояние (сильная боль до обморока, высокая температура, кровотечение, одышка) — тогда срабатывают exit conditions из базового промпта. - Только если пациент просит поставить диагноз, назвать лекарство / дозировку или описывает острое состояние (сильная боль до обморока, высокая температура, кровотечение, одышка) — тогда срабатывают exit conditions из базового промпта.
**Слоты этого шага:** `reason` (повод/жалоба как описал пациент), `specialist` (врач или специальность). ---
**Переход:** когда известны `reason` и `specialist``state_after: present`. Если чего-то не хватает — оставайся на `qualify`, спрашивай недостающее. ### Особая ситуация 1: запись ребёнка
Если пациент говорит, что записывает ребёнка («это для сына/дочки», «ребёнку 5 лет», «записать сына») — зафиксируй `is_child: true`.
При `is_child: true` **обязательно** нужно собрать до перехода на следующий шаг:
- `legal_rep_name` — ФИО законного представителя (родителя или опекуна)
- `legal_rep_phone` — его контактный телефон
Спроси их естественно: «Для записи ребёнка понадобятся ФИО и контактный телефон родителя или опекуна — подскажете?»
Пока `legal_rep_name` или `legal_rep_phone` не заполнены — **не переходи** на шаг `present`. Оставайся на `qualify`, продолжай уточнять.
### Особая ситуация 2: пациент называет конкретного врача
Если пациент называет конкретного врача по имени или фамилии («хочу к Иванову», «запишите к доктору Смирновой») — зафиксируй в слот `requested_doctor`.
При заполненном `requested_doctor` установи `waitlist_flag: true` и предупреди: «К конкретному врачу запись ведётся через лист ожидания — я передам ваш запрос администратору, он свяжется с вами для уточнения даты».
После этого можно двигаться по обычному сценарию.
### Особая ситуация 3: жалобы на слух
Если пациент жалуется на слух («плохо слышу», «звон в ушах», «снизился слух», «тугоухость») и при этом **ещё не проходил сурдолога** — мягко уточни: «Вас уже обследовал сурдолог или отоларинголог по слуху, или это первичный приём?»
Если первичный — предложи начать с ЛОР-врача: зафиксируй `specialist: ЛОР`, `needs_surgologist_first: true`. Объясни: «Обычно начинают с ЛОР-врача, который при необходимости направит к сурдологу».
---
**Слоты этого шага:**
- `reason` — повод/жалоба
- `specialist` — специалист
- `is_child``true`, если запись для ребёнка
- `legal_rep_name` — ФИО законного представителя (заполняется при `is_child: true`)
- `legal_rep_phone` — телефон законного представителя (заполняется при `is_child: true`)
- `requested_doctor` — имя/фамилия конкретного врача, если назвал
- `waitlist_flag``true`, если пациент в листе ожидания на конкретного врача
- `needs_surgologist_first``true`, если направить сначала к сурдологу
**Переход:** когда известны `reason` и `specialist`, и выполнены все условия guard'ов (при записи ребёнка — собраны `legal_rep_name` и `legal_rep_phone`) → `state_after: present`. Если чего-то не хватает — оставайся на `qualify`.
+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 import config_service, intent_step_service, thread_state_service
from services.llm_client import LLMClient, LLMUnavailableError from services.llm_client import LLMClient, LLMUnavailableError
from services.router_client import RouterClient 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 from services.vectorstore import VectorStoreService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -425,9 +425,25 @@ async def send_message(
) )
slots_updated = parsed["state_update"]["slots_updated"] slots_updated = parsed["state_update"]["slots_updated"]
merged_slots = {**snapshot.get("slots", {}), **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_count.
# Soft-insertion засчитываем только если ветка явно отметила его и
# одновременно осталась на том же шаге без новых сценарных слотов.
stayed_on_step = ok and requested == current_step.code stayed_on_step = ok and requested == current_step.code
if soft_insertion_flag and stayed_on_step and not slots_updated: if soft_insertion_flag and stayed_on_step and not slots_updated:
soft_insertion_count += 1 soft_insertion_count += 1
@@ -450,13 +466,18 @@ async def send_message(
} }
else: else:
logger.warning( 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, "current_step": current_step.code,
"requested_step": requested, "requested_step": requested,
"reason": reason, "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 = { snapshot = {
**base_state, **base_state,
+44
View File
@@ -28,11 +28,20 @@ SEED_INTENT_STEPS: dict[str, list[dict]] = {
"code": "intro", "code": "intro",
"name": "Приветствие", "name": "Приветствие",
"allowed_next": ["intro", "qualify"], "allowed_next": ["intro", "qualify"],
"guards": {},
}, },
{ {
"code": "qualify", "code": "qualify",
"name": "Повод и специалист", "name": "Повод и специалист",
"allowed_next": ["qualify", "present"], "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", "code": "present",
@@ -171,3 +180,38 @@ async def ensure_seed_steps(session: AsyncSession) -> None:
if added: if added:
await session.commit() await session.commit()
logger.info("Seeded %d missing intent_steps", added) 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, False,
f"requested {requested_step!r} not in allowed_next {allowed_next!r} of {current_step!r}", 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
+7 -1
View File
@@ -834,7 +834,13 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
: ""; : "";
const validationHtml = (validationEvents && validationEvents.length) const validationHtml = (validationEvents && validationEvents.length)
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef2f2;color:#991b1b;font-size:11px;"> ? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef2f2;color:#991b1b;font-size:11px;">
${validationEvents.map(v => `⚠️ модель просилась в <code>${esc(v.requested_step)}</code>, оставили на <code>${esc(v.current_step)}</code>. ${esc(v.reason)}`).join("<br>")} ${validationEvents.map(v => {
if (v.guard_name) {
const missing = (v.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ");
return `🔒 guard <b>${esc(v.guard_name)}</b> не пройден — ждём: ${missing}.<br><span style="opacity:.75">${esc(v.guard_description || "")}</span>`;
}
return `⚠️ модель просилась в <code>${esc(v.requested_step)}</code>, оставили на <code>${esc(v.current_step)}</code>. ${esc(v.reason)}`;
}).join("<br>")}
</div>` </div>`
: ""; : "";
const parseErrorHtml = parseError const parseErrorHtml = parseError
+1 -1
View File
@@ -821,7 +821,7 @@ function renderStepEditor() {
<div class="allowed-next" id="f-step-allowed">${checkboxes}</div> <div class="allowed-next" id="f-step-allowed">${checkboxes}</div>
</div> </div>
<div class="field"> <div class="field">
<label for="f-step-guards">Guards (JSON, наполняется в 6b — пока можно оставить <code>{}</code>)</label> <label for="f-step-guards">Guards (JSON) — условия, блокирующие переход до заполнения нужных слотов. Пример: <code>{"require_legal_rep": {"trigger_slot": "is_child", "trigger_value": true, "required_slots": ["legal_rep_name", "legal_rep_phone"], "description": "..."}}</code></label>
<textarea id="f-step-guards" class="rules" spellcheck="false">${esc(JSON.stringify(step.guards || {}, null, 2))}</textarea> <textarea id="f-step-guards" class="rules" spellcheck="false">${esc(JSON.stringify(step.guards || {}, null, 2))}</textarea>
</div> </div>
<div class="editor-actions"> <div class="editor-actions">