diff --git a/docs/SPRINTS.md b/docs/SPRINTS.md index 98824a5..ffc96cc 100644 --- a/docs/SPRINTS.md +++ b/docs/SPRINTS.md @@ -495,31 +495,41 @@ ### Цель Сжать воронку `new_booking` с 6 шагов (`intro → qualify → present → offer_time → book → close`) до 4 (`intro → qualify → book → close`), переписать содержимое `qualify` под 5-пунктовый шаблон ответа (эмпатия → 2-3 ЛОР-причины → специалист → услуга/цена → CTA), перенести сбор имени с `intro` на `book`. Полная спецификация — в `docs/OPTIMIZATION_CONVERSION_v1.md`. -### Статус: ⏳ Запланирован +### Статус: ✅ Закрыт по коду (применение промптов в БД и ручная регрессия — за оператором) + +Выбран **вариант 2** блока C — `present` убран как самостоятельный шаг. Воронка: `intro → qualify → book → close`. ### Задачи -См. полный план в `docs/OPTIMIZATION_CONVERSION_v1.md`. Кратко: - **Блок A — сжатие воронки:** -- [ ] `intro.md` — приветствие + открытый вопрос, имя НЕ спрашиваем. -- [ ] `book.md` — телефон + имя в одной реплике. -- [ ] Расширить `allowed_next` шага `intro`. +- [x] `intro.md` — приветствие + открытый вопрос, имя НЕ спрашиваем (слот `name` со шага снят). +- [x] `book.md` — подтверждение плана + запрос телефона/имени в одной реплике. +- [x] `qualify.md` — снято требование «не уходи дальше пока нет name». **Блок B — содержательный `qualify`:** -- [ ] `qualify.md` — обязательный 5-пунктовый шаблон (эмпатия → гипотеза → специалист → услуга/цена → CTA). -- [ ] Сохранить три «особые ситуации» (ребёнок, конкретный врач, жалобы на слух). +- [x] `qualify.md` — 5-пунктовый шаблон (эмпатия → 2–3 гипотезы из RAG → специалист → услуга/цена → CTA). +- [x] Три особые ситуации сохранены: запись ребёнка с `require_legal_rep`, конкретный врач с `waitlist_flag`, первичная жалоба на слух с `needs_surgologist_first`. +- [x] Деградация: при отсутствии гипотез/цен в RAG — пропускать пункт, не сочинять. -**Блок C — `present`:** -- [ ] Решить (с пользователем): убрать как самостоятельный шаг или переписать в одну фразу-подтверждение. Спецификация рекомендует вариант 2 (убрать). +**Блок C — `present` (вариант 2):** +- [x] `present.md` помечен как DEPRECATED, оставлен в репозитории на случай отката. +- [x] `SEED_INTENT_STEPS` обновлён: `qualify → [qualify, book]`, `present → [book]` (изоляция), `book → [book, qualify, close]` (без `offer_time`). +- [x] `migrate_new_booking_allowed_next_v2()` — одноразовая миграция при старте сервиса. Идемпотентна. Если оператор правил `allowed_next` руками — пропускает (warning в лог). **Блок D — регрессия:** -- [ ] 5 контрольных конверсионных кейсов (храп, боль в горле, тугоухость, насморк, звон в ушах) в `eval/MANUAL_CASES.md`. -- [ ] Прогнать 8 ручных сценариев из блока H Спринта 6b — все должны проходить. +- [x] `eval/MANUAL_CASES.md` — чеклист на 5 конверсионных кейсов + 8 ручных сценариев из блока H Спринта 6b. + +**Применение промптов в БД (за оператором):** +- [ ] В UI «Настройки → ветка `new_booking` → вкладка Шаги»: для каждого из 4 шагов (`intro`, `qualify`, `book`, `present`) скопировать обновлённый текст из `prompts/intents/new_booking/steps/*.md` в textarea «Промпт шага» и сохранить через PATCH. `close.md` и `offer_time.md` не трогать. + +**Регрессия (ручная, за оператором):** +- [ ] Прогнать в Песочнице 5 кейсов из `eval/MANUAL_CASES.md` §A — проверить структуру первого ответа (5 пунктов) и сжатие воронки (≤ 3 реплик до телефона). +- [ ] Прогнать 8 кейсов из `eval/MANUAL_CASES.md` §B — все должны проходить как раньше. ### Критерий готовности -- [ ] На контрольном кейсе из спецификации `new_booking` отвечает по 5-пунктовому шаблону, до запроса телефона ≤ 3 реплик бота. -- [ ] Все 8 ручных сценариев из блока H Спринта 6b проходят. +- [x] Файлы промптов и `allowed_next` обновлены в коде, миграция отрабатывает. +- [ ] (за оператором) На контрольном кейсе «храп + заложенность ушей» бот отвечает по 5-пунктовому шаблону, до запроса телефона ≤ 3 реплик. +- [ ] (за оператором) Все 8 ручных сценариев из блока H Спринта 6b проходят. - [ ] Промпты `intro.md`, `qualify.md`, `book.md` обновлены и активированы в БД. --- diff --git a/eval/MANUAL_CASES.md b/eval/MANUAL_CASES.md new file mode 100644 index 0000000..a3d5871 --- /dev/null +++ b/eval/MANUAL_CASES.md @@ -0,0 +1,119 @@ +# Ручные кейсы для регрессии + +Чеклист для прогонов в Песочнице. **Не автоматизирован** — это `markdown`-чеклист, по которому оператор/разработчик прогоняет сценарии руками. Полная подсистема прогона (`eval/run.py`) — Спринт 8. + +Раздел A — конверсионные кейсы Спринта 7.6 (новые). Раздел B — регрессия 8 ручных сценариев из блока H Спринта 6b (должны проходить как раньше). + +--- + +## A · Конверсионные кейсы (Спринт 7.6) + +Все 5 кейсов — про оптимизацию воронки `new_booking` до 4 шагов: `intro → qualify → book → close`. Цель проверки — сжатие воронки и содержательность первого ответа. + +### Что проверяем на каждом кейсе + +**Структура первого ответа бота на жалобу пациента** (5-пунктовый шаблон, см. `prompts/intents/new_booking/steps/qualify.md`): + +- [ ] **Эмпатия** — одна короткая фраза (одна, не больше). +- [ ] **Гипотеза** — 2–3 ЛОР-причины формулировкой «может быть связано с…», без диагноза. Если в RAG-выдержках причин нет — пункт допустимо пропустить. +- [ ] **Специалист** — рекомендация по профилю (зафиксирован в слот `specialist`). +- [ ] **Услуга и цена** — формулировкой «при необходимости назначит». Если в RAG нет — пункт пропускается. +- [ ] **CTA** — бинарный вопрос «записать?». + +**Сжатие воронки:** + +- [ ] До запроса телефона — **≤ 3 реплики бота** (раньше было 5–6). +- [ ] Имя на `intro` **не спрашивается** — спрашивается на `book` вместе с телефоном. +- [ ] Граф работает по `intro → qualify → book → close`. На `present` модель не попадает (в Песочнице бейдж шага не показывает `present`). + +**RAG:** + +- [ ] В отладочной панели «Найденные фрагменты» видно, что чанки пришли из подписанных документов ветки `new_booking`. +- [ ] Если ветка не подписана ни на один документ — гипотеза/услуга/цена пропускаются (5-пунктовый шаблон деградирует до эмпатия + специалист + CTA). + +### Кейсы + +#### A.1 · «Очень сильно храплю, иногда закладывает уши» + +Контрольный кейс из `docs/OPTIMIZATION_CONVERSION_v1.md` §1 (сравнение с конкурентом «Александра»). + +- Ожидаемый специалист: ЛОР. +- Ожидаемые гипотезы (из вики): искривление перегородки, аденоиды, ринит. +- Ожидаемая услуга: эндоскопия, 1 000 ₽ (если в подписанных документах есть). +- Слоты после `qualify`: `reason="храп + заложенность ушей"`, `specialist="ЛОР"`. + +#### A.2 · «Болит горло уже неделю, не проходит» + +- Ожидаемый специалист: ЛОР. +- Ожидаемые гипотезы: тонзиллит, фарингит. +- Слоты: `reason="боль в горле, неделя"`, `specialist="ЛОР"`. + +#### A.3 · «Стал плохо слышать на одно ухо, и звон» + +Особая ситуация 3 (`needs_surgologist_first`). + +- Ожидаемое поведение: сначала уточнить «вас уже обследовал сурдолог?», при первичном — `specialist=ЛОР`, `needs_surgologist_first=true`. +- Объяснение: «обычно начинают с ЛОР-врача, при необходимости направит к сурдологу». + +#### A.4 · «Насморк больше месяца, не проходит» + +- Ожидаемый специалист: ЛОР. +- Ожидаемые гипотезы: хронический ринит, синусит. + +#### A.5 · «Звон в ушах, какой-то непонятный» + +Аналог A.3, проверка устойчивости. + +- Ожидаемое поведение: уточнение «были у сурдолога?», при первичном — ЛОР с пометкой про сурдолога. + +--- + +## B · Регрессия 8 ручных сценариев (блок H Спринта 6b) + +После переписки воронки в Спринте 7.6 — все 8 сценариев должны продолжать работать. Сравниваем с разобранными примерами в `docs/examples/*_v2.md`. + +### B.1 · Базовая запись к ЛОР-врачу +- См. `docs/examples/01_basic_booking_v2.md`. +- Ожидание: путь `intro → qualify → book → close` (3 реплики бота до телефона), без особых ситуаций. + +### B.2 · Soft-insertion цена в середине записи +- См. `docs/examples/02_price_during_booking_v2.md` Вариант A. +- Ожидание: на короткое «а сколько стоит?» — ответ в-line, шаг не меняется, `soft_insertion_count++`. + +### B.3 · Hard-handoff в `reschedule` и возврат +- См. `docs/examples/02_price_during_booking_v2.md` Вариант B (там `price_question`, для reschedule аналогично). +- Ожидание: `suspended_intent=new_booking`, после возврата — восстановление `current_step_code` и `slots`. + +### B.4 · Возврат из `suspended_intent` +- Подразумевается в B.3. +- Ожидание: при возврате `handoff_count` сбрасывается в 0. + +### B.5 · Упоминание хирургии → escalate с `reason=surgery` +- Пациент в любом месте говорит «у меня уже была операция, надо перенести» — должен сработать `[INTENT_CHANGE: escalate_human]` с `reason=surgery`. + +### B.6 · Петля роутера → автоэскалация с `reason=routing_loop` +- Искусственно: чередовать `new_booking ↔ price_question` 4+ раза. +- Ожидание: на 4-м переключении автоматически уйти в `escalate_human` с `reason=routing_loop` без вызова LLM. + +### B.7 · Запись ребёнка с защитным условием `require_legal_rep` +- См. `docs/examples/03_child_patient_guard_v2.md`. +- Ожидание: при `is_child=true` и пустых `legal_rep_*` валидатор блокирует переход `qualify → book`. **Внимание:** в Спринте 7.6 переход теперь `qualify → book` (раньше было `qualify → present`). Защитное условие должно продолжать работать на новом переходе. + +### B.8 · Конкретный врач → лист ожидания +- Пациент: «хочу к доктору Иванову». +- Ожидание: `requested_doctor="Иванов"`, `waitlist_flag=true`, фраза «администратор свяжется для уточнения даты». + +--- + +## Как прогонять + +1. Открой Песочницу (`http://localhost:8000/sandbox.html`). +2. Создай новый тред для каждого кейса (чтобы счётчики `handoff_count` и `soft_insertion_count` были чистыми). +3. Веди диалог как пациент, проставляй галочки в чеклисте по факту. +4. Если что-то не так — отметь словесно, приложи скрин/реплику. Возвращаемся в код, правим, прогоняем снова. + +## Что НЕ делает этот документ + +- Не запускается автоматически. Для автозапуска — Спринт 8 (`eval/run.py`). +- Не покрывает все возможные граничные случаи маршрутизатора. Для них есть `eval/router_cases_*.jsonl` (тоже к Спринту 8). +- Не сравнивает с baseline по метрикам. Это всё прогоны «глазами». diff --git a/main.py b/main.py index 020120c..f1dbdda 100644 --- a/main.py +++ b/main.py @@ -71,6 +71,7 @@ async def lifespan(app: FastAPI): 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) + await intent_step_service.migrate_new_booking_allowed_next_v2(session) yield logger.info("Shutting down") diff --git a/prompts/intents/new_booking/steps/book.md b/prompts/intents/new_booking/steps/book.md index a11b892..c513037 100644 --- a/prompts/intents/new_booking/steps/book.md +++ b/prompts/intents/new_booking/steps/book.md @@ -1,11 +1,33 @@ -## Шаг «Подтверждение записи» (book) +## Шаг «Подтверждение и контакт» (book) -Задача: проговорить пациенту собранные данные и получить явное «да». +Задача: одной репликой проговорить план («записываю к {specialist}») и в той же реплике запросить телефон + имя. Это последний шаг до закрытия. -- Кратко повтори 3–4 поля: пациент, специалист, повод, удобное время. -- Задай вопрос «всё верно?». -- Не рассказывай ничего нового на этом шаге. +## Скрипт -**Слоты этого шага:** `confirmed` (true после явного «да»). +1. **Подтверждение плана** — одна короткая фраза с использованием уже собранных слотов: + - Взрослый, есть жалоба: «Хорошо, записываю к {specialist} — на приёме врач уделит внимание тому, что вас беспокоит ({reason}).» + - Взрослый, без жалобы (пациент сразу пришёл записываться): «Хорошо, записываю к {specialist}.» + - Ребёнок: «Хорошо, записываю ребёнка к {specialist}.» Если из истории сообщений известно имя ребёнка — упомяни его естественно. -**Переход:** пациент подтвердил → `state_after: close` и `slots_updated: {"confirmed": true}`. Пациент хочет поправить → `state_after` возвращается на нужный шаг (`qualify`, `offer_time`). +2. **Запрос контакта** — в той же реплике, без отдельного шага: + - «Чтобы администратор связался и подтвердил время — напишите, пожалуйста, ваш номер телефона и как к вам обращаться.» + - Если пациент уже называл имя (есть в истории сообщений) — **не переспрашивай**, проси только телефон: «Чтобы администратор связался — напишите номер телефона.» + - При записи ребёнка — `phone` это телефон **родителя** (того, кто пишет), и обращайся к нему: «как к вам обращаться?» Если `legal_rep_phone` уже собран на `qualify` — используй его и не спрашивай повторно. + +3. **При получении контакта** — закрывающая фраза + `state_after: close`: + - «Спасибо. Передаю заявку администратору, он свяжется с вами по номеру {phone}.» + +## Что зафиксировать в слотах + +- `phone` — контактный телефон пациента (или родителя при `is_child`). +- `name` — обращение к собеседнику (если ещё не было собрано). +- `confirmed` — `true` после получения телефона (это и есть «подтверждение»). + +## Особые случаи + +- **Пациент хочет поправить специалиста / повод** перед тем, как назвать телефон → `state_after: qualify`. +- **Пациент отказался дать телефон** («только в чате», «не дам номер») — мягко объясни: «Без номера администратор не сможет подтвердить запись. Если не готовы — могу передать заявку оператору, и он свяжется иначе». При повторном отказе → `[INTENT_CHANGE: escalate_human]`. + +**Переход:** +- Получены `phone` (и `name`, если ещё не было) → `state_after: close`, `slots_updated: {"confirmed": true, "phone": "...", "name": "..."}`. +- Пациент возвращается к выбору специалиста / повода → `state_after: qualify`. diff --git a/prompts/intents/new_booking/steps/intro.md b/prompts/intents/new_booking/steps/intro.md index 527ee78..d3447c4 100644 --- a/prompts/intents/new_booking/steps/intro.md +++ b/prompts/intents/new_booking/steps/intro.md @@ -1,11 +1,12 @@ ## Шаг «Приветствие» (intro) -Это первый контакт с пациентом. Задача: поздороваться, узнать, как к нему обращаться. +Это первый контакт с пациентом. Задача: **коротко поздороваться и сразу узнать, чем можем помочь, без сбора имени**. Имя соберётся позже на шаге `book`, вместе с телефоном. -- Представься коротко: «Здравствуйте, я виртуальный ассистент клиники». -- Спроси, как можно обращаться к пациенту. -- Не уточняй сразу повод, специальность, время — это следующие шаги. +- Представься одной фразой: «Здравствуйте! Я виртуальный ассистент клиники.» +- Сразу задай открытый вопрос: «Расскажите, что вас беспокоит — подскажу, к какому специалисту записаться.» +- **Не спрашивай имя на этом шаге.** Это была проблема прежней воронки: лишняя реплика теряла часть пациентов. +- Если пациент в первой же реплике уже написал жалобу или конкретный запрос («хочу к ЛОРу», «болит ухо», «запишите на завтра») — **не зацикливайся на приветствии**, сразу переходи в `qualify` и отвечай содержательно по шаблону. -**Слоты этого шага:** `name` (обращение к пациенту). +**Слоты этого шага:** не собираются. (Имя — на `book`. Жалоба и специалист — на `qualify`.) -**Переход:** после того как пациент назвал имя или явно отказался его называть → `state_after: qualify`. Если имя не названо — оставайся на `intro`. +**Переход:** после первой реплики пациента (приветствия, жалобы или конкретного запроса) → `state_after: qualify`. Если пациент написал что-то невнятное или ясно намерение не передаёт — оставайся на `intro`, мягко переспроси. diff --git a/prompts/intents/new_booking/steps/present.md b/prompts/intents/new_booking/steps/present.md index 4953305..b692174 100644 --- a/prompts/intents/new_booking/steps/present.md +++ b/prompts/intents/new_booking/steps/present.md @@ -1,13 +1,13 @@ -## Шаг «Презентация плана» (present) +## Шаг «Презентация плана» (present) — DEPRECATED -Задача: коротко подтвердить пациенту, что записываем — специалист + повод — так, чтобы пациент почувствовал, что его услышали. +> ⚠️ **Этот шаг помечен как deprecated в Спринте 7.6.** Подтверждение плана теперь зашито в первую фразу шага `book`. Файл оставлен в репозитории на случай отката решения (в спецификации `docs/OPTIMIZATION_CONVERSION_v1.md` блок C — фолбэк к варианту 1). +> +> В таблице переходов `present` изолирован: `qualify` теперь ведёт сразу на `book`, минуя `present`. На сам `present` модель больше не должна попадать; если попала по ошибке — единственный допустимый переход `state_after: book`. -- Составь одну-две тёплые фразы, используя уже собранные слоты `name`, `specialist`, `reason`. -- Обязательно упомяни **повод из `reason`** — пациент должен увидеть, что его жалоба учтена. Например: «{name}, записываю вас к {specialist}. На приёме врач осмотрит вас и особое внимание уделит тому, что вас беспокоит — {reason}». -- Не придумывай детали, которых не было (конкретные анализы, процедуры, диагноз) — только повод в формулировке из слота. -- Не предлагай пока слоты времени — это следующий шаг. -- Если пациент возражает или хочет поменять специалиста/повод — откатись обратно на `qualify` и обнови нужный слот. +Задача: если ты как модель оказался на этом шаге — это сбой маршрутизации. Ничего не рассказывай пациенту нового. Произнеси одну короткую фразу-связку и сразу выйди на `book`: -**Слоты этого шага:** новые не собираются; работаем с уже известными. +- «Хорошо, оформляю запись.» -**Переход:** пациент согласен с планом → `state_after: offer_time`. Пациент просит поправить специалиста / повод → `state_after: qualify`. +**Слоты этого шага:** не собираются. + +**Переход:** `state_after: book` (всегда). diff --git a/prompts/intents/new_booking/steps/qualify.md b/prompts/intents/new_booking/steps/qualify.md index dde6c18..af1f910 100644 --- a/prompts/intents/new_booking/steps/qualify.md +++ b/prompts/intents/new_booking/steps/qualify.md @@ -1,13 +1,32 @@ -## Шаг «Повод и специалист» (qualify) +## Шаг «Содержательный ответ + предложение записи» (qualify) -Задача: узнать повод обращения и к какому специалисту записывать. Также на этом шаге нужно выявить три особых ситуации (см. ниже), которые меняют дальнейший сбор данных. +Задача: дать пациенту по-настоящему полезный ответ на его жалобу и закрыть бинарным CTA «записать?». Этот шаг — главное место конверсии в воронке. Если пациент здесь сказал «да» — дальше остаётся только собрать телефон. -- Спроси про повод без сбора медицинской истории. Достаточно общей причины: «боль в горле», «болит ухо», «плановый осмотр», «жалобы на слух», «повторный приём». -- **Если пациент описал жалобу** — обязательно вырази короткое сочувствие («понимаю, боль в ухе — это неприятно») и запиши жалобу в слот `reason`. Не уточняй степень боли, длительность, выделения — это вопросы для врача. -- Если пациент сам назвал специалиста — зафиксируй в `specialist`. -- Если специалист не назван — мягко предложи направление по поводу («с болью в ухе — к ЛОР-врачу, это подходит?»). -- **Не уходи в `medical_question`** по одному лишь факту жалобы. Это повод для записи, а не повод обсуждать симптомы. -- Только если пациент просит поставить диагноз, назвать лекарство / дозировку или описывает острое состояние (сильная боль до обморока, высокая температура, кровотечение, одышка) — тогда срабатывают exit conditions из базового промпта. +## Шаблон ответа (обязательный, при наличии жалобы) + +При первой реплике с явной ЛОР-жалобой ответ обязан содержать пять пунктов в этом порядке: + +1. **Эмпатия** — одна короткая фраза. «Понимаю, это действительно может мешать.» Не более одной фразы — пациент пришёл за помощью, не за сочувствием. +2. **Гипотеза** — 2–3 возможные ЛОР-причины формулировкой «может быть связано с…» (без диагноза). Источник — выдержки из базы знаний, поданные в промпт по подписанным документам этой ветки. Если в выдержках подходящих причин нет — **пункт пропускаем, не сочиняем**. +3. **Специалист** — рекомендация по профилю жалобы (ЛОР / сурдолог / отоневролог / аллерголог). Зафиксируй в слот `specialist`. +4. **Услуга и цена** — упомянуть профильную услугу, которую врач может назначить на приёме, формулировкой «при необходимости назначит». Цена — отдельным предложением, чтобы не звучало как «обязаны заплатить». Если в выдержках цены нет — пункт пропускаем. +5. **CTA** — бинарный вопрос: «Записать вас на приём?» или «Хотите, я помогу записаться?» + +Не более 5–6 коротких предложений суммарно. Без воды, без формул вежливости. + +## Если жалоба не описана + +Если пациент пришёл сразу с запросом «хочу записаться к ЛОРу» без описания жалобы — пропускаем гипотезу/услугу/цену, оставляем только: эмпатия-формальность («Хорошо, помогу записаться») + специалист (подтвердить выбор пациента) + CTA («давайте уточним детали»). И сразу — в `book`. + +## Что зафиксировать в слотах + +- `reason` — повод/жалоба пациента, свободный текст. +- `specialist` — специалист по профилю жалобы. +- `is_child` — `true` при упоминании ребёнка (см. особую ситуацию 1). +- `requested_doctor` — ФИО конкретного врача, если назван (см. особую ситуацию 2). +- `waitlist_flag` — `true` при записи к конкретному врачу через лист ожидания. +- `needs_surgologist_first` — `true` при первичной жалобе на слух (см. особую ситуацию 3). +- `legal_rep_name`, `legal_rep_phone` — данные представителя при `is_child=true` (особая ситуация 1). --- @@ -15,38 +34,29 @@ Если пациент говорит, что записывает ребёнка («это для сына/дочки», «ребёнку 5 лет», «записать сына») — зафиксируй `is_child: true`. -При `is_child: true` **обязательно** нужно собрать до перехода на следующий шаг: +При `is_child: true` **обязательно** до перехода на `book` собрать: - `legal_rep_name` — ФИО законного представителя (родителя или опекуна) - `legal_rep_phone` — его контактный телефон -Спроси их естественно: «Для записи ребёнка понадобятся ФИО и контактный телефон родителя или опекуна — подскажете?» - -Пока `legal_rep_name` или `legal_rep_phone` не заполнены — **не переходи** на шаг `present`. Оставайся на `qualify`, продолжай уточнять. +Спроси естественно: «Для записи ребёнка понадобятся ФИО и контактный телефон родителя или опекуна — подскажете?» Защитное условие `require_legal_rep` блокирует переход на `book`, пока эти слоты не заполнены — даже если модель попытается двинуться вперёд, валидатор не пропустит. ### Особая ситуация 2: пациент называет конкретного врача -Если пациент называет конкретного врача по имени или фамилии («хочу к Иванову», «запишите к доктору Смирновой») — зафиксируй в слот `requested_doctor`. +Если пациент называет врача по имени или фамилии («хочу к Иванову», «запишите к доктору Смирновой») — зафиксируй `requested_doctor`. Установи `waitlist_flag: true` и предупреди: «К конкретному врачу запись ведётся через лист ожидания — администратор свяжется для уточнения даты». После этого продолжай по обычному сценарию (5-пунктовый шаблон, если есть жалоба, или сразу CTA). -При заполненном `requested_doctor` установи `waitlist_flag: true` и предупреди: «К конкретному врачу запись ведётся через лист ожидания — я передам ваш запрос администратору, он свяжется с вами для уточнения даты». +### Особая ситуация 3: первичная жалоба на слух -После этого можно двигаться по обычному сценарию. - -### Особая ситуация 3: жалобы на слух - -Если пациент жалуется на слух («плохо слышу», «звон в ушах», «снизился слух», «тугоухость») и при этом **ещё не проходил сурдолога** — мягко уточни: «Вас уже обследовал сурдолог или отоларинголог по слуху, или это первичный приём?» - -Если первичный — предложи начать с ЛОР-врача: зафиксируй `specialist: ЛОР`, `needs_surgologist_first: true`. Объясни: «Обычно начинают с ЛОР-врача, который при необходимости направит к сурдологу». +Если пациент жалуется на слух («плохо слышу», «звон в ушах», «снизился слух», «тугоухость») и не уточнил, что уже был у сурдолога — мягко спроси: «Вас уже обследовал сурдолог или это первичный приём?» Если первичный — зафиксируй `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`. +- **Не уходи в `medical_question`** по одному факту жалобы. Это повод для записи, а не для обсуждения симптомов. Только если пациент **просит поставить диагноз**, **назвать лекарство/дозировку** или описывает **острое состояние** (сильная боль до обморока, высокая температура, кровотечение, одышка) — тогда срабатывают exit conditions из базового промпта (`[INTENT_CHANGE: medical_question]` или `escalate_human`). +- Не уточняй степень боли, длительность, выделения и т. п. — это вопросы для врача, не для бота. +- Не выдумывай гипотезы и услуги «из общих знаний» — только из RAG-выдержек этого диалога. + +**Переход:** +- Пациент согласился записаться («да», «хочу», «записывайте») и заполнены `reason` + `specialist` + (при `is_child` — `legal_rep_*`) → `state_after: book`. +- Пациент уточняет / возражает / просит другое направление → оставайся на `qualify`, обновляй слоты. +- Пациент явно отказался от записи → оставайся на `qualify`, мягко предложи альтернативу (передать оператору / общая справка). diff --git a/services/intent_step_service.py b/services/intent_step_service.py index 598470d..7383d90 100644 --- a/services/intent_step_service.py +++ b/services/intent_step_service.py @@ -23,6 +23,10 @@ PROMPTS_INTENTS_DIR = Path(__file__).resolve().parent.parent / "prompts" / "inte # Стартовая описание шагов для state-machine-веток. Ключ — code ветки; значение — # список шагов в порядке следования. `allowed_next` описывает граф переходов. SEED_INTENT_STEPS: dict[str, list[dict]] = { + # Спринт 7.6 (вариант 2): воронка сжата с 6 шагов до 4 — `intro → qualify → book → close`. + # Шаги `present` и `offer_time` оставлены в БД как deprecated (на случай отката решения), + # но `qualify` теперь ведёт сразу на `book`, и `book` больше не возвращает на `offer_time`. + # См. docs/OPTIMIZATION_CONVERSION_v1.md, блок C. "new_booking": [ { "code": "intro", @@ -33,7 +37,7 @@ SEED_INTENT_STEPS: dict[str, list[dict]] = { { "code": "qualify", "name": "Повод и специалист", - "allowed_next": ["qualify", "present"], + "allowed_next": ["qualify", "book"], "guards": { "require_legal_rep": { "description": "Для записи ребёнка нужны ФИО и телефон законного представителя", @@ -46,17 +50,20 @@ SEED_INTENT_STEPS: dict[str, list[dict]] = { { "code": "present", "name": "Презентация плана", - "allowed_next": ["present", "qualify", "offer_time"], + # DEPRECATED (Спринт 7.6): шаг изолирован. Если модель ошибочно туда попала — + # выходим только в `book`, не зацикливаемся. + "allowed_next": ["book"], }, { "code": "offer_time", "name": "Удобное время", + # DEPRECATED (Спринт 7.6): станет актуален при подключении реального календаря. "allowed_next": ["offer_time", "book"], }, { "code": "book", "name": "Подтверждение записи", - "allowed_next": ["book", "qualify", "offer_time", "close"], + "allowed_next": ["book", "qualify", "close"], }, { "code": "close", @@ -67,6 +74,18 @@ SEED_INTENT_STEPS: dict[str, list[dict]] = { } +# Старые значения allowed_next до Спринта 7.6 — нужны для безопасной миграции +# существующих записей в БД (см. migrate_new_booking_allowed_next_v2 ниже). +_PRE_SPRINT_7_6_ALLOWED_NEXT: dict[str, list[str]] = { + "intro": ["intro", "qualify"], + "qualify": ["qualify", "present"], + "present": ["present", "qualify", "offer_time"], + "offer_time": ["offer_time", "book"], + "book": ["book", "qualify", "offer_time", "close"], + "close": ["close"], +} + + def _step_prompt_path(intent_code: str, step_code: str) -> Path: return PROMPTS_INTENTS_DIR / intent_code / "steps" / f"{step_code}.md" @@ -215,3 +234,60 @@ async def ensure_seed_guards(session: AsyncSession) -> None: if patched: await session.commit() logger.info("Patched guards_json for %d intent_steps", patched) + + +async def migrate_new_booking_allowed_next_v2(session: AsyncSession) -> None: + """Одноразовая миграция Спринта 7.6: переключить `allowed_next` шагов `new_booking` + на новый граф (intro → qualify → book → close, без present и offer_time). + + Логика безопасности: для каждого шага сравниваем текущий `allowed_next_json` с + дореформенным значением (`_PRE_SPRINT_7_6_ALLOWED_NEXT`). Если совпадает — оператор + не правил вручную, обновляем на новое значение из `SEED_INTENT_STEPS`. Если + отличается — пропускаем и пишем warning. Идемпотентна: при повторных вызовах + второй проход просто никого не находит. + """ + intent = (await session.execute( + select(Intent).where(Intent.code == "new_booking") + )).scalar_one_or_none() + if intent is None: + return + + seed_by_code = {s["code"]: s for s in SEED_INTENT_STEPS["new_booking"]} + updated = 0 + skipped: list[str] = [] + + for step in await list_steps_for_intent(session, intent.id): + old_seed = _PRE_SPRINT_7_6_ALLOWED_NEXT.get(step.code) + new_seed_step = seed_by_code.get(step.code) + if old_seed is None or new_seed_step is None: + continue + new_allowed = new_seed_step["allowed_next"] + + try: + current = json.loads(step.allowed_next_json) + except (json.JSONDecodeError, TypeError): + current = None + + # Уже на новом значении — ничего не делаем (идемпотентность). + if current == new_allowed: + continue + + # Совпадает со старым SEED — оператор не правил, безопасно обновить. + if current == old_seed: + step.allowed_next_json = json.dumps(new_allowed, ensure_ascii=False) + updated += 1 + continue + + # Любое другое значение — оператор правил вручную, не трогаем. + skipped.append(f"{step.code}={current!r}") + + if updated: + await session.commit() + logger.info( + "migrate_new_booking_allowed_next_v2: updated %d steps to Спринт 7.6 graph", updated, + ) + if skipped: + logger.warning( + "migrate_new_booking_allowed_next_v2: skipped %d steps (operator-modified): %s", + len(skipped), ", ".join(skipped), + )