Files
RAG_helper/services/thread_state_service.py
T
AR 15 M4 cac3d29273 feat(sprint5): state machine + bouncing — thread_state и служебные теги
Таблица thread_state (intent, step, slots) ведётся per-thread. В системный
промпт ветки дописывается текущее состояние, LLM возвращает служебный тег
[STATE: step=N; slots={...}] после основного ответа — парсер в chat_service
вырезает его и обновляет состояние. Если ветка решила, что тема ушла в другую,
она выдаёт [INTENT_CHANGE: code] — делаем один повторный вызов LLM с новой
веткой и сброшенным state (bouncing, MAX_BOUNCES=1). Если роутер сам выбрал
другую ветку, чем в thread_state, — state тоже сбрасывается. Промпт new_booking
переписан под 6-шаговый сценарий (имя → повод → специалист → время → подтверждение
→ запись), в «Песочнице» появился блок «Состояние треда» с intent/step/slots
и списком переходов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:12:36 +05:00

76 lines
2.6 KiB
Python

"""State machine треда: текущая ветка, шаг внутри ветки, собранные слоты.
Используется в chat_service для ведения многошаговых сценариев (Спринт 5).
Слоты — произвольный JSON-словарь, конкретные ключи определяются веткой.
"""
import json
import logging
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import ThreadState
logger = logging.getLogger(__name__)
async def get_state(session: AsyncSession, thread_id: int) -> ThreadState | None:
return await session.get(ThreadState, thread_id)
def _parse_slots(raw: str) -> dict:
if not raw:
return {}
try:
value = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Bad slots_json for thread_state, resetting to {}")
return {}
return value if isinstance(value, dict) else {}
async def load_snapshot(session: AsyncSession, thread_id: int) -> dict:
"""Удобный снимок состояния для чтения (intent, 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": state.current_intent_code,
"current_step": state.current_step,
"slots": _parse_slots(state.slots_json),
}
async def upsert(
session: AsyncSession,
thread_id: int,
*,
intent_code: str | None,
step: int,
slots: dict,
) -> ThreadState:
"""Создать или обновить состояние треда. Коммит — на совести вызывающего."""
state = await get_state(session, thread_id)
now = datetime.now(timezone.utc)
slots_raw = json.dumps(slots or {}, ensure_ascii=False)
if state is None:
state = ThreadState(
thread_id=thread_id,
current_intent_code=intent_code,
current_step=step,
slots_json=slots_raw,
updated_at=now,
)
session.add(state)
else:
state.current_intent_code = intent_code
state.current_step = step
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:
"""Сбросить шаг и слоты треда, выставить новую ветку (при смене intent)."""
return await upsert(session, thread_id, intent_code=new_intent_code, step=0, slots={})