All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m41s
CI / backend-tests (push) Successful in 6m24s
CI / backend-tests (pull_request) Successful in 6m23s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m12s
Add the LLM prose-composition path for the parcel-forecast chat, layered over the deterministic Step-1 fallback which stays the safety net. - chat/tools.py: 5 read-only section tools (exec_summary, product_recommendation, forecast, risks, scenarios) — pure slices of the loaded report dict, no DB/ recompute, graceful on missing sections. market_now (raw analyze blob) and meta are deliberately NOT exposed -> highest-PII data cannot reach the LLM. - chat/safe_payload.py: the §19 gate — single place that builds the outbound SafePayload from a section-aggregate allowlist; honors is_confidential hard-block. - chat/orchestrator.py: manual tool-call loop with call-cap/termination, real grounded_in provenance; any LLMResult.ok=False (disabled/timeout/rate_limited/ redaction_refused/call_cap/provider_error/empty) degrades to the deterministic answer. - llm/prompts.py: versioned chat_system@v1 — answer only from sections, never fabricate numbers, advisory tone, decline out-of-scope. - api/v1/chat.py: branch on settings.llm_enabled; sync complete bridged via run_in_threadpool. Default-off -> deterministic path, no provider built. - Tests: fake provider only (no network), planted-secret redaction-boundary + per-reason fallback + call-cap + numbers-from-report coverage. Refs #957
151 lines
6.7 KiB
Python
151 lines
6.7 KiB
Python
"""Чат по §22-форсайту участка — `POST /api/v1/chat/ask` (#957, Step 1 + Step 2 LLM).
|
||
|
||
Stateless-эндпоинт: читает УЖЕ-ПЕРСИСТЕНТНЫЙ SiteFinderReport участка и отдаёт RU-ответ.
|
||
Две ветки по `settings.llm_enabled`:
|
||
• False (дефолт) → ДЕТЕРМИНИРОВАННЫЙ Step-1 путь (route_intent → render_answer),
|
||
llm_used=False. В проде до настройки секретов сеть НЕ дёргается.
|
||
• True → Step-2 LLM tool-loop (orchestrate_chat) поверх того же отчёта; при ЛЮБОМ
|
||
сбое LLM оркестратор сам деградирует в Step-1 ответ (llm_used=False + fallback_reason).
|
||
|
||
Поток:
|
||
get_report_for_chat → отчёта нет (None) → report_status='pending' + детерминированный
|
||
RU-ответ «запустите анализ участка» (READ-ONLY: НЕ enqueue'им расчёт); иначе ветка по
|
||
llm_enabled → ChatAskResponse(report_status='ready', grounded_in).
|
||
|
||
Sync↔async мост: ядро LLM (`complete`) синхронное (httpx.Client) — async-хендлер мостит
|
||
через `run_in_threadpool` (НЕ делаем async Celery/блокирующий вызов в event-loop'е).
|
||
|
||
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, Any
|
||
|
||
from fastapi import APIRouter, Depends
|
||
from fastapi.concurrency import run_in_threadpool
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
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.orchestrator import orchestrate_chat
|
||
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-форсайту участка.
|
||
|
||
Отчёта нет → 200 + report_status='pending' + RU-подсказка «запустите анализ»
|
||
(READ-ONLY, ничего не считаем). Иначе ветка по `settings.llm_enabled`:
|
||
• False → детерминированный Step-1 ответ (intent → render_answer), llm_used=False;
|
||
• True → LLM tool-loop (orchestrate_chat через run_in_threadpool); при сбое LLM
|
||
оркестратор сам отдаёт детерминированный ответ (llm_used=False + fallback_reason).
|
||
"""
|
||
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",
|
||
)
|
||
|
||
if settings.llm_enabled:
|
||
return await _answer_via_llm(db, payload, report, run_id)
|
||
return _answer_deterministic(payload, report, run_id)
|
||
|
||
|
||
def _answer_deterministic(
|
||
payload: ChatAskRequest,
|
||
report: dict[str, Any],
|
||
run_id: int,
|
||
) -> ChatAskResponse:
|
||
"""Step-1 детерминированный ответ: intent → шаблонный RU-текст (числа из отчёта)."""
|
||
intent = route_intent(payload.message, payload.intent)
|
||
answer, sections = render_answer(intent, report)
|
||
# fallback_reason: помечаем, когда intent не распознан (отдали меню тем) — для
|
||
# аналитики (зеркалит поведение Step-1).
|
||
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",
|
||
)
|
||
|
||
|
||
async def _answer_via_llm(
|
||
db: Session,
|
||
payload: ChatAskRequest,
|
||
report: dict[str, Any],
|
||
run_id: int,
|
||
) -> ChatAskResponse:
|
||
"""Step-2 LLM tool-loop. Синхронный `complete` мостится через run_in_threadpool.
|
||
|
||
Оркестратор инкапсулирует деградацию: ЛЮБОЙ сбой LLM → детерминированный ответ
|
||
(llm_used=False + fallback_reason). Здесь просто переносим его поля в HTTP-контракт.
|
||
"""
|
||
result = await run_in_threadpool(
|
||
orchestrate_chat,
|
||
db,
|
||
payload.cad_num,
|
||
payload.message,
|
||
payload.history,
|
||
report,
|
||
run_id,
|
||
)
|
||
return ChatAskResponse(
|
||
answer=result.answer,
|
||
grounded_in=GroundedIn(
|
||
run_id=run_id,
|
||
schema_version=_FORECAST_SCHEMA_VERSION,
|
||
sections=result.sections,
|
||
),
|
||
llm_used=result.llm_used,
|
||
fallback_reason=result.fallback_reason,
|
||
report_status="ready",
|
||
)
|