feat(sprint7.6): оптимизация воронки new_booking до 4 шагов (вариант 2)
Воронка сжата с 6 шагов до 4: intro → qualify → book → close.
Спецификация: docs/OPTIMIZATION_CONVERSION_v1.md.
Цель: сравнимая с конкурентом (NEXTBOT/Александра) конверсия — ≤3 реплик
бота до запроса телефона, содержательный ответ на жалобу в первом
осмысленном сообщении.
Промпты шагов:
- intro.md — переписан. Приветствие + открытый вопрос «что беспокоит?».
Имя НЕ спрашиваем (слот name со шага снят), оно собирается на book
вместе с телефоном. Если пациент сразу написал жалобу — не зацикливаемся,
переходим в qualify.
- qualify.md — переписан. Обязательный 5-пунктовый шаблон ответа на жалобу:
эмпатия (одна фраза) → 2-3 ЛОР-гипотезы из RAG-выдержек («может быть
связано с») → специалист → услуга/цена («при необходимости назначит») →
бинарный CTA «записать?». Если в выдержках нет гипотез/цен — пункт
пропускается, не сочиняем. Если жалоба не описана (пациент сразу
«хочу записаться к ЛОРу») — пропускаем гипотезу/услугу, оставляем
эмпатию-формальность + специалист + CTA.
Три особые ситуации сохранены: ребёнок (require_legal_rep), конкретный
врач (waitlist_flag), первичная жалоба на слух (needs_surgologist_first).
- book.md — переписан. Одной репликой: подтверждение плана с
использованием {specialist}/{reason} + запрос телефона + имени (если
ещё не было в истории). При is_child=true — обращение к родителю,
legal_rep_phone используется, если уже собран.
- present.md — DEPRECATED. Файл оставлен в репо на случай отката
(вариант 1 спецификации). Внутри — заглушка «попал по ошибке —
выходи на book».
- close.md и offer_time.md не тронуты (offer_time станет актуален с
реальным календарём).
allowed_next в SEED_INTENT_STEPS:
- intro: [intro, qualify] (без изменений)
- qualify: [qualify, book] (раньше: [qualify, present])
- present: [book] (изоляция; раньше: [present, qualify, offer_time])
- offer_time: [offer_time, book] (deprecated, без изменений)
- book: [book, qualify, close] (раньше: [book, qualify, offer_time, close])
- close: [close] (без изменений)
migrate_new_booking_allowed_next_v2(session) — одноразовая миграция в
services/intent_step_service.py. При старте для каждого шага
new_booking сравнивает текущий allowed_next_json с дореформенным
значением (_PRE_SPRINT_7_6_ALLOWED_NEXT). Если совпадает — обновляет
на новое из SEED. Если оператор правил вручную — пропускает,
warning в лог. Идемпотентна (на повторных запусках ничего не делает).
Подключена в main.py lifespan после ensure_seed_guards.
Защитное условие require_legal_rep на qualify сохранено. Теперь блокирует
переход qualify → book (раньше qualify → present). Логика та же:
при is_child=true и пустых legal_rep_name/legal_rep_phone валидатор
отклоняет переход.
eval/MANUAL_CASES.md — markdown-чеклист для ручных прогонов:
- §A: 5 конверсионных кейсов (храп+уши, боль в горле, тугоухость,
насморк >месяца, звон в ушах) с чеклистом 5 пунктов на первый ответ
и проверкой ≤3 реплик до телефона.
- §B: регрессия 8 ручных сценариев из блока H Спринта 6b со ссылками
на docs/examples/*_v2.md.
SPRINTS.md: Спринт 7.6 → ✅ Закрыт по коду. Применение промптов в БД
и ручная регрессия — за оператором (через UI «Настройки → Шаги»
для каждого из 4 шагов new_booking).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user