gendesign/backend/app/api/v1/chat.py
bot-backend 57d156973e
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m44s
Deploy / build-worker (push) Successful in 2m46s
Deploy / deploy (push) Successful in 1m31s
feat(chat): tool get_parcel_info — курируемый градрегламент/ЗОУИТ/ЕГРН в контекст чата (#2366)
2026-07-04 01:24:42 +00:00

193 lines
8.9 KiB
Python
Raw Permalink 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 + 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",
)