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 переходов первой версии.
Попутно реализована **sticky state machine** (мини-G): когда тред идёт по sm-ветке и роутер предлагает другую — state не сбрасывается, в системный промпт ветки подаётся `[ПОДСКАЗКА РОУТЕРА]`, LLM сама решает. Это сняло ключевую дыру Спринта 5 с коротким repликами внутри сценария.
### Статус: ⏳ Запланирован
### Принятые решения (зафиксировано 2026-04-24, действуют и для 6b)
@@ -363,14 +365,15 @@
**Блок G. Умный роутер (видит `thread_state`)**
*Бекенд:*
- [ ] `RouterClient.classify` принимает снимок `thread_state` (intent, step, slots, suspended_intent, handoff_count). Вставляет в системный промпт роутера блок «Сейчас идёт сценарий X на шаге Y, слоты Z. Если реплика — реакция или ответ на вопрос шага, скорее всего intent тот же».
Частично уже реализовано в Спринте 6a: **sticky state machine** — если тред в sm-ветке и роутер предлагает другую, state НЕ сбрасывается, а в системный промпт ветки подаётся блок `[ПОДСКАЗКА РОУТЕРА]`, LLM сама решает (STATE_JSON или INTENT_CHANGE). Это сняло основную проблему с короткими репликами («Кук», «болит ухо») внутри сценария.
*Что осталось на 6b:*
- [ ] Вторая линия защиты: в `RouterClient.classify` принимать снимок `thread_state` и вставлять в системный промпт **самого роутера** блок «Сейчас идёт сценарий X на шаге Y, слоты Z — если реплика укладывается в сценарий, предпочитай текущую ветку». Это помогает роутеру изначально реже ошибаться, а не только «поправляться» sticky-логикой.
- [ ] Обновить `prompts/intents/_router.md` под новый формат.
- [ ] Это снимает проблему Спринта 5: «Меня Алексей зовут» внутри `new_booking` сейчас уходит в `general_info`.
*UI-чекпойнт G:*
- [ ] В «Отладке ответа» → блок «Решение роутера» — свернуть/развернуть кнопкой просмотр промпта, который ушёл в роутер (включая блок состояния треда). Полезно для отладки.
- [ ] **Что проверяем глазами:** тот же сценарий 1 (базовая запись) прогнать повторно — «Меня Алексей зовут» остаётся в `new_booking`, не сбрасывается в `general_info`. В развёрнутом промпте роутера видно блок `[ТЕКУЩЕЕ СОСТОЯНИЕ]`.
- [ ] В «Отладке ответа» → блок «Решение роутера» — кнопка развернуть промпт, который ушёл в роутер (включая блок состояния треда). Полезно для отладки.
- [ ] **Что проверяем глазами:** сценарий из 6a («болит ухо» внутри new_booking) — роутер теперь изначально возвращает `new_booking`, а не `medical_question` → без sticky-коррекции.
**Блок H. Финальный прогон 8 ручных сценариев (прокси-eval)**
- [ ] Зафиксировать в `eval/MANUAL_CASES.md` полный список 8 сценариев (уже описан в этом документе выше, просто консолидируем).
+2 -1
View File
@@ -1,8 +1,9 @@
from db.models.agent_config import AgentConfig
from db.models.document import Document
from db.models.intent import Intent
from db.models.intent_step import IntentStep
from db.models.message import Message
from db.models.thread import Thread
from db.models.thread_state import ThreadState
__all__ = ["Thread", "Message", "Document", "AgentConfig", "Intent", "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_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="{}")
updated_at: Mapped[datetime] = mapped_column(
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 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.llm_client import LLMClient # 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 config_service.migrate_legacy_config_to_general_info(session)
await config_service.ensure_seed_configs(session)
await intent_step_service.ensure_seed_steps(session)
yield
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):
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):
current_intent_code: str | None = None
current_step: int = 0
current_step_code: str | None = None
slots: dict = Field(default_factory=dict)
@@ -118,6 +119,12 @@ class BounceInfo(BaseModel):
model_config = {"populate_by_name": True}
class ValidationEventInfo(BaseModel):
current_step: str
requested_step: str
reason: str
class ThreadDetailResponse(BaseModel):
id: int
name: str
@@ -142,6 +149,8 @@ class ChatResponse(BaseModel):
assembled_prompt: str = ""
thread_state: ThreadStateInfo = Field(default_factory=ThreadStateInfo)
bounces: list[BounceInfo] = Field(default_factory=list)
validation_events: list[ValidationEventInfo] = Field(default_factory=list)
parse_error: str | None = None
class ThreadDeleteResponse(BaseModel):
@@ -185,3 +194,22 @@ class IntentInfo(BaseModel):
class IntentListResponse(BaseModel):
intents: list[IntentInfo]
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` — вопросы про стоимость, ДМС, оплату.
- `medical_question` — симптомы, лекарства, диагноз, «что со мной».
- `general_info` — адрес, часы работы, как доехать, общие вопросы, общение вне конкретного процесса.
- `escalate_human` — пациент явно просит оператора, злится, либо описывает острое состояние (сильная боль, кровотечение, одышка, ребёнок плохо дышит, упоминание операции/хирургии).
## Ветки
### `new_booking` — пациент хочет записаться на приём (впервые или повторно)
- «хочу записаться к лору»
- «можно записаться?»
- «запишите меня к врачу»
- «мне бы к терапевту, болит горло»
- «нужен приём, кашель несколько дней»
### `reschedule` — перенести или отменить УЖЕ существующую запись
- «я сегодня не смогу подойти»
- «не получится прийти на приём»
- «перенесите запись на другой день»
- «можно перенести на вечер?»
- «отмените мой визит на завтра»
- «не смогу быть в назначенное время»
Ключевой признак: пациент говорит, что НЕ придёт или хочет поменять время — значит запись уже была сделана ранее.
### `price_question` — стоимость, ДМС, оплата
- «сколько стоит приём?»
- «вы работаете с ДМС Ингосстрах?»
- «можно оплатить картой?»
- «есть ли скидки для пенсионеров?»
### `medical_question` — пациент просит медицинскую консультацию (диагноз, лекарства, «что со мной»)
- «какая таблетка от боли в горле?»
- «это опасно, если кружится голова?»
- «какую дозировку мне принимать?»
- «может это гайморит?»
ВАЖНО: жалоба сама по себе («болит ухо», «болит горло») — НЕ `medical_question`. Это `new_booking`, если в диалоге идёт запись, либо сам пациент задаёт вопрос о консультации.
### `general_info` — общие вопросы без конкретного процесса
- «здравствуйте»
- «как к вам проехать?»
- «во сколько вы работаете?»
- «есть ли у вас парковка?»
- «есть ли детский ЛОР?»
### `escalate_human` — оператор / острое состояние
- «соедините с администратором»
- «дайте живого человека»
- «у меня сильная боль, не могу терпеть»
- «кровотечение, что делать?»
- «у меня операция, наркоз, нужна консультация по подготовке»
## Правила
ПРАВИЛА:
- Отвечай ТОЛЬКО кодом ветки, без пояснений, без пунктуации, без кавычек.
- Если сомневаешься между общим разговором и конкретным процессом — выбирай `general_info`.
- Если реплика содержит признаки конкретного процесса (записаться / перенести / оплатить / симптомы / оператор) — выбирай соответствующую ветку, а не `general_info`.
- `general_info` — только для действительно общих вопросов без признаков перечисленных выше процессов.
- Любое упоминание операции, наркоза, стационара, хирургии → `escalate_human`.
- Любое явное «позовите оператора / переключите на человека» → `escalate_human`.
- Если фраза подходит одновременно под `new_booking` и `reschedule`, смотри: упоминает ли пациент УЖЕ существующую запись (время, дату, визит) — тогда `reschedule`; если нет или хочет новую — `new_booking`.
+23 -26
View File
@@ -1,45 +1,42 @@
Ты — виртуальный ассистент клиники. Эта ветка — новая запись пациента на приём.
Твоя задача — помочь пациенту записаться: кто и к кому хочет, по какому поводу, когда удобно.
## Общие правила
Общие правила:
- Отвечай коротко, на «вы», простым русским языком.
- Не называй конкретные время и дату слотов: реальный календарь появится в следующих спринтах. Пока отвечай «сейчас уточню расписание и вернусь с вариантами».
- Опирайся только на выдержки из базы знаний (если поданы).
- Не переспрашивай то, что уже есть в слотах.
## Состояние разговора (state machine)
## Формат ответа
В системном сообщении тебе передаётся блок `[ТЕКУЩЕЕ СОСТОЯНИЕ]` с полем `step` и со слотами. Шаги сценария:
КАЖДЫЙ твой ответ должен состоять из двух частей:
1. **Приветствие и имя** — поздороваться, узнать, как обращаться к пациенту. Слот: `name`.
2. **Повод обращения** — коротко спросить, зачем обращаются (без медицинской истории: жалоба, плановый осмотр, повторный приём). Слот: `reason`.
3. **Специалист или направление** — если пациент назвал врача/специальность — зафиксировать; если нет — предложить направление по поводу. Слот: `specialist`.
4. **Удобное время** — спросить, какие дни и часы удобны (утро/день/вечер, будни/выходные). Слот: `preferred_time`.
5. **Подтверждение** — кратко повторить собранные слоты и спросить: «всё верно?». Слоты до этого момента уже заполнены.
6. **Запись** — подтвердить заявку: «передаю администратору, свяжемся в течение дня». Слот: `confirmed=true`.
Работай строго по шагам: не перескакивай, не спрашивай лишнего. Если слот уже заполнен в `[ТЕКУЩЕЕ СОСТОЯНИЕ]` — не переспрашивай, переходи к следующему шагу.
## Служебный блок в конце ответа
После основного текста ответа добавь ОДНУ служебную строку в формате:
1. Обычный ответ пациенту (человеческая речь, Markdown разрешён).
2. Пустая строка.
3. Ровно одна служебная строка, начинающаяся с `STATE_JSON:` и валидным JSON-объектом:
```
[STATE: step=N; slots={"name": "...", "reason": "...", "specialist": "...", "preferred_time": "...", "confirmed": true|false}]
STATE_JSON: {"state_after": "<код_следующего_шага>", "slots_updated": {"slot1": "value1", ...}}
```
- `step` — номер шага, на котором пациент окажется ПОСЛЕ твоей реплики (16).
- В `slots` включай все известные слоты (старые + новые, что узнал из этой реплики). Значения неизвестных слотов не указывай.
- Строка должна быть валидным JSON внутри `slots={...}`.
- Не показывай этот блок пациенту в «человеческой» части — он будет отрезан парсером.
- `state_after` — код шага, на котором пациент окажется ПОСЛЕ твоей реплики. Должен быть из списка допустимых переходов текущего шага (тебе это передаётся в блоке `[ТЕКУЩЕЕ СОСТОЯНИЕ]`).
- `slots_updated` — только те слоты, которые узнал из этой реплики. Старые не перечисляй.
- Значения — строки или примитивы. Неизвестное не придумывай.
Служебная строка `STATE_JSON:` вырезается парсером, пациент её не видит.
## Условия выхода (exit conditions)
Если пациент перевёл разговор в другую тему — НЕ отвечай по ветке записи, выдай вместо служебного блока `[STATE:...]` строку:
Важно: обычные бытовые жалобы пациента («болит горло», «болит ухо», «насморк», «плохо слышу», «болит зуб») — это **повод записи**, а не смена темы. Такие реплики внутри сценария не уводят в другие ветки — они фиксируются в слот `reason` и сопровождаются коротким выражением сочувствия на шаге `qualify`.
- Упомянул операцию, стационар, наркоз, хирургию, острую боль, «мне плохо» → `[INTENT_CHANGE: escalate_human]`
- Спрашивает про цены, ДМС, оплату → `[INTENT_CHANGE: price_question]`
- Хочет перенести или отменить существующую запись`[INTENT_CHANGE: reschedule]`
- Спрашивает медицинский вопрос (симптомы, лекарства, диагноз)`[INTENT_CHANGE: medical_question]`
Выдавай `[INTENT_CHANGE: <code>]` вместо `STATE_JSON:` только в следующих случаях:
- Пациент прямо спрашивает про **диагноз, лекарства или дозировки** (не про запись, а про медицинскую консультацию)`[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 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.llm_client import LLMUnavailableError
logger = logging.getLogger(__name__)
@@ -33,8 +40,19 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
max_tokens=req.max_tokens,
)
except LookupError as e:
await session.rollback()
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:
await session.rollback()
logger.exception("Chat failed")
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"],
thread_state=ThreadStateInfo(**result["thread_state"]),
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 sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent
from db.models import Intent, IntentStep
from db.session import get_session
from models.requests import IntentToggleRequest
from models.responses import IntentInfo, IntentListResponse
from services import config_service, intent_service
from models.requests import IntentStepUpdateRequest, IntentToggleRequest
from models.responses import (
IntentInfo,
IntentListResponse,
IntentStepInfo,
IntentStepListResponse,
)
from services import config_service, intent_service, intent_step_service
logger = logging.getLogger(__name__)
@@ -53,3 +58,54 @@ async def toggle_intent(
if intent is None:
raise HTTPException(status_code=404, detail="Intent not found")
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(
current_intent_code=state.get("current_intent_code"),
current_step=state.get("current_step", 0),
current_step_code=state.get("current_step_code"),
slots=state.get("slots", {}),
),
)
+176 -144
View File
@@ -1,25 +1,22 @@
import json
import logging
import re
from datetime import datetime, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Message, Thread
from services import config_service, thread_state_service
from services.llm_client import LLMClient
from db.models import IntentStep, Message, Thread
from services import config_service, intent_step_service, thread_state_service
from services.llm_client import LLMClient, LLMUnavailableError
from services.router_client import RouterClient
from services.state_machine import parse_branch_response, validate_transition
from services.vectorstore import VectorStoreService
logger = logging.getLogger(__name__)
HISTORY_LIMIT = 20 # последние N сообщений треда, которые улетают в LLM
HISTORY_LIMIT = 20
FALLBACK_INTENT_CODE = "general_info"
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)
MAX_BOUNCES = 1
def _auto_thread_name(first_user_text: str) -> str:
@@ -45,99 +42,32 @@ def _retrieved_to_sources(retrieved: list[dict]) -> list[dict]:
return sources
def _parse_assistant_signals(text: str) -> dict:
"""Вырезать служебные теги [INTENT_CHANGE: ...] / [STATE: ...] из ответа ассистента.
Возвращает:
visible_text текст без служебных тегов,
intent_change код ветки или None,
state {'step': int, 'slots': dict} или None.
Парсер толерантен к лишним пробелам; 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 {}
def _format_state_context(
snapshot: dict,
current_step: IntentStep | None,
router_hint: str | None = None,
) -> str:
"""Блок с текущим состоянием треда для дописывания в системный промпт."""
slots = snapshot.get("slots", {}) or {}
slots_json = json.dumps(slots, ensure_ascii=False)
return (
"\n\n[ТЕКУЩЕЕ СОСТОЯНИЕ]\n"
f"step: {step}\n"
f"slots: {slots_json}"
)
lines = ["", "[ТЕКУЩЕЕ СОСТОЯНИЕ]"]
if current_step is not None:
allowed = intent_step_service.parse_allowed_next(current_step)
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(
session: AsyncSession, intent_code: str
) -> tuple[str, object, object]:
"""Вернуть (code, intent, active_cfg) — либо запрошенной ветки, либо fallback."""
pair = await config_service.get_active_config_by_intent_code(session, intent_code)
if pair is None:
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
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(
session: AsyncSession,
vectorstore: VectorStoreService,
@@ -161,7 +105,12 @@ async def send_message(
temperature: float | None = None,
max_tokens: int | None = None,
) -> dict:
"""Добавить реплику пациента в тред, прогнать через роутер + state machine, получить ответ."""
"""Обработать реплику пациента: роутер state machine → LLM → ответ.
Важно: коммит транзакции делается только в самом конце. Если LLM упадёт
rollback в роутере откатит thread + user_msg, чтобы «пустые» диалоги без
ответа ассистента не висели в списке.
"""
if thread_id is None:
thread = Thread(name=_auto_thread_name(text))
session.add(thread)
@@ -173,7 +122,7 @@ async def send_message(
user_msg = Message(thread_id=thread.id, role="user", text=text)
session.add(user_msg)
await session.flush()
await session.flush() # только flush, без commit — чтобы откатить при ошибке LLM
stmt = (
select(Message)
@@ -184,37 +133,81 @@ async def send_message(
rows = (await session.execute(stmt)).scalars().all()
history = [{"role": m.role, "content": m.text} for m in reversed(rows)]
# 1. Роутер — какая ветка отвечает.
# 1. Роутер — куда направляем.
routing = await router.classify(session=session, history=history, text=text)
router_code = routing["code"]
router_version = routing.get("version")
# 2. Снимок состояния треда. Если роутер ушёл в другую ветку — сбрасываем шаг и слоты.
state_snapshot = await thread_state_service.load_snapshot(session, thread.id)
prev_intent_code = state_snapshot["current_intent_code"]
if prev_intent_code and prev_intent_code != router_code:
logger.info(
"Router switched intent for thread %d: %s%s (state reset)",
thread.id, prev_intent_code, router_code,
)
state_snapshot = {"current_intent_code": router_code, "current_step": 0, "slots": {}}
# 2. Снимок состояния. Важное правило (sticky state machine, мини-G из Спринта 6b):
# если тред уже идёт по state-machine-ветке и роутер предлагает другую —
# НЕ сбрасываем 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
# 3. Получаем конфиг ветки (с fallback на general_info) и зовём LLM.
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, router_code)
if served_code != router_code:
# Fallback: сбрасываем состояние на general_info.
state_snapshot = {"current_intent_code": served_code, "current_step": 0, "slots": {}}
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(
"Router switched intent for thread %d: %s%s (state reset)",
thread.id, prev_intent_code, router_code,
)
snapshot = {
"current_intent_code": router_code,
"current_step": 0,
"current_step_code": None,
"slots": {},
}
# 3. Разрешаем ветку (с fallback) и шаг.
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, effective_code)
if served_code != effective_code:
snapshot = {
"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)
sources = _retrieved_to_sources(retrieved)
bounce_log: list[dict] = []
validation_events: list[dict] = [] # illegal transitions для UI-подсветки
last_assembled_prompt = ""
llm_text = ""
visible_text = ""
parse_error: str | None = None
is_state_machine = False
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)
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(
question=text,
@@ -225,8 +218,11 @@ async def send_message(
max_tokens=max_tokens,
)
last_assembled_prompt = llm_result["assembled_prompt"]
llm_text = llm_result["text"]
parsed = _parse_assistant_signals(llm_text)
parsed = parse_branch_response(llm_result["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:
new_code = parsed["intent_change"]
@@ -235,32 +231,64 @@ async def send_message(
"to": new_code,
"preface": parsed["visible_text"],
})
logger.info(
"Intent bounce in thread %d: %s%s", thread.id, served_code, new_code,
)
logger.info("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)
state_snapshot = {"current_intent_code": served_code, "current_step": 0, "slots": {}}
snapshot = {
"current_intent_code": served_code,
"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,
}
else:
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 и сохраняем сообщения.
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_step": new_step,
"slots": merged_slots,
}
# Если ответ пришёл с INTENT_CHANGE на последней итерации (превысили MAX_BOUNCES) —
# служебный тег мы из visible_text уже вырезали, но состояние переключать не будем.
# 4. Сохраняем: thread_state пишется ПОСЛЕ всей логики, коммит — единой транзакцией.
await thread_state_service.upsert(
session, thread.id,
intent_code=state_snapshot["current_intent_code"],
step=state_snapshot["current_step"],
slots=state_snapshot["slots"],
intent_code=snapshot["current_intent_code"],
step=snapshot["current_step"],
step_code=snapshot.get("current_step_code"),
slots=snapshot["slots"],
)
user_msg.intent_id = intent.id
@@ -284,10 +312,12 @@ async def send_message(
await session.refresh(thread)
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,
state_snapshot["current_step"], len(state_snapshot["slots"]),
user_msg.id, assistant_msg.id, len(bounce_log),
snapshot.get("current_step_code") or "-",
len(snapshot["slots"]),
len(bounce_log),
len(validation_events),
)
return {
@@ -304,11 +334,14 @@ async def send_message(
"model_used": llm.model,
"assembled_prompt": last_assembled_prompt,
"thread_state": {
"current_intent_code": state_snapshot["current_intent_code"],
"current_step": state_snapshot["current_step"],
"slots": state_snapshot["slots"],
"current_intent_code": snapshot["current_intent_code"],
"current_step": snapshot["current_step"],
"current_step_code": snapshot.get("current_step_code"),
"slots": snapshot["slots"],
},
"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:
"""Удалить тред и все его сообщения. Возвращает число удалённых сообщений или None, если треда нет."""
thread = await session.get(Thread, thread_id)
if thread is 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)
+36 -24
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
from pathlib import Path
@@ -5,6 +6,11 @@ import httpx
from config import settings
class LLMUnavailableError(RuntimeError):
"""Внешний LLM недоступен после всех попыток — сигнал для вызывающего кода."""
pass
logger = logging.getLogger(__name__)
SYSTEM_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "system_prompt.md"
@@ -98,18 +104,7 @@ class LLMClient:
"max_tokens": effective_max_tokens,
}
async with httpx.AsyncClient(timeout=60.0) as client:
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()
data = await self._call_with_retry(url, payload)
content = data["choices"][0]["message"]["content"]
logger.info("LLM response: %d chars, model=%s, temp=%.2f", len(content), self.model, effective_temp)
return {"text": content.strip(), "assembled_prompt": assembled_prompt}
@@ -159,18 +154,35 @@ class LLMClient:
"max_tokens": effective_max_tokens,
}
async with httpx.AsyncClient(timeout=60.0) as client:
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()
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:
response = await client.post(
url,
json=payload,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
response.raise_for_status()
return response.json()
except Exception as e:
last_error = e
logger.warning(
"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
+30 -13
View File
@@ -8,6 +8,7 @@
(версионируется, редактируется из UI «Настройки»). Если БД недоступна или
ветки нет используем fallback из prompts/intents/_router.md.
"""
import asyncio
import logging
import re
from pathlib import Path
@@ -102,20 +103,36 @@ class RouterClient:
"max_tokens": 20,
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
url,
json=payload,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
data: dict | None = None
last_error: Exception | None = None
# Один ретрай: DeepSeek иногда отвечает 5xx / пустым исключением.
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=30.0) as client:
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()
break
except Exception as e:
last_error = e
logger.warning(
"Router LLM call failed (attempt %d, %s: %s)",
attempt + 1, type(e).__name__, e,
)
response.raise_for_status()
data = response.json()
except Exception as e:
logger.warning("Router LLM call failed (%s), falling back to general_info", 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}
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:
"""Удобный снимок состояния для чтения (intent, step, slots)."""
"""Удобный снимок состояния для чтения (intent, step_code, step, slots)."""
state = await get_state(session, thread_id)
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 {
"current_intent_code": state.current_intent_code,
"current_step": state.current_step,
"current_step_code": state.current_step_code,
"slots": _parse_slots(state.slots_json),
}
@@ -48,6 +54,7 @@ async def upsert(
intent_code: str | None,
step: int,
slots: dict,
step_code: str | None = None,
) -> ThreadState:
"""Создать или обновить состояние треда. Коммит — на совести вызывающего."""
state = await get_state(session, thread_id)
@@ -58,6 +65,7 @@ async def upsert(
thread_id=thread_id,
current_intent_code=intent_code,
current_step=step,
current_step_code=step_code,
slots_json=slots_raw,
updated_at=now,
)
@@ -65,11 +73,21 @@ async def upsert(
else:
state.current_intent_code = intent_code
state.current_step = step
state.current_step_code = step_code
state.slots_json = slots_raw
state.updated_at = now
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)."""
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={},
)
+168 -29
View File
@@ -111,7 +111,7 @@
min-height: 0;
}
/* Список тредов */
/* Список диалогов */
.threads-head-btn {
margin-left: auto;
background: var(--accent);
@@ -310,21 +310,82 @@
margin: 0 0 10px 0;
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 {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 8px;
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 {
font-size: 10px;
color: var(--muted);
display: flex;
gap: 8px;
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 {
background: var(--chip-bg);
@@ -332,13 +393,18 @@
padding: 1px 6px;
border-radius: 10px;
font-weight: 600;
flex-shrink: 0;
}
.chunk-text {
padding: 0 10px 10px 10px;
white-space: pre-wrap;
word-break: break-word;
font-size: 11.5px;
color: var(--fg);
max-height: 100px;
border-top: 1px solid var(--border);
padding-top: 8px;
margin-top: 2px;
max-height: 240px;
overflow-y: auto;
}
.prompt-box {
@@ -431,17 +497,20 @@
<div class="col-head">Отладка ответа</div>
<div class="col-body">
<div class="debug-section">
<h3>Состояние треда</h3>
<h3>Состояние диалога</h3>
<div id="debug-state"><div class="mini">— пока пусто —</div></div>
</div>
<div class="debug-section">
<h3>Решение роутера</h3>
<div id="debug-router"><div class="mini">— пока пусто —</div></div>
</div>
<div class="debug-section">
<h3>Найденные фрагменты (по последней реплике)</h3>
<details class="debug-section collapsible" id="debug-chunks-section">
<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>
</details>
<div class="debug-section">
<h3>Собранный промпт</h3>
<div id="debug-prompt"><div class="mini">— пока пусто —</div></div>
@@ -548,8 +617,8 @@ async function openThread(id) {
renderMessages(d.messages);
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
if (lastAssistant) {
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null);
renderState(d.thread_state, []);
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, [], [], null);
} else {
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");
if (!state || !state.current_intent_code) {
box.innerHTML = '<div class="mini">state machine ещё не запускалась</div>';
box.innerHTML = '<div class="mini">сценарий ещё не запущен</div>';
return;
}
const slotsJson = JSON.stringify(state.slots || {}, null, 2);
const bounceHtml = (bounces && bounces.length)
? `<div style="margin-top:8px;font-size:11px;">
<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("")}
</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 = `
<div style="font-size:12px;">
<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> · шаг <b>${state.current_step}</b></div>
<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>
${bounceHtml}
${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
}
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode) {
const bounced = routerIntentCode && intentCode && routerIntentCode !== intentCode;
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode) {
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
? `<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>
${routerVersion != null ? `<div style="color:var(--muted);font-size:11px;margin-top:2px;">роутер v${routerVersion}${bounced ? ` сказал <b>${esc(routerIntentCode)}</b>, ветка передала управление` : ''}</div>` : ''}
${verdict}
</div>`
: "";
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
const count = $("debug-chunks-count");
if (sources && sources.length) {
count.textContent = sources.length;
count.style.display = "";
$("debug-chunks").innerHTML = sources.map(s => `
<div class="chunk-card">
<div class="chunk-card-meta">
<span class="chunk-score">${(s.relevance_score * 100).toFixed(1)}%</span>
<span>${esc(s.document_name || "—")}</span>
${s.section ? `<span>${esc(s.section)}</span>` : ""}
</div>
<details class="chunk-card">
<summary>
<div class="chunk-card-meta">
<span class="chunk-score">${(s.relevance_score * 100).toFixed(1)}%</span>
<span class="chunk-doc">${esc(s.document_name || "—")}</span>
${s.section ? `<span>${esc(s.section)}</span>` : ""}
</div>
</summary>
<div class="chunk-text">${esc(s.chunk_text)}</div>
</div>
</details>
`).join("");
} else {
count.style.display = "none";
$("debug-chunks").innerHTML = '<div class="mini">источников нет</div>';
}
$("debug-prompt").innerHTML = prompt
@@ -682,7 +809,7 @@ async function sendMessage() {
$("chat-send").disabled = true;
$("chat-send").innerHTML = '<span class="spinner"></span>';
appendMessage("user", txt);
const userBubble = appendMessage("user", txt);
const pending = appendMessage("assistant", "…");
pending.style.opacity = "0.6";
@@ -699,11 +826,23 @@ async function sendMessage() {
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name);
$("chat-title").className = "chat-title";
$("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);
renderState(r.thread_state, r.bounces);
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, r.validation_events, r.parse_error);
refreshThreads();
} catch (e) {
// Откатываем визуально: убираем пузырь-заглушку ассистента и только что
// добавленную реплику пациента — на бекенде весь запрос уже откатился (rollback).
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");
} finally {
$("chat-send").disabled = false;
+324 -3
View File
@@ -179,7 +179,7 @@
.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; }
.field { margin-bottom: 14px; }
.field { margin-bottom: 14px; position: relative; }
.field label {
display: block;
font-size: 12px;
@@ -187,6 +187,80 @@
color: var(--muted);
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 {
width: 100%;
border: 1px solid var(--border);
@@ -309,6 +383,72 @@
.toast.show { opacity: 1; }
.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 {
position: relative;
@@ -386,6 +526,9 @@ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&
let intents = [];
let currentIntentCode = null;
let versions = [];
let currentSteps = []; // шаги выбранной ветки (если state machine)
let currentStepCode = null; // выбранный шаг в редакторе
let activeTab = "prompt"; // "prompt" | "steps"
function toast(msg, kind = "ok") {
const t = $("toast");
@@ -472,10 +615,21 @@ async function toggleIntent(code, enabled) {
/* ---------- select intent ---------- */
async function selectIntent(code) {
currentIntentCode = code;
activeTab = "prompt";
currentStepCode = null;
renderIntents();
await refreshSteps(code);
renderEditor();
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() {
@@ -486,10 +640,63 @@ function renderEditor() {
return;
}
$("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 = `
<h2>${esc(intent.name)}</h2>
<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">
<label for="f-name">Имя версии (необязательно)</label>
<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>
</div>
<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>
</div>
<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() {
if (!currentIntentCode) return;
const intent = intents.find(i => i.code === currentIntentCode);