From 9eef2dab3a491f6f1f734121d423b701339a16df Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Sat, 25 Apr 2026 11:45:42 +0500 Subject: [PATCH] =?UTF-8?q?feat(sprint6a):=20=D0=B1=D0=BB=D0=BE=D0=BA=20A?= =?UTF-8?q?=20=E2=80=94=20structured=20output,=20intent=5Fsteps,=20sticky-?= =?UTF-8?q?=D1=83=D0=B4=D0=B5=D1=80=D0=B6=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменили строковый тег [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) --- SPRINTS.md | 13 +- db/models/__init__.py | 3 +- db/models/intent_step.py | 37 ++ db/models/thread_state.py | 1 + main.py | 3 +- .../versions/a4c82f1b9e33_add_intent_steps.py | 48 +++ .../b5e91c2d07f1_add_current_step_code.py | 30 ++ models/requests.py | 7 + models/responses.py | 28 ++ prompts/intents/_router.md | 61 +++- prompts/intents/new_booking.md | 49 ++- prompts/intents/new_booking/steps/book.md | 11 + prompts/intents/new_booking/steps/close.md | 11 + prompts/intents/new_booking/steps/intro.md | 11 + .../intents/new_booking/steps/offer_time.md | 11 + prompts/intents/new_booking/steps/present.md | 13 + prompts/intents/new_booking/steps/qualify.md | 14 + routers/chat.py | 22 +- routers/intents.py | 64 +++- routers/threads.py | 1 + services/chat_service.py | 320 +++++++++-------- services/intent_step_service.py | 173 +++++++++ services/llm_client.py | 60 ++-- services/router_client.py | 43 ++- services/state_machine.py | 149 ++++++++ services/thread_state_service.py | 26 +- static/sandbox.html | 197 +++++++++-- static/settings.html | 327 +++++++++++++++++- 28 files changed, 1469 insertions(+), 264 deletions(-) create mode 100644 db/models/intent_step.py create mode 100644 migrations/versions/a4c82f1b9e33_add_intent_steps.py create mode 100644 migrations/versions/b5e91c2d07f1_add_current_step_code.py create mode 100644 prompts/intents/new_booking/steps/book.md create mode 100644 prompts/intents/new_booking/steps/close.md create mode 100644 prompts/intents/new_booking/steps/intro.md create mode 100644 prompts/intents/new_booking/steps/offer_time.md create mode 100644 prompts/intents/new_booking/steps/present.md create mode 100644 prompts/intents/new_booking/steps/qualify.md create mode 100644 services/intent_step_service.py create mode 100644 services/state_machine.py diff --git a/SPRINTS.md b/SPRINTS.md index 85ee43b..42043c4 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -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 сценариев (уже описан в этом документе выше, просто консолидируем). diff --git a/db/models/__init__.py b/db/models/__init__.py index a4106ec..aacbc9d 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -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"] diff --git a/db/models/intent_step.py b/db/models/intent_step.py new file mode 100644 index 0000000..7819e2a --- /dev/null +++ b/db/models/intent_step.py @@ -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 + ) diff --git a/db/models/thread_state.py b/db/models/thread_state.py index 43446b5..4c936d2 100644 --- a/db/models/thread_state.py +++ b/db/models/thread_state.py @@ -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 diff --git a/main.py b/main.py index 00c1caf..7f6c91a 100644 --- a/main.py +++ b/main.py @@ -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") diff --git a/migrations/versions/a4c82f1b9e33_add_intent_steps.py b/migrations/versions/a4c82f1b9e33_add_intent_steps.py new file mode 100644 index 0000000..59d9261 --- /dev/null +++ b/migrations/versions/a4c82f1b9e33_add_intent_steps.py @@ -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') diff --git a/migrations/versions/b5e91c2d07f1_add_current_step_code.py b/migrations/versions/b5e91c2d07f1_add_current_step_code.py new file mode 100644 index 0000000..c7ef676 --- /dev/null +++ b/migrations/versions/b5e91c2d07f1_add_current_step_code.py @@ -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') diff --git a/models/requests.py b/models/requests.py index 0f939be..addd217 100644 --- a/models/requests.py +++ b/models/requests.py @@ -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 diff --git a/models/responses.py b/models/responses.py index 56b24a6..e185be6 100644 --- a/models/responses.py +++ b/models/responses.py @@ -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 diff --git a/prompts/intents/_router.md b/prompts/intents/_router.md index 3d6b34f..77d52de 100644 --- a/prompts/intents/_router.md +++ b/prompts/intents/_router.md @@ -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`. diff --git a/prompts/intents/new_booking.md b/prompts/intents/new_booking.md index c3f0822..080ecef 100644 --- a/prompts/intents/new_booking.md +++ b/prompts/intents/new_booking.md @@ -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: ]` вместо `STATE_JSON:` только в следующих случаях: + +- Пациент прямо спрашивает про **диагноз, лекарства или дозировки** (не про запись, а про медицинскую консультацию) → `[INTENT_CHANGE: medical_question]`. +- **Острое состояние**: сильная боль до обморока, высокая температура, кровотечение, одышка, ребёнок плохо дышит, упоминание наркоза / планируемой операции → `[INTENT_CHANGE: escalate_human]`. +- Пациент спрашивает про **цены, ДМС, оплату** → `[INTENT_CHANGE: price_question]`. +- Пациент хочет **перенести или отменить уже существующую запись** (не записаться впервые) → `[INTENT_CHANGE: reschedule]`. +- Пациент явно просит **соединить с оператором** / злится → `[INTENT_CHANGE: escalate_human]`. Перед служебной строкой можно дать короткую фразу-перелинковку («понимаю, передам коллеге, минутку»), но не отвечай по сути новой темы — это сделает другая ветка. + +Если в системном сообщении присутствует блок `[ПОДСКАЗКА РОУТЕРА]` — это значит, роутер засомневался. Прочти подсказку, сам оцени реплику пациента: укладывается ли она в текущий сценарий (жалоба/имя/повод/время) или действительно это смена темы. В сомнительных случаях предпочитай остаться в сценарии и собрать слот. diff --git a/prompts/intents/new_booking/steps/book.md b/prompts/intents/new_booking/steps/book.md new file mode 100644 index 0000000..a11b892 --- /dev/null +++ b/prompts/intents/new_booking/steps/book.md @@ -0,0 +1,11 @@ +## Шаг «Подтверждение записи» (book) + +Задача: проговорить пациенту собранные данные и получить явное «да». + +- Кратко повтори 3–4 поля: пациент, специалист, повод, удобное время. +- Задай вопрос «всё верно?». +- Не рассказывай ничего нового на этом шаге. + +**Слоты этого шага:** `confirmed` (true после явного «да»). + +**Переход:** пациент подтвердил → `state_after: close` и `slots_updated: {"confirmed": true}`. Пациент хочет поправить → `state_after` возвращается на нужный шаг (`qualify`, `offer_time`). diff --git a/prompts/intents/new_booking/steps/close.md b/prompts/intents/new_booking/steps/close.md new file mode 100644 index 0000000..9749c8b --- /dev/null +++ b/prompts/intents/new_booking/steps/close.md @@ -0,0 +1,11 @@ +## Шаг «Завершение» (close) + +Задача: закрыть разговор. + +- Короткое подтверждение: «Готово, передаю администратору. Свяжемся в течение дня». +- Поблагодари за обращение. +- Не задавай новых вопросов. + +**Слоты этого шага:** не меняются. + +**Переход:** финальный шаг, `state_after: close` (остаёмся на месте). Если пациент возвращается с новым вопросом — это поймает роутер или exit conditions. diff --git a/prompts/intents/new_booking/steps/intro.md b/prompts/intents/new_booking/steps/intro.md new file mode 100644 index 0000000..527ee78 --- /dev/null +++ b/prompts/intents/new_booking/steps/intro.md @@ -0,0 +1,11 @@ +## Шаг «Приветствие» (intro) + +Это первый контакт с пациентом. Задача: поздороваться, узнать, как к нему обращаться. + +- Представься коротко: «Здравствуйте, я виртуальный ассистент клиники». +- Спроси, как можно обращаться к пациенту. +- Не уточняй сразу повод, специальность, время — это следующие шаги. + +**Слоты этого шага:** `name` (обращение к пациенту). + +**Переход:** после того как пациент назвал имя или явно отказался его называть → `state_after: qualify`. Если имя не названо — оставайся на `intro`. diff --git a/prompts/intents/new_booking/steps/offer_time.md b/prompts/intents/new_booking/steps/offer_time.md new file mode 100644 index 0000000..45c3048 --- /dev/null +++ b/prompts/intents/new_booking/steps/offer_time.md @@ -0,0 +1,11 @@ +## Шаг «Удобное время» (offer_time) + +Задача: собрать предпочтения пациента по времени. + +- Спроси про удобные дни и часы (утро/день/вечер, будни/выходные, конкретные даты если пациент назвал). +- Реального календаря нет — не называй конкретные даты/часы как доступные. Отвечай «сейчас уточню расписание и вернусь с вариантами», если пациент спрашивает конкретику. +- Зафиксируй его предпочтения в слот. + +**Слоты этого шага:** `preferred_time` (строка-описание: «утро в будни», «суббота после 14:00», «любое рабочее время»). + +**Переход:** предпочтения понятны → `state_after: book`. Если пациент не определился — оставайся на `offer_time`. diff --git a/prompts/intents/new_booking/steps/present.md b/prompts/intents/new_booking/steps/present.md new file mode 100644 index 0000000..4953305 --- /dev/null +++ b/prompts/intents/new_booking/steps/present.md @@ -0,0 +1,13 @@ +## Шаг «Презентация плана» (present) + +Задача: коротко подтвердить пациенту, что записываем — специалист + повод — так, чтобы пациент почувствовал, что его услышали. + +- Составь одну-две тёплые фразы, используя уже собранные слоты `name`, `specialist`, `reason`. +- Обязательно упомяни **повод из `reason`** — пациент должен увидеть, что его жалоба учтена. Например: «{name}, записываю вас к {specialist}. На приёме врач осмотрит вас и особое внимание уделит тому, что вас беспокоит — {reason}». +- Не придумывай детали, которых не было (конкретные анализы, процедуры, диагноз) — только повод в формулировке из слота. +- Не предлагай пока слоты времени — это следующий шаг. +- Если пациент возражает или хочет поменять специалиста/повод — откатись обратно на `qualify` и обнови нужный слот. + +**Слоты этого шага:** новые не собираются; работаем с уже известными. + +**Переход:** пациент согласен с планом → `state_after: offer_time`. Пациент просит поправить специалиста / повод → `state_after: qualify`. diff --git a/prompts/intents/new_booking/steps/qualify.md b/prompts/intents/new_booking/steps/qualify.md new file mode 100644 index 0000000..1538857 --- /dev/null +++ b/prompts/intents/new_booking/steps/qualify.md @@ -0,0 +1,14 @@ +## Шаг «Повод и специалист» (qualify) + +Задача: узнать коротко повод обращения и к какому специалисту записывать. + +- Спроси про повод без сбора медицинской истории. Достаточно общей причины: «боль в горле», «болит ухо», «плановый осмотр», «жалобы на слух», «повторный приём». +- **Если пациент описал жалобу** — обязательно вырази короткое сочувствие («понимаю, боль в ухе — это неприятно», «понимаю, с горлом неприятно») и запиши жалобу в слот `reason` одной фразой так, как описал пациент («болит ухо», «боль в горле»). Не уточняй степень боли, длительность, выделения — это вопросы для врача на приёме. +- Если пациент сам назвал специалиста — зафиксируй в `specialist`. +- Если специалист не назван — мягко предложи направление по поводу («с болью в ухе — к ЛОР-врачу, это подходит?»). +- **Не уходи в `medical_question`** по одному лишь факту жалобы. Это повод для записи, а не повод обсуждать симптомы. +- Только если пациент просит тебя именно поставить диагноз, назвать лекарство / дозировку или описывает острое состояние (сильная боль до обморока, высокая температура, кровотечение, одышка) — тогда срабатывают exit conditions из базового промпта. + +**Слоты этого шага:** `reason` (повод/жалоба как описал пациент), `specialist` (врач или специальность). + +**Переход:** когда известны `reason` и `specialist` → `state_after: present`. Если чего-то не хватает — оставайся на `qualify`, спрашивай недостающее. diff --git a/routers/chat.py b/routers/chat.py index 6b30448..0e4d1ff 100644 --- a/routers/chat.py +++ b/routers/chat.py @@ -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"), ) diff --git a/routers/intents.py b/routers/intents.py index dfd99c3..a06dddb 100644 --- a/routers/intents.py +++ b/routers/intents.py @@ -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) diff --git a/routers/threads.py b/routers/threads.py index 182e3f4..0fd90d4 100644 --- a/routers/threads.py +++ b/routers/threads.py @@ -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", {}), ), ) diff --git a/services/chat_service.py b/services/chat_service.py index 2fe20f7..07dd0f8 100644 --- a/services/chat_service.py +++ b/services/chat_service.py @@ -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 diff --git a/services/intent_step_service.py b/services/intent_step_service.py new file mode 100644 index 0000000..0484b07 --- /dev/null +++ b/services/intent_step_service.py @@ -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) diff --git a/services/llm_client.py b/services/llm_client.py index 627b280..ec6801b 100644 --- a/services/llm_client.py +++ b/services/llm_client.py @@ -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 diff --git a/services/router_client.py b/services/router_client.py index 7528af6..64df587 100644 --- a/services/router_client.py +++ b/services/router_client.py @@ -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() diff --git a/services/state_machine.py b/services/state_machine.py new file mode 100644 index 0000000..2c31f46 --- /dev/null +++ b/services/state_machine.py @@ -0,0 +1,149 @@ +"""Парсер structured-output ветки + валидатор переходов state machine (Спринт 6a). + +Формат ответа ветки со state machine: + + Текст пациенту, markdown разрешён. + + STATE_JSON: {"state_after": "", "slots_updated": {"slot": "value"}} + +Либо — при exit condition — вместо `STATE_JSON:` строка `[INTENT_CHANGE: ]`. +Парсер вырезает служебную часть и возвращает видимый текст + решение модели. +""" +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}", + ) diff --git a/services/thread_state_service.py b/services/thread_state_service.py index 69885ab..1b2e888 100644 --- a/services/thread_state_service.py +++ b/services/thread_state_service.py @@ -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={}, + ) diff --git a/static/sandbox.html b/static/sandbox.html index 5ae6cb2..020824a 100644 --- a/static/sandbox.html +++ b/static/sandbox.html @@ -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 @@
Отладка ответа
-

Состояние треда

+

Состояние диалога

— пока пусто —

Решение роутера

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

Найденные фрагменты (по последней реплике)

+
+ + Найденные фрагменты + +
— пока пусто —
-
+

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

— пока пусто —
@@ -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 = '
state machine ещё не запускалась
'; + box.innerHTML = '
сценарий ещё не запущен
'; return; } - const slotsJson = JSON.stringify(state.slots || {}, null, 2); const bounceHtml = (bounces && bounces.length) ? `
переходы в этой реплике:
${bounces.map(b => `
${esc(b.from)}${esc(b.to)}${b.preface ? ` («${esc(b.preface).slice(0,60)}»)` : ''}
`).join("")}
` : ""; + const validationHtml = (validationEvents && validationEvents.length) + ? `
+ ${validationEvents.map(v => `⚠️ модель просилась в ${esc(v.requested_step)}, оставили на ${esc(v.current_step)}. ${esc(v.reason)}`).join("
")} +
` + : ""; + const parseErrorHtml = parseError + ? `
+ ⚠️ парсер: ${esc(parseError)} +
` + : ""; + + // Ветки без state machine (general_info, price_question и т.д.) шаги не ведут — + // показываем только intent, чтобы не путать пустым «шаг №0 · {}». + if (!state.current_step_code) { + box.innerHTML = ` +
+
+ ${esc(state.current_intent_code)} + — без пошагового сценария +
+ ${bounceHtml}${validationHtml}${parseErrorHtml} +
+ `; + return; + } + + const slotsJson = JSON.stringify(state.slots || {}, null, 2); box.innerHTML = `
-
${esc(state.current_intent_code)} · шаг ${state.current_step}
+
${esc(state.current_intent_code)} · шаг ${esc(state.current_step_code)}
${esc(slotsJson)}
- ${bounceHtml} + ${bounceHtml}${validationHtml}${parseErrorHtml}
`; } -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 => `${esc(b.from)}${esc(b.to)}`).join(", "); + verdict = `
+ ${routerVer} сказал ${esc(routerIntentCode)}.
+ Ветка сама выдала [INTENT_CHANGE] и передала управление: ${chain}. +
`; + } else if (routerDiffers) { + // Удержались в ветке: диалог в сценарии, роутер хотел переключить, но мы остались. + verdict = `
+ ${routerVer} предложил ${esc(routerIntentCode)}.
+ Но диалог идёт по сценарию ${esc(intentCode)}${stepCode ? ' (шаг ' + esc(stepCode) + ')' : ''} — + удержались в ветке: модель получила подсказку и осталась в сценарии. +
`; + } else { + // Обычный случай — роутер попал в ту же ветку. + verdict = `
+ ${routerVer} → та же ветка. +
`; + } + const routerLine = intentCode - ? `
+ ? `
${esc(intentCode)} — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}
- ${routerVersion != null ? `
роутер v${routerVersion}${bounced ? ` сказал ${esc(routerIntentCode)}, ветка передала управление` : ''}
` : ''} + ${verdict}
` : ""; $("debug-router").innerHTML = routerLine || '
— маршрутизация пока не выполнена —
'; + const count = $("debug-chunks-count"); if (sources && sources.length) { + count.textContent = sources.length; + count.style.display = ""; $("debug-chunks").innerHTML = sources.map(s => ` -
-
- ${(s.relevance_score * 100).toFixed(1)}% - ${esc(s.document_name || "—")} - ${s.section ? `${esc(s.section)}` : ""} -
+
+ +
+ ${(s.relevance_score * 100).toFixed(1)}% + ${esc(s.document_name || "—")} + ${s.section ? `${esc(s.section)}` : ""} +
+
${esc(s.chunk_text)}
-
+ `).join(""); } else { + count.style.display = "none"; $("debug-chunks").innerHTML = '
источников нет
'; } $("debug-prompt").innerHTML = prompt @@ -682,7 +809,7 @@ async function sendMessage() { $("chat-send").disabled = true; $("chat-send").innerHTML = ''; - 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 + ? '
Пусто. Напишите первую реплику.
' + : '
Это новый диалог.
Напишите реплику пациента снизу, чтобы начать.
'; + } + // Возвращаем текст в поле ввода — не заставлять пользователя перепечатывать. + $("chat-text").value = txt; toast("Ошибка: " + e.message, "err"); } finally { $("chat-send").disabled = false; diff --git a/static/settings.html b/static/settings.html index f9ba974..e01f8e4 100644 --- a/static/settings.html +++ b/static/settings.html @@ -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 + ? `
+ + +
` + : ""; + + let body; + if (hasSteps && activeTab === "steps") { + body = renderStepsPanel(); + } else { + body = renderPromptPanel(intent); + } + $("editor").innerHTML = `

${esc(intent.name)}

${esc(intent.code)} · ${esc(intent.description)}
+ ${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 `
@@ -499,7 +706,23 @@ function renderEditor() {
- + +
+ +

Что писать в «Правила»

+

Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.

+

Что нормально писать:

+
    +
  • «Если пациент уже наблюдается у конкретного врача — записывай к нему по умолчанию».
  • +
  • «Бесплатная парковка для пациентов 2 часа — упомяни, если спросят».
  • +
  • «После 19:00 предлагай только следующий рабочий день».
  • +
  • «Дети до 14 лет — сразу [INTENT_CHANGE: escalate_human], у нас нет педиатра».
  • +
+

Что сюда не стоит: изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода ([INTENT_CHANGE: ...]) пока тоже в основном промпте, в Спринте 6a выделим в отдельное поле.

+
@@ -510,6 +733,104 @@ function renderEditor() { `; } +function renderStepsPanel() { + const chips = currentSteps.map(s => ` +
+ ${s.order_index + 1}.${esc(s.code)} +
+ `).join(""); + return ` +
${chips}
+
+ `; +} + +function renderStepEditor() { + const step = currentSteps.find(s => s.code === currentStepCode); + if (!step) { + $("step-editor").innerHTML = '
Выберите шаг выше.
'; + return; + } + const otherCodes = currentSteps.map(s => s.code); + const allowedSet = new Set(step.allowed_next || []); + const checkboxes = otherCodes.map(code => ` + + `).join(""); + + $("step-editor").innerHTML = ` +
+ + +
+
+ + +
+
+ +
${checkboxes}
+
+
+ + +
+
+ + +
+ `; +} + +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 => ` +
+ ${s.order_index + 1}.${esc(s.code)} +
+ `).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);