From 3c71372ec80fff90de8f4b49b3b4a97461994a52 Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Sat, 25 Apr 2026 16:41:58 +0500 Subject: [PATCH] =?UTF-8?q?docs+ui:=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=20=C2=AB=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8F=C2=BB,=20=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=81=D1=82=D0=B8=D0=BB=D1=8C=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=BE=D0=B2,=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена /docs.html — обзор мультиагентной системы для оператора. Все термины в формате «русский (english)», жирным: ветка (intent), маршрутизатор (router), пошаговый сценарий (state machine), шаг (step), допустимые переходы (allowed_next), слоты (slots), условия выхода (exit conditions), переключение ветки (hard handoff), удержание в ветке (sticky state machine), структурированный ответ (structured output), отложенный сценарий (suspended/resume), защита от петли (routing loop guard), состояние диалога (thread state). Плюс пошаговая схема обработки реплики и резюме защитных механизмов. Ссылка «Документация» добавлена в шапку всех страниц. Унификация заголовков под стиль «Версии» в правом сайдбаре Настроек: убран uppercase, переход на 13px / var(--fg) / font-weight 600 / зажатый letter-spacing. Применилось к .col-head во всех колонках, .field label в редакторе, .section-header в списке веток, заголовкам столбцов на странице Отладки и заголовкам секций RAG-результата. Бейджи (АКТИВНАЯ, система) оставлены прежними — это статусные метки, не заголовки. Переименование ветки escalate_human для согласованности с русским UI: «Эскалация на оператора» → «Перевод на оператора», описание тоже. Точечная миграция при старте (intent_service.migrate_intent_copy) обновляет существующие записи в БД, только если поле в точности совпадает со старым значением — операторские правки не затираются. Co-Authored-By: Claude Opus 4.7 (1M context) --- main.py | 1 + services/intent_service.py | 37 +++- static/docs.html | 374 +++++++++++++++++++++++++++++++++++++ static/index.html | 15 +- static/sandbox.html | 94 ++++++---- static/settings.html | 57 ++++-- 6 files changed, 510 insertions(+), 68 deletions(-) create mode 100644 static/docs.html diff --git a/main.py b/main.py index b233b2f..0c181b5 100644 --- a/main.py +++ b/main.py @@ -64,6 +64,7 @@ async def lifespan(app: FastAPI): async with SessionLocal() as session: await intent_service.ensure_seed_intents(session) + await intent_service.migrate_intent_copy(session) await config_service.migrate_legacy_config_to_general_info(session) await config_service.ensure_seed_configs(session) await config_service.migrate_exit_conditions_to_field(session) diff --git a/services/intent_service.py b/services/intent_service.py index be192e7..fd1472a 100644 --- a/services/intent_service.py +++ b/services/intent_service.py @@ -22,7 +22,7 @@ SEED_INTENTS: list[dict] = [ {"code": "price_question", "name": "Цены и ДМС", "description": "Вопросы про стоимость услуг, оплату, ДМС."}, {"code": "medical_question", "name": "Медицинский вопрос", "description": "Симптомы, лекарства, диагноз — требует врача."}, {"code": "general_info", "name": "Общая справка", "description": "Адрес, часы работы, как доехать, общие вопросы."}, - {"code": "escalate_human", "name": "Эскалация на оператора", "description": "Передача диалога живому оператору."}, + {"code": "escalate_human", "name": "Перевод на оператора", "description": "Перевод диалога на живого оператора."}, {"code": ROUTER_INTENT_CODE, "name": "Маршрутизатор", "description": "Системная ветка: промпт классификатора намерений. Пациенту напрямую не отвечает."}, ] @@ -81,3 +81,38 @@ async def ensure_seed_intents(session: AsyncSession) -> None: if added: await session.commit() logger.info("Seeded %d missing intents", added) + + +# Точечные переименования: пользователь правит UI-копию, но в БД уже залит +# старый сид. Применяем мягко — только если поле в точности совпадает со старым +# значением (значит оператор не правил его сам). +_INTENT_NAME_MIGRATIONS: list[dict] = [ + { + "code": "escalate_human", + "old_name": "Эскалация на оператора", + "new_name": "Перевод на оператора", + "old_description": "Передача диалога живому оператору.", + "new_description": "Перевод диалога на живого оператора.", + }, +] + + +async def migrate_intent_copy(session: AsyncSession) -> None: + """Обновляет name/description у системных веток, если в БД лежит старый текст.""" + updated = 0 + for spec in _INTENT_NAME_MIGRATIONS: + intent = await get_intent_by_code(session, spec["code"]) + if intent is None: + continue + changed = False + if intent.name == spec["old_name"]: + intent.name = spec["new_name"] + changed = True + if intent.description == spec["old_description"]: + intent.description = spec["new_description"] + changed = True + if changed: + updated += 1 + if updated: + await session.commit() + logger.info("Migrated copy for %d intent(s)", updated) diff --git a/static/docs.html b/static/docs.html new file mode 100644 index 0000000..19d9cab --- /dev/null +++ b/static/docs.html @@ -0,0 +1,374 @@ + + + + + +Chat Agent for Patients — Документация + + + + +
+

Chat Agent for Patients

+ +
+ +
+
+ +

Как работает мультиагентная система

+

Здесь объясняется, что такое ветка (intent), как реплика пациента доходит до ответа, и какие защитные механизмы стоят на пути «петель» и потерянного контекста. Английские термины оставлены в скобках — на них завязан код и логи.

+ + + +

Зачем это всё

+

На пилоте у нас был «один большой системный промпт» — модель пыталась одновременно записывать на приём, отвечать на вопросы по ценам и эскалировать острые случаи. По мере усложнения скрипта запись начинала «плыть»: модель забывала шаги, путала ветки, перескакивала через мини-интервью.

+

Мы перешли на графовую архитектуру (graph-based routing): реплика пациента сначала идёт в маршрутизатор (router), который определяет тему, а потом — в ветку (intent), отвечающую только за свой узкий сценарий. У сложных веток внутри есть собственный пошаговый сценарий (state machine).

+ +

Главные термины

+ +
+
Ветка (intent)
+
Изолированный «под-агент» с собственным системным промптом. Отвечает за одну тему: запись, перенос, цены, медицинский вопрос, общая справка, перевод на оператора. У каждой ветки — свой код (new_booking, price_question и т. п.) и активная версия настроек.
+
+ +
+
Маршрутизатор (router)
+
Системная ветка _router: отдельный, дешёвый вызов модели, который по последней реплике пациента возвращает один из кодов веток. Не отвечает пациенту напрямую — только классифицирует. Вызывается на КАЖДОЙ реплике, не один раз при входе.
+
+ +
+
Пошаговый сценарий (state machine)
+
Внутренний граф шагов внутри ветки. Сейчас есть только у new_booking: 6 шагов от приветствия до подтверждения записи. Модель на каждой реплике видит, на каком шаге сейчас, и какие слоты уже собраны.
+
+ +
+
Шаг (step)
+
Узел пошагового сценария. У каждого шага свой код (intro, qualify, present, offer_time, book, close), свой кусок промпта и список допустимых переходов.
+
+ +
+
Допустимые переходы (allowed_next)
+
Список кодов шагов, в которые можно перейти с текущего. Например, с qualify разрешено в qualify (остаться) или present (двигаться вперёд), но не в close. Если модель попытается перепрыгнуть через шаг — валидатор переходов (transition validator) отклонит запрос, мы останемся на шаге.
+
+ +
+
Слоты (slots)
+
JSON-словарь данных, которые ветка собирает по ходу разговора. Для записи это name, reason, specialist, preferred_time, confirmed. Модель на каждой реплике их видит и обновляет — старые не переспрашиваются.
+
+ +
+
Условия выхода (exit conditions)
+
Список ситуаций, когда ветка должна вместо обычного ответа выдать служебный сигнал [INTENT_CHANGE: <код_ветки>] и передать диалог другой ветке. Например, если пациент в записи упомянул хирургию — ветка new_booking сама вернёт [INTENT_CHANGE: escalate_human].
+
+ +
+
Переключение ветки (hard handoff)
+
Полная смена ветки внутри одного диалога с обнулением шага и слотов. Бывает в двух случаях: ветка сама выдала [INTENT_CHANGE] или маршрутизатор предложил другую ветку, которая не имеет пошагового сценария.
+
+ +
+
Удержание в ветке (sticky state machine)
+
Защита от ложных переключений: если диалог идёт по пошаговому сценарию (например, new_booking · qualify) и маршрутизатор на короткой реплике («Алексей», «болит ухо») предлагает другую ветку — мы НЕ сбрасываем состояние, а передаём модели подсказку «маршрутизатор думает X, но ты в Y». Модель сама решает: остаться в сценарии (заполнить слот) или явно выйти через [INTENT_CHANGE].
+
+ +
+
Структурированный ответ ветки (structured output)
+
Каждая sm-ветка возвращает не только текст пациенту, но и служебный JSON-блок в хвосте ответа: +
STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Алексей"}}
+ Парсер вырезает этот блок (пациент его не видит), валидатор проверяет легальность state_after, обновляет состояние диалога.
+
+ +
+
Отложенный сценарий (suspended intent / resume)
+
Если из пошаговой ветки произошёл переход в другую (например, посреди записи спросили про цены), её состояние (текущий шаг и собранные слоты) запоминается в полях suspended_intent, resumable_step_code, resumable_slots. Когда маршрутизатор увидит, что пациент возвращается к исходной теме («ладно, продолжаем запись»), мы автоматически восстановим шаг и слоты.
+
+ +
+
Защита от петли (routing loop guard)
+
Счётчик handoff_count в состоянии диалога считает все переключения ветки. При превышении 3 переключений за диалог следующее переключение блокируется: диалог автоматически уходит в escalate_human с шаблонным ответом «Уточню детали с администратором клиники, свяжемся с вами в течение ближайшего часа». Это страховка от циклов вроде «запись ↔ цены ↔ запись ↔ цены».
+
+ +
+
Состояние диалога (thread state)
+
Запись в БД (одна на диалог), хранящая текущую ветку, текущий шаг (если есть), собранные слоты, handoff_count и поля отложенного сценария. Видно в Песочнице справа, в блоке «Состояние диалога».
+
+ +

Что происходит на каждой реплике

+
+ +
+
1
+
+ Маршрутизатор классифицирует реплику. +
Отдельный вызов модели с короткой системой и историей. Возвращает один код ветки.
+
+
+ +
+
2
+
+ Проверяем отложенный сценарий. +
Если в состоянии диалога есть suspended_intent и маршрутизатор вернул именно его — восстанавливаем шаг и слоты, очищаем поля, обнуляем счётчик переключений.
+
+
+ +
+
3
+
+ Применяем удержание в ветке. +
Если диалог уже идёт по sm-ветке и маршрутизатор предлагает другую — состояние не сбрасываем, в системный промпт ветки добавляется блок [ПОДСКАЗКА РОУТЕРА].
+
+
+ +
+
4
+
+ Если переключение всё-таки происходит — инкрементим счётчик. +
При превышении 3 — авто-перевод на оператора, без вызова модели.
+
+
+ +
+
5
+
+ Собираем системный промпт ветки. +
Базовый промпт + промпт текущего шага (если есть) + блок текущего состояния (шаг, слоты, подсказка). Зовём модель.
+
+
+ +
+
6
+
+ Парсим ответ. +
Если есть [INTENT_CHANGE] — переключаемся в новую ветку (если из sm-ветки — запоминаем в отложенный сценарий) и зовём модель ещё раз. Если есть STATE_JSON: — валидируем переход, обновляем шаг и сливаем слоты.
+
+
+ +
+
7
+
+ Сохраняем сообщение пациента, ответ модели и обновлённое состояние диалога. +
Всё одной транзакцией. Если что-то упадёт — откатываем целиком, «диалог-призрак» в списке не появится.
+
+
+
+ +

Защитные механизмы

+
    +
  • Удержание в ветке защищает от ложного сброса сценария на коротких репликах вроде «Алексей» или «болит ухо».
  • +
  • Валидатор переходов блокирует «прыжки через шаг» — модель не сможет уйти из intro сразу в book.
  • +
  • Защита от петли ограничивает число переключений ветки за диалог. После 3-го — авто-перевод на оператора.
  • +
  • Отложенный сценарий возвращает прерванный сценарий с теми же слотами и шагом — пациент не должен повторять имя или повод.
  • +
  • Ретрай LLM: и маршрутизатор, и ветка делают один повтор при сетевом сбое DeepSeek. При полном падении — откат транзакции и понятный ответ «модель временно недоступна».
  • +
+ +

Где что настраивается

+
    +
  • Настройки — список веток, активные версии промптов, поля «Системный промпт», «Правила», «Условия выхода». Для веток с пошаговым сценарием — вкладка «Шаги» с редактором каждого шага и его допустимых переходов.
  • +
  • Песочница — живые диалоги от лица пациента. В правой панели видны: состояние диалога (ветка, шаг, слоты, счётчик переключений, отложенный сценарий), решение маршрутизатора, RAG-фрагменты и собранный системный промпт.
  • +
  • Отладка — база знаний (загрузка / переразметка документов), одиночные тестовые вопросы без памяти диалога.
  • +
+ +
+ Документ описывает текущее состояние после Спринта 6a. Перевод на оператора с указанием причины (acute_pain / surgery / routing_loop / …), сводка для оператора и умный маршрутизатор, видящий состояние диалога — в Спринте 6b. +
+ +
+
+ + + diff --git a/static/index.html b/static/index.html index 53d76b0..08576c4 100644 --- a/static/index.html +++ b/static/index.html @@ -125,10 +125,9 @@ } th { font-weight: 600; - color: var(--muted); - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.03em; + color: var(--fg); + font-size: 13px; + letter-spacing: -0.01em; } tr:last-child td { border-bottom: none; } tr.doc-row { cursor: pointer; } @@ -250,11 +249,10 @@ } .col h3 { margin: 0 0 10px 0; - font-size: 12px; + font-size: 13px; font-weight: 600; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.03em; + color: var(--fg); + letter-spacing: -0.01em; } .chunk { border: 1px solid var(--border); @@ -343,6 +341,7 @@ Отладка Песочница Настройки + Документация проверяю… diff --git a/static/sandbox.html b/static/sandbox.html index 4f25784..3ca0520 100644 --- a/static/sandbox.html +++ b/static/sandbox.html @@ -93,14 +93,25 @@ flex-direction: column; min-height: 0; } - .col-panel:last-child { border-right: none; border-left: 1px solid var(--border); } + .col-panel:last-child { + border-right: none; + border-left: 1px solid var(--border); + background: var(--bg); + } + /* Правая панель — стек карточек на сером фоне */ + .col-panel:last-child .col-body { + padding: 14px 14px 18px 14px; + display: flex; + flex-direction: column; + gap: 12px; + } .col-head { - padding: 12px 16px; + padding: 14px 16px 10px; border-bottom: 1px solid var(--border); - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--muted); + font-size: 13px; + color: var(--fg); + font-weight: 600; + letter-spacing: -0.01em; display: flex; align-items: center; gap: 8px; @@ -300,53 +311,56 @@ .chat-input button:hover { background: var(--accent-hover); } .chat-input button:disabled { background: var(--muted); cursor: not-allowed; } - /* Правая панель — отладка */ - .debug-section { padding: 14px 16px; border-bottom: 1px solid var(--border); } + /* Правая панель — карточки */ + .debug-section { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + } .debug-section h3 { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--muted); + font-size: 13px; + color: var(--fg); margin: 0 0 10px 0; font-weight: 600; + letter-spacing: -0.01em; } - /* Сворачиваемая секция (details/summary) */ + /* Сворачиваемая секция (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); + gap: 8px; + margin: 0; + font-size: 13px; + color: var(--fg); font-weight: 600; + letter-spacing: -0.01em; } + .debug-section.collapsible[open] > summary { margin: 0 0 10px 0; } .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 { + .debug-section.collapsible > summary::after { + content: "⌄"; margin-left: auto; + font-size: 14px; + line-height: 1; + color: var(--muted); + transition: transform 0.15s; + } + .debug-section.collapsible[open] > summary::after { transform: rotate(180deg); } + .debug-section.collapsible > summary:hover { color: var(--accent); } + .debug-section.collapsible > summary .summary-count { background: var(--chip-bg); color: var(--accent); - padding: 1px 7px; + padding: 1px 8px; border-radius: 10px; - font-size: 10px; - text-transform: none; - letter-spacing: 0; + font-size: 11px; + font-weight: 500; } .chunk-card { - background: var(--panel); + background: #fafbfd; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; @@ -408,7 +422,7 @@ overflow-y: auto; } .prompt-box { - background: var(--panel); + background: #fafbfd; color: var(--fg); border: 1px solid var(--border); padding: 10px 12px; @@ -464,6 +478,7 @@ Отладка Песочница Настройки + Документация проверяю… @@ -494,7 +509,6 @@ diff --git a/static/settings.html b/static/settings.html index ee6aba3..2611be9 100644 --- a/static/settings.html +++ b/static/settings.html @@ -72,15 +72,30 @@ flex-direction: column; min-height: 0; } - .col-panel:last-child { border-right: none; border-left: 1px solid var(--border); } - .col-head { - padding: 12px 16px; - border-bottom: 1px solid var(--border); - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; + .col-panel:last-child { + border-right: none; + border-left: 1px solid var(--border); + background: var(--bg); + } + /* Правая колонка Настроек — серый фон, заголовок без рамки и стек карточек */ + .col-panel:last-child > .col-head { + border-bottom: none; + padding: 16px 14px 8px 14px; + } + .col-panel:last-child > .col-head #versions-intent { color: var(--muted); + font-weight: normal; + } + .col-panel:last-child > .col-body { + padding: 0 14px 18px 14px; + } + .col-head { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border); + font-size: 13px; + color: var(--fg); font-weight: 600; + letter-spacing: -0.01em; } .col-body { flex: 1; @@ -91,9 +106,7 @@ /* Список веток */ .section-header { padding: 10px 16px 6px; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; + font-size: 11px; color: var(--muted); font-weight: 600; background: #fafbfd; @@ -182,10 +195,11 @@ .field { margin-bottom: 14px; position: relative; } .field label { display: block; - font-size: 12px; - font-weight: 500; - color: var(--muted); - margin-bottom: 4px; + font-size: 13px; + font-weight: 600; + color: var(--fg); + letter-spacing: -0.01em; + margin-bottom: 6px; } .field label.with-hint { display: flex; @@ -313,16 +327,18 @@ } /* Версии */ - .versions { padding: 10px; } + .versions { + display: flex; + flex-direction: column; + gap: 10px; + } .version-card { + background: var(--panel); border: 1px solid var(--border); - border-radius: 8px; - padding: 10px 12px; - margin-bottom: 8px; - background: #fafbfd; + border-radius: 10px; + padding: 12px 14px; } .version-card.active { - background: #fff; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } @@ -486,6 +502,7 @@ Отладка Песочница Настройки + Документация