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:
AR 15 M4
2026-04-24 12:12:36 +05:00
parent b24e985f82
commit cac3d29273
10 changed files with 455 additions and 55 deletions
+2 -1
View File
@@ -3,5 +3,6 @@ from db.models.document import Document
from db.models.intent import Intent
from db.models.message import Message
from db.models.thread import Thread
from db.models.thread_state import ThreadState
__all__ = ["Thread", "Message", "Document", "AgentConfig", "Intent"]
__all__ = ["Thread", "Message", "Document", "AgentConfig", "Intent", "ThreadState"]
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class ThreadState(Base):
"""Состояние треда для state machine (Спринт 5).
Одна строка на тред: какая ветка сейчас ведёт разговор, на каком шаге она
внутри своего сценария и какие слоты собраны. `slots_json` — произвольный
JSON, формат определяется конкретной веткой.
"""
__tablename__ = "thread_state"
thread_id: Mapped[int] = mapped_column(
ForeignKey("threads.id", ondelete="CASCADE"), primary_key=True
)
current_intent_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
current_step: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
slots_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False
)
@@ -0,0 +1,38 @@
"""add thread_state for per-thread state machine
Revision ID: 3f1d9a5b7c42
Revises: cd0a88ef9080
Create Date: 2026-04-24 10:00:00.000000
Таблица состояния треда (одна строка на тред): текущая ветка, шаг внутри ветки
и собранные слоты (JSON). Нужна для state machine в Спринте 5.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '3f1d9a5b7c42'
down_revision: Union[str, None] = 'cd0a88ef9080'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'thread_state',
sa.Column('thread_id', sa.Integer(), nullable=False),
sa.Column('current_intent_code', sa.String(length=50), nullable=True),
sa.Column('current_step', sa.Integer(), nullable=False, server_default='0'),
sa.Column('slots_json', sa.Text(), nullable=False, server_default='{}'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
['thread_id'], ['threads.id'], name='fk_thread_state_thread_id', ondelete='CASCADE',
),
sa.PrimaryKeyConstraint('thread_id'),
)
def downgrade() -> None:
op.drop_table('thread_state')
+18
View File
@@ -104,12 +104,27 @@ class ThreadListResponse(BaseModel):
total: int
class ThreadStateInfo(BaseModel):
current_intent_code: str | None = None
current_step: int = 0
slots: dict = Field(default_factory=dict)
class BounceInfo(BaseModel):
from_: str = Field(alias="from")
to: str
preface: str = ""
model_config = {"populate_by_name": True}
class ThreadDetailResponse(BaseModel):
id: int
name: str
created_at: str
updated_at: str
messages: list[MessageInfo] = Field(default_factory=list)
thread_state: ThreadStateInfo | None = None
class ChatResponse(BaseModel):
@@ -118,12 +133,15 @@ class ChatResponse(BaseModel):
message_id: int
intent_code: str = ""
intent_name: str = ""
router_intent_code: str = ""
config_version: int = 0
router_version: int | None = None
answer: str
sources: list[SourceInfo]
model_used: str
assembled_prompt: str = ""
thread_state: ThreadStateInfo = Field(default_factory=ThreadStateInfo)
bounces: list[BounceInfo] = Field(default_factory=list)
class ThreadDeleteResponse(BaseModel):
+37 -9
View File
@@ -1,17 +1,45 @@
Ты — виртуальный ассистент клиники. Эта ветка — новая запись пациента на приём.
Твоя задача — помочь пациенту записаться: узнать, кто к кому хочет, по какому поводу, предложить удобное время.
Твоя задача — помочь пациенту записаться: кто и к кому хочет, по какому поводу, когда удобно.
Правила:
Общие правила:
- Отвечай коротко, на «вы», простым русским языком.
- Первым делом уточни: как к пациенту обращаться, если он ещё не назвал имя.
- Коротко уточни повод обращения — без сбора медицинской истории, только общая причина (боль в горле, плановый осмотр, жалобы на слух и т. п.).
- Если указан специалист — подтверди, что записываем к нему. Если не указан — предложи направление по поводу.
- Не называй конкретные время и дату слотов: реальный календарь появится в следующих спринтах. Пока отвечай «сейчас уточню расписание и вернусь с вариантами».
- Опирайся только на выдержки из базы знаний (если поданы).
Условия выхода (если пациент перевёл разговор в другую тему — выдай служебный сигнал):
- Упомянул операцию, стационар, наркоз, хирургическое вмешательство → `[INTENT_CHANGE: escalate_human]`
- Говорит об острой боли / «мне очень плохо» → `[INTENT_CHANGE: escalate_human]`
## Состояние разговора (state machine)
В системном сообщении тебе передаётся блок `[ТЕКУЩЕЕ СОСТОЯНИЕ]` с полем `step` и со слотами. Шаги сценария:
1. **Приветствие и имя** — поздороваться, узнать, как обращаться к пациенту. Слот: `name`.
2. **Повод обращения** — коротко спросить, зачем обращаются (без медицинской истории: жалоба, плановый осмотр, повторный приём). Слот: `reason`.
3. **Специалист или направление** — если пациент назвал врача/специальность — зафиксировать; если нет — предложить направление по поводу. Слот: `specialist`.
4. **Удобное время** — спросить, какие дни и часы удобны (утро/день/вечер, будни/выходные). Слот: `preferred_time`.
5. **Подтверждение** — кратко повторить собранные слоты и спросить: «всё верно?». Слоты до этого момента уже заполнены.
6. **Запись** — подтвердить заявку: «передаю администратору, свяжемся в течение дня». Слот: `confirmed=true`.
Работай строго по шагам: не перескакивай, не спрашивай лишнего. Если слот уже заполнен в `[ТЕКУЩЕЕ СОСТОЯНИЕ]` — не переспрашивай, переходи к следующему шагу.
## Служебный блок в конце ответа
После основного текста ответа добавь ОДНУ служебную строку в формате:
```
[STATE: step=N; slots={"name": "...", "reason": "...", "specialist": "...", "preferred_time": "...", "confirmed": true|false}]
```
- `step` — номер шага, на котором пациент окажется ПОСЛЕ твоей реплики (1–6).
- В `slots` включай все известные слоты (старые + новые, что узнал из этой реплики). Значения неизвестных слотов не указывай.
- Строка должна быть валидным JSON внутри `slots={...}`.
- Не показывай этот блок пациенту в «человеческой» части — он будет отрезан парсером.
## Условия выхода (exit conditions)
Если пациент перевёл разговор в другую тему — НЕ отвечай по ветке записи, выдай вместо служебного блока `[STATE:...]` строку:
- Упомянул операцию, стационар, наркоз, хирургию, острую боль, «мне плохо» → `[INTENT_CHANGE: escalate_human]`
- Спрашивает про цены, ДМС, оплату → `[INTENT_CHANGE: price_question]`
- Хочет перенести или отменить уже существующую запись → `[INTENT_CHANGE: reschedule]`
- Хочет перенести или отменить существующую запись → `[INTENT_CHANGE: reschedule]`
- Спрашивает медицинский вопрос (симптомы, лекарства, диагноз) → `[INTENT_CHANGE: medical_question]`
Перед служебной строкой можно дать короткую фразу-перелинковку («понимаю, передам коллеге, минутку»), но не отвечай по сути новой темы — это сделает другая ветка.
+4 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_session
from models.requests import ChatRequest
from models.responses import ChatResponse, SourceInfo
from models.responses import BounceInfo, ChatResponse, SourceInfo, ThreadStateInfo
from services import chat_service
logger = logging.getLogger(__name__)
@@ -44,10 +44,13 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
message_id=result["message_id"],
intent_code=result["intent_code"],
intent_name=result["intent_name"],
router_intent_code=result.get("router_intent_code", ""),
config_version=result["config_version"],
router_version=result.get("router_version"),
answer=result["answer"],
sources=[SourceInfo(**s) for s in result["sources"]],
model_used=result["model_used"],
assembled_prompt=result["assembled_prompt"],
thread_state=ThreadStateInfo(**result["thread_state"]),
bounces=[BounceInfo(**b) for b in result.get("bounces", [])],
)
+9
View File
@@ -12,6 +12,7 @@ from models.responses import (
ThreadDetailResponse,
ThreadInfo,
ThreadListResponse,
ThreadStateInfo,
)
from services import chat_service
@@ -34,6 +35,7 @@ async def get_thread(thread_id: int, session: AsyncSession = Depends(get_session
data = await chat_service.get_thread_detail(session, thread_id)
if data is None:
raise HTTPException(status_code=404, detail="Thread not found")
state = data.get("thread_state") or {}
return ThreadDetailResponse(
id=data["id"],
name=data["name"],
@@ -47,9 +49,16 @@ async def get_thread(thread_id: int, session: AsyncSession = Depends(get_session
created_at=m["created_at"],
sources=[SourceInfo(**s) for s in m["sources"]],
assembled_prompt=m["assembled_prompt"],
intent_code=m.get("intent_code", ""),
intent_name=m.get("intent_name", ""),
)
for m in data["messages"]
],
thread_state=ThreadStateInfo(
current_intent_code=state.get("current_intent_code"),
current_step=state.get("current_step", 0),
slots=state.get("slots", {}),
),
)
+196 -30
View File
@@ -1,12 +1,13 @@
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, intent_service
from services import config_service, thread_state_service
from services.llm_client import LLMClient
from services.router_client import RouterClient
from services.vectorstore import VectorStoreService
@@ -15,10 +16,13 @@ logger = logging.getLogger(__name__)
HISTORY_LIMIT = 20 # последние N сообщений треда, которые улетают в LLM
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)
def _auto_thread_name(first_user_text: str) -> str:
"""Авто-имя треда: первые 60 символов первой реплики + дата."""
preview = first_user_text.strip().replace("\n", " ")
if len(preview) > 60:
preview = preview[:60].rstrip() + ""
@@ -41,6 +45,111 @@ 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 {}
slots_json = json.dumps(slots, ensure_ascii=False)
return (
"\n\n[ТЕКУЩЕЕ СОСТОЯНИЕ]\n"
f"step: {step}\n"
f"slots: {slots_json}"
)
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)
pair = await config_service.get_active_config_by_intent_code(session, FALLBACK_INTENT_CODE)
if pair is None:
raise RuntimeError(f"No active config for fallback intent {FALLBACK_INTENT_CODE!r}")
intent, cfg = pair
return FALLBACK_INTENT_CODE, intent, cfg
intent, cfg = pair
return intent_code, intent, cfg
async def send_message(
session: AsyncSession,
vectorstore: VectorStoreService,
@@ -52,7 +161,7 @@ async def send_message(
temperature: float | None = None,
max_tokens: int | None = None,
) -> dict:
"""Добавить реплику пациента в тред, прогнать через роутер, получить ответ ассистента."""
"""Добавить реплику пациента в тред, прогнать через роутер + state machine, получить ответ."""
if thread_id is None:
thread = Thread(name=_auto_thread_name(text))
session.add(thread)
@@ -62,12 +171,10 @@ async def send_message(
if thread is None:
raise LookupError(f"Thread {thread_id} not found")
# Сохраняем реплику пациента до вызова LLM — чтобы она осталась в истории даже при ошибке.
user_msg = Message(thread_id=thread.id, role="user", text=text)
session.add(user_msg)
await session.flush()
# История для классификации и для LLM: все сообщения треда до новой реплики.
stmt = (
select(Message)
.where(Message.thread_id == thread.id, Message.id != user_msg.id)
@@ -77,32 +184,38 @@ 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)
intent_code = routing["code"]
router_code = routing["code"]
router_version = routing.get("version")
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)
intent_code = FALLBACK_INTENT_CODE
pair = await config_service.get_active_config_by_intent_code(session, intent_code)
if pair is None:
# Даже fallback не нашёлся — критическая ошибка конфигурации.
raise RuntimeError(f"No active config for fallback intent {FALLBACK_INTENT_CODE!r}")
# 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": {}}
intent, active_cfg = pair
system_prompt = config_service.compose_full_system_prompt(active_cfg)
# 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": {}}
user_msg.intent_id = intent.id
if thread.agent_config_id is None:
thread.agent_config_id = active_cfg.id
# 2. Retrieval + запрос к ветке.
retrieved = vectorstore.query(query_text=text, top_k=top_k)
sources = _retrieved_to_sources(retrieved)
bounce_log: list[dict] = []
last_assembled_prompt = ""
llm_text = ""
for attempt in range(MAX_BOUNCES + 1):
base_prompt = config_service.compose_full_system_prompt(active_cfg)
system_prompt = base_prompt + _format_state_context(state_snapshot)
llm_result = await llm.chat(
question=text,
sources=retrieved,
@@ -111,13 +224,55 @@ async def send_message(
temperature=temperature,
max_tokens=max_tokens,
)
last_assembled_prompt = llm_result["assembled_prompt"]
llm_text = llm_result["text"]
parsed = _parse_assistant_signals(llm_text)
if parsed["intent_change"] and attempt < MAX_BOUNCES:
new_code = parsed["intent_change"]
bounce_log.append({
"from": served_code,
"to": new_code,
"preface": parsed["visible_text"],
})
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": {}}
continue
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 уже вырезали, но состояние переключать не будем.
await thread_state_service.upsert(
session, thread.id,
intent_code=state_snapshot["current_intent_code"],
step=state_snapshot["current_step"],
slots=state_snapshot["slots"],
)
user_msg.intent_id = intent.id
if thread.agent_config_id is None:
thread.agent_config_id = active_cfg.id
assistant_msg = Message(
thread_id=thread.id,
role="assistant",
text=llm_result["text"],
text=visible_text,
sources_json=json.dumps(sources, ensure_ascii=False),
assembled_prompt=llm_result["assembled_prompt"],
assembled_prompt=last_assembled_prompt,
intent_id=intent.id,
)
session.add(assistant_msg)
@@ -129,8 +284,10 @@ async def send_message(
await session.refresh(thread)
logger.info(
"Chat: thread=%d, intent=%s (v%d), user_msg=%d, assistant_msg=%d, sources=%d",
thread.id, intent.code, active_cfg.version, user_msg.id, assistant_msg.id, len(sources),
"Chat: thread=%d, router=%s, served=%s (v%d), step=%d, slots=%d keys, user_msg=%d, assistant_msg=%d, bounces=%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),
)
return {
@@ -139,17 +296,23 @@ async def send_message(
"message_id": assistant_msg.id,
"intent_code": intent.code,
"intent_name": intent.name,
"router_intent_code": router_code,
"config_version": active_cfg.version,
"router_version": router_version,
"answer": llm_result["text"],
"answer": visible_text,
"sources": sources,
"model_used": llm.model,
"assembled_prompt": llm_result["assembled_prompt"],
"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"],
},
"bounces": bounce_log,
}
async def list_threads(session: AsyncSession) -> list[dict]:
"""Список всех тредов с превью первой реплики и количеством сообщений."""
count_subq = (
select(Message.thread_id, func.count(Message.id).label("cnt"))
.group_by(Message.thread_id)
@@ -224,12 +387,15 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non
"intent_code": intent_code or "",
"intent_name": intent_name or "",
})
state = await thread_state_service.load_snapshot(session, thread_id)
return {
"id": thread.id,
"name": thread.name,
"created_at": thread.created_at.isoformat(),
"updated_at": thread.updated_at.isoformat(),
"messages": messages,
"thread_state": state,
}
+75
View File
@@ -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={})
+38 -6
View File
@@ -430,6 +430,10 @@
<aside class="col-panel">
<div class="col-head">Отладка ответа</div>
<div class="col-body">
<div class="debug-section">
<h3>Состояние треда</h3>
<div id="debug-state"><div class="mini">— пока пусто —</div></div>
</div>
<div class="debug-section">
<h3>Решение роутера</h3>
<div id="debug-router"><div class="mini">— пока пусто —</div></div>
@@ -543,8 +547,12 @@ async function openThread(id) {
$("chat-title").textContent = d.name;
renderMessages(d.messages);
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
if (lastAssistant) renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null);
else clearDebug();
if (lastAssistant) {
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null);
renderState(d.thread_state, []);
} else {
clearDebug();
}
refreshThreads();
} catch (e) {
toast("Ошибка: " + e.message, "err");
@@ -596,12 +604,34 @@ function appendMessage(role, text, iso, intentCode, intentName) {
}
/* ---------- отладка ---------- */
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion) {
const routerTag = routerVersion != null ? ` · роутер v${routerVersion}` : "";
function renderState(state, bounces) {
const box = $("debug-state");
if (!state || !state.current_intent_code) {
box.innerHTML = '<div class="mini">state machine ещё не запускалась</div>';
return;
}
const slotsJson = JSON.stringify(state.slots || {}, null, 2);
const bounceHtml = (bounces && bounces.length)
? `<div style="margin-top:8px;font-size:11px;">
<div style="color:var(--muted);margin-bottom:3px;">переходы в этой реплике:</div>
${bounces.map(b => `<div>• <b>${esc(b.from)}</b> → <b>${esc(b.to)}</b>${b.preface ? ` <span style="color:var(--muted);">(«${esc(b.preface).slice(0,60)}»)</span>` : ''}</div>`).join("")}
</div>`
: "";
box.innerHTML = `
<div style="font-size:12px;">
<div><b>${esc(state.current_intent_code)}</b> · шаг <b>${state.current_step}</b></div>
<div class="prompt-box" style="margin-top:6px;max-height:200px;">${esc(slotsJson)}</div>
${bounceHtml}
</div>
`;
}
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode) {
const bounced = routerIntentCode && intentCode && routerIntentCode !== intentCode;
const routerLine = intentCode
? `<div style="padding:10px 16px;background:#ecfdf5;font-size:12px;">
<div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div>
${routerVersion != null ? `<div style="color:var(--muted);font-size:11px;margin-top:2px;">классифицировано роутером${routerTag.replace(' · роутер', '')}</div>` : ''}
${routerVersion != null ? `<div style="color:var(--muted);font-size:11px;margin-top:2px;">роутер v${routerVersion}${bounced ? ` сказал <b>${esc(routerIntentCode)}</b>, ветка передала управление` : ''}</div>` : ''}
</div>`
: "";
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
@@ -626,6 +656,7 @@ function renderDebug(sources, prompt, intentCode, intentName, configVersion, rou
}
function clearDebug() {
$("debug-state").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-router").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-chunks").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-prompt").innerHTML = '<div class="mini">— пока пусто —</div>';
@@ -668,7 +699,8 @@ async function sendMessage() {
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name);
$("chat-title").className = "chat-title";
$("chat-title").textContent = r.thread_name;
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version);
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version, r.router_intent_code);
renderState(r.thread_state, r.bounces);
refreshThreads();
} catch (e) {
pending.remove();