Files
RAG_helper/services/intent_step_service.py
T
AR 15 M4 60f8a7b398 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>
2026-04-28 21:04:09 +05:00

294 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Шаги state machine внутри ветки: сид, чтение, правка (Спринт 6a).
Шаги живут в БД (`intent_steps`), сид при старте читает файлы промптов из
`prompts/intents/{intent_code}/steps/{step_code}.md`. Список шагов и переходы
описаны в словаре `SEED_INTENT_STEPS` ниже — новые state-machine-ветки
добавляются сюда + соответствующие файлы.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent, IntentStep
logger = logging.getLogger(__name__)
PROMPTS_INTENTS_DIR = Path(__file__).resolve().parent.parent / "prompts" / "intents"
# Стартовая описание шагов для 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",
"name": "Приветствие",
"allowed_next": ["intro", "qualify"],
"guards": {},
},
{
"code": "qualify",
"name": "Повод и специалист",
"allowed_next": ["qualify", "book"],
"guards": {
"require_legal_rep": {
"description": "Для записи ребёнка нужны ФИО и телефон законного представителя",
"trigger_slot": "is_child",
"trigger_value": True,
"required_slots": ["legal_rep_name", "legal_rep_phone"],
},
},
},
{
"code": "present",
"name": "Презентация плана",
# 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", "close"],
},
{
"code": "close",
"name": "Завершение",
"allowed_next": ["close"],
},
],
}
# Старые значения 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"
def load_seed_step_prompt(intent_code: str, step_code: str) -> str:
path = _step_prompt_path(intent_code, step_code)
try:
return path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("Seed prompt for step %s/%s not found at %s", intent_code, step_code, path)
return ""
def has_state_machine(intent_code: str) -> bool:
return intent_code in SEED_INTENT_STEPS
def parse_allowed_next(step: IntentStep) -> list[str]:
try:
value = json.loads(step.allowed_next_json)
except (json.JSONDecodeError, TypeError):
return []
return value if isinstance(value, list) else []
def parse_guards(step: IntentStep) -> dict:
try:
value = json.loads(step.guards_json)
except (json.JSONDecodeError, TypeError):
return {}
return value if isinstance(value, dict) else {}
async def list_steps_for_intent(session: AsyncSession, intent_id: int) -> list[IntentStep]:
stmt = select(IntentStep).where(IntentStep.intent_id == intent_id).order_by(IntentStep.order_index, IntentStep.id)
return list((await session.execute(stmt)).scalars().all())
async def get_step_by_code(
session: AsyncSession, intent_id: int, step_code: str
) -> IntentStep | None:
stmt = select(IntentStep).where(
IntentStep.intent_id == intent_id, IntentStep.code == step_code
)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_first_step(session: AsyncSession, intent_id: int) -> IntentStep | None:
stmt = (
select(IntentStep)
.where(IntentStep.intent_id == intent_id)
.order_by(IntentStep.order_index, IntentStep.id)
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def update_step(
session: AsyncSession,
step: IntentStep,
*,
name: str | None = None,
system_prompt: str | None = None,
allowed_next: list[str] | None = None,
guards: dict | None = None,
) -> IntentStep:
if name is not None:
step.name = name
if system_prompt is not None:
step.system_prompt = system_prompt
if allowed_next is not None:
step.allowed_next_json = json.dumps(allowed_next, ensure_ascii=False)
if guards is not None:
step.guards_json = json.dumps(guards, ensure_ascii=False)
step.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(step)
return step
async def ensure_seed_steps(session: AsyncSession) -> None:
"""Досиживает недостающие шаги для state-machine-веток. Существующие не трогаются."""
added = 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:
logger.warning("Cannot seed steps for %s: intent not found", intent_code)
continue
existing = set((await session.execute(
select(IntentStep.code).where(IntentStep.intent_id == intent.id)
)).scalars().all())
for order, data in enumerate(steps_def):
if data["code"] in existing:
continue
prompt = load_seed_step_prompt(intent_code, data["code"])
session.add(IntentStep(
intent_id=intent.id,
code=data["code"],
name=data["name"],
order_index=order,
system_prompt=prompt,
allowed_next_json=json.dumps(data["allowed_next"], ensure_ascii=False),
guards_json="{}",
))
added += 1
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)
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),
)