From a79b6f9d05bec132777e405516e8536c7a6d4a6c Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Sat, 2 May 2026 14:29:07 +0500 Subject: [PATCH] =?UTF-8?q?feat(sprint7.7):=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=BE=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B3=D1=80=D0=B0=D1=84=D0=B0=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=20=D0=91=D0=94=20+=20UI=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хранить старый 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) --- db/models/__init__.py | 6 +- db/models/intent_step.py | 14 +- db/models/intent_step_graph.py | 39 +++ docs/SPRINTS.md | 38 +++ main.py | 3 +- .../j6d8c4b56g23_add_intent_step_graphs.py | 62 +++++ models/responses.py | 17 ++ .../new_booking/steps/_archived_v1/book.md | 11 + .../new_booking/steps/_archived_v1/close.md | 11 + .../new_booking/steps/_archived_v1/intro.md | 11 + .../steps/_archived_v1/offer_time.md | 11 + .../new_booking/steps/_archived_v1/present.md | 13 + .../new_booking/steps/_archived_v1/qualify.md | 52 ++++ routers/intents.py | 58 ++++- services/chat_service.py | 9 +- services/intent_step_graph_service.py | 235 ++++++++++++++++++ services/intent_step_service.py | 86 ++++--- static/settings.html | 188 +++++++++++--- 18 files changed, 799 insertions(+), 65 deletions(-) create mode 100644 db/models/intent_step_graph.py create mode 100644 migrations/versions/j6d8c4b56g23_add_intent_step_graphs.py create mode 100644 prompts/intents/new_booking/steps/_archived_v1/book.md create mode 100644 prompts/intents/new_booking/steps/_archived_v1/close.md create mode 100644 prompts/intents/new_booking/steps/_archived_v1/intro.md create mode 100644 prompts/intents/new_booking/steps/_archived_v1/offer_time.md create mode 100644 prompts/intents/new_booking/steps/_archived_v1/present.md create mode 100644 prompts/intents/new_booking/steps/_archived_v1/qualify.md create mode 100644 services/intent_step_graph_service.py diff --git a/db/models/__init__.py b/db/models/__init__.py index 0d5ac1a..fa53a3f 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -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", +] diff --git a/db/models/intent_step.py b/db/models/intent_step.py index 7819e2a..3377a81 100644 --- a/db/models/intent_step.py +++ b/db/models/intent_step.py @@ -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) diff --git a/db/models/intent_step_graph.py b/db/models/intent_step_graph.py new file mode 100644 index 0000000..81e4884 --- /dev/null +++ b/db/models/intent_step_graph.py @@ -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 + ) diff --git a/docs/SPRINTS.md b/docs/SPRINTS.md index ffc96cc..1d8b81c 100644 --- a/docs/SPRINTS.md +++ b/docs/SPRINTS.md @@ -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 ### Цель diff --git a/main.py b/main.py index f1dbdda..8ec54eb 100644 --- a/main.py +++ b/main.py @@ -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") diff --git a/migrations/versions/j6d8c4b56g23_add_intent_step_graphs.py b/migrations/versions/j6d8c4b56g23_add_intent_step_graphs.py new file mode 100644 index 0000000..af6f500 --- /dev/null +++ b/migrations/versions/j6d8c4b56g23_add_intent_step_graphs.py @@ -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') diff --git a/models/responses.py b/models/responses.py index fbf43f9..83efeae 100644 --- a/models/responses.py +++ b/models/responses.py @@ -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 diff --git a/prompts/intents/new_booking/steps/_archived_v1/book.md b/prompts/intents/new_booking/steps/_archived_v1/book.md new file mode 100644 index 0000000..a11b892 --- /dev/null +++ b/prompts/intents/new_booking/steps/_archived_v1/book.md @@ -0,0 +1,11 @@ +## Шаг «Подтверждение записи» (book) + +Задача: проговорить пациенту собранные данные и получить явное «да». + +- Кратко повтори 3–4 поля: пациент, специалист, повод, удобное время. +- Задай вопрос «всё верно?». +- Не рассказывай ничего нового на этом шаге. + +**Слоты этого шага:** `confirmed` (true после явного «да»). + +**Переход:** пациент подтвердил → `state_after: close` и `slots_updated: {"confirmed": true}`. Пациент хочет поправить → `state_after` возвращается на нужный шаг (`qualify`, `offer_time`). diff --git a/prompts/intents/new_booking/steps/_archived_v1/close.md b/prompts/intents/new_booking/steps/_archived_v1/close.md new file mode 100644 index 0000000..9749c8b --- /dev/null +++ b/prompts/intents/new_booking/steps/_archived_v1/close.md @@ -0,0 +1,11 @@ +## Шаг «Завершение» (close) + +Задача: закрыть разговор. + +- Короткое подтверждение: «Готово, передаю администратору. Свяжемся в течение дня». +- Поблагодари за обращение. +- Не задавай новых вопросов. + +**Слоты этого шага:** не меняются. + +**Переход:** финальный шаг, `state_after: close` (остаёмся на месте). Если пациент возвращается с новым вопросом — это поймает роутер или exit conditions. diff --git a/prompts/intents/new_booking/steps/_archived_v1/intro.md b/prompts/intents/new_booking/steps/_archived_v1/intro.md new file mode 100644 index 0000000..527ee78 --- /dev/null +++ b/prompts/intents/new_booking/steps/_archived_v1/intro.md @@ -0,0 +1,11 @@ +## Шаг «Приветствие» (intro) + +Это первый контакт с пациентом. Задача: поздороваться, узнать, как к нему обращаться. + +- Представься коротко: «Здравствуйте, я виртуальный ассистент клиники». +- Спроси, как можно обращаться к пациенту. +- Не уточняй сразу повод, специальность, время — это следующие шаги. + +**Слоты этого шага:** `name` (обращение к пациенту). + +**Переход:** после того как пациент назвал имя или явно отказался его называть → `state_after: qualify`. Если имя не названо — оставайся на `intro`. diff --git a/prompts/intents/new_booking/steps/_archived_v1/offer_time.md b/prompts/intents/new_booking/steps/_archived_v1/offer_time.md new file mode 100644 index 0000000..45c3048 --- /dev/null +++ b/prompts/intents/new_booking/steps/_archived_v1/offer_time.md @@ -0,0 +1,11 @@ +## Шаг «Удобное время» (offer_time) + +Задача: собрать предпочтения пациента по времени. + +- Спроси про удобные дни и часы (утро/день/вечер, будни/выходные, конкретные даты если пациент назвал). +- Реального календаря нет — не называй конкретные даты/часы как доступные. Отвечай «сейчас уточню расписание и вернусь с вариантами», если пациент спрашивает конкретику. +- Зафиксируй его предпочтения в слот. + +**Слоты этого шага:** `preferred_time` (строка-описание: «утро в будни», «суббота после 14:00», «любое рабочее время»). + +**Переход:** предпочтения понятны → `state_after: book`. Если пациент не определился — оставайся на `offer_time`. diff --git a/prompts/intents/new_booking/steps/_archived_v1/present.md b/prompts/intents/new_booking/steps/_archived_v1/present.md new file mode 100644 index 0000000..4953305 --- /dev/null +++ b/prompts/intents/new_booking/steps/_archived_v1/present.md @@ -0,0 +1,13 @@ +## Шаг «Презентация плана» (present) + +Задача: коротко подтвердить пациенту, что записываем — специалист + повод — так, чтобы пациент почувствовал, что его услышали. + +- Составь одну-две тёплые фразы, используя уже собранные слоты `name`, `specialist`, `reason`. +- Обязательно упомяни **повод из `reason`** — пациент должен увидеть, что его жалоба учтена. Например: «{name}, записываю вас к {specialist}. На приёме врач осмотрит вас и особое внимание уделит тому, что вас беспокоит — {reason}». +- Не придумывай детали, которых не было (конкретные анализы, процедуры, диагноз) — только повод в формулировке из слота. +- Не предлагай пока слоты времени — это следующий шаг. +- Если пациент возражает или хочет поменять специалиста/повод — откатись обратно на `qualify` и обнови нужный слот. + +**Слоты этого шага:** новые не собираются; работаем с уже известными. + +**Переход:** пациент согласен с планом → `state_after: offer_time`. Пациент просит поправить специалиста / повод → `state_after: qualify`. diff --git a/prompts/intents/new_booking/steps/_archived_v1/qualify.md b/prompts/intents/new_booking/steps/_archived_v1/qualify.md new file mode 100644 index 0000000..dde6c18 --- /dev/null +++ b/prompts/intents/new_booking/steps/_archived_v1/qualify.md @@ -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`. diff --git a/routers/intents.py b/routers/intents.py index 453b82d..dfd069b 100644 --- a/routers/intents.py +++ b/routers/intents.py @@ -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, diff --git a/services/chat_service.py b/services/chat_service.py index 936469a..964d0d7 100644 --- a/services/chat_service.py +++ b/services/chat_service.py @@ -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 = [] diff --git a/services/intent_step_graph_service.py b/services/intent_step_graph_service.py new file mode 100644 index 0000000..6b581a4 --- /dev/null +++ b/services/intent_step_graph_service.py @@ -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, + ) diff --git a/services/intent_step_service.py b/services/intent_step_service.py index 7383d90..75cbae5 100644 --- a/services/intent_step_service.py +++ b/services/intent_step_service.py @@ -13,7 +13,7 @@ from pathlib import Path from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from db.models import Intent, IntentStep +from db.models import Intent, IntentStep, IntentStepGraph logger = logging.getLogger(__name__) @@ -23,10 +23,10 @@ PROMPTS_INTENTS_DIR = Path(__file__).resolve().parent.parent / "prompts" / "inte # Стартовая описание шагов для state-machine-веток. Ключ — code ветки; значение — # список шагов в порядке следования. `allowed_next` описывает граф переходов. SEED_INTENT_STEPS: dict[str, list[dict]] = { - # Спринт 7.6 (вариант 2): воронка сжата с 6 шагов до 4 — `intro → qualify → book → close`. - # Шаги `present` и `offer_time` оставлены в БД как deprecated (на случай отката решения), - # но `qualify` теперь ведёт сразу на `book`, и `book` больше не возвращает на `offer_time`. - # См. docs/OPTIMIZATION_CONVERSION_v1.md, блок C. + # Спринт 7.6/7.7 (вариант 2): активный граф new_booking — ровно 4 шага + # `intro → qualify → book → close`. Старый 6-шаговый сценарий (с `present` + # и `offer_time`) сохранён как архивный граф v1, см. + # `services/intent_step_graph_service._ARCHIVED_NEW_BOOKING_V1_STEPS`. "new_booking": [ { "code": "intro", @@ -47,19 +47,6 @@ SEED_INTENT_STEPS: dict[str, list[dict]] = { }, }, }, - { - "code": "present", - "name": "Презентация плана", - # DEPRECATED (Спринт 7.6): шаг изолирован. Если модель ошибочно туда попала — - # выходим только в `book`, не зацикливаемся. - "allowed_next": ["book"], - }, - { - "code": "offer_time", - "name": "Удобное время", - # DEPRECATED (Спринт 7.6): станет актуален при подключении реального календаря. - "allowed_next": ["offer_time", "book"], - }, { "code": "book", "name": "Подтверждение записи", @@ -74,6 +61,12 @@ SEED_INTENT_STEPS: dict[str, list[dict]] = { } +# Коды шагов, которые когда-то были в активном графе new_booking (Спринт 6a), +# но в Спринте 7.6 удалены. Список используется для одноразовой чистки активного +# графа в `ensure_seed_graphs`. Сами шаги остаются в архивном v1. +_NEW_BOOKING_DEPRECATED_STEP_CODES: set[str] = {"present", "offer_time"} + + # Старые значения allowed_next до Спринта 7.6 — нужны для безопасной миграции # существующих записей в БД (см. migrate_new_booking_allowed_next_v2 ниже). _PRE_SPRINT_7_6_ALLOWED_NEXT: dict[str, list[str]] = { @@ -119,24 +112,38 @@ def parse_guards(step: IntentStep) -> dict: return value if isinstance(value, dict) else {} +def _active_graph_filter(intent_id: int): + """Базовый фильтр: шаги принадлежат активному графу указанного intent. + + Со Спринта 7.7 у ветки может быть несколько графов; чат и UI «Шаги» работают + только с активным. Шаги с graph_id=NULL могут существовать только в окне + миграции до первого вызова ensure_seed_graphs — в нормальной работе их нет. + """ + return ( + select(IntentStep) + .join(IntentStepGraph, IntentStepGraph.id == IntentStep.graph_id) + .where( + IntentStepGraph.intent_id == intent_id, + IntentStepGraph.is_active.is_(True), + ) + ) + + async def list_steps_for_intent(session: AsyncSession, intent_id: int) -> list[IntentStep]: - stmt = select(IntentStep).where(IntentStep.intent_id == intent_id).order_by(IntentStep.order_index, IntentStep.id) + stmt = _active_graph_filter(intent_id).order_by(IntentStep.order_index, IntentStep.id) return list((await session.execute(stmt)).scalars().all()) async def get_step_by_code( session: AsyncSession, intent_id: int, step_code: str ) -> IntentStep | None: - stmt = select(IntentStep).where( - IntentStep.intent_id == intent_id, IntentStep.code == step_code - ) + stmt = _active_graph_filter(intent_id).where(IntentStep.code == step_code) return (await session.execute(stmt)).scalar_one_or_none() async def get_first_step(session: AsyncSession, intent_id: int) -> IntentStep | None: stmt = ( - select(IntentStep) - .where(IntentStep.intent_id == intent_id) + _active_graph_filter(intent_id) .order_by(IntentStep.order_index, IntentStep.id) .limit(1) ) @@ -219,12 +226,21 @@ async def ensure_seed_guards(session: AsyncSession) -> None: seed_guards = step_data.get("guards") if not seed_guards: continue + # Сидинг guards применяется только к шагам активного графа: архивные + # графы (Спринт 7.7) могут иметь свою историю guards и трогать их нельзя. + # Если активного графа ещё нет (первый запуск до ensure_seed_graphs) — + # ищем по graph_id IS NULL. step = (await session.execute( - select(IntentStep).where( - IntentStep.intent_id == intent.id, - IntentStep.code == step_data["code"], - ) + _active_graph_filter(intent.id).where(IntentStep.code == step_data["code"]) )).scalar_one_or_none() + if step is None: + step = (await session.execute( + select(IntentStep).where( + IntentStep.intent_id == intent.id, + IntentStep.code == step_data["code"], + IntentStep.graph_id.is_(None), + ) + )).scalar_one_or_none() if step is None: continue if step.guards_json in ("{}", "", None): @@ -256,7 +272,19 @@ async def migrate_new_booking_allowed_next_v2(session: AsyncSession) -> None: updated = 0 skipped: list[str] = [] - for step in await list_steps_for_intent(session, intent.id): + # Берём шаги активного графа + «бесхозные» (graph_id IS NULL — первый запуск + # до ensure_seed_graphs). Архивные графы трогать нельзя — их allowed_next + # должен остаться дореформенным (Спринт 7.7). + candidates_stmt = ( + select(IntentStep) + .outerjoin(IntentStepGraph, IntentStepGraph.id == IntentStep.graph_id) + .where( + IntentStep.intent_id == intent.id, + (IntentStep.graph_id.is_(None)) | (IntentStepGraph.is_active.is_(True)), + ) + .order_by(IntentStep.order_index, IntentStep.id) + ) + for step in (await session.execute(candidates_stmt)).scalars().all(): old_seed = _PRE_SPRINT_7_6_ALLOWED_NEXT.get(step.code) new_seed_step = seed_by_code.get(step.code) if old_seed is None or new_seed_step is None: diff --git a/static/settings.html b/static/settings.html index 9663cd7..a936b3a 100644 --- a/static/settings.html +++ b/static/settings.html @@ -335,6 +335,12 @@ background: var(--panel); padding: 14px 16px 16px; } + /* В сворачиваемой обёртке (Спринт 7.7) внешняя рамка/фон не нужны — их даёт .prompt-block */ + .test-query-block .test-query { + border: 0; + border-radius: 0; + background: transparent; + } .test-query h3 { margin: 0 0 6px; font-size: 14px; @@ -643,6 +649,75 @@ font-weight: 500; } + /* Версии графа шагов (Спринт 7.7) */ + .graph-versions-block { + margin-bottom: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + } + .graph-versions-summary { + list-style: none; + cursor: pointer; + padding: 10px 14px; + font-weight: 600; + font-size: 14px; + position: relative; + padding-left: 32px; + } + .graph-versions-summary::-webkit-details-marker { display: none; } + .graph-versions-summary::before { + content: "▶"; + position: absolute; + left: 14px; + transition: transform 0.15s; + font-size: 10px; + } + .graph-versions-block[open] .graph-versions-summary::before { transform: rotate(90deg); } + .graph-versions-block[open] .graph-versions-summary { border-bottom: 1px solid var(--border); } + .graph-versions-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + } + .graph-card { + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px 12px; + background: #fafbfd; + } + .graph-card.active { + border-color: var(--accent); + background: #f3f6ff; + } + .graph-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + } + .graph-card-name { font-weight: 600; font-size: 13px; } + .graph-card-meta { color: var(--muted); font-size: 12px; margin-top: 4px; font-family: var(--mono); } + .graph-badge.active { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background: #dcfce7; + color: #166534; + font-weight: 600; + } + .graph-activate { + font-size: 12px; + padding: 4px 10px; + border: 1px solid var(--accent); + background: #fff; + color: var(--accent); + border-radius: 4px; + cursor: pointer; + } + .graph-activate:hover { background: var(--accent); color: #fff; } + /* Список шагов */ .steps-chips { display: flex; @@ -837,6 +912,7 @@ let currentIntentCode = null; let versions = []; let currentSteps = []; // шаги выбранной ветки (если state machine) let currentStepCode = null; // выбранный шаг в редакторе +let currentStepGraphs = []; // версии графа шагов выбранной ветки (Спринт 7.7) let activeTab = "prompt"; // "prompt" | "steps" function toast(msg, kind = "ok") { @@ -941,6 +1017,31 @@ async function refreshSteps(code) { } catch (_) { currentSteps = []; } + await refreshStepGraphs(code); +} + +async function refreshStepGraphs(code) { + try { + const d = await api(`/intents/${encodeURIComponent(code)}/step-graphs`); + currentStepGraphs = d.graphs || []; + } catch (_) { + currentStepGraphs = []; + } +} + +async function activateStepGraph(graphId) { + if (!currentIntentCode) return; + try { + await api(`/intents/${encodeURIComponent(currentIntentCode)}/step-graphs/${graphId}/activate`, { + method: "POST", + }); + toast("Версия графа шагов активирована"); + currentStepCode = null; + await refreshSteps(currentIntentCode); + renderEditor(); + } catch (e) { + toast("Ошибка: " + e.message, "err"); + } } function renderEditor() { @@ -1083,38 +1184,40 @@ function renderTestQueryPanel(intent) { ? '
У маршрутизатора нет RAG — тест идёт без чанков.
' : '
Промпт берётся из черновика выше (даже если он не сохранён). Подписки на документы — те, что сохранены в правом сайдбаре.
'; return ` -
-

Тест-вопрос от пациента — ветка ${esc(intent.code)}

- ${ragHint} - - -
- - - - -
-
-
-

Что нашёл RAG

-
— пока пусто —
+
+ Тест-вопрос от пациента — ветка ${esc(intent.code)} +
+ ${ragHint} + -
-

Собранный промпт

-
— пока пусто —
+ +
+ + + +
-
-

Ответ агента

-
— пока пусто —
+
+
+

Что нашёл RAG

+
— пока пусто —
+
+
+

Собранный промпт

+
— пока пусто —
+
+
+

Ответ агента

+
— пока пусто —
+
-
+
`; } @@ -1241,6 +1344,34 @@ async function runTestQuery() { } } +function renderStepGraphsBlock() { + if (!currentStepGraphs.length) return ""; + const cards = currentStepGraphs.map(g => ` +
+
+ ${esc(g.name)} + ${g.is_active + ? 'активная' + : ``} +
+
v${g.version} · ${g.steps_count} ${pluralSteps(g.steps_count)}
+
+ `).join(""); + return ` +
+ Версии графа шагов — активная используется в чате и Песочнице +
${cards}
+
+ `; +} + +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 => `
`).join(""); return ` + ${renderStepGraphsBlock()}
${chips}
`;