All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / backend-tests (pull_request) Successful in 6m23s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m43s
Deploy / deploy (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 7m39s
Step 1 of #957. Answers parcel questions by reading the already-persisted §22 SiteFinderReport (latest_run_for, schema "1.0", read-only) and returning templated RU answers with engine numbers verbatim (§16, never fabricated). Intent routing (explicit or RU keyword match) -> per-section renderers; graceful on partial/missing sections and pending (no run / DB error) without 500s. Works with llm_enabled=False (llm_used always False); LLM composition is Step 2. Mounted off /api/v1/chat so rbac_guard auto-requires an authed known user. Refs #957
99 lines
4.5 KiB
Python
99 lines
4.5 KiB
Python
"""Чат по §22-форсайту участка — `POST /api/v1/chat/ask` (#957, Step 1, БЕЗ LLM).
|
||
|
||
ДЕТЕРМИНИРОВАННЫЙ stateless-эндпоинт: читает УЖЕ-ПЕРСИСТЕНТНЫЙ SiteFinderReport
|
||
участка и отдаёт шаблонный RU-ответ. LLM НЕ участвует (`llm_used=False` всегда) —
|
||
композиция через LLM это Step 2.
|
||
|
||
Поток:
|
||
get_report_for_chat → отчёта нет (None) → report_status='pending' + детерминированный
|
||
RU-ответ «запустите анализ участка» (READ-ONLY: НЕ enqueue'им расчёт); иначе
|
||
route_intent → render_answer → ChatAskResponse(report_status='ready', grounded_in).
|
||
|
||
RBAC: смонтировано под /api/v1/chat (НЕ /admin) → middleware rbac_guard (app/main.py)
|
||
АВТОМАТИЧЕСКИ требует аутентифицированного известного юзера (X-Authenticated-User из
|
||
Caddy). Доп. guard-код тут НЕ нужен. Сессия БД — синхронная (`Depends(get_db)`, как
|
||
get_parcel_forecast); хендлер async def (FastAPI house-style).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
from app.schemas.chat import ChatAskRequest, ChatAskResponse, ChatIntent, GroundedIn
|
||
from app.services.chat.intents import render_answer, route_intent
|
||
from app.services.chat.retrieval import _FORECAST_SCHEMA_VERSION, get_report_for_chat
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
# Детерминированный RU-ответ, когда §22-отчёта ещё нет (READ-ONLY: чат не триггерит
|
||
# расчёт — его запускает POST /analyze).
|
||
_PENDING_ANSWER = (
|
||
"Отчёт по этому участку ещё не готов. Запустите анализ участка — после расчёта "
|
||
"форсайта я смогу ответить на вопросы по нему."
|
||
)
|
||
|
||
|
||
@router.post("/ask", response_model=ChatAskResponse)
|
||
async def ask(
|
||
payload: ChatAskRequest,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> ChatAskResponse:
|
||
"""Ответить на вопрос по §22-форсайту участка (детерминированно, БЕЗ LLM).
|
||
|
||
Отчёта нет → 200 + report_status='pending' + RU-подсказка «запустите анализ»
|
||
(READ-ONLY, ничего не считаем). Иначе роутим intent (явный или по тексту) и
|
||
собираем шаблонный RU-ответ с числами ВЕРБАТИМ из отчёта + advisory-оговорка.
|
||
`history` принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
|
||
"""
|
||
try:
|
||
report, run_id = get_report_for_chat(db, payload.cad_num)
|
||
except Exception:
|
||
# Read-only сбой БД — не валим клиента 500-кой, отдаём pending (как
|
||
# get_parcel_forecast). Клиент может повторить.
|
||
logger.warning(
|
||
"chat: report read failed for cad=%s — returning pending",
|
||
payload.cad_num,
|
||
exc_info=True,
|
||
)
|
||
return ChatAskResponse(
|
||
answer=_PENDING_ANSWER,
|
||
grounded_in=None,
|
||
llm_used=False,
|
||
fallback_reason="report_unavailable",
|
||
report_status="pending",
|
||
)
|
||
|
||
if report is None or run_id is None:
|
||
return ChatAskResponse(
|
||
answer=_PENDING_ANSWER,
|
||
grounded_in=None,
|
||
llm_used=False,
|
||
fallback_reason="report_pending",
|
||
report_status="pending",
|
||
)
|
||
|
||
intent = route_intent(payload.message, payload.intent)
|
||
answer, sections = render_answer(intent, report)
|
||
|
||
# fallback_reason: помечаем, когда intent не распознан (отдали меню тем) — для
|
||
# аналитики и будущего LLM-фолбэка (Step 2).
|
||
fallback_reason = "intent_unknown" if intent is ChatIntent.unknown else None
|
||
|
||
return ChatAskResponse(
|
||
answer=answer,
|
||
grounded_in=GroundedIn(
|
||
run_id=run_id,
|
||
schema_version=_FORECAST_SCHEMA_VERSION,
|
||
sections=sections,
|
||
),
|
||
llm_used=False,
|
||
fallback_reason=fallback_reason,
|
||
report_status="ready",
|
||
)
|