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:
+8
-5
@@ -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 сценариев (уже описан в этом документе выше, просто консолидируем).
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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` — номер шага, на котором пациент окажется ПОСЛЕ твоей реплики (1–6).
|
||||
- В `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]`.
|
||||
|
||||
Перед служебной строкой можно дать короткую фразу-перелинковку («понимаю, передам коллеге, минутку»), но не отвечай по сути новой темы — это сделает другая ветка.
|
||||
|
||||
Если в системном сообщении присутствует блок `[ПОДСКАЗКА РОУТЕРА]` — это значит, роутер засомневался. Прочти подсказку, сам оцени реплику пациента: укладывается ли она в текущий сценарий (жалоба/имя/повод/время) или действительно это смена темы. В сомнительных случаях предпочитай остаться в сценарии и собрать слот.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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 => ({'&':'&','<':'&
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user