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:
@@ -3,8 +3,12 @@ from db.models.document import Document
|
|||||||
from db.models.intent import Intent
|
from db.models.intent import Intent
|
||||||
from db.models.intent_document import IntentDocument
|
from db.models.intent_document import IntentDocument
|
||||||
from db.models.intent_step import IntentStep
|
from db.models.intent_step import IntentStep
|
||||||
|
from db.models.intent_step_graph import IntentStepGraph
|
||||||
from db.models.message import Message
|
from db.models.message import Message
|
||||||
from db.models.thread import Thread
|
from db.models.thread import Thread
|
||||||
from db.models.thread_state import ThreadState
|
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,20 +11,28 @@ def _utcnow() -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
class IntentStep(Base):
|
class IntentStep(Base):
|
||||||
"""Шаг state machine внутри ветки (Спринт 6a).
|
"""Шаг state machine внутри ветки (Спринт 6a, версионирование — Спринт 7.7).
|
||||||
|
|
||||||
Шаги живут в БД, а не в коде: оператор редактирует промпт шага и список
|
Шаги живут в БД, а не в коде: оператор редактирует промпт шага и список
|
||||||
допустимых переходов через UI «Настройки → Шаги». `allowed_next` и `guards`
|
допустимых переходов через UI «Настройки → Шаги». `allowed_next` и `guards`
|
||||||
хранятся как JSON-строки (парсим в сервисе), чтобы не городить отдельные
|
хранятся как 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"
|
__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)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
intent_id: Mapped[int] = mapped_column(
|
intent_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("intents.id", ondelete="CASCADE"), nullable=False, index=True
|
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)
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
## Спринт 8. Мини-eval: роутер, handoff, resumable
|
||||||
|
|
||||||
### Цель
|
### Цель
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
|
|
||||||
from db.session import SessionLocal # noqa: E402
|
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.embeddings import EmbeddingService # noqa: E402
|
||||||
from services.llm_client import LLMClient # noqa: E402
|
from services.llm_client import LLMClient # noqa: E402
|
||||||
from services.router_client import RouterClient # 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_steps(session)
|
||||||
await intent_step_service.ensure_seed_guards(session)
|
await intent_step_service.ensure_seed_guards(session)
|
||||||
await intent_step_service.migrate_new_booking_allowed_next_v2(session)
|
await intent_step_service.migrate_new_booking_allowed_next_v2(session)
|
||||||
|
await intent_step_graph_service.ensure_seed_graphs(session)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
logger.info("Shutting down")
|
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')
|
||||||
@@ -245,3 +245,20 @@ class IntentStepListResponse(BaseModel):
|
|||||||
intent_code: str
|
intent_code: str
|
||||||
steps: list[IntentStepInfo]
|
steps: list[IntentStepInfo]
|
||||||
total: int
|
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
@@ -3,7 +3,7 @@ import logging
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 db.session import get_session
|
||||||
from models.requests import (
|
from models.requests import (
|
||||||
IntentDocumentsUpdateRequest,
|
IntentDocumentsUpdateRequest,
|
||||||
@@ -14,10 +14,18 @@ from models.responses import (
|
|||||||
IntentDocumentsResponse,
|
IntentDocumentsResponse,
|
||||||
IntentInfo,
|
IntentInfo,
|
||||||
IntentListResponse,
|
IntentListResponse,
|
||||||
|
IntentStepGraphInfo,
|
||||||
|
IntentStepGraphListResponse,
|
||||||
IntentStepInfo,
|
IntentStepInfo,
|
||||||
IntentStepListResponse,
|
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__)
|
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)
|
@router.patch("/{code}/steps/{step_code}", response_model=IntentStepInfo)
|
||||||
async def update_intent_step(
|
async def update_intent_step(
|
||||||
code: str,
|
code: str,
|
||||||
|
|||||||
@@ -750,7 +750,14 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non
|
|||||||
rows = (await session.execute(stmt)).all()
|
rows = (await session.execute(stmt)).all()
|
||||||
|
|
||||||
# Lookup для обогащения старых meta: (intent_id, step_code) -> step_name
|
# 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}
|
step_name_lookup: dict[tuple, str] = {(iid, sc): sn for iid, sc, sn in step_rows}
|
||||||
|
|
||||||
messages = []
|
messages = []
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from db.models import Intent, IntentStep
|
from db.models import Intent, IntentStep, IntentStepGraph
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,10 +23,10 @@ PROMPTS_INTENTS_DIR = Path(__file__).resolve().parent.parent / "prompts" / "inte
|
|||||||
# Стартовая описание шагов для state-machine-веток. Ключ — code ветки; значение —
|
# Стартовая описание шагов для state-machine-веток. Ключ — code ветки; значение —
|
||||||
# список шагов в порядке следования. `allowed_next` описывает граф переходов.
|
# список шагов в порядке следования. `allowed_next` описывает граф переходов.
|
||||||
SEED_INTENT_STEPS: dict[str, list[dict]] = {
|
SEED_INTENT_STEPS: dict[str, list[dict]] = {
|
||||||
# Спринт 7.6 (вариант 2): воронка сжата с 6 шагов до 4 — `intro → qualify → book → close`.
|
# Спринт 7.6/7.7 (вариант 2): активный граф new_booking — ровно 4 шага
|
||||||
# Шаги `present` и `offer_time` оставлены в БД как deprecated (на случай отката решения),
|
# `intro → qualify → book → close`. Старый 6-шаговый сценарий (с `present`
|
||||||
# но `qualify` теперь ведёт сразу на `book`, и `book` больше не возвращает на `offer_time`.
|
# и `offer_time`) сохранён как архивный граф v1, см.
|
||||||
# См. docs/OPTIMIZATION_CONVERSION_v1.md, блок C.
|
# `services/intent_step_graph_service._ARCHIVED_NEW_BOOKING_V1_STEPS`.
|
||||||
"new_booking": [
|
"new_booking": [
|
||||||
{
|
{
|
||||||
"code": "intro",
|
"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",
|
"code": "book",
|
||||||
"name": "Подтверждение записи",
|
"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 — нужны для безопасной миграции
|
# Старые значения allowed_next до Спринта 7.6 — нужны для безопасной миграции
|
||||||
# существующих записей в БД (см. migrate_new_booking_allowed_next_v2 ниже).
|
# существующих записей в БД (см. migrate_new_booking_allowed_next_v2 ниже).
|
||||||
_PRE_SPRINT_7_6_ALLOWED_NEXT: dict[str, list[str]] = {
|
_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 {}
|
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]:
|
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())
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def get_step_by_code(
|
async def get_step_by_code(
|
||||||
session: AsyncSession, intent_id: int, step_code: str
|
session: AsyncSession, intent_id: int, step_code: str
|
||||||
) -> IntentStep | None:
|
) -> IntentStep | None:
|
||||||
stmt = select(IntentStep).where(
|
stmt = _active_graph_filter(intent_id).where(IntentStep.code == step_code)
|
||||||
IntentStep.intent_id == intent_id, IntentStep.code == step_code
|
|
||||||
)
|
|
||||||
return (await session.execute(stmt)).scalar_one_or_none()
|
return (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def get_first_step(session: AsyncSession, intent_id: int) -> IntentStep | None:
|
async def get_first_step(session: AsyncSession, intent_id: int) -> IntentStep | None:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(IntentStep)
|
_active_graph_filter(intent_id)
|
||||||
.where(IntentStep.intent_id == intent_id)
|
|
||||||
.order_by(IntentStep.order_index, IntentStep.id)
|
.order_by(IntentStep.order_index, IntentStep.id)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -219,10 +226,19 @@ async def ensure_seed_guards(session: AsyncSession) -> None:
|
|||||||
seed_guards = step_data.get("guards")
|
seed_guards = step_data.get("guards")
|
||||||
if not seed_guards:
|
if not seed_guards:
|
||||||
continue
|
continue
|
||||||
|
# Сидинг guards применяется только к шагам активного графа: архивные
|
||||||
|
# графы (Спринт 7.7) могут иметь свою историю guards и трогать их нельзя.
|
||||||
|
# Если активного графа ещё нет (первый запуск до ensure_seed_graphs) —
|
||||||
|
# ищем по graph_id IS NULL.
|
||||||
|
step = (await session.execute(
|
||||||
|
_active_graph_filter(intent.id).where(IntentStep.code == step_data["code"])
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if step is None:
|
||||||
step = (await session.execute(
|
step = (await session.execute(
|
||||||
select(IntentStep).where(
|
select(IntentStep).where(
|
||||||
IntentStep.intent_id == intent.id,
|
IntentStep.intent_id == intent.id,
|
||||||
IntentStep.code == step_data["code"],
|
IntentStep.code == step_data["code"],
|
||||||
|
IntentStep.graph_id.is_(None),
|
||||||
)
|
)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if step is None:
|
if step is None:
|
||||||
@@ -256,7 +272,19 @@ async def migrate_new_booking_allowed_next_v2(session: AsyncSession) -> None:
|
|||||||
updated = 0
|
updated = 0
|
||||||
skipped: list[str] = []
|
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)
|
old_seed = _PRE_SPRINT_7_6_ALLOWED_NEXT.get(step.code)
|
||||||
new_seed_step = seed_by_code.get(step.code)
|
new_seed_step = seed_by_code.get(step.code)
|
||||||
if old_seed is None or new_seed_step is None:
|
if old_seed is None or new_seed_step is None:
|
||||||
|
|||||||
+133
-1
@@ -335,6 +335,12 @@
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: 14px 16px 16px;
|
padding: 14px 16px 16px;
|
||||||
}
|
}
|
||||||
|
/* В сворачиваемой обёртке (Спринт 7.7) внешняя рамка/фон не нужны — их даёт .prompt-block */
|
||||||
|
.test-query-block .test-query {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
.test-query h3 {
|
.test-query h3 {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -643,6 +649,75 @@
|
|||||||
font-weight: 500;
|
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 {
|
.steps-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -837,6 +912,7 @@ let currentIntentCode = null;
|
|||||||
let versions = [];
|
let versions = [];
|
||||||
let currentSteps = []; // шаги выбранной ветки (если state machine)
|
let currentSteps = []; // шаги выбранной ветки (если state machine)
|
||||||
let currentStepCode = null; // выбранный шаг в редакторе
|
let currentStepCode = null; // выбранный шаг в редакторе
|
||||||
|
let currentStepGraphs = []; // версии графа шагов выбранной ветки (Спринт 7.7)
|
||||||
let activeTab = "prompt"; // "prompt" | "steps"
|
let activeTab = "prompt"; // "prompt" | "steps"
|
||||||
|
|
||||||
function toast(msg, kind = "ok") {
|
function toast(msg, kind = "ok") {
|
||||||
@@ -941,6 +1017,31 @@ async function refreshSteps(code) {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
currentSteps = [];
|
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() {
|
function renderEditor() {
|
||||||
@@ -1083,8 +1184,9 @@ function renderTestQueryPanel(intent) {
|
|||||||
? '<div class="tq-rag-note">У маршрутизатора нет RAG — тест идёт без чанков.</div>'
|
? '<div class="tq-rag-note">У маршрутизатора нет RAG — тест идёт без чанков.</div>'
|
||||||
: '<div class="tq-rag-note">Промпт берётся из черновика выше (даже если он не сохранён). Подписки на документы — те, что сохранены в правом сайдбаре.</div>';
|
: '<div class="tq-rag-note">Промпт берётся из черновика выше (даже если он не сохранён). Подписки на документы — те, что сохранены в правом сайдбаре.</div>';
|
||||||
return `
|
return `
|
||||||
|
<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">
|
<div class="test-query">
|
||||||
<h3>Тест-вопрос от пациента <span class="tq-meta">— ветка <code>${esc(intent.code)}</code></span></h3>
|
|
||||||
${ragHint}
|
${ragHint}
|
||||||
<div class="tq-cases" id="tq-cases-bar" style="display:none;">
|
<div class="tq-cases" id="tq-cases-bar" style="display:none;">
|
||||||
<span class="tq-cases-label">Готовый кейс:</span>
|
<span class="tq-cases-label">Готовый кейс:</span>
|
||||||
@@ -1115,6 +1217,7 @@ function renderTestQueryPanel(intent) {
|
|||||||
</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() {
|
function renderStepsPanel() {
|
||||||
const chips = currentSteps.map(s => `
|
const chips = currentSteps.map(s => `
|
||||||
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
|
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
|
||||||
@@ -1249,6 +1380,7 @@ function renderStepsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
`).join("");
|
`).join("");
|
||||||
return `
|
return `
|
||||||
|
${renderStepGraphsBlock()}
|
||||||
<div class="steps-chips">${chips}</div>
|
<div class="steps-chips">${chips}</div>
|
||||||
<div id="step-editor"></div>
|
<div id="step-editor"></div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user