All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m8s
CI / backend-tests (pull_request) Successful in 14m34s
Chat could not answer 'какая зона ПЗЗ?' — retrieval loaded only the §22 forecast run (schema 1.0); тер.зона/градрегламент/ЗОУИТ/ЕГРН live in the analyze-1.0 run and never reached the chat context. Add get_parcel_context_for_chat: a §19-safe whitelist slice of analyze (fixed keys AND scalar-only values — dict/list dropped at the boundary; ЗОУИТ zone names as a dedicated str-only capped list; no geometry/PII/raw sub-dicts) merged into a copy of report_dict under 'parcel_context'; expose it via the sixth sectional tool get_parcel_info. Bump chat_system prompt v2->v3 so the model uses it and no longer treats the parcel's own ПЗЗ zone as out of scope.
193 lines
8.9 KiB
Python
193 lines
8.9 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_parcel_context_for_chat,
|
||
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",
|
||
)
|
||
|
||
# Курируемый паспорт участка + градрегламент (§1 analyze-рана) — отдельный read-only
|
||
# seam. §22-отчёт (форсайт) НЕ несёт тер.зону/ЗОУИТ/ЕГРН, поэтому дотягиваем их из
|
||
# analyze-1.0 и вливаем в КОПИЮ report_dict под ключ "parcel_context" (tool
|
||
# get_parcel_info режет именно его). Analyze-рана нет/сбой чтения → работаем как
|
||
# раньше (только форсайт); НЕ меняем pending-поведение (оно завязано на §22-ран выше).
|
||
report = await _with_parcel_context(db, payload.cad_num, report)
|
||
|
||
if settings.llm_enabled:
|
||
return await _answer_via_llm(db, payload, report, run_id)
|
||
return _answer_deterministic(payload, report, run_id)
|
||
|
||
|
||
async def _with_parcel_context(
|
||
db: Session,
|
||
cad_num: str,
|
||
report: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""Дотянуть курируемый паспорт участка и влить его в КОПИЮ report_dict.
|
||
|
||
Read-only: sync-чтение analyze-рана мостим через run_in_threadpool (как §22-отчёт).
|
||
None (рана нет) → возвращаем report без изменений. Сбой БД глотаем в pending-стиле
|
||
эндпоинта: паспорт участка — обогащение, его отсутствие не должно ронять чат.
|
||
"""
|
||
try:
|
||
parcel_context = await run_in_threadpool(get_parcel_context_for_chat, db, cad_num)
|
||
except Exception:
|
||
logger.warning(
|
||
"chat: parcel context read failed for cad=%s — continuing without it",
|
||
cad_num,
|
||
exc_info=True,
|
||
)
|
||
return report
|
||
if not parcel_context:
|
||
return report
|
||
# Копия: не мутируем report_dict, пришедший из get_report_for_chat.
|
||
merged = dict(report)
|
||
merged["parcel_context"] = parcel_context
|
||
return merged
|
||
|
||
|
||
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",
|
||
)
|