feat(sprint7.7): версионирование графа шагов в БД + UI переключения

Хранить старый 6-шаговый сценарий new_booking параллельно с новым 4-шаговым,
чтобы оператор мог откатиться или сравнить варианты. Активный граф ровно один,
переключение через UI «Настройки → Шаги».

Модель и миграция:
- Таблица intent_step_graphs (id, intent_id, version, name, is_active, created_at).
- intent_steps.graph_id FK + UNIQUE сменён с (intent_id, code) на (graph_id, code).
- Alembic-миграция j6d8c4b56g23 (batch_alter_table для SQLite).

Сервис и сидинг (services/intent_step_graph_service.py):
- ensure_seed_graphs идемпотентен: создаёт активный граф для каждой ветки,
  привязывает существующие шаги (graph_id IS NULL), для new_booking
  восстанавливает архивный v1 из _archived_v1/*.md и _PRE_SPRINT_7_6_ALLOWED_NEXT.
- Активный граф new_booking сжат до 4 шагов: deprecated present/offer_time
  удаляются из активного, остаются только в архивном v1.
- list_graphs / get_active_graph / set_active_graph.

API:
- GET /intents/{code}/step-graphs — список с steps_count и is_active.
- POST /intents/{code}/step-graphs/{graph_id}/activate.
- list_steps_for_intent / get_step_by_code / get_first_step фильтруют
  по активному графу через _active_graph_filter (join с intent_step_graphs).
- ensure_seed_guards и migrate_new_booking_allowed_next_v2 защищены: не
  трогают шаги архивных графов.

UI (static/settings.html):
- Во вкладке «Шаги» вверху блок «Версии графа шагов»: карточки с именем,
  кол-вом шагов, бейджем «активная» или кнопкой «Активировать». При
  переключении заголовок меняется «Шаги (4)» ↔ «Шаги (6)».
- Раздел «Тест-вопрос от пациента» сделан сворачиваемым (details/summary
  в стиле prompt-block).

Промпты архивного графа восстановлены из коммита 60f8a7b^ в
prompts/intents/new_booking/steps/_archived_v1/*.md.

SPRINTS.md: Спринт 7.7 →  Закрыт.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-05-02 14:29:07 +05:00
parent 60f8a7b398
commit a79b6f9d05
18 changed files with 799 additions and 65 deletions
+57 -29
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent, IntentStep
from db.models import Intent, IntentStep, IntentStepGraph
logger = logging.getLogger(__name__)
@@ -23,10 +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.
# Спринт 7.6/7.7 (вариант 2): активный граф new_booking — ровно 4 шага
# `intro → qualify → book → close`. Старый 6-шаговый сценарий (с `present`
# и `offer_time`) сохранён как архивный граф v1, см.
# `services/intent_step_graph_service._ARCHIVED_NEW_BOOKING_V1_STEPS`.
"new_booking": [
{
"code": "intro",
@@ -47,19 +47,6 @@ SEED_INTENT_STEPS: dict[str, list[dict]] = {
},
},
},
{
"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": "Подтверждение записи",
@@ -74,6 +61,12 @@ SEED_INTENT_STEPS: dict[str, list[dict]] = {
}
# Коды шагов, которые когда-то были в активном графе new_booking (Спринт 6a),
# но в Спринте 7.6 удалены. Список используется для одноразовой чистки активного
# графа в `ensure_seed_graphs`. Сами шаги остаются в архивном v1.
_NEW_BOOKING_DEPRECATED_STEP_CODES: set[str] = {"present", "offer_time"}
# Старые значения allowed_next до Спринта 7.6 — нужны для безопасной миграции
# существующих записей в БД (см. migrate_new_booking_allowed_next_v2 ниже).
_PRE_SPRINT_7_6_ALLOWED_NEXT: dict[str, list[str]] = {
@@ -119,24 +112,38 @@ def parse_guards(step: IntentStep) -> dict:
return value if isinstance(value, dict) else {}
def _active_graph_filter(intent_id: int):
"""Базовый фильтр: шаги принадлежат активному графу указанного intent.
Со Спринта 7.7 у ветки может быть несколько графов; чат и UI «Шаги» работают
только с активным. Шаги с graph_id=NULL могут существовать только в окне
миграции до первого вызова ensure_seed_graphs — в нормальной работе их нет.
"""
return (
select(IntentStep)
.join(IntentStepGraph, IntentStepGraph.id == IntentStep.graph_id)
.where(
IntentStepGraph.intent_id == intent_id,
IntentStepGraph.is_active.is_(True),
)
)
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)
stmt = _active_graph_filter(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
)
stmt = _active_graph_filter(intent_id).where(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)
_active_graph_filter(intent_id)
.order_by(IntentStep.order_index, IntentStep.id)
.limit(1)
)
@@ -219,12 +226,21 @@ async def ensure_seed_guards(session: AsyncSession) -> None:
seed_guards = step_data.get("guards")
if not seed_guards:
continue
# Сидинг guards применяется только к шагам активного графа: архивные
# графы (Спринт 7.7) могут иметь свою историю guards и трогать их нельзя.
# Если активного графа ещё нет (первый запуск до ensure_seed_graphs) —
# ищем по graph_id IS NULL.
step = (await session.execute(
select(IntentStep).where(
IntentStep.intent_id == intent.id,
IntentStep.code == step_data["code"],
)
_active_graph_filter(intent.id).where(IntentStep.code == step_data["code"])
)).scalar_one_or_none()
if step is None:
step = (await session.execute(
select(IntentStep).where(
IntentStep.intent_id == intent.id,
IntentStep.code == step_data["code"],
IntentStep.graph_id.is_(None),
)
)).scalar_one_or_none()
if step is None:
continue
if step.guards_json in ("{}", "", None):
@@ -256,7 +272,19 @@ async def migrate_new_booking_allowed_next_v2(session: AsyncSession) -> None:
updated = 0
skipped: list[str] = []
for step in await list_steps_for_intent(session, intent.id):
# Берём шаги активного графа + «бесхозные» (graph_id IS NULL — первый запуск
# до ensure_seed_graphs). Архивные графы трогать нельзя — их allowed_next
# должен остаться дореформенным (Спринт 7.7).
candidates_stmt = (
select(IntentStep)
.outerjoin(IntentStepGraph, IntentStepGraph.id == IntentStep.graph_id)
.where(
IntentStep.intent_id == intent.id,
(IntentStep.graph_id.is_(None)) | (IntentStepGraph.is_active.is_(True)),
)
.order_by(IntentStep.order_index, IntentStep.id)
)
for step in (await session.execute(candidates_stmt)).scalars().all():
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: