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