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
+8 -1
View File
@@ -750,7 +750,14 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non
rows = (await session.execute(stmt)).all()
# Lookup для обогащения старых meta: (intent_id, step_code) -> step_name
step_rows = (await session.execute(select(IntentStep.intent_id, IntentStep.code, IntentStep.name))).all()
# Имена шагов берём только из активного графа: на исторических сообщениях
# отображается текущая версия имени, архивные графы (Спринт 7.7) не считаем.
from db.models import IntentStepGraph
step_rows = (await session.execute(
select(IntentStep.intent_id, IntentStep.code, IntentStep.name)
.join(IntentStepGraph, IntentStepGraph.id == IntentStep.graph_id)
.where(IntentStepGraph.is_active.is_(True))
)).all()
step_name_lookup: dict[tuple, str] = {(iid, sc): sn for iid, sc, sn in step_rows}
messages = []
+235
View File
@@ -0,0 +1,235 @@
"""Версионирование графа шагов state machine (Спринт 7.7).
Каждая state-machine-ветка хранит один или несколько графов в `intent_step_graphs`.
Активен ровно один (is_active=True): чат и Песочница используют только его.
Остальные графы резерв на случай отката или A/B-сравнения.
Шаги (`intent_steps`) ссылаются на граф через `graph_id`. Один шаг принадлежит
одному графу; чтобы шаг работал в разных версиях, он дублируется при создании
копии графа.
Здесь:
- `ensure_seed_graphs` идемпотентная data-migration. Создаёт активный граф
для каждой ветки и привязывает к нему существующие шаги (graph_id=NULL).
Для `new_booking` дополнительно восстанавливает архивный 6-шаговый граф v1
из `_archived_v1/*.md` и `_PRE_SPRINT_7_6_ALLOWED_NEXT`.
- `get_active_graph` выдаёт активный граф ветки (для фильтра шагов).
"""
import json
import logging
from pathlib import Path
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent, IntentStep, IntentStepGraph
from services.intent_step_service import (
SEED_INTENT_STEPS,
_NEW_BOOKING_DEPRECATED_STEP_CODES,
_PRE_SPRINT_7_6_ALLOWED_NEXT,
PROMPTS_INTENTS_DIR,
)
logger = logging.getLogger(__name__)
# Имена активных графов по веткам. Для new_booking — это вариант 2 Спринта 7.6.
_ACTIVE_GRAPH_NAMES: dict[str, str] = {
"new_booking": "v2 (4 шага, Спринт 7.6)",
}
_DEFAULT_ACTIVE_GRAPH_NAME = "v1 (исходный)"
# Архивный 6-шаговый граф new_booking — снапшот до Спринта 7.6.
_ARCHIVED_NEW_BOOKING_V1_NAME = "v1 (6 шагов, до Спринта 7.6)"
_ARCHIVED_NEW_BOOKING_V1_STEPS: list[dict] = [
{"code": "intro", "name": "Приветствие", "guards": {}},
{
"code": "qualify",
"name": "Повод и специалист",
"guards": {
"require_legal_rep": {
"description": "Для записи ребёнка нужны ФИО и телефон законного представителя",
"trigger_slot": "is_child",
"trigger_value": True,
"required_slots": ["legal_rep_name", "legal_rep_phone"],
},
},
},
{"code": "present", "name": "Презентация плана", "guards": {}},
{"code": "offer_time", "name": "Удобное время", "guards": {}},
{"code": "book", "name": "Подтверждение записи", "guards": {}},
{"code": "close", "name": "Завершение", "guards": {}},
]
def _archived_prompt_path(intent_code: str, step_code: str) -> Path:
return PROMPTS_INTENTS_DIR / intent_code / "steps" / "_archived_v1" / f"{step_code}.md"
def _load_archived_prompt(intent_code: str, step_code: str) -> str:
path = _archived_prompt_path(intent_code, step_code)
try:
return path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("Archived prompt %s/%s not found at %s", intent_code, step_code, path)
return ""
async def get_active_graph(session: AsyncSession, intent_id: int) -> IntentStepGraph | None:
stmt = (
select(IntentStepGraph)
.where(IntentStepGraph.intent_id == intent_id, IntentStepGraph.is_active.is_(True))
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def list_graphs(session: AsyncSession, intent_id: int) -> list[tuple[IntentStepGraph, int]]:
"""Все графы ветки + кол-во шагов в каждом, в порядке: активный первым, затем по version."""
graphs = list((await session.execute(
select(IntentStepGraph)
.where(IntentStepGraph.intent_id == intent_id)
.order_by(IntentStepGraph.is_active.desc(), IntentStepGraph.version)
)).scalars().all())
counts: dict[int, int] = {}
if graphs:
from sqlalchemy import func
rows = (await session.execute(
select(IntentStep.graph_id, func.count(IntentStep.id))
.where(IntentStep.graph_id.in_([g.id for g in graphs]))
.group_by(IntentStep.graph_id)
)).all()
counts = {gid: cnt for gid, cnt in rows}
return [(g, counts.get(g.id, 0)) for g in graphs]
async def set_active_graph(
session: AsyncSession, intent_id: int, graph_id: int
) -> IntentStepGraph | None:
"""Сделать `graph_id` активным графом ветки, остальные — неактивными."""
target = (await session.execute(
select(IntentStepGraph).where(
IntentStepGraph.intent_id == intent_id,
IntentStepGraph.id == graph_id,
)
)).scalar_one_or_none()
if target is None:
return None
await session.execute(
update(IntentStepGraph)
.where(IntentStepGraph.intent_id == intent_id)
.values(is_active=False)
)
target.is_active = True
await session.commit()
await session.refresh(target)
return target
async def ensure_seed_graphs(session: AsyncSession) -> None:
"""Создаёт активный граф для каждой state-machine-ветки и привязывает
существующие шаги. Для `new_booking` восстанавливает резервный v1-граф.
Идемпотентность: при повторном вызове ничего не делает, если активный граф
уже создан, а шагов с graph_id=NULL не осталось.
"""
created_graphs = 0
bound_steps = 0
archived_v1_created = False
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 graph for %s: intent not found", intent_code)
continue
active = await get_active_graph(session, intent.id)
if active is None:
active = IntentStepGraph(
intent_id=intent.id,
version=1,
name=_ACTIVE_GRAPH_NAMES.get(intent_code, _DEFAULT_ACTIVE_GRAPH_NAME),
is_active=True,
)
session.add(active)
await session.flush()
created_graphs += 1
logger.info("Created active graph %r for intent %s", active.name, intent_code)
# Привязываем «бесхозные» шаги (graph_id IS NULL) — это существующие записи
# из intent_steps, которые жили до миграции версии j6d8c4b56g23.
result = await session.execute(
update(IntentStep)
.where(IntentStep.intent_id == intent.id, IntentStep.graph_id.is_(None))
.values(graph_id=active.id)
)
bound_steps += result.rowcount or 0
# Спринт 7.7: создать архивный 6-шаговый граф new_booking. Один раз.
nb_intent = (await session.execute(
select(Intent).where(Intent.code == "new_booking")
)).scalar_one_or_none()
nb_active_graph: IntentStepGraph | None = None
if nb_intent is not None:
nb_active_graph = await get_active_graph(session, nb_intent.id)
existing_v1 = (await session.execute(
select(IntentStepGraph).where(
IntentStepGraph.intent_id == nb_intent.id,
IntentStepGraph.name == _ARCHIVED_NEW_BOOKING_V1_NAME,
)
)).scalar_one_or_none()
if existing_v1 is None:
v1_graph = IntentStepGraph(
intent_id=nb_intent.id,
version=2,
name=_ARCHIVED_NEW_BOOKING_V1_NAME,
is_active=False,
)
session.add(v1_graph)
await session.flush()
for order, data in enumerate(_ARCHIVED_NEW_BOOKING_V1_STEPS):
allowed_next = _PRE_SPRINT_7_6_ALLOWED_NEXT[data["code"]]
prompt = _load_archived_prompt("new_booking", data["code"])
session.add(IntentStep(
intent_id=nb_intent.id,
graph_id=v1_graph.id,
code=data["code"],
name=data["name"],
order_index=order,
system_prompt=prompt,
allowed_next_json=json.dumps(allowed_next, ensure_ascii=False),
guards_json=json.dumps(data["guards"], ensure_ascii=False),
))
archived_v1_created = True
logger.info(
"Created archived graph %r for new_booking (6 steps)",
_ARCHIVED_NEW_BOOKING_V1_NAME,
)
# Спринт 7.7: чистим активный граф new_booking от deprecated шагов (present,
# offer_time). Они должны жить только в архивном v1. Идемпотентно.
deprecated_removed = 0
if nb_intent is not None and nb_active_graph is not None:
result = await session.execute(
delete(IntentStep).where(
IntentStep.graph_id == nb_active_graph.id,
IntentStep.code.in_(_NEW_BOOKING_DEPRECATED_STEP_CODES),
)
)
deprecated_removed = result.rowcount or 0
if deprecated_removed:
logger.info(
"Removed %d deprecated steps from active new_booking graph: %s",
deprecated_removed, sorted(_NEW_BOOKING_DEPRECATED_STEP_CODES),
)
if created_graphs or bound_steps or archived_v1_created or deprecated_removed:
await session.commit()
logger.info(
"ensure_seed_graphs: graphs=%d, bound_steps=%d, archived_v1=%s",
created_graphs, bound_steps, archived_v1_created,
)
+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: