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>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""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={})
|
||||
Reference in New Issue
Block a user