gendesign/backend/app/api/v1/chat.py
Light1YT 4847a2eb01
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
feat(chat): deterministic §22 chat foundation — POST /api/v1/chat/ask (no LLM)
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
2026-06-08 17:21:51 +05:00

99 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Чат по §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",
)