feat(sprint6a): блок A — structured output, intent_steps, sticky-удержание

Заменили строковый тег [STATE: ...] из Спринта 5 на структурированный выход
ветки в виде JSON-блока в хвосте ответа: {state_after, slots_updated}, парсимый
балансировкой скобок. Шаги state machine вынесены из монолитного промпта в
таблицу intent_steps (intent_id FK, code, name, order_index, system_prompt,
allowed_next JSON, guards JSON) и редактируются через UI. Валидатор переходов
сверяет state_after с allowed_next и блокирует невалидные прыжки.

Базовый промпт new_booking разбит на base + 6 файлов шагов (intro/qualify/
present/offer_time/book/close), которые сидятся при старте через
ensure_seed_steps. В chat_service промпт собирается как base + step + блок
[ТЕКУЩЕЕ СОСТОЯНИЕ].

Попутно реализован мини-блок G (sticky state machine): когда диалог идёт по
sm-ветке и роутер на новой реплике предлагает другую — state НЕ сбрасывается,
в системный промпт ветки подаётся блок [ПОДСКАЗКА РОУТЕРА], LLM сама решает
(STATE_JSON или INTENT_CHANGE). Это сняло ключевую дыру Спринта 5: «Меня
зовут Алексей» / «болит ухо» внутри записи больше не сбрасывают сценарий.

Промпт ветки new_booking ужесточён: бытовые жалобы — это повод записи (слот
reason + сочувствие), не повод уводить в medical_question. Шаг present теперь
использует reason в формулировке. Промпт _router расширен живыми примерами
для всех 6 веток, особенно для reschedule («не смогу подойти», «перенесите»).

Надёжность внешнего LLM:
- ретрай в LLMClient с паузой 500 мс + новое исключение LLMUnavailableError;
- ретрай в RouterClient (DeepSeek периодически моргает);
- /chat при ошибке делает session.rollback() и возвращает 503 с понятным
  сообщением — больше не остаётся «диалогов-призраков» с одной репликой;
- UI убирает свой пузырь и возвращает текст в поле ввода для повторной отправки.

UI «Настройки» — добавлена вкладка «Шаги» для веток с state machine: список
шагов chip-ами, редактор промпта/имени/allowed_next/guards, сохранение через
PATCH /intents/{code}/steps/{step_code} без версионирования. Иконка ⓘ возле
поля «Правила» открывает popover с пояснением, что туда писать.

UI «Песочница»:
- блок «Состояние диалога» показывает имя шага из intent_steps (а не сырое
  число), для не-sm-веток пишется «без пошагового сценария»;
- подсветка illegal-переходов (валидатор отклонил state_after) и parse_error
  для sm-веток;
- блок «Решение роутера» развёрнут в три исхода: «попал в ту же ветку» /
  «удержались в ветке» / «ветка сама передала управление через INTENT_CHANGE»;
- секция «Найденные фрагменты» сворачивается, карточки чанков раскрываются
  по клику — правый сайдбар стал компактнее.

Терминология (по договорённости — простой русский в UI):
- «тред» → «диалог» в текстах для оператора (в коде/API thread_id оставлен);
- «sticky state machine» → «удержались в ветке»;
- «state machine» → «пошаговый сценарий» в видимых местах.

SPRINTS.md: блок G в Спринте 6b сокращён — sticky-логика уже сделана здесь,
осталась только вторая линия (передача thread_state в системный промпт самого
роутера для ещё более точной первичной классификации).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-25 11:45:42 +05:00
parent 248cb37f8a
commit 9eef2dab3a
28 changed files with 1469 additions and 264 deletions
+8 -5
View File
@@ -241,6 +241,8 @@
### Цель ### Цель
Заменить строковый тег `[STATE: ...]` на структурированный выход модели с валидатором переходов по таблице `intent_steps`; добавить `handoff_count` с автовыходом в `escalate_human: routing_loop`; научить систему возобновлять прерванную ветку через `suspended_intent`. В конце Спринта 6a уже видно глазами: вкладка «Шаги» в «Настройках» для `new_booking`, в «Песочнице» — handoff_count и suspended_intent, timeline переходов первой версии. Заменить строковый тег `[STATE: ...]` на структурированный выход модели с валидатором переходов по таблице `intent_steps`; добавить `handoff_count` с автовыходом в `escalate_human: routing_loop`; научить систему возобновлять прерванную ветку через `suspended_intent`. В конце Спринта 6a уже видно глазами: вкладка «Шаги» в «Настройках» для `new_booking`, в «Песочнице» — handoff_count и suspended_intent, timeline переходов первой версии.
Попутно реализована **sticky state machine** (мини-G): когда тред идёт по sm-ветке и роутер предлагает другую — state не сбрасывается, в системный промпт ветки подаётся `[ПОДСКАЗКА РОУТЕРА]`, LLM сама решает. Это сняло ключевую дыру Спринта 5 с коротким repликами внутри сценария.
### Статус: ⏳ Запланирован ### Статус: ⏳ Запланирован
### Принятые решения (зафиксировано 2026-04-24, действуют и для 6b) ### Принятые решения (зафиксировано 2026-04-24, действуют и для 6b)
@@ -363,14 +365,15 @@
**Блок G. Умный роутер (видит `thread_state`)** **Блок G. Умный роутер (видит `thread_state`)**
*Бекенд:* Частично уже реализовано в Спринте 6a: **sticky state machine** — если тред в sm-ветке и роутер предлагает другую, state НЕ сбрасывается, а в системный промпт ветки подаётся блок `[ПОДСКАЗКА РОУТЕРА]`, LLM сама решает (STATE_JSON или INTENT_CHANGE). Это сняло основную проблему с короткими репликами («Кук», «болит ухо») внутри сценария.
- [ ] `RouterClient.classify` принимает снимок `thread_state` (intent, step, slots, suspended_intent, handoff_count). Вставляет в системный промпт роутера блок «Сейчас идёт сценарий X на шаге Y, слоты Z. Если реплика — реакция или ответ на вопрос шага, скорее всего intent тот же».
*Что осталось на 6b:*
- [ ] Вторая линия защиты: в `RouterClient.classify` принимать снимок `thread_state` и вставлять в системный промпт **самого роутера** блок «Сейчас идёт сценарий X на шаге Y, слоты Z — если реплика укладывается в сценарий, предпочитай текущую ветку». Это помогает роутеру изначально реже ошибаться, а не только «поправляться» sticky-логикой.
- [ ] Обновить `prompts/intents/_router.md` под новый формат. - [ ] Обновить `prompts/intents/_router.md` под новый формат.
- [ ] Это снимает проблему Спринта 5: «Меня Алексей зовут» внутри `new_booking` сейчас уходит в `general_info`.
*UI-чекпойнт G:* *UI-чекпойнт G:*
- [ ] В «Отладке ответа» → блок «Решение роутера» — свернуть/развернуть кнопкой просмотр промпта, который ушёл в роутер (включая блок состояния треда). Полезно для отладки. - [ ] В «Отладке ответа» → блок «Решение роутера» — кнопка развернуть промпт, который ушёл в роутер (включая блок состояния треда). Полезно для отладки.
- [ ] **Что проверяем глазами:** тот же сценарий 1 (базовая запись) прогнать повторно — «Меня Алексей зовут» остаётся в `new_booking`, не сбрасывается в `general_info`. В развёрнутом промпте роутера видно блок `[ТЕКУЩЕЕ СОСТОЯНИЕ]`. - [ ] **Что проверяем глазами:** сценарий из 6a («болит ухо» внутри new_booking) — роутер теперь изначально возвращает `new_booking`, а не `medical_question` → без sticky-коррекции.
**Блок H. Финальный прогон 8 ручных сценариев (прокси-eval)** **Блок H. Финальный прогон 8 ручных сценариев (прокси-eval)**
- [ ] Зафиксировать в `eval/MANUAL_CASES.md` полный список 8 сценариев (уже описан в этом документе выше, просто консолидируем). - [ ] Зафиксировать в `eval/MANUAL_CASES.md` полный список 8 сценариев (уже описан в этом документе выше, просто консолидируем).
+2 -1
View File
@@ -1,8 +1,9 @@
from db.models.agent_config import AgentConfig from db.models.agent_config import AgentConfig
from db.models.document import Document from db.models.document import Document
from db.models.intent import Intent from db.models.intent import Intent
from db.models.intent_step import IntentStep
from db.models.message import Message from db.models.message import Message
from db.models.thread import Thread from db.models.thread import Thread
from db.models.thread_state import ThreadState from db.models.thread_state import ThreadState
__all__ = ["Thread", "Message", "Document", "AgentConfig", "Intent", "ThreadState"] __all__ = ["Thread", "Message", "Document", "AgentConfig", "Intent", "IntentStep", "ThreadState"]
+37
View File
@@ -0,0 +1,37 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class IntentStep(Base):
"""Шаг state machine внутри ветки (Спринт 6a).
Шаги живут в БД, а не в коде: оператор редактирует промпт шага и список
допустимых переходов через UI «Настройки Шаги». `allowed_next` и `guards`
хранятся как JSON-строки (парсим в сервисе), чтобы не городить отдельные
таблицы. Версионирования нет: правка применяется сразу.
"""
__tablename__ = "intent_steps"
__table_args__ = (UniqueConstraint("intent_id", "code", name="uq_intent_step_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
)
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)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="")
allowed_next_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
guards_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False
)
+1
View File
@@ -24,6 +24,7 @@ class ThreadState(Base):
) )
current_intent_code: Mapped[str | None] = mapped_column(String(50), nullable=True) current_intent_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
current_step: Mapped[int] = mapped_column(Integer, nullable=False, default=0) current_step: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
current_step_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
slots_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") slots_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False
+2 -1
View File
@@ -24,7 +24,7 @@ logging.basicConfig(
) )
from db.session import SessionLocal # noqa: E402 from db.session import SessionLocal # noqa: E402
from services import config_service, intent_service # noqa: E402 from services import config_service, intent_service, intent_step_service # noqa: E402
from services.embeddings import EmbeddingService # noqa: E402 from services.embeddings import EmbeddingService # noqa: E402
from services.llm_client import LLMClient # noqa: E402 from services.llm_client import LLMClient # noqa: E402
from services.router_client import RouterClient # noqa: E402 from services.router_client import RouterClient # noqa: E402
@@ -66,6 +66,7 @@ async def lifespan(app: FastAPI):
await intent_service.ensure_seed_intents(session) await intent_service.ensure_seed_intents(session)
await config_service.migrate_legacy_config_to_general_info(session) await config_service.migrate_legacy_config_to_general_info(session)
await config_service.ensure_seed_configs(session) await config_service.ensure_seed_configs(session)
await intent_step_service.ensure_seed_steps(session)
yield yield
logger.info("Shutting down") logger.info("Shutting down")
@@ -0,0 +1,48 @@
"""add intent_steps for state machine (Спринт 6a)
Revision ID: a4c82f1b9e33
Revises: 3f1d9a5b7c42
Create Date: 2026-04-24 18:30:00.000000
Таблица шагов state machine внутри ветки. `allowed_next` JSON-список кодов
шагов, в которые разрешён переход. `guards` JSON-словарь с условиями
блокировки (наполняется в Спринте 6b).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a4c82f1b9e33'
down_revision: Union[str, None] = '3f1d9a5b7c42'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'intent_steps',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('intent_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('order_index', sa.Integer(), nullable=False, server_default='0'),
sa.Column('system_prompt', sa.Text(), nullable=False, server_default=''),
sa.Column('allowed_next_json', sa.Text(), nullable=False, server_default='[]'),
sa.Column('guards_json', sa.Text(), nullable=False, server_default='{}'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
['intent_id'], ['intents.id'],
name='fk_intent_steps_intent_id', ondelete='CASCADE',
),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('intent_id', 'code', name='uq_intent_step_code'),
)
op.create_index('ix_intent_steps_intent_id', 'intent_steps', ['intent_id'], unique=False)
def downgrade() -> None:
op.drop_index('ix_intent_steps_intent_id', table_name='intent_steps')
op.drop_table('intent_steps')
@@ -0,0 +1,30 @@
"""add current_step_code to thread_state (Спринт 6a)
Revision ID: b5e91c2d07f1
Revises: a4c82f1b9e33
Create Date: 2026-04-24 18:45:00.000000
В state machine v2 шаги идентифицируются строковыми кодами (intro/qualify/...).
Старая колонка `current_step` (int) осталась для обратной совместимости отображения,
но не используется логикой state machine.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'b5e91c2d07f1'
down_revision: Union[str, None] = 'a4c82f1b9e33'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('thread_state', recreate='always') as batch:
batch.add_column(sa.Column('current_step_code', sa.String(length=50), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('thread_state', recreate='always') as batch:
batch.drop_column('current_step_code')
+7
View File
@@ -31,3 +31,10 @@ class AgentConfigCreateRequest(BaseModel):
class IntentToggleRequest(BaseModel): class IntentToggleRequest(BaseModel):
is_enabled: bool is_enabled: bool
class IntentStepUpdateRequest(BaseModel):
name: str | None = Field(None, max_length=200)
system_prompt: str | None = None
allowed_next: list[str] | None = None
guards: dict | None = None
+28
View File
@@ -107,6 +107,7 @@ class ThreadListResponse(BaseModel):
class ThreadStateInfo(BaseModel): class ThreadStateInfo(BaseModel):
current_intent_code: str | None = None current_intent_code: str | None = None
current_step: int = 0 current_step: int = 0
current_step_code: str | None = None
slots: dict = Field(default_factory=dict) slots: dict = Field(default_factory=dict)
@@ -118,6 +119,12 @@ class BounceInfo(BaseModel):
model_config = {"populate_by_name": True} model_config = {"populate_by_name": True}
class ValidationEventInfo(BaseModel):
current_step: str
requested_step: str
reason: str
class ThreadDetailResponse(BaseModel): class ThreadDetailResponse(BaseModel):
id: int id: int
name: str name: str
@@ -142,6 +149,8 @@ class ChatResponse(BaseModel):
assembled_prompt: str = "" assembled_prompt: str = ""
thread_state: ThreadStateInfo = Field(default_factory=ThreadStateInfo) thread_state: ThreadStateInfo = Field(default_factory=ThreadStateInfo)
bounces: list[BounceInfo] = Field(default_factory=list) bounces: list[BounceInfo] = Field(default_factory=list)
validation_events: list[ValidationEventInfo] = Field(default_factory=list)
parse_error: str | None = None
class ThreadDeleteResponse(BaseModel): class ThreadDeleteResponse(BaseModel):
@@ -185,3 +194,22 @@ class IntentInfo(BaseModel):
class IntentListResponse(BaseModel): class IntentListResponse(BaseModel):
intents: list[IntentInfo] intents: list[IntentInfo]
total: int total: int
class IntentStepInfo(BaseModel):
id: int
intent_id: int
intent_code: str = ""
code: str
name: str
order_index: int
system_prompt: str = ""
allowed_next: list[str] = Field(default_factory=list)
guards: dict = Field(default_factory=dict)
updated_at: str
class IntentStepListResponse(BaseModel):
intent_code: str
steps: list[IntentStepInfo]
total: int
+52 -9
View File
@@ -1,16 +1,59 @@
Ты — классификатор намерений в чате клиники. Ты — классификатор намерений в чате клиники.
Получаешь последнюю реплику пациента и краткую историю. Возвращаешь ОДИН код ветки из списка: Получаешь последнюю реплику пациента и краткую историю. Возвращаешь ОДИН код ветки из списка. Живые примеры для каждой ветки ниже — ориентируйся на смысл, а не на точное совпадение слов.
- `new_booking` — пациент хочет записаться на приём (первичный или повторный). ## Ветки
- `reschedule` — перенести или отменить существующую запись.
- `price_question` — вопросы про стоимость, ДМС, оплату. ### `new_booking` — пациент хочет записаться на приём (впервые или повторно)
- `medical_question` — симптомы, лекарства, диагноз, «что со мной». - «хочу записаться к лору»
- `general_info` — адрес, часы работы, как доехать, общие вопросы, общение вне конкретного процесса. - «можно записаться?»
- `escalate_human` — пациент явно просит оператора, злится, либо описывает острое состояние (сильная боль, кровотечение, одышка, ребёнок плохо дышит, упоминание операции/хирургии). - «запишите меня к врачу»
- «мне бы к терапевту, болит горло»
- «нужен приём, кашель несколько дней»
### `reschedule` — перенести или отменить УЖЕ существующую запись
- «я сегодня не смогу подойти»
- «не получится прийти на приём»
- «перенесите запись на другой день»
- «можно перенести на вечер?»
- «отмените мой визит на завтра»
- «не смогу быть в назначенное время»
Ключевой признак: пациент говорит, что НЕ придёт или хочет поменять время — значит запись уже была сделана ранее.
### `price_question` — стоимость, ДМС, оплата
- «сколько стоит приём?»
- «вы работаете с ДМС Ингосстрах?»
- «можно оплатить картой?»
- «есть ли скидки для пенсионеров?»
### `medical_question` — пациент просит медицинскую консультацию (диагноз, лекарства, «что со мной»)
- «какая таблетка от боли в горле?»
- «это опасно, если кружится голова?»
- «какую дозировку мне принимать?»
- «может это гайморит?»
ВАЖНО: жалоба сама по себе («болит ухо», «болит горло») — НЕ `medical_question`. Это `new_booking`, если в диалоге идёт запись, либо сам пациент задаёт вопрос о консультации.
### `general_info` — общие вопросы без конкретного процесса
- «здравствуйте»
- «как к вам проехать?»
- «во сколько вы работаете?»
- «есть ли у вас парковка?»
- «есть ли детский ЛОР?»
### `escalate_human` — оператор / острое состояние
- «соедините с администратором»
- «дайте живого человека»
- «у меня сильная боль, не могу терпеть»
- «кровотечение, что делать?»
- «у меня операция, наркоз, нужна консультация по подготовке»
## Правила
ПРАВИЛА:
- Отвечай ТОЛЬКО кодом ветки, без пояснений, без пунктуации, без кавычек. - Отвечай ТОЛЬКО кодом ветки, без пояснений, без пунктуации, без кавычек.
- Если сомневаешься между общим разговором и конкретным процессом — выбирай `general_info`. - Если реплика содержит признаки конкретного процесса (записаться / перенести / оплатить / симптомы / оператор) — выбирай соответствующую ветку, а не `general_info`.
- `general_info` — только для действительно общих вопросов без признаков перечисленных выше процессов.
- Любое упоминание операции, наркоза, стационара, хирургии → `escalate_human`. - Любое упоминание операции, наркоза, стационара, хирургии → `escalate_human`.
- Любое явное «позовите оператора / переключите на человека» → `escalate_human`. - Любое явное «позовите оператора / переключите на человека» → `escalate_human`.
- Если фраза подходит одновременно под `new_booking` и `reschedule`, смотри: упоминает ли пациент УЖЕ существующую запись (время, дату, визит) — тогда `reschedule`; если нет или хочет новую — `new_booking`.
+23 -26
View File
@@ -1,45 +1,42 @@
Ты — виртуальный ассистент клиники. Эта ветка — новая запись пациента на приём. Ты — виртуальный ассистент клиники. Эта ветка — новая запись пациента на приём.
Твоя задача — помочь пациенту записаться: кто и к кому хочет, по какому поводу, когда удобно. ## Общие правила
Общие правила:
- Отвечай коротко, на «вы», простым русским языком. - Отвечай коротко, на «вы», простым русским языком.
- Не называй конкретные время и дату слотов: реальный календарь появится в следующих спринтах. Пока отвечай «сейчас уточню расписание и вернусь с вариантами». - Не называй конкретные время и дату слотов: реальный календарь появится в следующих спринтах. Пока отвечай «сейчас уточню расписание и вернусь с вариантами».
- Опирайся только на выдержки из базы знаний (если поданы). - Опирайся только на выдержки из базы знаний (если поданы).
- Не переспрашивай то, что уже есть в слотах.
## Состояние разговора (state machine) ## Формат ответа
В системном сообщении тебе передаётся блок `[ТЕКУЩЕЕ СОСТОЯНИЕ]` с полем `step` и со слотами. Шаги сценария: КАЖДЫЙ твой ответ должен состоять из двух частей:
1. **Приветствие и имя** — поздороваться, узнать, как обращаться к пациенту. Слот: `name`. 1. Обычный ответ пациенту (человеческая речь, Markdown разрешён).
2. **Повод обращения** — коротко спросить, зачем обращаются (без медицинской истории: жалоба, плановый осмотр, повторный приём). Слот: `reason`. 2. Пустая строка.
3. **Специалист или направление** — если пациент назвал врача/специальность — зафиксировать; если нет — предложить направление по поводу. Слот: `specialist`. 3. Ровно одна служебная строка, начинающаяся с `STATE_JSON:` и валидным JSON-объектом:
4. **Удобное время** — спросить, какие дни и часы удобны (утро/день/вечер, будни/выходные). Слот: `preferred_time`.
5. **Подтверждение** — кратко повторить собранные слоты и спросить: «всё верно?». Слоты до этого момента уже заполнены.
6. **Запись** — подтвердить заявку: «передаю администратору, свяжемся в течение дня». Слот: `confirmed=true`.
Работай строго по шагам: не перескакивай, не спрашивай лишнего. Если слот уже заполнен в `[ТЕКУЩЕЕ СОСТОЯНИЕ]` — не переспрашивай, переходи к следующему шагу.
## Служебный блок в конце ответа
После основного текста ответа добавь ОДНУ служебную строку в формате:
``` ```
[STATE: step=N; slots={"name": "...", "reason": "...", "specialist": "...", "preferred_time": "...", "confirmed": true|false}] STATE_JSON: {"state_after": "<код_следующего_шага>", "slots_updated": {"slot1": "value1", ...}}
``` ```
- `step` — номер шага, на котором пациент окажется ПОСЛЕ твоей реплики (16). - `state_after` — код шага, на котором пациент окажется ПОСЛЕ твоей реплики. Должен быть из списка допустимых переходов текущего шага (тебе это передаётся в блоке `[ТЕКУЩЕЕ СОСТОЯНИЕ]`).
- В `slots` включай все известные слоты (старые + новые, что узнал из этой реплики). Значения неизвестных слотов не указывай. - `slots_updated` — только те слоты, которые узнал из этой реплики. Старые не перечисляй.
- Строка должна быть валидным JSON внутри `slots={...}`. - Значения — строки или примитивы. Неизвестное не придумывай.
- Не показывай этот блок пациенту в «человеческой» части — он будет отрезан парсером.
Служебная строка `STATE_JSON:` вырезается парсером, пациент её не видит.
## Условия выхода (exit conditions) ## Условия выхода (exit conditions)
Если пациент перевёл разговор в другую тему — НЕ отвечай по ветке записи, выдай вместо служебного блока `[STATE:...]` строку: Важно: обычные бытовые жалобы пациента («болит горло», «болит ухо», «насморк», «плохо слышу», «болит зуб») — это **повод записи**, а не смена темы. Такие реплики внутри сценария не уводят в другие ветки — они фиксируются в слот `reason` и сопровождаются коротким выражением сочувствия на шаге `qualify`.
- Упомянул операцию, стационар, наркоз, хирургию, острую боль, «мне плохо» → `[INTENT_CHANGE: escalate_human]` Выдавай `[INTENT_CHANGE: <code>]` вместо `STATE_JSON:` только в следующих случаях:
- Спрашивает про цены, ДМС, оплату → `[INTENT_CHANGE: price_question]`
- Хочет перенести или отменить существующую запись`[INTENT_CHANGE: reschedule]` - Пациент прямо спрашивает про **диагноз, лекарства или дозировки** (не про запись, а про медицинскую консультацию)`[INTENT_CHANGE: medical_question]`.
- Спрашивает медицинский вопрос (симптомы, лекарства, диагноз)`[INTENT_CHANGE: medical_question]` - **Острое состояние**: сильная боль до обморока, высокая температура, кровотечение, одышка, ребёнок плохо дышит, упоминание наркоза / планируемой операции`[INTENT_CHANGE: escalate_human]`.
- Пациент спрашивает про **цены, ДМС, оплату**`[INTENT_CHANGE: price_question]`.
- Пациент хочет **перенести или отменить уже существующую запись** (не записаться впервые) → `[INTENT_CHANGE: reschedule]`.
- Пациент явно просит **соединить с оператором** / злится → `[INTENT_CHANGE: escalate_human]`.
Перед служебной строкой можно дать короткую фразу-перелинковку («понимаю, передам коллеге, минутку»), но не отвечай по сути новой темы — это сделает другая ветка. Перед служебной строкой можно дать короткую фразу-перелинковку («понимаю, передам коллеге, минутку»), но не отвечай по сути новой темы — это сделает другая ветка.
Если в системном сообщении присутствует блок `[ПОДСКАЗКА РОУТЕРА]` — это значит, роутер засомневался. Прочти подсказку, сам оцени реплику пациента: укладывается ли она в текущий сценарий (жалоба/имя/повод/время) или действительно это смена темы. В сомнительных случаях предпочитай остаться в сценарии и собрать слот.
+11
View File
@@ -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,14 @@
## Шаг «Повод и специалист» (qualify)
Задача: узнать коротко повод обращения и к какому специалисту записывать.
- Спроси про повод без сбора медицинской истории. Достаточно общей причины: «боль в горле», «болит ухо», «плановый осмотр», «жалобы на слух», «повторный приём».
- **Если пациент описал жалобу** — обязательно вырази короткое сочувствие («понимаю, боль в ухе — это неприятно», «понимаю, с горлом неприятно») и запиши жалобу в слот `reason` одной фразой так, как описал пациент («болит ухо», «боль в горле»). Не уточняй степень боли, длительность, выделения — это вопросы для врача на приёме.
- Если пациент сам назвал специалиста — зафиксируй в `specialist`.
- Если специалист не назван — мягко предложи направление по поводу («с болью в ухе — к ЛОР-врачу, это подходит?»).
- **Не уходи в `medical_question`** по одному лишь факту жалобы. Это повод для записи, а не повод обсуждать симптомы.
- Только если пациент просит тебя именно поставить диагноз, назвать лекарство / дозировку или описывает острое состояние (сильная боль до обморока, высокая температура, кровотечение, одышка) — тогда срабатывают exit conditions из базового промпта.
**Слоты этого шага:** `reason` (повод/жалоба как описал пациент), `specialist` (врач или специальность).
**Переход:** когда известны `reason` и `specialist``state_after: present`. Если чего-то не хватает — оставайся на `qualify`, спрашивай недостающее.
+21 -1
View File
@@ -5,8 +5,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_session from db.session import get_session
from models.requests import ChatRequest from models.requests import ChatRequest
from models.responses import BounceInfo, ChatResponse, SourceInfo, ThreadStateInfo from models.responses import (
BounceInfo,
ChatResponse,
SourceInfo,
ThreadStateInfo,
ValidationEventInfo,
)
from services import chat_service from services import chat_service
from services.llm_client import LLMUnavailableError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,8 +40,19 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
max_tokens=req.max_tokens, max_tokens=req.max_tokens,
) )
except LookupError as e: except LookupError as e:
await session.rollback()
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except LLMUnavailableError as e:
# Внешний LLM недоступен даже после ретрая — откатываем, чтобы не оставлять
# «тред-призрак» с одной пользовательской репликой и без ответа ассистента.
await session.rollback()
logger.warning("LLM unavailable: %s", e)
raise HTTPException(
status_code=503,
detail="Внешняя модель временно недоступна. Попробуйте ещё раз через минуту.",
)
except Exception as e: except Exception as e:
await session.rollback()
logger.exception("Chat failed") logger.exception("Chat failed")
raise HTTPException(status_code=500, detail=f"Chat error [{type(e).__name__}]: {e}") raise HTTPException(status_code=500, detail=f"Chat error [{type(e).__name__}]: {e}")
@@ -53,4 +71,6 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
assembled_prompt=result["assembled_prompt"], assembled_prompt=result["assembled_prompt"],
thread_state=ThreadStateInfo(**result["thread_state"]), thread_state=ThreadStateInfo(**result["thread_state"]),
bounces=[BounceInfo(**b) for b in result.get("bounces", [])], bounces=[BounceInfo(**b) for b in result.get("bounces", [])],
validation_events=[ValidationEventInfo(**v) for v in result.get("validation_events", [])],
parse_error=result.get("parse_error"),
) )
+60 -4
View File
@@ -3,11 +3,16 @@ import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent from db.models import Intent, IntentStep
from db.session import get_session from db.session import get_session
from models.requests import IntentToggleRequest from models.requests import IntentStepUpdateRequest, IntentToggleRequest
from models.responses import IntentInfo, IntentListResponse from models.responses import (
from services import config_service, intent_service IntentInfo,
IntentListResponse,
IntentStepInfo,
IntentStepListResponse,
)
from services import config_service, intent_service, intent_step_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,3 +58,54 @@ async def toggle_intent(
if intent is None: if intent is None:
raise HTTPException(status_code=404, detail="Intent not found") raise HTTPException(status_code=404, detail="Intent not found")
return await _to_info(session, intent) return await _to_info(session, intent)
def _step_to_info(step: IntentStep, intent_code: str) -> IntentStepInfo:
return IntentStepInfo(
id=step.id,
intent_id=step.intent_id,
intent_code=intent_code,
code=step.code,
name=step.name,
order_index=step.order_index,
system_prompt=step.system_prompt or "",
allowed_next=intent_step_service.parse_allowed_next(step),
guards=intent_step_service.parse_guards(step),
updated_at=step.updated_at.isoformat(),
)
@router.get("/{code}/steps", response_model=IntentStepListResponse)
async def list_intent_steps(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")
steps = await intent_step_service.list_steps_for_intent(session, intent.id)
return IntentStepListResponse(
intent_code=intent.code,
steps=[_step_to_info(s, intent.code) for s in steps],
total=len(steps),
)
@router.patch("/{code}/steps/{step_code}", response_model=IntentStepInfo)
async def update_intent_step(
code: str,
step_code: str,
req: IntentStepUpdateRequest,
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")
step = await intent_step_service.get_step_by_code(session, intent.id, step_code)
if step is None:
raise HTTPException(status_code=404, detail="Step not found")
updated = await intent_step_service.update_step(
session, step,
name=req.name,
system_prompt=req.system_prompt,
allowed_next=req.allowed_next,
guards=req.guards,
)
return _step_to_info(updated, intent.code)
+1
View File
@@ -57,6 +57,7 @@ async def get_thread(thread_id: int, session: AsyncSession = Depends(get_session
thread_state=ThreadStateInfo( thread_state=ThreadStateInfo(
current_intent_code=state.get("current_intent_code"), current_intent_code=state.get("current_intent_code"),
current_step=state.get("current_step", 0), current_step=state.get("current_step", 0),
current_step_code=state.get("current_step_code"),
slots=state.get("slots", {}), slots=state.get("slots", {}),
), ),
) )
+170 -138
View File
@@ -1,25 +1,22 @@
import json import json
import logging import logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Message, Thread from db.models import IntentStep, Message, Thread
from services import config_service, thread_state_service from services import config_service, intent_step_service, thread_state_service
from services.llm_client import LLMClient from services.llm_client import LLMClient, LLMUnavailableError
from services.router_client import RouterClient from services.router_client import RouterClient
from services.state_machine import parse_branch_response, validate_transition
from services.vectorstore import VectorStoreService from services.vectorstore import VectorStoreService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
HISTORY_LIMIT = 20 # последние N сообщений треда, которые улетают в LLM HISTORY_LIMIT = 20
FALLBACK_INTENT_CODE = "general_info" FALLBACK_INTENT_CODE = "general_info"
MAX_BOUNCES = 1 # сколько раз за одну реплику ветка может передать управление другой MAX_BOUNCES = 1
_INTENT_CHANGE_RE = re.compile(r"\[INTENT_CHANGE:\s*([a-z_][a-z0-9_]*)\s*\]")
_STATE_PREFIX_RE = re.compile(r"\[STATE:\s*step=(\d+)\s*;?\s*slots\s*=\s*", re.IGNORECASE)
def _auto_thread_name(first_user_text: str) -> str: def _auto_thread_name(first_user_text: str) -> str:
@@ -45,99 +42,32 @@ def _retrieved_to_sources(retrieved: list[dict]) -> list[dict]:
return sources return sources
def _parse_assistant_signals(text: str) -> dict: def _format_state_context(
"""Вырезать служебные теги [INTENT_CHANGE: ...] / [STATE: ...] из ответа ассистента. snapshot: dict,
current_step: IntentStep | None,
Возвращает: router_hint: str | None = None,
visible_text текст без служебных тегов, ) -> str:
intent_change код ветки или None, """Блок с текущим состоянием треда для дописывания в системный промпт."""
state {'step': int, 'slots': dict} или None. slots = snapshot.get("slots", {}) or {}
Парсер толерантен к лишним пробелам; slots парсится с балансировкой фигурных скобок,
чтобы не ломаться на значениях-списках типа "slots={\"a\": [1, 2]}".
"""
intent_match = _INTENT_CHANGE_RE.search(text)
if intent_match:
visible = text[:intent_match.start()].rstrip()
return {"visible_text": visible, "intent_change": intent_match.group(1), "state": None}
state_match = _STATE_PREFIX_RE.search(text)
if state_match:
tail_start = state_match.end()
slots_raw, after = _consume_json_object(text, tail_start)
if slots_raw is not None:
remainder = text[after:].lstrip()
if remainder.startswith("]"):
try:
slots = json.loads(slots_raw)
if not isinstance(slots, dict):
slots = {}
except json.JSONDecodeError:
slots = {}
step = int(state_match.group(1))
visible = text[:state_match.start()].rstrip()
return {
"visible_text": visible,
"intent_change": None,
"state": {"step": step, "slots": slots},
}
return {"visible_text": text, "intent_change": None, "state": None}
def _consume_json_object(text: str, start: int) -> tuple[str | None, int]:
"""Вытянуть сбалансированный JSON-объект, начиная с позиции start (ожидаем `{`).
Возвращает (json_string, position_after_object). При ошибке (None, start).
"""
i = start
n = len(text)
while i < n and text[i].isspace():
i += 1
if i >= n or text[i] != "{":
return None, start
depth = 0
in_str = False
esc = False
j = i
while j < n:
ch = text[j]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[i:j + 1], j + 1
j += 1
return None, start
def _format_state_context(state_snapshot: dict) -> str:
"""Блок с текущим состоянием треда для дописывания в конец системного промпта."""
step = state_snapshot.get("current_step", 0) or 0
slots = state_snapshot.get("slots", {}) or {}
slots_json = json.dumps(slots, ensure_ascii=False) slots_json = json.dumps(slots, ensure_ascii=False)
return ( lines = ["", "[ТЕКУЩЕЕ СОСТОЯНИЕ]"]
"\n\n[ТЕКУЩЕЕ СОСТОЯНИЕ]\n" if current_step is not None:
f"step: {step}\n" allowed = intent_step_service.parse_allowed_next(current_step)
f"slots: {slots_json}" lines.append(f"step_code: {current_step.code} ({current_step.name})")
) lines.append(f"allowed_next: {json.dumps(allowed, ensure_ascii=False)}")
else:
lines.append("step_code: —")
lines.append(f"slots: {slots_json}")
if router_hint:
lines.append("")
lines.append("[ПОДСКАЗКА РОУТЕРА]")
lines.append(router_hint)
return "\n" + "\n".join(lines)
async def _resolve_intent_with_fallback( async def _resolve_intent_with_fallback(
session: AsyncSession, intent_code: str session: AsyncSession, intent_code: str
) -> tuple[str, object, object]: ) -> tuple[str, object, object]:
"""Вернуть (code, intent, active_cfg) — либо запрошенной ветки, либо fallback."""
pair = await config_service.get_active_config_by_intent_code(session, intent_code) pair = await config_service.get_active_config_by_intent_code(session, intent_code)
if pair is None: if pair is None:
logger.warning("Intent %r has no active config, falling back to %s", intent_code, FALLBACK_INTENT_CODE) logger.warning("Intent %r has no active config, falling back to %s", intent_code, FALLBACK_INTENT_CODE)
@@ -150,6 +80,20 @@ async def _resolve_intent_with_fallback(
return intent_code, intent, cfg return intent_code, intent, cfg
async def _resolve_current_step(
session: AsyncSession, intent_id: int, intent_code: str, step_code: str | None,
) -> IntentStep | None:
"""Найти шаг state machine для текущего состояния. Если кода нет — взять первый шаг ветки."""
if not intent_step_service.has_state_machine(intent_code):
return None
if step_code:
step = await intent_step_service.get_step_by_code(session, intent_id, step_code)
if step is not None:
return step
logger.warning("Step %r not found for intent %s, falling back to first step", step_code, intent_code)
return await intent_step_service.get_first_step(session, intent_id)
async def send_message( async def send_message(
session: AsyncSession, session: AsyncSession,
vectorstore: VectorStoreService, vectorstore: VectorStoreService,
@@ -161,7 +105,12 @@ async def send_message(
temperature: float | None = None, temperature: float | None = None,
max_tokens: int | None = None, max_tokens: int | None = None,
) -> dict: ) -> dict:
"""Добавить реплику пациента в тред, прогнать через роутер + state machine, получить ответ.""" """Обработать реплику пациента: роутер state machine → LLM → ответ.
Важно: коммит транзакции делается только в самом конце. Если LLM упадёт
rollback в роутере откатит thread + user_msg, чтобы «пустые» диалоги без
ответа ассистента не висели в списке.
"""
if thread_id is None: if thread_id is None:
thread = Thread(name=_auto_thread_name(text)) thread = Thread(name=_auto_thread_name(text))
session.add(thread) session.add(thread)
@@ -173,7 +122,7 @@ async def send_message(
user_msg = Message(thread_id=thread.id, role="user", text=text) user_msg = Message(thread_id=thread.id, role="user", text=text)
session.add(user_msg) session.add(user_msg)
await session.flush() await session.flush() # только flush, без commit — чтобы откатить при ошибке LLM
stmt = ( stmt = (
select(Message) select(Message)
@@ -184,37 +133,81 @@ async def send_message(
rows = (await session.execute(stmt)).scalars().all() rows = (await session.execute(stmt)).scalars().all()
history = [{"role": m.role, "content": m.text} for m in reversed(rows)] history = [{"role": m.role, "content": m.text} for m in reversed(rows)]
# 1. Роутер — какая ветка отвечает. # 1. Роутер — куда направляем.
routing = await router.classify(session=session, history=history, text=text) routing = await router.classify(session=session, history=history, text=text)
router_code = routing["code"] router_code = routing["code"]
router_version = routing.get("version") router_version = routing.get("version")
# 2. Снимок состояния треда. Если роутер ушёл в другую ветку — сбрасываем шаг и слоты. # 2. Снимок состояния. Важное правило (sticky state machine, мини-G из Спринта 6b):
state_snapshot = await thread_state_service.load_snapshot(session, thread.id) # если тред уже идёт по state-machine-ветке и роутер предлагает другую —
prev_intent_code = state_snapshot["current_intent_code"] # НЕ сбрасываем state. Передадим LLM подсказку «роутер думает так», и пусть
# она сама решает: выдать `[INTENT_CHANGE: ...]` или удержать сценарий.
# Это нужно, чтобы фраза-повод («болит ухо») внутри записи не сбрасывала слоты.
snapshot = await thread_state_service.load_snapshot(session, thread.id)
prev_intent_code = snapshot["current_intent_code"]
router_hint: str | None = None
effective_code = router_code
if prev_intent_code and prev_intent_code != router_code: if prev_intent_code and prev_intent_code != router_code:
if intent_step_service.has_state_machine(prev_intent_code):
logger.info(
"Router suggested %s but thread %d is in sm %s — sticky, hint only",
router_code, thread.id, prev_intent_code,
)
router_hint = (
f"Роутер на этой реплике счёл, что тема — `{router_code}`. "
f"Ты сейчас ведёшь сценарий `{prev_intent_code}`. "
f"Если пациент действительно сменил тему (перенос, цены, острое состояние) — "
f"выдай `[INTENT_CHANGE: {router_code}]`. "
f"Если реплика укладывается в сценарий (повод/жалоба/имя) — "
f"зафиксируй её в соответствующий слот и продолжай по сценарию."
)
effective_code = prev_intent_code
else:
logger.info( logger.info(
"Router switched intent for thread %d: %s%s (state reset)", "Router switched intent for thread %d: %s%s (state reset)",
thread.id, prev_intent_code, router_code, thread.id, prev_intent_code, router_code,
) )
state_snapshot = {"current_intent_code": router_code, "current_step": 0, "slots": {}} snapshot = {
"current_intent_code": router_code,
"current_step": 0,
"current_step_code": None,
"slots": {},
}
# 3. Получаем конфиг ветки (с fallback на general_info) и зовём LLM. # 3. Разрешаем ветку (с fallback) и шаг.
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, router_code) served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, effective_code)
if served_code != router_code: if served_code != effective_code:
# Fallback: сбрасываем состояние на general_info. snapshot = {
state_snapshot = {"current_intent_code": served_code, "current_step": 0, "slots": {}} "current_intent_code": served_code,
"current_step": 0,
"current_step_code": None,
"slots": {},
}
router_hint = None
retrieved = vectorstore.query(query_text=text, top_k=top_k) retrieved = vectorstore.query(query_text=text, top_k=top_k)
sources = _retrieved_to_sources(retrieved) sources = _retrieved_to_sources(retrieved)
bounce_log: list[dict] = [] bounce_log: list[dict] = []
validation_events: list[dict] = [] # illegal transitions для UI-подсветки
last_assembled_prompt = "" last_assembled_prompt = ""
llm_text = "" visible_text = ""
parse_error: str | None = None
is_state_machine = False
for attempt in range(MAX_BOUNCES + 1): for attempt in range(MAX_BOUNCES + 1):
current_step = await _resolve_current_step(
session, intent.id, served_code, snapshot.get("current_step_code"),
)
is_state_machine = current_step is not None
if current_step is not None and snapshot.get("current_step_code") != current_step.code:
snapshot["current_step_code"] = current_step.code
base_prompt = config_service.compose_full_system_prompt(active_cfg) base_prompt = config_service.compose_full_system_prompt(active_cfg)
system_prompt = base_prompt + _format_state_context(state_snapshot) step_prompt = f"\n\n{current_step.system_prompt}" if current_step else ""
state_context = _format_state_context(snapshot, current_step, router_hint)
system_prompt = base_prompt + step_prompt + state_context
llm_result = await llm.chat( llm_result = await llm.chat(
question=text, question=text,
@@ -225,8 +218,11 @@ async def send_message(
max_tokens=max_tokens, max_tokens=max_tokens,
) )
last_assembled_prompt = llm_result["assembled_prompt"] last_assembled_prompt = llm_result["assembled_prompt"]
llm_text = llm_result["text"] parsed = parse_branch_response(llm_result["text"])
parsed = _parse_assistant_signals(llm_text) visible_text = parsed["visible_text"] or llm_result["text"]
# STATE_JSON-блок ждём только от state-machine-веток. У остальных (general_info,
# price_question и т.п.) «no STATE_JSON» — ожидаемое состояние, не ошибка.
parse_error = parsed["parse_error"] if is_state_machine else None
if parsed["intent_change"] and attempt < MAX_BOUNCES: if parsed["intent_change"] and attempt < MAX_BOUNCES:
new_code = parsed["intent_change"] new_code = parsed["intent_change"]
@@ -235,32 +231,64 @@ async def send_message(
"to": new_code, "to": new_code,
"preface": parsed["visible_text"], "preface": parsed["visible_text"],
}) })
logger.info( logger.info("Intent bounce in thread %d: %s%s", thread.id, served_code, new_code)
"Intent bounce in thread %d: %s%s", thread.id, served_code, new_code,
)
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, new_code) served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, new_code)
state_snapshot = {"current_intent_code": served_code, "current_step": 0, "slots": {}} snapshot = {
continue
break
# 4. Обновляем thread_state и сохраняем сообщения.
visible_text = parsed["visible_text"] or llm_text
if parsed["state"] is not None:
new_step = parsed["state"]["step"]
merged_slots = {**state_snapshot.get("slots", {}), **parsed["state"]["slots"]}
state_snapshot = {
"current_intent_code": served_code, "current_intent_code": served_code,
"current_step": new_step, "current_step": 0,
"current_step_code": None,
"slots": {},
}
router_hint = None # новая ветка — подсказка больше неактуальна
continue
if parsed["state_update"] is not None and current_step is not None:
requested = parsed["state_update"]["state_after"]
allowed = intent_step_service.parse_allowed_next(current_step)
ok, reason = validate_transition(
current_step=current_step.code,
requested_step=requested,
allowed_next=allowed,
)
slots_updated = parsed["state_update"]["slots_updated"]
merged_slots = {**snapshot.get("slots", {}), **slots_updated}
if ok:
snapshot = {
"current_intent_code": served_code,
"current_step": snapshot["current_step"] + (1 if requested != current_step.code else 0),
"current_step_code": requested,
"slots": merged_slots, "slots": merged_slots,
} }
# Если ответ пришёл с INTENT_CHANGE на последней итерации (превысили MAX_BOUNCES) — else:
# служебный тег мы из visible_text уже вырезали, но состояние переключать не будем. logger.warning(
"Illegal state_after in thread %d (%s): %s", thread.id, served_code, reason,
)
validation_events.append({
"current_step": current_step.code,
"requested_step": requested,
"reason": reason,
})
# Слоты всё равно мёржим (информация полезная), шаг не двигаем.
snapshot = {
"current_intent_code": served_code,
"current_step": snapshot["current_step"],
"current_step_code": current_step.code,
"slots": merged_slots,
}
elif parsed["state_update"] is None and current_step is not None and parse_error:
logger.warning(
"State machine branch %s returned no STATE_JSON: %s", served_code, parse_error,
)
break
# 4. Сохраняем: thread_state пишется ПОСЛЕ всей логики, коммит — единой транзакцией.
await thread_state_service.upsert( await thread_state_service.upsert(
session, thread.id, session, thread.id,
intent_code=state_snapshot["current_intent_code"], intent_code=snapshot["current_intent_code"],
step=state_snapshot["current_step"], step=snapshot["current_step"],
slots=state_snapshot["slots"], step_code=snapshot.get("current_step_code"),
slots=snapshot["slots"],
) )
user_msg.intent_id = intent.id user_msg.intent_id = intent.id
@@ -284,10 +312,12 @@ async def send_message(
await session.refresh(thread) await session.refresh(thread)
logger.info( logger.info(
"Chat: thread=%d, router=%s, served=%s (v%d), step=%d, slots=%d keys, user_msg=%d, assistant_msg=%d, bounces=%d", "Chat: thread=%d, router=%s, served=%s (v%d), step=%s, slots=%d keys, bounces=%d, validation_events=%d",
thread.id, router_code, served_code, active_cfg.version, thread.id, router_code, served_code, active_cfg.version,
state_snapshot["current_step"], len(state_snapshot["slots"]), snapshot.get("current_step_code") or "-",
user_msg.id, assistant_msg.id, len(bounce_log), len(snapshot["slots"]),
len(bounce_log),
len(validation_events),
) )
return { return {
@@ -304,11 +334,14 @@ async def send_message(
"model_used": llm.model, "model_used": llm.model,
"assembled_prompt": last_assembled_prompt, "assembled_prompt": last_assembled_prompt,
"thread_state": { "thread_state": {
"current_intent_code": state_snapshot["current_intent_code"], "current_intent_code": snapshot["current_intent_code"],
"current_step": state_snapshot["current_step"], "current_step": snapshot["current_step"],
"slots": state_snapshot["slots"], "current_step_code": snapshot.get("current_step_code"),
"slots": snapshot["slots"],
}, },
"bounces": bounce_log, "bounces": bounce_log,
"validation_events": validation_events,
"parse_error": parse_error,
} }
@@ -418,7 +451,6 @@ async def rename_thread(session: AsyncSession, thread_id: int, name: str) -> dic
async def delete_thread(session: AsyncSession, thread_id: int) -> int | None: async def delete_thread(session: AsyncSession, thread_id: int) -> int | None:
"""Удалить тред и все его сообщения. Возвращает число удалённых сообщений или None, если треда нет."""
thread = await session.get(Thread, thread_id) thread = await session.get(Thread, thread_id)
if thread is None: if thread is None:
return None return None
+173
View File
@@ -0,0 +1,173 @@
"""Шаги state machine внутри ветки: сид, чтение, правка (Спринт 6a).
Шаги живут в БД (`intent_steps`), сид при старте читает файлы промптов из
`prompts/intents/{intent_code}/steps/{step_code}.md`. Список шагов и переходы
описаны в словаре `SEED_INTENT_STEPS` ниже новые state-machine-ветки
добавляются сюда + соответствующие файлы.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent, IntentStep
logger = logging.getLogger(__name__)
PROMPTS_INTENTS_DIR = Path(__file__).resolve().parent.parent / "prompts" / "intents"
# Стартовая описание шагов для state-machine-веток. Ключ — code ветки; значение —
# список шагов в порядке следования. `allowed_next` описывает граф переходов.
SEED_INTENT_STEPS: dict[str, list[dict]] = {
"new_booking": [
{
"code": "intro",
"name": "Приветствие",
"allowed_next": ["intro", "qualify"],
},
{
"code": "qualify",
"name": "Повод и специалист",
"allowed_next": ["qualify", "present"],
},
{
"code": "present",
"name": "Презентация плана",
"allowed_next": ["present", "qualify", "offer_time"],
},
{
"code": "offer_time",
"name": "Удобное время",
"allowed_next": ["offer_time", "book"],
},
{
"code": "book",
"name": "Подтверждение записи",
"allowed_next": ["book", "qualify", "offer_time", "close"],
},
{
"code": "close",
"name": "Завершение",
"allowed_next": ["close"],
},
],
}
def _step_prompt_path(intent_code: str, step_code: str) -> Path:
return PROMPTS_INTENTS_DIR / intent_code / "steps" / f"{step_code}.md"
def load_seed_step_prompt(intent_code: str, step_code: str) -> str:
path = _step_prompt_path(intent_code, step_code)
try:
return path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("Seed prompt for step %s/%s not found at %s", intent_code, step_code, path)
return ""
def has_state_machine(intent_code: str) -> bool:
return intent_code in SEED_INTENT_STEPS
def parse_allowed_next(step: IntentStep) -> list[str]:
try:
value = json.loads(step.allowed_next_json)
except (json.JSONDecodeError, TypeError):
return []
return value if isinstance(value, list) else []
def parse_guards(step: IntentStep) -> dict:
try:
value = json.loads(step.guards_json)
except (json.JSONDecodeError, TypeError):
return {}
return value if isinstance(value, dict) else {}
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)
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
)
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)
.order_by(IntentStep.order_index, IntentStep.id)
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def update_step(
session: AsyncSession,
step: IntentStep,
*,
name: str | None = None,
system_prompt: str | None = None,
allowed_next: list[str] | None = None,
guards: dict | None = None,
) -> IntentStep:
if name is not None:
step.name = name
if system_prompt is not None:
step.system_prompt = system_prompt
if allowed_next is not None:
step.allowed_next_json = json.dumps(allowed_next, ensure_ascii=False)
if guards is not None:
step.guards_json = json.dumps(guards, ensure_ascii=False)
step.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(step)
return step
async def ensure_seed_steps(session: AsyncSession) -> None:
"""Досиживает недостающие шаги для state-machine-веток. Существующие не трогаются."""
added = 0
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 steps for %s: intent not found", intent_code)
continue
existing = set((await session.execute(
select(IntentStep.code).where(IntentStep.intent_id == intent.id)
)).scalars().all())
for order, data in enumerate(steps_def):
if data["code"] in existing:
continue
prompt = load_seed_step_prompt(intent_code, data["code"])
session.add(IntentStep(
intent_id=intent.id,
code=data["code"],
name=data["name"],
order_index=order,
system_prompt=prompt,
allowed_next_json=json.dumps(data["allowed_next"], ensure_ascii=False),
guards_json="{}",
))
added += 1
if added:
await session.commit()
logger.info("Seeded %d missing intent_steps", added)
+29 -17
View File
@@ -1,3 +1,4 @@
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
@@ -5,6 +6,11 @@ import httpx
from config import settings from config import settings
class LLMUnavailableError(RuntimeError):
"""Внешний LLM недоступен после всех попыток — сигнал для вызывающего кода."""
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SYSTEM_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "system_prompt.md" SYSTEM_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "system_prompt.md"
@@ -98,18 +104,7 @@ class LLMClient:
"max_tokens": effective_max_tokens, "max_tokens": effective_max_tokens,
} }
async with httpx.AsyncClient(timeout=60.0) as client: data = await self._call_with_retry(url, payload)
response = await client.post(
url,
json=payload,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
response.raise_for_status()
data = response.json()
content = data["choices"][0]["message"]["content"] content = data["choices"][0]["message"]["content"]
logger.info("LLM response: %d chars, model=%s, temp=%.2f", len(content), self.model, effective_temp) logger.info("LLM response: %d chars, model=%s, temp=%.2f", len(content), self.model, effective_temp)
return {"text": content.strip(), "assembled_prompt": assembled_prompt} return {"text": content.strip(), "assembled_prompt": assembled_prompt}
@@ -159,6 +154,16 @@ class LLMClient:
"max_tokens": effective_max_tokens, "max_tokens": effective_max_tokens,
} }
data = await self._call_with_retry(url, payload)
content = data["choices"][0]["message"]["content"]
logger.info("LLM chat response: %d chars, history=%d, model=%s", len(content), len(history), self.model)
return {"text": content.strip(), "assembled_prompt": assembled_prompt}
async def _call_with_retry(self, url: str, payload: dict) -> dict:
"""POST к DeepSeek с одним ретраем — модель периодически моргает по сети."""
last_error: Exception | None = None
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=60.0) as client: async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post( response = await client.post(
url, url,
@@ -169,8 +174,15 @@ class LLMClient:
}, },
) )
response.raise_for_status() response.raise_for_status()
data = response.json() return response.json()
except Exception as e:
content = data["choices"][0]["message"]["content"] last_error = e
logger.info("LLM chat response: %d chars, history=%d, model=%s", len(content), len(history), self.model) logger.warning(
return {"text": content.strip(), "assembled_prompt": assembled_prompt} "LLM call failed (attempt %d, %s: %s)",
attempt + 1, type(e).__name__, e,
)
if attempt < 1:
await asyncio.sleep(0.5)
raise LLMUnavailableError(
f"LLM unavailable after retries: {type(last_error).__name__}: {last_error}"
) from last_error
+18 -1
View File
@@ -8,6 +8,7 @@
(версионируется, редактируется из UI «Настройки»). Если БД недоступна или (версионируется, редактируется из UI «Настройки»). Если БД недоступна или
ветки нет используем fallback из prompts/intents/_router.md. ветки нет используем fallback из prompts/intents/_router.md.
""" """
import asyncio
import logging import logging
import re import re
from pathlib import Path from pathlib import Path
@@ -102,6 +103,10 @@ class RouterClient:
"max_tokens": 20, "max_tokens": 20,
} }
data: dict | None = None
last_error: Exception | None = None
# Один ретрай: DeepSeek иногда отвечает 5xx / пустым исключением.
for attempt in range(2):
try: try:
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post( response = await client.post(
@@ -114,8 +119,20 @@ class RouterClient:
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
break
except Exception as e: except Exception as e:
logger.warning("Router LLM call failed (%s), falling back to general_info", e) last_error = e
logger.warning(
"Router LLM call failed (attempt %d, %s: %s)",
attempt + 1, type(e).__name__, e,
)
if attempt < 1:
await asyncio.sleep(0.5)
if data is None:
logger.warning(
"Router LLM failed after retries (%s), falling back to general_info",
last_error,
)
return {"code": "general_info", "version": version} return {"code": "general_info", "version": version}
raw = (data["choices"][0]["message"]["content"] or "").strip() raw = (data["choices"][0]["message"]["content"] or "").strip()
+149
View File
@@ -0,0 +1,149 @@
"""Парсер structured-output ветки + валидатор переходов state machine (Спринт 6a).
Формат ответа ветки со state machine:
Текст пациенту, markdown разрешён.
STATE_JSON: {"state_after": "<step_code>", "slots_updated": {"slot": "value"}}
Либо при exit condition вместо `STATE_JSON:` строка `[INTENT_CHANGE: <code>]`.
Парсер вырезает служебную часть и возвращает видимый текст + решение модели.
"""
import json
import logging
import re
logger = logging.getLogger(__name__)
_INTENT_CHANGE_RE = re.compile(r"\[INTENT_CHANGE:\s*([a-z_][a-z0-9_]*)\s*\]")
_STATE_JSON_RE = re.compile(r"STATE_JSON\s*:\s*", re.IGNORECASE)
def parse_branch_response(text: str) -> dict:
"""Разобрать ответ ветки на visible_text + intent_change / state_update.
Возвращает:
visible_text: str текст пациенту (без служебных тегов),
intent_change: str | None код ветки, если сработал exit condition,
state_update: {'state_after': str, 'slots_updated': dict} | None при штатном ответе,
parse_error: str | None если что-то не разобралось, сюда кладётся причина
(visible_text при этом = исходный текст без мусора).
"""
# Exit condition имеет приоритет.
intent_match = _INTENT_CHANGE_RE.search(text)
if intent_match:
visible = text[:intent_match.start()].rstrip()
return {
"visible_text": visible,
"intent_change": intent_match.group(1),
"state_update": None,
"parse_error": None,
}
state_match = _STATE_JSON_RE.search(text)
if not state_match:
# Модель не вернула служебную часть. Возвращаем весь текст и ошибку парсинга.
return {
"visible_text": text.rstrip(),
"intent_change": None,
"state_update": None,
"parse_error": "no STATE_JSON block",
}
raw_json, _ = _consume_json_object(text, state_match.end())
if raw_json is None:
return {
"visible_text": text[:state_match.start()].rstrip(),
"intent_change": None,
"state_update": None,
"parse_error": "STATE_JSON present but no balanced JSON object",
}
try:
data = json.loads(raw_json)
except json.JSONDecodeError as e:
return {
"visible_text": text[:state_match.start()].rstrip(),
"intent_change": None,
"state_update": None,
"parse_error": f"STATE_JSON invalid JSON: {e}",
}
if not isinstance(data, dict):
return {
"visible_text": text[:state_match.start()].rstrip(),
"intent_change": None,
"state_update": None,
"parse_error": "STATE_JSON is not an object",
}
state_after = data.get("state_after")
slots_updated = data.get("slots_updated", {})
if not isinstance(state_after, str) or not state_after:
return {
"visible_text": text[:state_match.start()].rstrip(),
"intent_change": None,
"state_update": None,
"parse_error": "STATE_JSON missing state_after",
}
if not isinstance(slots_updated, dict):
slots_updated = {}
return {
"visible_text": text[:state_match.start()].rstrip(),
"intent_change": None,
"state_update": {"state_after": state_after, "slots_updated": slots_updated},
"parse_error": None,
}
def _consume_json_object(text: str, start: int) -> tuple[str | None, int]:
"""Вытянуть сбалансированный JSON-объект из text[start:]. См. парсер в chat_service."""
i = start
n = len(text)
while i < n and text[i].isspace():
i += 1
if i >= n or text[i] != "{":
return None, start
depth = 0
in_str = False
esc = False
j = i
while j < n:
ch = text[j]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[i:j + 1], j + 1
j += 1
return None, start
def validate_transition(
*, current_step: str, requested_step: str, allowed_next: list[str],
) -> tuple[bool, str]:
"""Разрешён ли переход `current_step → requested_step`.
Остаться на месте (`requested_step == current_step`) разрешено всегда.
Возвращает (ok, reason).
"""
if requested_step == current_step:
return True, "stay"
if requested_step in allowed_next:
return True, "ok"
return (
False,
f"requested {requested_step!r} not in allowed_next {allowed_next!r} of {current_step!r}",
)
+22 -4
View File
@@ -30,13 +30,19 @@ def _parse_slots(raw: str) -> dict:
async def load_snapshot(session: AsyncSession, thread_id: int) -> dict: async def load_snapshot(session: AsyncSession, thread_id: int) -> dict:
"""Удобный снимок состояния для чтения (intent, step, slots).""" """Удобный снимок состояния для чтения (intent, step_code, step, slots)."""
state = await get_state(session, thread_id) state = await get_state(session, thread_id)
if state is None: if state is None:
return {"current_intent_code": None, "current_step": 0, "slots": {}} return {
"current_intent_code": None,
"current_step": 0,
"current_step_code": None,
"slots": {},
}
return { return {
"current_intent_code": state.current_intent_code, "current_intent_code": state.current_intent_code,
"current_step": state.current_step, "current_step": state.current_step,
"current_step_code": state.current_step_code,
"slots": _parse_slots(state.slots_json), "slots": _parse_slots(state.slots_json),
} }
@@ -48,6 +54,7 @@ async def upsert(
intent_code: str | None, intent_code: str | None,
step: int, step: int,
slots: dict, slots: dict,
step_code: str | None = None,
) -> ThreadState: ) -> ThreadState:
"""Создать или обновить состояние треда. Коммит — на совести вызывающего.""" """Создать или обновить состояние треда. Коммит — на совести вызывающего."""
state = await get_state(session, thread_id) state = await get_state(session, thread_id)
@@ -58,6 +65,7 @@ async def upsert(
thread_id=thread_id, thread_id=thread_id,
current_intent_code=intent_code, current_intent_code=intent_code,
current_step=step, current_step=step,
current_step_code=step_code,
slots_json=slots_raw, slots_json=slots_raw,
updated_at=now, updated_at=now,
) )
@@ -65,11 +73,21 @@ async def upsert(
else: else:
state.current_intent_code = intent_code state.current_intent_code = intent_code
state.current_step = step state.current_step = step
state.current_step_code = step_code
state.slots_json = slots_raw state.slots_json = slots_raw
state.updated_at = now state.updated_at = now
return state return state
async def reset(session: AsyncSession, thread_id: int, *, new_intent_code: str | None) -> ThreadState: async def reset(
session: AsyncSession,
thread_id: int,
*,
new_intent_code: str | None,
new_step_code: str | None = None,
) -> ThreadState:
"""Сбросить шаг и слоты треда, выставить новую ветку (при смене intent).""" """Сбросить шаг и слоты треда, выставить новую ветку (при смене intent)."""
return await upsert(session, thread_id, intent_code=new_intent_code, step=0, slots={}) return await upsert(
session, thread_id,
intent_code=new_intent_code, step=0, step_code=new_step_code, slots={},
)
+164 -25
View File
@@ -111,7 +111,7 @@
min-height: 0; min-height: 0;
} }
/* Список тредов */ /* Список диалогов */
.threads-head-btn { .threads-head-btn {
margin-left: auto; margin-left: auto;
background: var(--accent); background: var(--accent);
@@ -310,21 +310,82 @@
margin: 0 0 10px 0; margin: 0 0 10px 0;
font-weight: 600; font-weight: 600;
} }
/* Сворачиваемая секция (details/summary) */
.debug-section.collapsible > summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 10px 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
font-weight: 600;
}
.debug-section.collapsible > summary::-webkit-details-marker { display: none; }
.debug-section.collapsible > summary::before {
content: "▸";
display: inline-block;
transition: transform 0.15s;
font-size: 10px;
color: var(--muted);
}
.debug-section.collapsible[open] > summary::before { transform: rotate(90deg); }
.debug-section.collapsible > summary:hover { color: var(--fg); }
.debug-section.collapsible > summary .summary-count {
margin-left: auto;
background: var(--chip-bg);
color: var(--accent);
padding: 1px 7px;
border-radius: 10px;
font-size: 10px;
text-transform: none;
letter-spacing: 0;
}
.chunk-card { .chunk-card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
padding: 8px 10px;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 12px; font-size: 12px;
overflow: hidden;
} }
.chunk-card > summary {
padding: 8px 10px;
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.chunk-card > summary::-webkit-details-marker { display: none; }
.chunk-card > summary::before {
content: "▸";
font-size: 10px;
color: var(--muted);
flex-shrink: 0;
transition: transform 0.15s;
}
.chunk-card[open] > summary::before { transform: rotate(90deg); }
.chunk-card > summary:hover { background: #f9fafb; }
.chunk-card-meta { .chunk-card-meta {
font-size: 10px; font-size: 10px;
color: var(--muted); color: var(--muted);
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 4px; flex: 1;
min-width: 0;
align-items: center;
}
.chunk-card-meta .chunk-doc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
} }
.chunk-score { .chunk-score {
background: var(--chip-bg); background: var(--chip-bg);
@@ -332,13 +393,18 @@
padding: 1px 6px; padding: 1px 6px;
border-radius: 10px; border-radius: 10px;
font-weight: 600; font-weight: 600;
flex-shrink: 0;
} }
.chunk-text { .chunk-text {
padding: 0 10px 10px 10px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
font-size: 11.5px; font-size: 11.5px;
color: var(--fg); color: var(--fg);
max-height: 100px; border-top: 1px solid var(--border);
padding-top: 8px;
margin-top: 2px;
max-height: 240px;
overflow-y: auto; overflow-y: auto;
} }
.prompt-box { .prompt-box {
@@ -431,17 +497,20 @@
<div class="col-head">Отладка ответа</div> <div class="col-head">Отладка ответа</div>
<div class="col-body"> <div class="col-body">
<div class="debug-section"> <div class="debug-section">
<h3>Состояние треда</h3> <h3>Состояние диалога</h3>
<div id="debug-state"><div class="mini">— пока пусто —</div></div> <div id="debug-state"><div class="mini">— пока пусто —</div></div>
</div> </div>
<div class="debug-section"> <div class="debug-section">
<h3>Решение роутера</h3> <h3>Решение роутера</h3>
<div id="debug-router"><div class="mini">— пока пусто —</div></div> <div id="debug-router"><div class="mini">— пока пусто —</div></div>
</div> </div>
<div class="debug-section"> <details class="debug-section collapsible" id="debug-chunks-section">
<h3>Найденные фрагменты (по последней реплике)</h3> <summary>
<span>Найденные фрагменты</span>
<span class="summary-count" id="debug-chunks-count" style="display:none;">0</span>
</summary>
<div id="debug-chunks"><div class="mini">— пока пусто —</div></div> <div id="debug-chunks"><div class="mini">— пока пусто —</div></div>
</div> </details>
<div class="debug-section"> <div class="debug-section">
<h3>Собранный промпт</h3> <h3>Собранный промпт</h3>
<div id="debug-prompt"><div class="mini">— пока пусто —</div></div> <div id="debug-prompt"><div class="mini">— пока пусто —</div></div>
@@ -548,8 +617,8 @@ async function openThread(id) {
renderMessages(d.messages); renderMessages(d.messages);
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant"); const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
if (lastAssistant) { if (lastAssistant) {
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null); renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null, [], d.thread_state && d.thread_state.current_step_code);
renderState(d.thread_state, []); renderState(d.thread_state, [], [], null);
} else { } else {
clearDebug(); clearDebug();
} }
@@ -604,50 +673,108 @@ function appendMessage(role, text, iso, intentCode, intentName) {
} }
/* ---------- отладка ---------- */ /* ---------- отладка ---------- */
function renderState(state, bounces) { function renderState(state, bounces, validationEvents, parseError) {
const box = $("debug-state"); const box = $("debug-state");
if (!state || !state.current_intent_code) { if (!state || !state.current_intent_code) {
box.innerHTML = '<div class="mini">state machine ещё не запускалась</div>'; box.innerHTML = '<div class="mini">сценарий ещё не запущен</div>';
return; return;
} }
const slotsJson = JSON.stringify(state.slots || {}, null, 2);
const bounceHtml = (bounces && bounces.length) const bounceHtml = (bounces && bounces.length)
? `<div style="margin-top:8px;font-size:11px;"> ? `<div style="margin-top:8px;font-size:11px;">
<div style="color:var(--muted);margin-bottom:3px;">переходы в этой реплике:</div> <div style="color:var(--muted);margin-bottom:3px;">переходы в этой реплике:</div>
${bounces.map(b => `<div><b>${esc(b.from)}</b><b>${esc(b.to)}</b>${b.preface ? ` <span style="color:var(--muted);">(«${esc(b.preface).slice(0,60)}»)</span>` : ''}</div>`).join("")} ${bounces.map(b => `<div><b>${esc(b.from)}</b><b>${esc(b.to)}</b>${b.preface ? ` <span style="color:var(--muted);">(«${esc(b.preface).slice(0,60)}»)</span>` : ''}</div>`).join("")}
</div>` </div>`
: ""; : "";
const validationHtml = (validationEvents && validationEvents.length)
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef2f2;color:#991b1b;font-size:11px;">
${validationEvents.map(v => `⚠️ модель просилась в <code>${esc(v.requested_step)}</code>, оставили на <code>${esc(v.current_step)}</code>. ${esc(v.reason)}`).join("<br>")}
</div>`
: "";
const parseErrorHtml = parseError
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
⚠️ парсер: ${esc(parseError)}
</div>`
: "";
// Ветки без state machine (general_info, price_question и т.д.) шаги не ведут —
// показываем только intent, чтобы не путать пустым «шаг №0 · {}».
if (!state.current_step_code) {
box.innerHTML = ` box.innerHTML = `
<div style="font-size:12px;"> <div style="font-size:12px;">
<div><b>${esc(state.current_intent_code)}</b> · шаг <b>${state.current_step}</b></div> <div>
<b>${esc(state.current_intent_code)}</b>
<span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span>
</div>
${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
return;
}
const slotsJson = JSON.stringify(state.slots || {}, null, 2);
box.innerHTML = `
<div style="font-size:12px;">
<div><b>${esc(state.current_intent_code)}</b> · шаг <code>${esc(state.current_step_code)}</code></div>
<div class="prompt-box" style="margin-top:6px;max-height:200px;">${esc(slotsJson)}</div> <div class="prompt-box" style="margin-top:6px;max-height:200px;">${esc(slotsJson)}</div>
${bounceHtml} ${bounceHtml}${validationHtml}${parseErrorHtml}
</div> </div>
`; `;
} }
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode) { function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode) {
const bounced = routerIntentCode && intentCode && routerIntentCode !== intentCode; const routerVer = routerVersion != null ? `роутер v${routerVersion}` : "роутер";
const hasBounces = bounces && bounces.length > 0;
const routerDiffers = routerIntentCode && intentCode && routerIntentCode !== intentCode;
// Три разных исхода — объясняем отдельно, чтобы не путать «sticky» и «bouncing».
let verdict;
if (hasBounces) {
// Ветка сама выдала INTENT_CHANGE — bounce через [INTENT_CHANGE: ...].
const chain = bounces.map(b => `<code>${esc(b.from)}</code><code>${esc(b.to)}</code>`).join(", ");
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;line-height:1.5;">
${routerVer} сказал <code>${esc(routerIntentCode)}</code>.<br>
Ветка сама выдала <code>[INTENT_CHANGE]</code> и передала управление: ${chain}.
</div>`;
} else if (routerDiffers) {
// Удержались в ветке: диалог в сценарии, роутер хотел переключить, но мы остались.
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;line-height:1.5;">
${routerVer} предложил <code>${esc(routerIntentCode)}</code>.<br>
Но диалог идёт по сценарию <code>${esc(intentCode)}</code>${stepCode ? ' (шаг <code>' + esc(stepCode) + '</code>)' : ''} —
<b>удержались в ветке</b>: модель получила подсказку и осталась в сценарии.
</div>`;
} else {
// Обычный случай — роутер попал в ту же ветку.
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;">
${routerVer} → та же ветка.
</div>`;
}
const routerLine = intentCode const routerLine = intentCode
? `<div style="padding:10px 16px;background:#ecfdf5;font-size:12px;"> ? `<div style="padding:10px 14px;background:#ecfdf5;font-size:12px;border-radius:6px;">
<div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div> <div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div>
${routerVersion != null ? `<div style="color:var(--muted);font-size:11px;margin-top:2px;">роутер v${routerVersion}${bounced ? ` сказал <b>${esc(routerIntentCode)}</b>, ветка передала управление` : ''}</div>` : ''} ${verdict}
</div>` </div>`
: ""; : "";
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>'; $("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
const count = $("debug-chunks-count");
if (sources && sources.length) { if (sources && sources.length) {
count.textContent = sources.length;
count.style.display = "";
$("debug-chunks").innerHTML = sources.map(s => ` $("debug-chunks").innerHTML = sources.map(s => `
<div class="chunk-card"> <details class="chunk-card">
<summary>
<div class="chunk-card-meta"> <div class="chunk-card-meta">
<span class="chunk-score">${(s.relevance_score * 100).toFixed(1)}%</span> <span class="chunk-score">${(s.relevance_score * 100).toFixed(1)}%</span>
<span>${esc(s.document_name || "—")}</span> <span class="chunk-doc">${esc(s.document_name || "—")}</span>
${s.section ? `<span>${esc(s.section)}</span>` : ""} ${s.section ? `<span>${esc(s.section)}</span>` : ""}
</div> </div>
</summary>
<div class="chunk-text">${esc(s.chunk_text)}</div> <div class="chunk-text">${esc(s.chunk_text)}</div>
</div> </details>
`).join(""); `).join("");
} else { } else {
count.style.display = "none";
$("debug-chunks").innerHTML = '<div class="mini">источников нет</div>'; $("debug-chunks").innerHTML = '<div class="mini">источников нет</div>';
} }
$("debug-prompt").innerHTML = prompt $("debug-prompt").innerHTML = prompt
@@ -682,7 +809,7 @@ async function sendMessage() {
$("chat-send").disabled = true; $("chat-send").disabled = true;
$("chat-send").innerHTML = '<span class="spinner"></span>'; $("chat-send").innerHTML = '<span class="spinner"></span>';
appendMessage("user", txt); const userBubble = appendMessage("user", txt);
const pending = appendMessage("assistant", "…"); const pending = appendMessage("assistant", "…");
pending.style.opacity = "0.6"; pending.style.opacity = "0.6";
@@ -699,11 +826,23 @@ async function sendMessage() {
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name); appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name);
$("chat-title").className = "chat-title"; $("chat-title").className = "chat-title";
$("chat-title").textContent = r.thread_name; $("chat-title").textContent = r.thread_name;
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version, r.router_intent_code); renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version, r.router_intent_code, r.bounces, r.thread_state && r.thread_state.current_step_code);
renderState(r.thread_state, r.bounces); renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error);
refreshThreads(); refreshThreads();
} catch (e) { } catch (e) {
// Откатываем визуально: убираем пузырь-заглушку ассистента и только что
// добавленную реплику пациента — на бекенде весь запрос уже откатился (rollback).
pending.remove(); pending.remove();
userBubble.remove();
// Если после удаления пузырей чат стал пустым — вернём плейсхолдер.
const box = $("chat-messages");
if (!box.querySelector(".msg")) {
box.innerHTML = activeThreadId
? '<div class="chat-empty">Пусто. Напишите первую реплику.</div>'
: '<div class="chat-empty">Это новый диалог.<br>Напишите реплику пациента снизу, чтобы начать.</div>';
}
// Возвращаем текст в поле ввода — не заставлять пользователя перепечатывать.
$("chat-text").value = txt;
toast("Ошибка: " + e.message, "err"); toast("Ошибка: " + e.message, "err");
} finally { } finally {
$("chat-send").disabled = false; $("chat-send").disabled = false;
+324 -3
View File
@@ -179,7 +179,7 @@
.toolbar button:hover { background: #f9fafb; } .toolbar button:hover { background: #f9fafb; }
.toolbar .toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; } .toolbar .toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
.field { margin-bottom: 14px; } .field { margin-bottom: 14px; position: relative; }
.field label { .field label {
display: block; display: block;
font-size: 12px; font-size: 12px;
@@ -187,6 +187,80 @@
color: var(--muted); color: var(--muted);
margin-bottom: 4px; margin-bottom: 4px;
} }
.field label.with-hint {
display: flex;
align-items: center;
gap: 6px;
}
.hint-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--border);
background: #fff;
color: var(--muted);
cursor: pointer;
font-size: 10px;
font-weight: 600;
line-height: 1;
padding: 0;
font-family: serif;
font-style: italic;
}
.hint-btn:hover { background: var(--chip-bg); color: var(--accent); border-color: var(--accent); }
.hint-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.hint-popover {
display: none;
position: absolute;
z-index: 50;
top: 28px;
left: 0;
right: 0;
max-width: 560px;
background: #fff;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
box-shadow: 0 6px 20px rgba(17, 24, 39, 0.12);
font-size: 12px;
line-height: 1.55;
color: var(--fg);
}
.hint-popover.show { display: block; }
.hint-popover h4 {
margin: 0 0 6px 0;
font-size: 12px;
font-weight: 600;
color: var(--fg);
}
.hint-popover p { margin: 0 0 6px 0; }
.hint-popover ul { margin: 4px 0 6px 0; padding-left: 18px; }
.hint-popover li { margin: 2px 0; }
.hint-popover code {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 5px;
border-radius: 4px;
font-size: 11px;
font-family: var(--mono);
}
.hint-popover .hint-close {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
color: var(--muted);
font-size: 16px;
cursor: pointer;
line-height: 1;
padding: 2px 6px;
}
.hint-popover .hint-close:hover { color: var(--fg); }
.field input[type=text], .field textarea { .field input[type=text], .field textarea {
width: 100%; width: 100%;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -309,6 +383,72 @@
.toast.show { opacity: 1; } .toast.show { opacity: 1; }
.toast.err { background: var(--err); } .toast.err { background: var(--err); }
/* Вкладки Промпт / Шаги */
.editor-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.editor-tab {
padding: 6px 14px;
font-size: 13px;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.editor-tab:hover { color: var(--fg); }
.editor-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 500;
}
/* Список шагов */
.steps-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.step-chip {
padding: 5px 11px;
border-radius: 14px;
border: 1px solid var(--border);
background: #fafbfd;
font-size: 12px;
cursor: pointer;
font-family: var(--mono);
}
.step-chip:hover { background: #fff; }
.step-chip.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.step-order { opacity: 0.6; margin-right: 4px; }
.allowed-next {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 10px 12px;
background: #fafbfd;
border: 1px solid var(--border);
border-radius: 6px;
}
.allowed-next label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-family: var(--mono);
cursor: pointer;
}
/* Свитч включён/выключен */ /* Свитч включён/выключен */
.switch { .switch {
position: relative; position: relative;
@@ -386,6 +526,9 @@ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&
let intents = []; let intents = [];
let currentIntentCode = null; let currentIntentCode = null;
let versions = []; let versions = [];
let currentSteps = []; // шаги выбранной ветки (если state machine)
let currentStepCode = null; // выбранный шаг в редакторе
let activeTab = "prompt"; // "prompt" | "steps"
function toast(msg, kind = "ok") { function toast(msg, kind = "ok") {
const t = $("toast"); const t = $("toast");
@@ -472,10 +615,21 @@ async function toggleIntent(code, enabled) {
/* ---------- select intent ---------- */ /* ---------- select intent ---------- */
async function selectIntent(code) { async function selectIntent(code) {
currentIntentCode = code; currentIntentCode = code;
activeTab = "prompt";
currentStepCode = null;
renderIntents(); renderIntents();
await refreshSteps(code);
renderEditor(); renderEditor();
await refreshVersions(code); await refreshVersions(code);
await loadActiveIntoEditor(); }
async function refreshSteps(code) {
try {
const d = await api(`/intents/${encodeURIComponent(code)}/steps`);
currentSteps = d.steps || [];
} catch (_) {
currentSteps = [];
}
} }
function renderEditor() { function renderEditor() {
@@ -486,10 +640,63 @@ function renderEditor() {
return; return;
} }
$("editor-head").textContent = `${intent.name} · редактор`; $("editor-head").textContent = `${intent.name} · редактор`;
const hasSteps = currentSteps.length > 0;
const tabs = hasSteps
? `<div class="editor-tabs">
<button class="editor-tab ${activeTab === 'prompt' ? 'active' : ''}" onclick="switchTab('prompt')">Промпт</button>
<button class="editor-tab ${activeTab === 'steps' ? 'active' : ''}" onclick="switchTab('steps')">Шаги (${currentSteps.length})</button>
</div>`
: "";
let body;
if (hasSteps && activeTab === "steps") {
body = renderStepsPanel();
} else {
body = renderPromptPanel(intent);
}
$("editor").innerHTML = ` $("editor").innerHTML = `
<h2>${esc(intent.name)}</h2> <h2>${esc(intent.name)}</h2>
<div class="sub"><code>${esc(intent.code)}</code> · ${esc(intent.description)}</div> <div class="sub"><code>${esc(intent.code)}</code> · ${esc(intent.description)}</div>
${tabs}
${body}
`;
// Если перешли на вкладку шагов и шаг не выбран — выбираем первый.
if (hasSteps && activeTab === "steps") {
if (!currentStepCode) currentStepCode = currentSteps[0].code;
renderStepEditor();
} else if (activeTab === "prompt") {
loadActiveIntoEditor();
}
}
function switchTab(tab) {
activeTab = tab;
renderEditor();
}
function toggleRulesHint(force) {
const pop = document.getElementById("rules-hint-popover");
const btn = document.getElementById("rules-hint-btn");
if (!pop || !btn) return;
const willShow = typeof force === "boolean" ? force : !pop.classList.contains("show");
pop.classList.toggle("show", willShow);
btn.classList.toggle("active", willShow);
}
// Клик вне popover-а — закрываем.
document.addEventListener("click", (e) => {
const pop = document.getElementById("rules-hint-popover");
const btn = document.getElementById("rules-hint-btn");
if (!pop || !btn || !pop.classList.contains("show")) return;
if (pop.contains(e.target) || btn.contains(e.target)) return;
toggleRulesHint(false);
});
function renderPromptPanel(intent) {
return `
<div class="field"> <div class="field">
<label for="f-name">Имя версии (необязательно)</label> <label for="f-name">Имя версии (необязательно)</label>
<input type="text" id="f-name" placeholder="например: после фидбэка операторов 24.04" maxlength="200"> <input type="text" id="f-name" placeholder="например: после фидбэка операторов 24.04" maxlength="200">
@@ -499,7 +706,23 @@ function renderEditor() {
<textarea id="f-prompt" class="prompt" spellcheck="false"></textarea> <textarea id="f-prompt" class="prompt" spellcheck="false"></textarea>
</div> </div>
<div class="field"> <div class="field">
<label for="f-rules">Правила (дополнение к промпту; свободная markdown-форма)</label> <label for="f-rules" class="with-hint">
<span>Правила (дополнение к промпту; свободная markdown-форма)</span>
<button type="button" class="hint-btn" id="rules-hint-btn" onclick="toggleRulesHint()" aria-label="Подсказка">i</button>
</label>
<div class="hint-popover" id="rules-hint-popover">
<button type="button" class="hint-close" onclick="toggleRulesHint(false)" aria-label="Закрыть">×</button>
<h4>Что писать в «Правила»</h4>
<p>Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.</p>
<p><b>Что нормально писать:</b></p>
<ul>
<li>«Если пациент уже наблюдается у конкретного врача — записывай к нему по умолчанию».</li>
<li>«Бесплатная парковка для пациентов 2 часа — упомяни, если спросят».</li>
<li>«После 19:00 предлагай только следующий рабочий день».</li>
<li>«Дети до 14 лет — сразу <code>[INTENT_CHANGE: escalate_human]</code>, у нас нет педиатра».</li>
</ul>
<p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода (<code>[INTENT_CHANGE: ...]</code>) пока тоже в основном промпте, в Спринте 6a выделим в отдельное поле.</p>
</div>
<textarea id="f-rules" class="rules" spellcheck="false"></textarea> <textarea id="f-rules" class="rules" spellcheck="false"></textarea>
</div> </div>
<div class="editor-actions"> <div class="editor-actions">
@@ -510,6 +733,104 @@ function renderEditor() {
`; `;
} }
function renderStepsPanel() {
const chips = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
onclick="selectStep('${esc(s.code)}')">
<span class="step-order">${s.order_index + 1}.</span>${esc(s.code)}
</div>
`).join("");
return `
<div class="steps-chips">${chips}</div>
<div id="step-editor"></div>
`;
}
function renderStepEditor() {
const step = currentSteps.find(s => s.code === currentStepCode);
if (!step) {
$("step-editor").innerHTML = '<div class="mini">Выберите шаг выше.</div>';
return;
}
const otherCodes = currentSteps.map(s => s.code);
const allowedSet = new Set(step.allowed_next || []);
const checkboxes = otherCodes.map(code => `
<label>
<input type="checkbox" value="${esc(code)}" ${allowedSet.has(code) ? 'checked' : ''}>
${esc(code)}
</label>
`).join("");
$("step-editor").innerHTML = `
<div class="field">
<label for="f-step-name">Имя шага</label>
<input type="text" id="f-step-name" maxlength="200" value="${esc(step.name)}">
</div>
<div class="field">
<label for="f-step-prompt">Промпт шага (склеивается с промптом ветки)</label>
<textarea id="f-step-prompt" class="prompt" spellcheck="false">${esc(step.system_prompt)}</textarea>
</div>
<div class="field">
<label>Допустимые переходы (allowed_next)</label>
<div class="allowed-next" id="f-step-allowed">${checkboxes}</div>
</div>
<div class="field">
<label for="f-step-guards">Guards (JSON, наполняется в 6b — пока можно оставить <code>{}</code>)</label>
<textarea id="f-step-guards" class="rules" spellcheck="false">${esc(JSON.stringify(step.guards || {}, null, 2))}</textarea>
</div>
<div class="editor-actions">
<button onclick="saveStep()">Сохранить шаг</button>
<button class="secondary" onclick="selectStep('${esc(step.code)}')">Отменить правки</button>
</div>
`;
}
function selectStep(code) {
currentStepCode = code;
// Rerender chips для подсветки, редактор обновляется отдельно.
const panel = $("editor");
if (!panel) return;
// Перерисуем только секцию шагов.
const chipsRoot = panel.querySelector(".steps-chips");
if (chipsRoot) {
chipsRoot.innerHTML = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
onclick="selectStep('${esc(s.code)}')">
<span class="step-order">${s.order_index + 1}.</span>${esc(s.code)}
</div>
`).join("");
}
renderStepEditor();
}
async function saveStep() {
if (!currentIntentCode || !currentStepCode) return;
const name = $("f-step-name").value.trim();
const system_prompt = $("f-step-prompt").value;
const allowed_next = Array.from($("f-step-allowed").querySelectorAll("input[type=checkbox]:checked"))
.map(cb => cb.value);
let guards = {};
try {
guards = JSON.parse($("f-step-guards").value.trim() || "{}");
if (typeof guards !== "object" || Array.isArray(guards)) throw new Error("JSON должен быть объектом");
} catch (e) {
toast("Guards: невалидный JSON — " + e.message, "err");
return;
}
try {
const r = await api(`/intents/${encodeURIComponent(currentIntentCode)}/steps/${encodeURIComponent(currentStepCode)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, system_prompt, allowed_next, guards }),
});
toast(`Шаг ${r.code} сохранён`);
await refreshSteps(currentIntentCode);
renderEditor();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function loadActiveIntoEditor() { async function loadActiveIntoEditor() {
if (!currentIntentCode) return; if (!currentIntentCode) return;
const intent = intents.find(i => i.code === currentIntentCode); const intent = intents.find(i => i.code === currentIntentCode);