feat(sprint6a): блок A — structured output, intent_steps, sticky-удержание

Заменили строковый тег [STATE: ...] из Спринта 5 на структурированный выход
ветки в виде JSON-блока в хвосте ответа: {state_after, slots_updated}, парсимый
балансировкой скобок. Шаги state machine вынесены из монолитного промпта в
таблицу intent_steps (intent_id FK, code, name, order_index, system_prompt,
allowed_next JSON, guards JSON) и редактируются через UI. Валидатор переходов
сверяет state_after с allowed_next и блокирует невалидные прыжки.

Базовый промпт new_booking разбит на base + 6 файлов шагов (intro/qualify/
present/offer_time/book/close), которые сидятся при старте через
ensure_seed_steps. В chat_service промпт собирается как base + step + блок
[ТЕКУЩЕЕ СОСТОЯНИЕ].

Попутно реализован мини-блок G (sticky state machine): когда диалог идёт по
sm-ветке и роутер на новой реплике предлагает другую — state НЕ сбрасывается,
в системный промпт ветки подаётся блок [ПОДСКАЗКА РОУТЕРА], LLM сама решает
(STATE_JSON или INTENT_CHANGE). Это сняло ключевую дыру Спринта 5: «Меня
зовут Алексей» / «болит ухо» внутри записи больше не сбрасывают сценарий.

Промпт ветки new_booking ужесточён: бытовые жалобы — это повод записи (слот
reason + сочувствие), не повод уводить в medical_question. Шаг present теперь
использует reason в формулировке. Промпт _router расширен живыми примерами
для всех 6 веток, особенно для reschedule («не смогу подойти», «перенесите»).

Надёжность внешнего LLM:
- ретрай в LLMClient с паузой 500 мс + новое исключение LLMUnavailableError;
- ретрай в RouterClient (DeepSeek периодически моргает);
- /chat при ошибке делает session.rollback() и возвращает 503 с понятным
  сообщением — больше не остаётся «диалогов-призраков» с одной репликой;
- UI убирает свой пузырь и возвращает текст в поле ввода для повторной отправки.

UI «Настройки» — добавлена вкладка «Шаги» для веток с state machine: список
шагов chip-ами, редактор промпта/имени/allowed_next/guards, сохранение через
PATCH /intents/{code}/steps/{step_code} без версионирования. Иконка ⓘ возле
поля «Правила» открывает popover с пояснением, что туда писать.

UI «Песочница»:
- блок «Состояние диалога» показывает имя шага из intent_steps (а не сырое
  число), для не-sm-веток пишется «без пошагового сценария»;
- подсветка illegal-переходов (валидатор отклонил state_after) и parse_error
  для sm-веток;
- блок «Решение роутера» развёрнут в три исхода: «попал в ту же ветку» /
  «удержались в ветке» / «ветка сама передала управление через INTENT_CHANGE»;
- секция «Найденные фрагменты» сворачивается, карточки чанков раскрываются
  по клику — правый сайдбар стал компактнее.

Терминология (по договорённости — простой русский в UI):
- «тред» → «диалог» в текстах для оператора (в коде/API thread_id оставлен);
- «sticky state machine» → «удержались в ветке»;
- «state machine» → «пошаговый сценарий» в видимых местах.

SPRINTS.md: блок G в Спринте 6b сокращён — sticky-логика уже сделана здесь,
осталась только вторая линия (передача thread_state в системный промпт самого
роутера для ещё более точной первичной классификации).

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