Some checks failed
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m38s
CI / backend-tests (pull_request) Successful in 6m37s
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
#1244 (security): внешние/скрейпинг-строки (comm_name из DOM.РФ, headline/usp_text) с ведущим = + - @ \t \r писались как есть → openpyxl сохранял как формулы (data_type='f'), исполнялись при открытии в Excel/LibreOffice. _sanitize_formula префиксует такие строки апострофом (OWASP CSV-injection escape); числа/даты/bool не трогаются. _write_kv labels тоже санитизируются. Подтверждено на openpyxl 3.1.5. #1245 (concurrency): async ask() вызывал sync get_report_for_chat() (sync SQLAlchemy тянет крупный JSONB §22-отчёт) напрямую — блокировал event loop, в отличие от LLM-ветки (run_in_threadpool). Обёрнуто в run_in_threadpool. Closes #1244 Closes #1245
154 lines
7 KiB
Python
154 lines
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:
|
||
# Sync SQLAlchemy чтение (тянет крупный JSONB §22-отчёт) — мостим через
|
||
# run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и
|
||
# синхронный LLM `complete` в _answer_via_llm ниже).
|
||
report, run_id = await run_in_threadpool(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",
|
||
)
|