85c3ec0222
- thread_state.soft_insertion_count: растёт при боковом ответе (soft_insertion=true
в STATE_JSON без смены шага/слотов), сбрасывается при продвижении или handoff
- При soft_insertion_count >= 3 в системный промпт ветки добавляется SOFT_INSERTION_NUDGE
— явная инструкция вернуть пациента к вопросу текущего шага
- state_machine.parse_branch_response читает флаг soft_insertion из STATE_JSON
- Новая колонка message.meta_json: {router_intent_code, served_intent_code, step_code, events}
— хранит снимок маршрутизации каждой реплики ассистента
- «Песочница»: бейджи событий (sticky / soft_insertion / hard_handoff / resumed /
routing_loop / validation_blocked) над каждым ответом ассистента
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
80 lines
3.2 KiB
Python
80 lines
3.2 KiB
Python
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db.session import get_session
|
|
from models.requests import ChatRequest
|
|
from models.responses import (
|
|
BounceInfo,
|
|
ChatResponse,
|
|
SourceInfo,
|
|
ThreadStateInfo,
|
|
ValidationEventInfo,
|
|
)
|
|
from services import chat_service
|
|
from services.llm_client import LLMUnavailableError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/chat", tags=["chat"])
|
|
|
|
|
|
@router.post("", response_model=ChatResponse)
|
|
async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
|
|
from main import llm_client, router_client, vectorstore_service
|
|
|
|
if vectorstore_service is None or llm_client is None or router_client is None:
|
|
raise HTTPException(status_code=503, detail="Service not ready")
|
|
|
|
try:
|
|
result = await chat_service.send_message(
|
|
session=session,
|
|
vectorstore=vectorstore_service,
|
|
llm=llm_client,
|
|
router=router_client,
|
|
text=req.text,
|
|
thread_id=req.thread_id,
|
|
top_k=req.top_k,
|
|
temperature=req.temperature,
|
|
max_tokens=req.max_tokens,
|
|
)
|
|
except LookupError as e:
|
|
await session.rollback()
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except LLMUnavailableError as e:
|
|
# Внешний LLM недоступен даже после ретрая — откатываем, чтобы не оставлять
|
|
# «тред-призрак» с одной пользовательской репликой и без ответа ассистента.
|
|
await session.rollback()
|
|
logger.warning("LLM unavailable: %s", e)
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Внешняя модель временно недоступна. Попробуйте ещё раз через минуту.",
|
|
)
|
|
except Exception as e:
|
|
await session.rollback()
|
|
logger.exception("Chat failed")
|
|
raise HTTPException(status_code=500, detail=f"Chat error [{type(e).__name__}]: {e}")
|
|
|
|
return ChatResponse(
|
|
thread_id=result["thread_id"],
|
|
thread_name=result["thread_name"],
|
|
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", [])],
|
|
validation_events=[ValidationEventInfo(**v) for v in result.get("validation_events", [])],
|
|
parse_error=result.get("parse_error"),
|
|
routing_loop_triggered=result.get("routing_loop_triggered", False),
|
|
resumed_from_suspended=result.get("resumed_from_suspended", False),
|
|
message_meta=result.get("message_meta"),
|
|
)
|