feat(sprint6a): блок A — structured output, intent_steps, sticky-удержание
Заменили строковый тег [STATE: ...] из Спринта 5 на структурированный выход
ветки в виде JSON-блока в хвосте ответа: {state_after, slots_updated}, парсимый
балансировкой скобок. Шаги state machine вынесены из монолитного промпта в
таблицу intent_steps (intent_id FK, code, name, order_index, system_prompt,
allowed_next JSON, guards JSON) и редактируются через UI. Валидатор переходов
сверяет state_after с allowed_next и блокирует невалидные прыжки.
Базовый промпт new_booking разбит на base + 6 файлов шагов (intro/qualify/
present/offer_time/book/close), которые сидятся при старте через
ensure_seed_steps. В chat_service промпт собирается как base + step + блок
[ТЕКУЩЕЕ СОСТОЯНИЕ].
Попутно реализован мини-блок G (sticky state machine): когда диалог идёт по
sm-ветке и роутер на новой реплике предлагает другую — state НЕ сбрасывается,
в системный промпт ветки подаётся блок [ПОДСКАЗКА РОУТЕРА], LLM сама решает
(STATE_JSON или INTENT_CHANGE). Это сняло ключевую дыру Спринта 5: «Меня
зовут Алексей» / «болит ухо» внутри записи больше не сбрасывают сценарий.
Промпт ветки new_booking ужесточён: бытовые жалобы — это повод записи (слот
reason + сочувствие), не повод уводить в medical_question. Шаг present теперь
использует reason в формулировке. Промпт _router расширен живыми примерами
для всех 6 веток, особенно для reschedule («не смогу подойти», «перенесите»).
Надёжность внешнего LLM:
- ретрай в LLMClient с паузой 500 мс + новое исключение LLMUnavailableError;
- ретрай в RouterClient (DeepSeek периодически моргает);
- /chat при ошибке делает session.rollback() и возвращает 503 с понятным
сообщением — больше не остаётся «диалогов-призраков» с одной репликой;
- UI убирает свой пузырь и возвращает текст в поле ввода для повторной отправки.
UI «Настройки» — добавлена вкладка «Шаги» для веток с state machine: список
шагов chip-ами, редактор промпта/имени/allowed_next/guards, сохранение через
PATCH /intents/{code}/steps/{step_code} без версионирования. Иконка ⓘ возле
поля «Правила» открывает popover с пояснением, что туда писать.
UI «Песочница»:
- блок «Состояние диалога» показывает имя шага из intent_steps (а не сырое
число), для не-sm-веток пишется «без пошагового сценария»;
- подсветка illegal-переходов (валидатор отклонил state_after) и parse_error
для sm-веток;
- блок «Решение роутера» развёрнут в три исхода: «попал в ту же ветку» /
«удержались в ветке» / «ветка сама передала управление через INTENT_CHANGE»;
- секция «Найденные фрагменты» сворачивается, карточки чанков раскрываются
по клику — правый сайдбар стал компактнее.
Терминология (по договорённости — простой русский в UI):
- «тред» → «диалог» в текстах для оператора (в коде/API thread_id оставлен);
- «sticky state machine» → «удержались в ветке»;
- «state machine» → «пошаговый сценарий» в видимых местах.
SPRINTS.md: блок G в Спринте 6b сокращён — sticky-логика уже сделана здесь,
осталась только вторая линия (передача thread_state в системный промпт самого
роутера для ещё более точной первичной классификации).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+176
-144
@@ -1,25 +1,22 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Message, Thread
|
||||
from services import config_service, thread_state_service
|
||||
from services.llm_client import LLMClient
|
||||
from db.models import IntentStep, Message, Thread
|
||||
from services import config_service, intent_step_service, thread_state_service
|
||||
from services.llm_client import LLMClient, LLMUnavailableError
|
||||
from services.router_client import RouterClient
|
||||
from services.state_machine import parse_branch_response, validate_transition
|
||||
from services.vectorstore import VectorStoreService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HISTORY_LIMIT = 20 # последние N сообщений треда, которые улетают в LLM
|
||||
HISTORY_LIMIT = 20
|
||||
FALLBACK_INTENT_CODE = "general_info"
|
||||
MAX_BOUNCES = 1 # сколько раз за одну реплику ветка может передать управление другой
|
||||
|
||||
_INTENT_CHANGE_RE = re.compile(r"\[INTENT_CHANGE:\s*([a-z_][a-z0-9_]*)\s*\]")
|
||||
_STATE_PREFIX_RE = re.compile(r"\[STATE:\s*step=(\d+)\s*;?\s*slots\s*=\s*", re.IGNORECASE)
|
||||
MAX_BOUNCES = 1
|
||||
|
||||
|
||||
def _auto_thread_name(first_user_text: str) -> str:
|
||||
@@ -45,99 +42,32 @@ def _retrieved_to_sources(retrieved: list[dict]) -> list[dict]:
|
||||
return sources
|
||||
|
||||
|
||||
def _parse_assistant_signals(text: str) -> dict:
|
||||
"""Вырезать служебные теги [INTENT_CHANGE: ...] / [STATE: ...] из ответа ассистента.
|
||||
|
||||
Возвращает:
|
||||
visible_text — текст без служебных тегов,
|
||||
intent_change — код ветки или None,
|
||||
state — {'step': int, 'slots': dict} или None.
|
||||
|
||||
Парсер толерантен к лишним пробелам; slots парсится с балансировкой фигурных скобок,
|
||||
чтобы не ломаться на значениях-списках типа "slots={\"a\": [1, 2]}".
|
||||
"""
|
||||
intent_match = _INTENT_CHANGE_RE.search(text)
|
||||
if intent_match:
|
||||
visible = text[:intent_match.start()].rstrip()
|
||||
return {"visible_text": visible, "intent_change": intent_match.group(1), "state": None}
|
||||
|
||||
state_match = _STATE_PREFIX_RE.search(text)
|
||||
if state_match:
|
||||
tail_start = state_match.end()
|
||||
slots_raw, after = _consume_json_object(text, tail_start)
|
||||
if slots_raw is not None:
|
||||
remainder = text[after:].lstrip()
|
||||
if remainder.startswith("]"):
|
||||
try:
|
||||
slots = json.loads(slots_raw)
|
||||
if not isinstance(slots, dict):
|
||||
slots = {}
|
||||
except json.JSONDecodeError:
|
||||
slots = {}
|
||||
step = int(state_match.group(1))
|
||||
visible = text[:state_match.start()].rstrip()
|
||||
return {
|
||||
"visible_text": visible,
|
||||
"intent_change": None,
|
||||
"state": {"step": step, "slots": slots},
|
||||
}
|
||||
|
||||
return {"visible_text": text, "intent_change": None, "state": None}
|
||||
|
||||
|
||||
def _consume_json_object(text: str, start: int) -> tuple[str | None, int]:
|
||||
"""Вытянуть сбалансированный JSON-объект, начиная с позиции start (ожидаем `{`).
|
||||
|
||||
Возвращает (json_string, position_after_object). При ошибке — (None, start).
|
||||
"""
|
||||
i = start
|
||||
n = len(text)
|
||||
while i < n and text[i].isspace():
|
||||
i += 1
|
||||
if i >= n or text[i] != "{":
|
||||
return None, start
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
j = i
|
||||
while j < n:
|
||||
ch = text[j]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif ch == "\\":
|
||||
esc = True
|
||||
elif ch == '"':
|
||||
in_str = False
|
||||
else:
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[i:j + 1], j + 1
|
||||
j += 1
|
||||
return None, start
|
||||
|
||||
|
||||
def _format_state_context(state_snapshot: dict) -> str:
|
||||
"""Блок с текущим состоянием треда для дописывания в конец системного промпта."""
|
||||
step = state_snapshot.get("current_step", 0) or 0
|
||||
slots = state_snapshot.get("slots", {}) or {}
|
||||
def _format_state_context(
|
||||
snapshot: dict,
|
||||
current_step: IntentStep | None,
|
||||
router_hint: str | None = None,
|
||||
) -> str:
|
||||
"""Блок с текущим состоянием треда для дописывания в системный промпт."""
|
||||
slots = snapshot.get("slots", {}) or {}
|
||||
slots_json = json.dumps(slots, ensure_ascii=False)
|
||||
return (
|
||||
"\n\n[ТЕКУЩЕЕ СОСТОЯНИЕ]\n"
|
||||
f"step: {step}\n"
|
||||
f"slots: {slots_json}"
|
||||
)
|
||||
lines = ["", "[ТЕКУЩЕЕ СОСТОЯНИЕ]"]
|
||||
if current_step is not None:
|
||||
allowed = intent_step_service.parse_allowed_next(current_step)
|
||||
lines.append(f"step_code: {current_step.code} ({current_step.name})")
|
||||
lines.append(f"allowed_next: {json.dumps(allowed, ensure_ascii=False)}")
|
||||
else:
|
||||
lines.append("step_code: —")
|
||||
lines.append(f"slots: {slots_json}")
|
||||
if router_hint:
|
||||
lines.append("")
|
||||
lines.append("[ПОДСКАЗКА РОУТЕРА]")
|
||||
lines.append(router_hint)
|
||||
return "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def _resolve_intent_with_fallback(
|
||||
session: AsyncSession, intent_code: str
|
||||
) -> tuple[str, object, object]:
|
||||
"""Вернуть (code, intent, active_cfg) — либо запрошенной ветки, либо fallback."""
|
||||
pair = await config_service.get_active_config_by_intent_code(session, intent_code)
|
||||
if pair is None:
|
||||
logger.warning("Intent %r has no active config, falling back to %s", intent_code, FALLBACK_INTENT_CODE)
|
||||
@@ -150,6 +80,20 @@ async def _resolve_intent_with_fallback(
|
||||
return intent_code, intent, cfg
|
||||
|
||||
|
||||
async def _resolve_current_step(
|
||||
session: AsyncSession, intent_id: int, intent_code: str, step_code: str | None,
|
||||
) -> IntentStep | None:
|
||||
"""Найти шаг state machine для текущего состояния. Если кода нет — взять первый шаг ветки."""
|
||||
if not intent_step_service.has_state_machine(intent_code):
|
||||
return None
|
||||
if step_code:
|
||||
step = await intent_step_service.get_step_by_code(session, intent_id, step_code)
|
||||
if step is not None:
|
||||
return step
|
||||
logger.warning("Step %r not found for intent %s, falling back to first step", step_code, intent_code)
|
||||
return await intent_step_service.get_first_step(session, intent_id)
|
||||
|
||||
|
||||
async def send_message(
|
||||
session: AsyncSession,
|
||||
vectorstore: VectorStoreService,
|
||||
@@ -161,7 +105,12 @@ async def send_message(
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
) -> dict:
|
||||
"""Добавить реплику пациента в тред, прогнать через роутер + state machine, получить ответ."""
|
||||
"""Обработать реплику пациента: роутер → state machine → LLM → ответ.
|
||||
|
||||
Важно: коммит транзакции делается только в самом конце. Если LLM упадёт —
|
||||
rollback в роутере откатит thread + user_msg, чтобы «пустые» диалоги без
|
||||
ответа ассистента не висели в списке.
|
||||
"""
|
||||
if thread_id is None:
|
||||
thread = Thread(name=_auto_thread_name(text))
|
||||
session.add(thread)
|
||||
@@ -173,7 +122,7 @@ async def send_message(
|
||||
|
||||
user_msg = Message(thread_id=thread.id, role="user", text=text)
|
||||
session.add(user_msg)
|
||||
await session.flush()
|
||||
await session.flush() # только flush, без commit — чтобы откатить при ошибке LLM
|
||||
|
||||
stmt = (
|
||||
select(Message)
|
||||
@@ -184,37 +133,81 @@ async def send_message(
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
history = [{"role": m.role, "content": m.text} for m in reversed(rows)]
|
||||
|
||||
# 1. Роутер — какая ветка отвечает.
|
||||
# 1. Роутер — куда направляем.
|
||||
routing = await router.classify(session=session, history=history, text=text)
|
||||
router_code = routing["code"]
|
||||
router_version = routing.get("version")
|
||||
|
||||
# 2. Снимок состояния треда. Если роутер ушёл в другую ветку — сбрасываем шаг и слоты.
|
||||
state_snapshot = await thread_state_service.load_snapshot(session, thread.id)
|
||||
prev_intent_code = state_snapshot["current_intent_code"]
|
||||
if prev_intent_code and prev_intent_code != router_code:
|
||||
logger.info(
|
||||
"Router switched intent for thread %d: %s → %s (state reset)",
|
||||
thread.id, prev_intent_code, router_code,
|
||||
)
|
||||
state_snapshot = {"current_intent_code": router_code, "current_step": 0, "slots": {}}
|
||||
# 2. Снимок состояния. Важное правило (sticky state machine, мини-G из Спринта 6b):
|
||||
# если тред уже идёт по state-machine-ветке и роутер предлагает другую —
|
||||
# НЕ сбрасываем state. Передадим LLM подсказку «роутер думает так», и пусть
|
||||
# она сама решает: выдать `[INTENT_CHANGE: ...]` или удержать сценарий.
|
||||
# Это нужно, чтобы фраза-повод («болит ухо») внутри записи не сбрасывала слоты.
|
||||
snapshot = await thread_state_service.load_snapshot(session, thread.id)
|
||||
prev_intent_code = snapshot["current_intent_code"]
|
||||
router_hint: str | None = None
|
||||
effective_code = router_code
|
||||
|
||||
# 3. Получаем конфиг ветки (с fallback на general_info) и зовём LLM.
|
||||
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, router_code)
|
||||
if served_code != router_code:
|
||||
# Fallback: сбрасываем состояние на general_info.
|
||||
state_snapshot = {"current_intent_code": served_code, "current_step": 0, "slots": {}}
|
||||
if prev_intent_code and prev_intent_code != router_code:
|
||||
if intent_step_service.has_state_machine(prev_intent_code):
|
||||
logger.info(
|
||||
"Router suggested %s but thread %d is in sm %s — sticky, hint only",
|
||||
router_code, thread.id, prev_intent_code,
|
||||
)
|
||||
router_hint = (
|
||||
f"Роутер на этой реплике счёл, что тема — `{router_code}`. "
|
||||
f"Ты сейчас ведёшь сценарий `{prev_intent_code}`. "
|
||||
f"Если пациент действительно сменил тему (перенос, цены, острое состояние) — "
|
||||
f"выдай `[INTENT_CHANGE: {router_code}]`. "
|
||||
f"Если реплика укладывается в сценарий (повод/жалоба/имя) — "
|
||||
f"зафиксируй её в соответствующий слот и продолжай по сценарию."
|
||||
)
|
||||
effective_code = prev_intent_code
|
||||
else:
|
||||
logger.info(
|
||||
"Router switched intent for thread %d: %s → %s (state reset)",
|
||||
thread.id, prev_intent_code, router_code,
|
||||
)
|
||||
snapshot = {
|
||||
"current_intent_code": router_code,
|
||||
"current_step": 0,
|
||||
"current_step_code": None,
|
||||
"slots": {},
|
||||
}
|
||||
|
||||
# 3. Разрешаем ветку (с fallback) и шаг.
|
||||
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, effective_code)
|
||||
if served_code != effective_code:
|
||||
snapshot = {
|
||||
"current_intent_code": served_code,
|
||||
"current_step": 0,
|
||||
"current_step_code": None,
|
||||
"slots": {},
|
||||
}
|
||||
router_hint = None
|
||||
|
||||
retrieved = vectorstore.query(query_text=text, top_k=top_k)
|
||||
sources = _retrieved_to_sources(retrieved)
|
||||
|
||||
bounce_log: list[dict] = []
|
||||
validation_events: list[dict] = [] # illegal transitions для UI-подсветки
|
||||
last_assembled_prompt = ""
|
||||
llm_text = ""
|
||||
visible_text = ""
|
||||
parse_error: str | None = None
|
||||
is_state_machine = False
|
||||
|
||||
for attempt in range(MAX_BOUNCES + 1):
|
||||
current_step = await _resolve_current_step(
|
||||
session, intent.id, served_code, snapshot.get("current_step_code"),
|
||||
)
|
||||
is_state_machine = current_step is not None
|
||||
if current_step is not None and snapshot.get("current_step_code") != current_step.code:
|
||||
snapshot["current_step_code"] = current_step.code
|
||||
|
||||
base_prompt = config_service.compose_full_system_prompt(active_cfg)
|
||||
system_prompt = base_prompt + _format_state_context(state_snapshot)
|
||||
step_prompt = f"\n\n{current_step.system_prompt}" if current_step else ""
|
||||
state_context = _format_state_context(snapshot, current_step, router_hint)
|
||||
system_prompt = base_prompt + step_prompt + state_context
|
||||
|
||||
llm_result = await llm.chat(
|
||||
question=text,
|
||||
@@ -225,8 +218,11 @@ async def send_message(
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
last_assembled_prompt = llm_result["assembled_prompt"]
|
||||
llm_text = llm_result["text"]
|
||||
parsed = _parse_assistant_signals(llm_text)
|
||||
parsed = parse_branch_response(llm_result["text"])
|
||||
visible_text = parsed["visible_text"] or llm_result["text"]
|
||||
# STATE_JSON-блок ждём только от state-machine-веток. У остальных (general_info,
|
||||
# price_question и т.п.) «no STATE_JSON» — ожидаемое состояние, не ошибка.
|
||||
parse_error = parsed["parse_error"] if is_state_machine else None
|
||||
|
||||
if parsed["intent_change"] and attempt < MAX_BOUNCES:
|
||||
new_code = parsed["intent_change"]
|
||||
@@ -235,32 +231,64 @@ async def send_message(
|
||||
"to": new_code,
|
||||
"preface": parsed["visible_text"],
|
||||
})
|
||||
logger.info(
|
||||
"Intent bounce in thread %d: %s → %s", thread.id, served_code, new_code,
|
||||
)
|
||||
logger.info("Intent bounce in thread %d: %s → %s", thread.id, served_code, new_code)
|
||||
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, new_code)
|
||||
state_snapshot = {"current_intent_code": served_code, "current_step": 0, "slots": {}}
|
||||
snapshot = {
|
||||
"current_intent_code": served_code,
|
||||
"current_step": 0,
|
||||
"current_step_code": None,
|
||||
"slots": {},
|
||||
}
|
||||
router_hint = None # новая ветка — подсказка больше неактуальна
|
||||
continue
|
||||
|
||||
if parsed["state_update"] is not None and current_step is not None:
|
||||
requested = parsed["state_update"]["state_after"]
|
||||
allowed = intent_step_service.parse_allowed_next(current_step)
|
||||
ok, reason = validate_transition(
|
||||
current_step=current_step.code,
|
||||
requested_step=requested,
|
||||
allowed_next=allowed,
|
||||
)
|
||||
slots_updated = parsed["state_update"]["slots_updated"]
|
||||
merged_slots = {**snapshot.get("slots", {}), **slots_updated}
|
||||
if ok:
|
||||
snapshot = {
|
||||
"current_intent_code": served_code,
|
||||
"current_step": snapshot["current_step"] + (1 if requested != current_step.code else 0),
|
||||
"current_step_code": requested,
|
||||
"slots": merged_slots,
|
||||
}
|
||||
else:
|
||||
logger.warning(
|
||||
"Illegal state_after in thread %d (%s): %s", thread.id, served_code, reason,
|
||||
)
|
||||
validation_events.append({
|
||||
"current_step": current_step.code,
|
||||
"requested_step": requested,
|
||||
"reason": reason,
|
||||
})
|
||||
# Слоты всё равно мёржим (информация полезная), шаг не двигаем.
|
||||
snapshot = {
|
||||
"current_intent_code": served_code,
|
||||
"current_step": snapshot["current_step"],
|
||||
"current_step_code": current_step.code,
|
||||
"slots": merged_slots,
|
||||
}
|
||||
elif parsed["state_update"] is None and current_step is not None and parse_error:
|
||||
logger.warning(
|
||||
"State machine branch %s returned no STATE_JSON: %s", served_code, parse_error,
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
# 4. Обновляем thread_state и сохраняем сообщения.
|
||||
visible_text = parsed["visible_text"] or llm_text
|
||||
if parsed["state"] is not None:
|
||||
new_step = parsed["state"]["step"]
|
||||
merged_slots = {**state_snapshot.get("slots", {}), **parsed["state"]["slots"]}
|
||||
state_snapshot = {
|
||||
"current_intent_code": served_code,
|
||||
"current_step": new_step,
|
||||
"slots": merged_slots,
|
||||
}
|
||||
# Если ответ пришёл с INTENT_CHANGE на последней итерации (превысили MAX_BOUNCES) —
|
||||
# служебный тег мы из visible_text уже вырезали, но состояние переключать не будем.
|
||||
|
||||
# 4. Сохраняем: thread_state пишется ПОСЛЕ всей логики, коммит — единой транзакцией.
|
||||
await thread_state_service.upsert(
|
||||
session, thread.id,
|
||||
intent_code=state_snapshot["current_intent_code"],
|
||||
step=state_snapshot["current_step"],
|
||||
slots=state_snapshot["slots"],
|
||||
intent_code=snapshot["current_intent_code"],
|
||||
step=snapshot["current_step"],
|
||||
step_code=snapshot.get("current_step_code"),
|
||||
slots=snapshot["slots"],
|
||||
)
|
||||
|
||||
user_msg.intent_id = intent.id
|
||||
@@ -284,10 +312,12 @@ async def send_message(
|
||||
await session.refresh(thread)
|
||||
|
||||
logger.info(
|
||||
"Chat: thread=%d, router=%s, served=%s (v%d), step=%d, slots=%d keys, user_msg=%d, assistant_msg=%d, bounces=%d",
|
||||
"Chat: thread=%d, router=%s, served=%s (v%d), step=%s, slots=%d keys, bounces=%d, validation_events=%d",
|
||||
thread.id, router_code, served_code, active_cfg.version,
|
||||
state_snapshot["current_step"], len(state_snapshot["slots"]),
|
||||
user_msg.id, assistant_msg.id, len(bounce_log),
|
||||
snapshot.get("current_step_code") or "-",
|
||||
len(snapshot["slots"]),
|
||||
len(bounce_log),
|
||||
len(validation_events),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -304,11 +334,14 @@ async def send_message(
|
||||
"model_used": llm.model,
|
||||
"assembled_prompt": last_assembled_prompt,
|
||||
"thread_state": {
|
||||
"current_intent_code": state_snapshot["current_intent_code"],
|
||||
"current_step": state_snapshot["current_step"],
|
||||
"slots": state_snapshot["slots"],
|
||||
"current_intent_code": snapshot["current_intent_code"],
|
||||
"current_step": snapshot["current_step"],
|
||||
"current_step_code": snapshot.get("current_step_code"),
|
||||
"slots": snapshot["slots"],
|
||||
},
|
||||
"bounces": bounce_log,
|
||||
"validation_events": validation_events,
|
||||
"parse_error": parse_error,
|
||||
}
|
||||
|
||||
|
||||
@@ -418,7 +451,6 @@ async def rename_thread(session: AsyncSession, thread_id: int, name: str) -> dic
|
||||
|
||||
|
||||
async def delete_thread(session: AsyncSession, thread_id: int) -> int | None:
|
||||
"""Удалить тред и все его сообщения. Возвращает число удалённых сообщений или None, если треда нет."""
|
||||
thread = await session.get(Thread, thread_id)
|
||||
if thread is None:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Шаги state machine внутри ветки: сид, чтение, правка (Спринт 6a).
|
||||
|
||||
Шаги живут в БД (`intent_steps`), сид при старте читает файлы промптов из
|
||||
`prompts/intents/{intent_code}/steps/{step_code}.md`. Список шагов и переходы
|
||||
описаны в словаре `SEED_INTENT_STEPS` ниже — новые state-machine-ветки
|
||||
добавляются сюда + соответствующие файлы.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Intent, IntentStep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROMPTS_INTENTS_DIR = Path(__file__).resolve().parent.parent / "prompts" / "intents"
|
||||
|
||||
|
||||
# Стартовая описание шагов для state-machine-веток. Ключ — code ветки; значение —
|
||||
# список шагов в порядке следования. `allowed_next` описывает граф переходов.
|
||||
SEED_INTENT_STEPS: dict[str, list[dict]] = {
|
||||
"new_booking": [
|
||||
{
|
||||
"code": "intro",
|
||||
"name": "Приветствие",
|
||||
"allowed_next": ["intro", "qualify"],
|
||||
},
|
||||
{
|
||||
"code": "qualify",
|
||||
"name": "Повод и специалист",
|
||||
"allowed_next": ["qualify", "present"],
|
||||
},
|
||||
{
|
||||
"code": "present",
|
||||
"name": "Презентация плана",
|
||||
"allowed_next": ["present", "qualify", "offer_time"],
|
||||
},
|
||||
{
|
||||
"code": "offer_time",
|
||||
"name": "Удобное время",
|
||||
"allowed_next": ["offer_time", "book"],
|
||||
},
|
||||
{
|
||||
"code": "book",
|
||||
"name": "Подтверждение записи",
|
||||
"allowed_next": ["book", "qualify", "offer_time", "close"],
|
||||
},
|
||||
{
|
||||
"code": "close",
|
||||
"name": "Завершение",
|
||||
"allowed_next": ["close"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _step_prompt_path(intent_code: str, step_code: str) -> Path:
|
||||
return PROMPTS_INTENTS_DIR / intent_code / "steps" / f"{step_code}.md"
|
||||
|
||||
|
||||
def load_seed_step_prompt(intent_code: str, step_code: str) -> str:
|
||||
path = _step_prompt_path(intent_code, step_code)
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
logger.warning("Seed prompt for step %s/%s not found at %s", intent_code, step_code, path)
|
||||
return ""
|
||||
|
||||
|
||||
def has_state_machine(intent_code: str) -> bool:
|
||||
return intent_code in SEED_INTENT_STEPS
|
||||
|
||||
|
||||
def parse_allowed_next(step: IntentStep) -> list[str]:
|
||||
try:
|
||||
value = json.loads(step.allowed_next_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def parse_guards(step: IntentStep) -> dict:
|
||||
try:
|
||||
value = json.loads(step.guards_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
async def list_steps_for_intent(session: AsyncSession, intent_id: int) -> list[IntentStep]:
|
||||
stmt = select(IntentStep).where(IntentStep.intent_id == intent_id).order_by(IntentStep.order_index, IntentStep.id)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def get_step_by_code(
|
||||
session: AsyncSession, intent_id: int, step_code: str
|
||||
) -> IntentStep | None:
|
||||
stmt = select(IntentStep).where(
|
||||
IntentStep.intent_id == intent_id, IntentStep.code == step_code
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_first_step(session: AsyncSession, intent_id: int) -> IntentStep | None:
|
||||
stmt = (
|
||||
select(IntentStep)
|
||||
.where(IntentStep.intent_id == intent_id)
|
||||
.order_by(IntentStep.order_index, IntentStep.id)
|
||||
.limit(1)
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_step(
|
||||
session: AsyncSession,
|
||||
step: IntentStep,
|
||||
*,
|
||||
name: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
allowed_next: list[str] | None = None,
|
||||
guards: dict | None = None,
|
||||
) -> IntentStep:
|
||||
if name is not None:
|
||||
step.name = name
|
||||
if system_prompt is not None:
|
||||
step.system_prompt = system_prompt
|
||||
if allowed_next is not None:
|
||||
step.allowed_next_json = json.dumps(allowed_next, ensure_ascii=False)
|
||||
if guards is not None:
|
||||
step.guards_json = json.dumps(guards, ensure_ascii=False)
|
||||
step.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(step)
|
||||
return step
|
||||
|
||||
|
||||
async def ensure_seed_steps(session: AsyncSession) -> None:
|
||||
"""Досиживает недостающие шаги для state-machine-веток. Существующие не трогаются."""
|
||||
added = 0
|
||||
for intent_code, steps_def in SEED_INTENT_STEPS.items():
|
||||
intent = (await session.execute(
|
||||
select(Intent).where(Intent.code == intent_code)
|
||||
)).scalar_one_or_none()
|
||||
if intent is None:
|
||||
logger.warning("Cannot seed steps for %s: intent not found", intent_code)
|
||||
continue
|
||||
|
||||
existing = set((await session.execute(
|
||||
select(IntentStep.code).where(IntentStep.intent_id == intent.id)
|
||||
)).scalars().all())
|
||||
|
||||
for order, data in enumerate(steps_def):
|
||||
if data["code"] in existing:
|
||||
continue
|
||||
prompt = load_seed_step_prompt(intent_code, data["code"])
|
||||
session.add(IntentStep(
|
||||
intent_id=intent.id,
|
||||
code=data["code"],
|
||||
name=data["name"],
|
||||
order_index=order,
|
||||
system_prompt=prompt,
|
||||
allowed_next_json=json.dumps(data["allowed_next"], ensure_ascii=False),
|
||||
guards_json="{}",
|
||||
))
|
||||
added += 1
|
||||
|
||||
if added:
|
||||
await session.commit()
|
||||
logger.info("Seeded %d missing intent_steps", added)
|
||||
+36
-24
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
@@ -5,6 +6,11 @@ import httpx
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
class LLMUnavailableError(RuntimeError):
|
||||
"""Внешний LLM недоступен после всех попыток — сигнал для вызывающего кода."""
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "system_prompt.md"
|
||||
@@ -98,18 +104,7 @@ class LLMClient:
|
||||
"max_tokens": effective_max_tokens,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
data = await self._call_with_retry(url, payload)
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
logger.info("LLM response: %d chars, model=%s, temp=%.2f", len(content), self.model, effective_temp)
|
||||
return {"text": content.strip(), "assembled_prompt": assembled_prompt}
|
||||
@@ -159,18 +154,35 @@ class LLMClient:
|
||||
"max_tokens": effective_max_tokens,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
data = await self._call_with_retry(url, payload)
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
logger.info("LLM chat response: %d chars, history=%d, model=%s", len(content), len(history), self.model)
|
||||
return {"text": content.strip(), "assembled_prompt": assembled_prompt}
|
||||
|
||||
async def _call_with_retry(self, url: str, payload: dict) -> dict:
|
||||
"""POST к DeepSeek с одним ретраем — модель периодически моргает по сети."""
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
"LLM call failed (attempt %d, %s: %s)",
|
||||
attempt + 1, type(e).__name__, e,
|
||||
)
|
||||
if attempt < 1:
|
||||
await asyncio.sleep(0.5)
|
||||
raise LLMUnavailableError(
|
||||
f"LLM unavailable after retries: {type(last_error).__name__}: {last_error}"
|
||||
) from last_error
|
||||
|
||||
+30
-13
@@ -8,6 +8,7 @@
|
||||
(версионируется, редактируется из UI «Настройки»). Если БД недоступна или
|
||||
ветки нет — используем fallback из prompts/intents/_router.md.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -102,20 +103,36 @@ class RouterClient:
|
||||
"max_tokens": 20,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: dict | None = None
|
||||
last_error: Exception | None = None
|
||||
# Один ретрай: DeepSeek иногда отвечает 5xx / пустым исключением.
|
||||
for attempt in range(2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
"Router LLM call failed (attempt %d, %s: %s)",
|
||||
attempt + 1, type(e).__name__, e,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
logger.warning("Router LLM call failed (%s), falling back to general_info", e)
|
||||
if attempt < 1:
|
||||
await asyncio.sleep(0.5)
|
||||
if data is None:
|
||||
logger.warning(
|
||||
"Router LLM failed after retries (%s), falling back to general_info",
|
||||
last_error,
|
||||
)
|
||||
return {"code": "general_info", "version": version}
|
||||
|
||||
raw = (data["choices"][0]["message"]["content"] or "").strip()
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Парсер structured-output ветки + валидатор переходов state machine (Спринт 6a).
|
||||
|
||||
Формат ответа ветки со state machine:
|
||||
|
||||
Текст пациенту, markdown разрешён.
|
||||
|
||||
STATE_JSON: {"state_after": "<step_code>", "slots_updated": {"slot": "value"}}
|
||||
|
||||
Либо — при exit condition — вместо `STATE_JSON:` строка `[INTENT_CHANGE: <code>]`.
|
||||
Парсер вырезает служебную часть и возвращает видимый текст + решение модели.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INTENT_CHANGE_RE = re.compile(r"\[INTENT_CHANGE:\s*([a-z_][a-z0-9_]*)\s*\]")
|
||||
_STATE_JSON_RE = re.compile(r"STATE_JSON\s*:\s*", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_branch_response(text: str) -> dict:
|
||||
"""Разобрать ответ ветки на visible_text + intent_change / state_update.
|
||||
|
||||
Возвращает:
|
||||
visible_text: str — текст пациенту (без служебных тегов),
|
||||
intent_change: str | None — код ветки, если сработал exit condition,
|
||||
state_update: {'state_after': str, 'slots_updated': dict} | None — при штатном ответе,
|
||||
parse_error: str | None — если что-то не разобралось, сюда кладётся причина
|
||||
(visible_text при этом = исходный текст без мусора).
|
||||
"""
|
||||
# Exit condition имеет приоритет.
|
||||
intent_match = _INTENT_CHANGE_RE.search(text)
|
||||
if intent_match:
|
||||
visible = text[:intent_match.start()].rstrip()
|
||||
return {
|
||||
"visible_text": visible,
|
||||
"intent_change": intent_match.group(1),
|
||||
"state_update": None,
|
||||
"parse_error": None,
|
||||
}
|
||||
|
||||
state_match = _STATE_JSON_RE.search(text)
|
||||
if not state_match:
|
||||
# Модель не вернула служебную часть. Возвращаем весь текст и ошибку парсинга.
|
||||
return {
|
||||
"visible_text": text.rstrip(),
|
||||
"intent_change": None,
|
||||
"state_update": None,
|
||||
"parse_error": "no STATE_JSON block",
|
||||
}
|
||||
|
||||
raw_json, _ = _consume_json_object(text, state_match.end())
|
||||
if raw_json is None:
|
||||
return {
|
||||
"visible_text": text[:state_match.start()].rstrip(),
|
||||
"intent_change": None,
|
||||
"state_update": None,
|
||||
"parse_error": "STATE_JSON present but no balanced JSON object",
|
||||
}
|
||||
|
||||
try:
|
||||
data = json.loads(raw_json)
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
"visible_text": text[:state_match.start()].rstrip(),
|
||||
"intent_change": None,
|
||||
"state_update": None,
|
||||
"parse_error": f"STATE_JSON invalid JSON: {e}",
|
||||
}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"visible_text": text[:state_match.start()].rstrip(),
|
||||
"intent_change": None,
|
||||
"state_update": None,
|
||||
"parse_error": "STATE_JSON is not an object",
|
||||
}
|
||||
|
||||
state_after = data.get("state_after")
|
||||
slots_updated = data.get("slots_updated", {})
|
||||
if not isinstance(state_after, str) or not state_after:
|
||||
return {
|
||||
"visible_text": text[:state_match.start()].rstrip(),
|
||||
"intent_change": None,
|
||||
"state_update": None,
|
||||
"parse_error": "STATE_JSON missing state_after",
|
||||
}
|
||||
if not isinstance(slots_updated, dict):
|
||||
slots_updated = {}
|
||||
|
||||
return {
|
||||
"visible_text": text[:state_match.start()].rstrip(),
|
||||
"intent_change": None,
|
||||
"state_update": {"state_after": state_after, "slots_updated": slots_updated},
|
||||
"parse_error": None,
|
||||
}
|
||||
|
||||
|
||||
def _consume_json_object(text: str, start: int) -> tuple[str | None, int]:
|
||||
"""Вытянуть сбалансированный JSON-объект из text[start:]. См. парсер в chat_service."""
|
||||
i = start
|
||||
n = len(text)
|
||||
while i < n and text[i].isspace():
|
||||
i += 1
|
||||
if i >= n or text[i] != "{":
|
||||
return None, start
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
j = i
|
||||
while j < n:
|
||||
ch = text[j]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif ch == "\\":
|
||||
esc = True
|
||||
elif ch == '"':
|
||||
in_str = False
|
||||
else:
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[i:j + 1], j + 1
|
||||
j += 1
|
||||
return None, start
|
||||
|
||||
|
||||
def validate_transition(
|
||||
*, current_step: str, requested_step: str, allowed_next: list[str],
|
||||
) -> tuple[bool, str]:
|
||||
"""Разрешён ли переход `current_step → requested_step`.
|
||||
|
||||
Остаться на месте (`requested_step == current_step`) разрешено всегда.
|
||||
Возвращает (ok, reason).
|
||||
"""
|
||||
if requested_step == current_step:
|
||||
return True, "stay"
|
||||
if requested_step in allowed_next:
|
||||
return True, "ok"
|
||||
return (
|
||||
False,
|
||||
f"requested {requested_step!r} not in allowed_next {allowed_next!r} of {current_step!r}",
|
||||
)
|
||||
@@ -30,13 +30,19 @@ def _parse_slots(raw: str) -> dict:
|
||||
|
||||
|
||||
async def load_snapshot(session: AsyncSession, thread_id: int) -> dict:
|
||||
"""Удобный снимок состояния для чтения (intent, step, slots)."""
|
||||
"""Удобный снимок состояния для чтения (intent, step_code, step, slots)."""
|
||||
state = await get_state(session, thread_id)
|
||||
if state is None:
|
||||
return {"current_intent_code": None, "current_step": 0, "slots": {}}
|
||||
return {
|
||||
"current_intent_code": None,
|
||||
"current_step": 0,
|
||||
"current_step_code": None,
|
||||
"slots": {},
|
||||
}
|
||||
return {
|
||||
"current_intent_code": state.current_intent_code,
|
||||
"current_step": state.current_step,
|
||||
"current_step_code": state.current_step_code,
|
||||
"slots": _parse_slots(state.slots_json),
|
||||
}
|
||||
|
||||
@@ -48,6 +54,7 @@ async def upsert(
|
||||
intent_code: str | None,
|
||||
step: int,
|
||||
slots: dict,
|
||||
step_code: str | None = None,
|
||||
) -> ThreadState:
|
||||
"""Создать или обновить состояние треда. Коммит — на совести вызывающего."""
|
||||
state = await get_state(session, thread_id)
|
||||
@@ -58,6 +65,7 @@ async def upsert(
|
||||
thread_id=thread_id,
|
||||
current_intent_code=intent_code,
|
||||
current_step=step,
|
||||
current_step_code=step_code,
|
||||
slots_json=slots_raw,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -65,11 +73,21 @@ async def upsert(
|
||||
else:
|
||||
state.current_intent_code = intent_code
|
||||
state.current_step = step
|
||||
state.current_step_code = step_code
|
||||
state.slots_json = slots_raw
|
||||
state.updated_at = now
|
||||
return state
|
||||
|
||||
|
||||
async def reset(session: AsyncSession, thread_id: int, *, new_intent_code: str | None) -> ThreadState:
|
||||
async def reset(
|
||||
session: AsyncSession,
|
||||
thread_id: int,
|
||||
*,
|
||||
new_intent_code: str | None,
|
||||
new_step_code: str | None = None,
|
||||
) -> ThreadState:
|
||||
"""Сбросить шаг и слоты треда, выставить новую ветку (при смене intent)."""
|
||||
return await upsert(session, thread_id, intent_code=new_intent_code, step=0, slots={})
|
||||
return await upsert(
|
||||
session, thread_id,
|
||||
intent_code=new_intent_code, step=0, step_code=new_step_code, slots={},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user