cac3d29273
Таблица 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>
76 lines
2.6 KiB
Python
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={})
|