diff --git a/main.py b/main.py index 0c181b5..b7e3b60 100644 --- a/main.py +++ b/main.py @@ -69,6 +69,7 @@ async def lifespan(app: FastAPI): await config_service.ensure_seed_configs(session) await config_service.migrate_exit_conditions_to_field(session) await intent_step_service.ensure_seed_steps(session) + await intent_step_service.ensure_seed_guards(session) yield logger.info("Shutting down") diff --git a/prompts/intents/new_booking/steps/qualify.md b/prompts/intents/new_booking/steps/qualify.md index 1538857..dde6c18 100644 --- a/prompts/intents/new_booking/steps/qualify.md +++ b/prompts/intents/new_booking/steps/qualify.md @@ -1,14 +1,52 @@ ## Шаг «Повод и специалист» (qualify) -Задача: узнать коротко повод обращения и к какому специалисту записывать. +Задача: узнать повод обращения и к какому специалисту записывать. Также на этом шаге нужно выявить три особых ситуации (см. ниже), которые меняют дальнейший сбор данных. - Спроси про повод без сбора медицинской истории. Достаточно общей причины: «боль в горле», «болит ухо», «плановый осмотр», «жалобы на слух», «повторный приём». -- **Если пациент описал жалобу** — обязательно вырази короткое сочувствие («понимаю, боль в ухе — это неприятно», «понимаю, с горлом неприятно») и запиши жалобу в слот `reason` одной фразой так, как описал пациент («болит ухо», «боль в горле»). Не уточняй степень боли, длительность, выделения — это вопросы для врача на приёме. +- **Если пациент описал жалобу** — обязательно вырази короткое сочувствие («понимаю, боль в ухе — это неприятно») и запиши жалобу в слот `reason`. Не уточняй степень боли, длительность, выделения — это вопросы для врача. - Если пациент сам назвал специалиста — зафиксируй в `specialist`. - Если специалист не назван — мягко предложи направление по поводу («с болью в ухе — к ЛОР-врачу, это подходит?»). - **Не уходи в `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`. diff --git a/services/chat_service.py b/services/chat_service.py index 7ff94c9..e073ce9 100644 --- a/services/chat_service.py +++ b/services/chat_service.py @@ -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, diff --git a/services/intent_step_service.py b/services/intent_step_service.py index 0484b07..598470d 100644 --- a/services/intent_step_service.py +++ b/services/intent_step_service.py @@ -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) diff --git a/services/state_machine.py b/services/state_machine.py index 0a4adf3..0293b6f 100644 --- a/services/state_machine.py +++ b/services/state_machine.py @@ -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 diff --git a/static/sandbox.html b/static/sandbox.html index 026c034..aba50b8 100644 --- a/static/sandbox.html +++ b/static/sandbox.html @@ -834,7 +834,13 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr : ""; const validationHtml = (validationEvents && validationEvents.length) ? `
- ${validationEvents.map(v => `⚠️ модель просилась в ${esc(v.requested_step)}, оставили на ${esc(v.current_step)}. ${esc(v.reason)}`).join("
")} + ${validationEvents.map(v => { + if (v.guard_name) { + const missing = (v.missing_slots || []).map(s => `${esc(s)}`).join(", "); + return `🔒 guard ${esc(v.guard_name)} не пройден — ждём: ${missing}.
${esc(v.guard_description || "")}`; + } + return `⚠️ модель просилась в ${esc(v.requested_step)}, оставили на ${esc(v.current_step)}. ${esc(v.reason)}`; + }).join("
")}
` : ""; const parseErrorHtml = parseError diff --git a/static/settings.html b/static/settings.html index 2611be9..cb66d1e 100644 --- a/static/settings.html +++ b/static/settings.html @@ -821,7 +821,7 @@ function renderStepEditor() {
${checkboxes}
- +