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
+5 -1
View File
@@ -3,8 +3,12 @@ from db.models.document import Document
from db.models.intent import Intent
from db.models.intent_document import IntentDocument
from db.models.intent_step import IntentStep
from db.models.intent_step_graph import IntentStepGraph
from db.models.message import Message
from db.models.thread import Thread
from db.models.thread_state import ThreadState
__all__ = ["Thread", "Message", "Document", "AgentConfig", "Intent", "IntentDocument", "IntentStep", "ThreadState"]
__all__ = [
"Thread", "Message", "Document", "AgentConfig", "Intent",
"IntentDocument", "IntentStep", "IntentStepGraph", "ThreadState",
]
+11 -3
View File
@@ -11,20 +11,28 @@ def _utcnow() -> datetime:
class IntentStep(Base):
"""Шаг state machine внутри ветки (Спринт 6a).
"""Шаг state machine внутри ветки (Спринт 6a, версионирование — Спринт 7.7).
Шаги живут в БД, а не в коде: оператор редактирует промпт шага и список
допустимых переходов через UI «Настройки → Шаги». `allowed_next` и `guards`
хранятся как JSON-строки (парсим в сервисе), чтобы не городить отдельные
таблицы. Версионирования нет: правка применяется сразу.
таблицы.
Со Спринта 7.7 каждый шаг принадлежит конкретной версии графа
(`graph_id` → `intent_step_graphs.id`); UNIQUE по `(graph_id, code)`. Старая
запись без графа допустима только в окне data-migration через
`intent_step_graph_service.ensure_seed_graphs`.
"""
__tablename__ = "intent_steps"
__table_args__ = (UniqueConstraint("intent_id", "code", name="uq_intent_step_code"),)
__table_args__ = (UniqueConstraint("graph_id", "code", name="uq_intent_step_graph_code"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
intent_id: Mapped[int] = mapped_column(
ForeignKey("intents.id", ondelete="CASCADE"), nullable=False, index=True
)
graph_id: Mapped[int | None] = mapped_column(
ForeignKey("intent_step_graphs.id", ondelete="CASCADE"), nullable=True, index=True
)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class IntentStepGraph(Base):
"""Версия графа шагов state machine ветки (Спринт 7.7).
У одной ветки может быть несколько графов (исходный 6-шаговый, оптимизированный
4-шаговый Спринта 7.6 и т. п.). Активен ровно один (is_active=True): чат и
Песочница используют только его. Остальные — резерв на случай отката или
A/B-сравнения, в Песочнице не используются.
Сами шаги (`intent_steps`) ссылаются на граф через `graph_id`. Один шаг
принадлежит одному графу; чтобы шаг работал в нескольких версиях, он
дублируется при создании копии графа.
"""
__tablename__ = "intent_step_graphs"
__table_args__ = (
UniqueConstraint("intent_id", "version", name="uq_intent_step_graph_version"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
intent_id: Mapped[int] = mapped_column(
ForeignKey("intents.id", ondelete="CASCADE"), nullable=False, index=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, nullable=False
)
+38
View File
@@ -534,6 +534,44 @@
---
## Спринт 7.7. Версионирование графа шагов в БД + UI переключения
### Цель
Хранить старый 6-шаговый сценарий `new_booking` параллельно с новым 4-шаговым, чтобы можно было откатиться или сравнить варианты, не теряя историю. Оператор переключает активную версию из UI «Настройки → Шаги».
### Статус: ✅ Закрыт
### Задачи
**Модель и миграция:**
- [x] Таблица `intent_step_graphs` (id, intent_id, version, name, is_active, created_at). Активный ровно один на ветку.
- [x] `intent_steps.graph_id` FK на `intent_step_graphs`. UNIQUE сменён с `(intent_id, code)` на `(graph_id, code)`.
- [x] Alembic-миграция `j6d8c4b56g23` (batch_alter_table для SQLite).
**Сервис и сидинг:**
- [x] `services/intent_step_graph_service.py`: `ensure_seed_graphs`, `list_graphs`, `get_active_graph`, `set_active_graph`.
- [x] `ensure_seed_graphs` идемпотентен: создаёт активный граф для каждой state-machine-ветки, привязывает существующие шаги (graph_id IS NULL), для `new_booking` восстанавливает архивный 6-шаговый граф из `prompts/intents/new_booking/steps/_archived_v1/*.md` и `_PRE_SPRINT_7_6_ALLOWED_NEXT`.
- [x] Активный граф `new_booking` сжат до 4 шагов: deprecated `present` и `offer_time` удаляются (живут только в архивном v1).
- [x] `SEED_INTENT_STEPS["new_booking"]` обновлён под 4 шага.
**API (`routers/intents.py`):**
- [x] `GET /intents/{code}/step-graphs` — список графов с `steps_count` и `is_active`.
- [x] `POST /intents/{code}/step-graphs/{graph_id}/activate` — переключение активного.
- [x] Чтение шагов (`list_steps_for_intent`, `get_step_by_code`, `get_first_step`) фильтруется по активному графу.
**UI (`static/settings.html`):**
- [x] На вкладке «Шаги» вверху блок «Версии графа шагов» с карточками: имя, кол-во шагов, бейдж «активная» / кнопка «Активировать».
- [x] Заголовок вкладки «Шаги (N)» считается по активному графу: для new_booking активный = 4, после переключения на v1 = 6.
- [x] Раздел «Тест-вопрос от пациента» сделан сворачиваемым.
### Критерий готовности
- [x] В БД 2 графа для `new_booking`: активный v2 (4 шага) и архивный v1 (6 шагов).
- [x] API `/step-graphs` возвращает оба, `/steps` — только шаги активного.
- [x] Переключение через UI меняет `Шаги (4)``Шаги (6)` и список шагов.
- [x] Все остальные ветки получили один активный граф автоматически.
---
## Спринт 8. Мини-eval: роутер, handoff, resumable
### Цель
+2 -1
View File
@@ -25,7 +25,7 @@ logging.basicConfig(
)
from db.session import SessionLocal # noqa: E402
from services import config_service, intent_service, intent_step_service # noqa: E402
from services import config_service, intent_service, intent_step_graph_service, intent_step_service # noqa: E402
from services.embeddings import EmbeddingService # noqa: E402
from services.llm_client import LLMClient # noqa: E402
from services.router_client import RouterClient # noqa: E402
@@ -72,6 +72,7 @@ async def lifespan(app: FastAPI):
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)
await intent_step_graph_service.ensure_seed_graphs(session)
yield
logger.info("Shutting down")
@@ -0,0 +1,62 @@
"""add intent_step_graphs (Спринт 7.7 — версионирование графа шагов)
Revision ID: j6d8c4b56g23
Revises: i5c8b3a45f12
Create Date: 2026-04-28 16:00:00.000000
Версионирование графа шагов state machine. Один intent может иметь несколько
графов; ровно один is_active=True. Существующие intent_steps остаются с
graph_id=NULL после этой миграции; data migration делается в lifespan через
intent_step_graph_service.ensure_seed_graphs (так чище — Alembic не лезет в
бизнес-логику сидинга).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'j6d8c4b56g23'
down_revision: Union[str, None] = 'i5c8b3a45f12'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'intent_step_graphs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('intent_id', sa.Integer(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['intent_id'], ['intents.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('intent_id', 'version', name='uq_intent_step_graph_version'),
)
op.create_index('ix_intent_step_graphs_intent_id', 'intent_step_graphs', ['intent_id'])
# В intent_steps: добавляем graph_id (FK), снимаем старый UNIQUE (intent_id, code),
# ставим новый UNIQUE (graph_id, code). У существующих записей graph_id=NULL — миграция
# данных идёт в lifespan, см. intent_step_graph_service.ensure_seed_graphs.
with op.batch_alter_table('intent_steps', recreate='always') as batch:
batch.add_column(sa.Column('graph_id', sa.Integer(), nullable=True))
batch.create_foreign_key(
'fk_intent_steps_graph_id',
'intent_step_graphs',
['graph_id'], ['id'],
ondelete='CASCADE',
)
batch.drop_constraint('uq_intent_step_code', type_='unique')
batch.create_unique_constraint('uq_intent_step_graph_code', ['graph_id', 'code'])
def downgrade() -> None:
with op.batch_alter_table('intent_steps', recreate='always') as batch:
batch.drop_constraint('uq_intent_step_graph_code', type_='unique')
batch.create_unique_constraint('uq_intent_step_code', ['intent_id', 'code'])
batch.drop_constraint('fk_intent_steps_graph_id', type_='foreignkey')
batch.drop_column('graph_id')
op.drop_index('ix_intent_step_graphs_intent_id', table_name='intent_step_graphs')
op.drop_table('intent_step_graphs')
+17
View File
@@ -245,3 +245,20 @@ class IntentStepListResponse(BaseModel):
intent_code: str
steps: list[IntentStepInfo]
total: int
class IntentStepGraphInfo(BaseModel):
id: int
intent_code: str
version: int
name: str
is_active: bool
steps_count: int
created_at: str
class IntentStepGraphListResponse(BaseModel):
intent_code: str
graphs: list[IntentStepGraphInfo]
active_graph_id: int | None
total: int
@@ -0,0 +1,11 @@
## Шаг «Подтверждение записи» (book)
Задача: проговорить пациенту собранные данные и получить явное «да».
- Кратко повтори 3–4 поля: пациент, специалист, повод, удобное время.
- Задай вопрос «всё верно?».
- Не рассказывай ничего нового на этом шаге.
**Слоты этого шага:** `confirmed` (true после явного «да»).
**Переход:** пациент подтвердил → `state_after: close` и `slots_updated: {"confirmed": true}`. Пациент хочет поправить → `state_after` возвращается на нужный шаг (`qualify`, `offer_time`).
@@ -0,0 +1,11 @@
## Шаг «Завершение» (close)
Задача: закрыть разговор.
- Короткое подтверждение: «Готово, передаю администратору. Свяжемся в течение дня».
- Поблагодари за обращение.
- Не задавай новых вопросов.
**Слоты этого шага:** не меняются.
**Переход:** финальный шаг, `state_after: close` (остаёмся на месте). Если пациент возвращается с новым вопросом — это поймает роутер или exit conditions.
@@ -0,0 +1,11 @@
## Шаг «Приветствие» (intro)
Это первый контакт с пациентом. Задача: поздороваться, узнать, как к нему обращаться.
- Представься коротко: «Здравствуйте, я виртуальный ассистент клиники».
- Спроси, как можно обращаться к пациенту.
- Не уточняй сразу повод, специальность, время — это следующие шаги.
**Слоты этого шага:** `name` (обращение к пациенту).
**Переход:** после того как пациент назвал имя или явно отказался его называть → `state_after: qualify`. Если имя не названо — оставайся на `intro`.
@@ -0,0 +1,11 @@
## Шаг «Удобное время» (offer_time)
Задача: собрать предпочтения пациента по времени.
- Спроси про удобные дни и часы (утро/день/вечер, будни/выходные, конкретные даты если пациент назвал).
- Реального календаря нет — не называй конкретные даты/часы как доступные. Отвечай «сейчас уточню расписание и вернусь с вариантами», если пациент спрашивает конкретику.
- Зафиксируй его предпочтения в слот.
**Слоты этого шага:** `preferred_time` (строка-описание: «утро в будни», «суббота после 14:00», «любое рабочее время»).
**Переход:** предпочтения понятны → `state_after: book`. Если пациент не определился — оставайся на `offer_time`.
@@ -0,0 +1,13 @@
## Шаг «Презентация плана» (present)
Задача: коротко подтвердить пациенту, что записываем — специалист + повод — так, чтобы пациент почувствовал, что его услышали.
- Составь одну-две тёплые фразы, используя уже собранные слоты `name`, `specialist`, `reason`.
- Обязательно упомяни **повод из `reason`** — пациент должен увидеть, что его жалоба учтена. Например: «{name}, записываю вас к {specialist}. На приёме врач осмотрит вас и особое внимание уделит тому, что вас беспокоит — {reason}».
- Не придумывай детали, которых не было (конкретные анализы, процедуры, диагноз) — только повод в формулировке из слота.
- Не предлагай пока слоты времени — это следующий шаг.
- Если пациент возражает или хочет поменять специалиста/повод — откатись обратно на `qualify` и обнови нужный слот.
**Слоты этого шага:** новые не собираются; работаем с уже известными.
**Переход:** пациент согласен с планом → `state_after: offer_time`. Пациент просит поправить специалиста / повод → `state_after: qualify`.
@@ -0,0 +1,52 @@
## Шаг «Повод и специалист» (qualify)
Задача: узнать повод обращения и к какому специалисту записывать. Также на этом шаге нужно выявить три особых ситуации (см. ниже), которые меняют дальнейший сбор данных.
- Спроси про повод без сбора медицинской истории. Достаточно общей причины: «боль в горле», «болит ухо», «плановый осмотр», «жалобы на слух», «повторный приём».
- **Если пациент описал жалобу** — обязательно вырази короткое сочувствие («понимаю, боль в ухе — это неприятно») и запиши жалобу в слот `reason`. Не уточняй степень боли, длительность, выделения — это вопросы для врача.
- Если пациент сам назвал специалиста — зафиксируй в `specialist`.
- Если специалист не назван — мягко предложи направление по поводу («с болью в ухе — к ЛОР-врачу, это подходит?»).
- **Не уходи в `medical_question`** по одному лишь факту жалобы. Это повод для записи, а не повод обсуждать симптомы.
- Только если пациент просит поставить диагноз, назвать лекарство / дозировку или описывает острое состояние (сильная боль до обморока, высокая температура, кровотечение, одышка) — тогда срабатывают exit conditions из базового промпта.
---
### Особая ситуация 1: запись ребёнка
Если пациент говорит, что записывает ребёнка («это для сына/дочки», «ребёнку 5 лет», «записать сына») — зафиксируй `is_child: true`.
При `is_child: true` **обязательно** нужно собрать до перехода на следующий шаг:
- `legal_rep_name` — ФИО законного представителя (родителя или опекуна)
- `legal_rep_phone` — его контактный телефон
Спроси их естественно: «Для записи ребёнка понадобятся ФИО и контактный телефон родителя или опекуна — подскажете?»
Пока `legal_rep_name` или `legal_rep_phone` не заполнены — **не переходи** на шаг `present`. Оставайся на `qualify`, продолжай уточнять.
### Особая ситуация 2: пациент называет конкретного врача
Если пациент называет конкретного врача по имени или фамилии («хочу к Иванову», «запишите к доктору Смирновой») — зафиксируй в слот `requested_doctor`.
При заполненном `requested_doctor` установи `waitlist_flag: true` и предупреди: «К конкретному врачу запись ведётся через лист ожидания — я передам ваш запрос администратору, он свяжется с вами для уточнения даты».
После этого можно двигаться по обычному сценарию.
### Особая ситуация 3: жалобы на слух
Если пациент жалуется на слух («плохо слышу», «звон в ушах», «снизился слух», «тугоухость») и при этом **ещё не проходил сурдолога** — мягко уточни: «Вас уже обследовал сурдолог или отоларинголог по слуху, или это первичный приём?»
Если первичный — предложи начать с ЛОР-врача: зафиксируй `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`.
+56 -2
View File
@@ -3,7 +3,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent, IntentStep
from db.models import Intent, IntentStep, IntentStepGraph
from db.session import get_session
from models.requests import (
IntentDocumentsUpdateRequest,
@@ -14,10 +14,18 @@ from models.responses import (
IntentDocumentsResponse,
IntentInfo,
IntentListResponse,
IntentStepGraphInfo,
IntentStepGraphListResponse,
IntentStepInfo,
IntentStepListResponse,
)
from services import config_service, intent_document_service, intent_service, intent_step_service
from services import (
config_service,
intent_document_service,
intent_service,
intent_step_graph_service,
intent_step_service,
)
logger = logging.getLogger(__name__)
@@ -93,6 +101,52 @@ async def list_intent_steps(code: str, session: AsyncSession = Depends(get_sessi
)
def _graph_to_info(graph: IntentStepGraph, intent_code: str, steps_count: int) -> IntentStepGraphInfo:
return IntentStepGraphInfo(
id=graph.id,
intent_code=intent_code,
version=graph.version,
name=graph.name,
is_active=graph.is_active,
steps_count=steps_count,
created_at=graph.created_at.isoformat(),
)
@router.get("/{code}/step-graphs", response_model=IntentStepGraphListResponse)
async def list_intent_step_graphs(code: str, session: AsyncSession = Depends(get_session)):
intent = await intent_service.get_intent_by_code(session, code)
if intent is None:
raise HTTPException(status_code=404, detail="Intent not found")
pairs = await intent_step_graph_service.list_graphs(session, intent.id)
active_id = next((g.id for g, _ in pairs if g.is_active), None)
return IntentStepGraphListResponse(
intent_code=intent.code,
graphs=[_graph_to_info(g, intent.code, c) for g, c in pairs],
active_graph_id=active_id,
total=len(pairs),
)
@router.post("/{code}/step-graphs/{graph_id}/activate", response_model=IntentStepGraphListResponse)
async def activate_intent_step_graph(
code: str, graph_id: int, session: AsyncSession = Depends(get_session)
):
intent = await intent_service.get_intent_by_code(session, code)
if intent is None:
raise HTTPException(status_code=404, detail="Intent not found")
target = await intent_step_graph_service.set_active_graph(session, intent.id, graph_id)
if target is None:
raise HTTPException(status_code=404, detail="Step graph not found")
pairs = await intent_step_graph_service.list_graphs(session, intent.id)
return IntentStepGraphListResponse(
intent_code=intent.code,
graphs=[_graph_to_info(g, intent.code, c) for g, c in pairs],
active_graph_id=target.id,
total=len(pairs),
)
@router.patch("/{code}/steps/{step_code}", response_model=IntentStepInfo)
async def update_intent_step(
code: str,
+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:
+160 -28
View File
@@ -335,6 +335,12 @@
background: var(--panel);
padding: 14px 16px 16px;
}
/* В сворачиваемой обёртке (Спринт 7.7) внешняя рамка/фон не нужны — их даёт .prompt-block */
.test-query-block .test-query {
border: 0;
border-radius: 0;
background: transparent;
}
.test-query h3 {
margin: 0 0 6px;
font-size: 14px;
@@ -643,6 +649,75 @@
font-weight: 500;
}
/* Версии графа шагов (Спринт 7.7) */
.graph-versions-block {
margin-bottom: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
}
.graph-versions-summary {
list-style: none;
cursor: pointer;
padding: 10px 14px;
font-weight: 600;
font-size: 14px;
position: relative;
padding-left: 32px;
}
.graph-versions-summary::-webkit-details-marker { display: none; }
.graph-versions-summary::before {
content: "▶";
position: absolute;
left: 14px;
transition: transform 0.15s;
font-size: 10px;
}
.graph-versions-block[open] .graph-versions-summary::before { transform: rotate(90deg); }
.graph-versions-block[open] .graph-versions-summary { border-bottom: 1px solid var(--border); }
.graph-versions-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
}
.graph-card {
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
background: #fafbfd;
}
.graph-card.active {
border-color: var(--accent);
background: #f3f6ff;
}
.graph-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.graph-card-name { font-weight: 600; font-size: 13px; }
.graph-card-meta { color: var(--muted); font-size: 12px; margin-top: 4px; font-family: var(--mono); }
.graph-badge.active {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #dcfce7;
color: #166534;
font-weight: 600;
}
.graph-activate {
font-size: 12px;
padding: 4px 10px;
border: 1px solid var(--accent);
background: #fff;
color: var(--accent);
border-radius: 4px;
cursor: pointer;
}
.graph-activate:hover { background: var(--accent); color: #fff; }
/* Список шагов */
.steps-chips {
display: flex;
@@ -837,6 +912,7 @@ let currentIntentCode = null;
let versions = [];
let currentSteps = []; // шаги выбранной ветки (если state machine)
let currentStepCode = null; // выбранный шаг в редакторе
let currentStepGraphs = []; // версии графа шагов выбранной ветки (Спринт 7.7)
let activeTab = "prompt"; // "prompt" | "steps"
function toast(msg, kind = "ok") {
@@ -941,6 +1017,31 @@ async function refreshSteps(code) {
} catch (_) {
currentSteps = [];
}
await refreshStepGraphs(code);
}
async function refreshStepGraphs(code) {
try {
const d = await api(`/intents/${encodeURIComponent(code)}/step-graphs`);
currentStepGraphs = d.graphs || [];
} catch (_) {
currentStepGraphs = [];
}
}
async function activateStepGraph(graphId) {
if (!currentIntentCode) return;
try {
await api(`/intents/${encodeURIComponent(currentIntentCode)}/step-graphs/${graphId}/activate`, {
method: "POST",
});
toast("Версия графа шагов активирована");
currentStepCode = null;
await refreshSteps(currentIntentCode);
renderEditor();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
function renderEditor() {
@@ -1083,38 +1184,40 @@ function renderTestQueryPanel(intent) {
? '<div class="tq-rag-note">У маршрутизатора нет RAG — тест идёт без чанков.</div>'
: '<div class="tq-rag-note">Промпт берётся из черновика выше (даже если он не сохранён). Подписки на документы — те, что сохранены в правом сайдбаре.</div>';
return `
<div class="test-query">
<h3>Тест-вопрос от пациента <span class="tq-meta">— ветка <code>${esc(intent.code)}</code></span></h3>
${ragHint}
<div class="tq-cases" id="tq-cases-bar" style="display:none;">
<span class="tq-cases-label">Готовый кейс:</span>
<input list="tq-cases-list" id="tq-cases-input" placeholder="— выбрать или начать вводить —" autocomplete="off">
<datalist id="tq-cases-list"></datalist>
<button type="button" class="tq-cases-btn" onclick="pickRandomCase()">🎲 Случайный</button>
<span class="tq-cases-count" id="tq-cases-count"></span>
</div>
<textarea id="tq-text" placeholder="Например: где вы находитесь?"></textarea>
<div class="tq-row">
<label>top_k <input type="number" class="tq-num" id="tq-top-k" value="5" min="1" max="20"></label>
<label>temperature <input type="number" class="tq-num" id="tq-temp" value="0.2" min="0" max="2" step="0.1"></label>
<button class="primary" id="tq-btn" onclick="runTestQuery()">Отправить</button>
<span id="tq-status" class="mini"></span>
</div>
<div class="tq-cols">
<div class="tq-col">
<h4>Что нашёл RAG</h4>
<div id="tq-chunks" class="tq-pane"><div class="mini">— пока пусто —</div></div>
<details class="prompt-block test-query-block">
<summary class="prompt-block-summary">Тест-вопрос от пациента <span class="pbs-hint">— ветка <code>${esc(intent.code)}</code></span></summary>
<div class="test-query">
${ragHint}
<div class="tq-cases" id="tq-cases-bar" style="display:none;">
<span class="tq-cases-label">Готовый кейс:</span>
<input list="tq-cases-list" id="tq-cases-input" placeholder="— выбрать или начать вводить —" autocomplete="off">
<datalist id="tq-cases-list"></datalist>
<button type="button" class="tq-cases-btn" onclick="pickRandomCase()">🎲 Случайный</button>
<span class="tq-cases-count" id="tq-cases-count"></span>
</div>
<div class="tq-col">
<h4>Собранный промпт</h4>
<div id="tq-prompt" class="tq-pane"><div class="mini">— пока пусто —</div></div>
<textarea id="tq-text" placeholder="Например: где вы находитесь?"></textarea>
<div class="tq-row">
<label>top_k <input type="number" class="tq-num" id="tq-top-k" value="5" min="1" max="20"></label>
<label>temperature <input type="number" class="tq-num" id="tq-temp" value="0.2" min="0" max="2" step="0.1"></label>
<button class="primary" id="tq-btn" onclick="runTestQuery()">Отправить</button>
<span id="tq-status" class="mini"></span>
</div>
<div class="tq-col">
<h4>Ответ агента</h4>
<div id="tq-answer" class="tq-pane"><div class="mini">— пока пусто —</div></div>
<div class="tq-cols">
<div class="tq-col">
<h4>Что нашёл RAG</h4>
<div id="tq-chunks" class="tq-pane"><div class="mini">— пока пусто —</div></div>
</div>
<div class="tq-col">
<h4>Собранный промпт</h4>
<div id="tq-prompt" class="tq-pane"><div class="mini">— пока пусто —</div></div>
</div>
<div class="tq-col">
<h4>Ответ агента</h4>
<div id="tq-answer" class="tq-pane"><div class="mini">— пока пусто —</div></div>
</div>
</div>
</div>
</div>
</details>
`;
}
@@ -1241,6 +1344,34 @@ async function runTestQuery() {
}
}
function renderStepGraphsBlock() {
if (!currentStepGraphs.length) return "";
const cards = currentStepGraphs.map(g => `
<div class="graph-card ${g.is_active ? 'active' : ''}">
<div class="graph-card-head">
<span class="graph-card-name">${esc(g.name)}</span>
${g.is_active
? '<span class="graph-badge active">активная</span>'
: `<button class="graph-activate" onclick="activateStepGraph(${g.id})">Активировать</button>`}
</div>
<div class="graph-card-meta">v${g.version} · ${g.steps_count} ${pluralSteps(g.steps_count)}</div>
</div>
`).join("");
return `
<details class="graph-versions-block" open>
<summary class="graph-versions-summary">Версии графа шагов <span class="pbs-hint">— активная используется в чате и Песочнице</span></summary>
<div class="graph-versions-list">${cards}</div>
</details>
`;
}
function pluralSteps(n) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return "шаг";
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "шага";
return "шагов";
}
function renderStepsPanel() {
const chips = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
@@ -1249,6 +1380,7 @@ function renderStepsPanel() {
</div>
`).join("");
return `
${renderStepGraphsBlock()}
<div class="steps-chips">${chips}</div>
<div id="step-editor"></div>
`;