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_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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
### Цель
|
||||
|
||||
@@ -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')
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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.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,10 +226,19 @@ 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(
|
||||
_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:
|
||||
@@ -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:
|
||||
|
||||
+133
-1
@@ -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,8 +1184,9 @@ function renderTestQueryPanel(intent) {
|
||||
? '<div class="tq-rag-note">У маршрутизатора нет RAG — тест идёт без чанков.</div>'
|
||||
: '<div class="tq-rag-note">Промпт берётся из черновика выше (даже если он не сохранён). Подписки на документы — те, что сохранены в правом сайдбаре.</div>';
|
||||
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">
|
||||
<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>
|
||||
@@ -1115,6 +1217,7 @@ function renderTestQueryPanel(intent) {
|
||||
</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>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user