feat(chat): deterministic §22 chat foundation — POST /api/v1/chat/ask (no LLM)
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
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
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
This commit is contained in:
parent
347203dfda
commit
4847a2eb01
9 changed files with 1275 additions and 0 deletions
99
backend/app/api/v1/chat.py
Normal file
99
backend/app/api/v1/chat.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Чат по §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",
|
||||
)
|
||||
|
|
@ -25,6 +25,7 @@ from app.api.v1 import (
|
|||
admin_scrape,
|
||||
admin_weight_profiles,
|
||||
analytics,
|
||||
chat,
|
||||
concepts,
|
||||
custom_pois,
|
||||
insights,
|
||||
|
|
@ -159,6 +160,7 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
|
||||
app.include_router(chat.router, prefix="/api/v1/chat", tags=["chat"])
|
||||
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
|
||||
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
||||
app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"])
|
||||
|
|
|
|||
122
backend/app/schemas/chat.py
Normal file
122
backend/app/schemas/chat.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Pydantic v2 контракты чата по §22-форсайту участка (#957, Step 1 — БЕЗ LLM).
|
||||
|
||||
ДЕТЕРМИНИРОВАННЫЙ слой: эндпоинт `POST /api/v1/chat/ask` читает УЖЕ-ПЕРСИСТЕНТНЫЙ
|
||||
SiteFinderReport участка (`as_dict()`, 8 секций) и отдаёт шаблонный RU-ответ. LLM-
|
||||
композиция — Step 2 (#957), поэтому `llm_used` ВСЕГДА False, а `history` принимается,
|
||||
но в Step 1 НЕ используется (форвардится в LLM только на Step 2; длину кэпируем здесь,
|
||||
чтобы контракт был стабилен и не раздувался).
|
||||
|
||||
advisory: зеркалит `report.advisory` — отчёт советующий, не основание для инвест-решения,
|
||||
поэтому каждый ответ несёт advisory-оговорку (см. services/chat/intents.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
|
||||
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
|
||||
_HISTORY_MAX_TURNS = 20
|
||||
|
||||
|
||||
class ChatIntent(StrEnum):
|
||||
"""Поддерживаемые типы вопросов по отчёту (детерминированный роутинг §22-чата).
|
||||
|
||||
StrEnum → значение члена и есть строка (сериализуется как plain-строка в JSON-
|
||||
ответе, сравнимо со строкой). `unknown` — graceful-фолбэк: вопрос не распознан,
|
||||
отдаём меню поддерживаемых тем.
|
||||
"""
|
||||
|
||||
summary = "summary" # что строить здесь / общий вердикт (exec_summary)
|
||||
what_to_build = "what_to_build" # рекомендация продукта (product_tz)
|
||||
why_forecast = "why_forecast" # почему такой прогноз (future_market + reasons)
|
||||
risks = "risks" # риски / спец-индексы §25 + уверенность
|
||||
scenarios = "scenarios" # разброс сценариев (conservative/base/aggressive)
|
||||
unknown = "unknown" # вопрос не распознан → меню тем
|
||||
|
||||
|
||||
class ChatTurn(BaseModel):
|
||||
"""Один ход диалога (роль + текст). Принимается, но в Step 1 НЕ используется.
|
||||
|
||||
Форвардится в LLM-контекст только на Step 2 (#957). frozen — это входной факт.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
role: str = Field(description="Роль хода: 'user' | 'assistant'")
|
||||
content: str = Field(description="Текст хода диалога")
|
||||
|
||||
|
||||
class GroundedIn(BaseModel):
|
||||
"""Привязка ответа к источнику — какой ран/схема/секции легли в основу ответа.
|
||||
|
||||
Делает ответ explainable: фронт может показать «основано на ране N, секции X, Y».
|
||||
None в ответе, когда отчёта ещё нет (report_status='pending').
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
run_id: int = Field(description="ID персистентного analysis_run, давшего отчёт")
|
||||
schema_version: str = Field(description="Версия схемы отчёта (контракт as_dict)")
|
||||
sections: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Секции отчёта, использованные для ответа (explainability)",
|
||||
)
|
||||
|
||||
|
||||
class ChatAskRequest(BaseModel):
|
||||
"""Запрос вопроса по §22-форсайту участка.
|
||||
|
||||
`cad_num` + `message` обязательны. `intent` — опциональный явный intent (если задан,
|
||||
роутер его уважает и не угадывает по тексту). `history` — опциональная история
|
||||
диалога: принимается для совместимости контракта, но в Step 1 НЕ используется
|
||||
(LLM-контекст — Step 2); длину кэпируем (последние _HISTORY_MAX_TURNS ходов).
|
||||
"""
|
||||
|
||||
cad_num: str = Field(min_length=1, description="Кадастровый номер участка")
|
||||
message: str = Field(min_length=1, description="Вопрос пользователя (RU, free-form)")
|
||||
intent: ChatIntent | None = Field(
|
||||
default=None,
|
||||
description="Явный intent — если задан, роутер его уважает (не угадывает по тексту)",
|
||||
)
|
||||
history: list[ChatTurn] | None = Field(
|
||||
default=None,
|
||||
max_length=_HISTORY_MAX_TURNS,
|
||||
description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)",
|
||||
)
|
||||
|
||||
|
||||
class ChatAskResponse(BaseModel):
|
||||
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).
|
||||
|
||||
`answer` — готовый RU-ответ с числами ВЕРБАТИМ из отчёта (никогда не фабрикуем) +
|
||||
advisory-оговорка. `grounded_in` — привязка к рану/секциям (None если pending).
|
||||
`llm_used` ВСЕГДА False в Step 1. `fallback_reason` — почему ответ деградировал
|
||||
(отчёта нет / intent не распознан / секция отсутствует). `advisory` зеркалит
|
||||
report.advisory (пока всегда True). `report_status` — 'ready' (отчёт прочитан) или
|
||||
'pending' (отчёта ещё нет — запустите анализ участка).
|
||||
"""
|
||||
|
||||
answer: str = Field(description="Готовый RU-ответ (числа из отчёта + advisory-оговорка)")
|
||||
grounded_in: GroundedIn | None = Field(
|
||||
default=None,
|
||||
description="Привязка к рану/схеме/секциям (None если отчёта ещё нет)",
|
||||
)
|
||||
llm_used: bool = Field(
|
||||
default=False,
|
||||
description="Использовался ли LLM (Step 1 — ВСЕГДА False; LLM — Step 2)",
|
||||
)
|
||||
fallback_reason: str | None = Field(
|
||||
default=None,
|
||||
description="Почему ответ деградировал (нет отчёта / intent unknown / нет секции)",
|
||||
)
|
||||
advisory: bool = Field(
|
||||
default=True,
|
||||
description="Отчёт советующий (зеркало report.advisory — пока всегда True)",
|
||||
)
|
||||
report_status: Literal["ready", "pending"] = Field(
|
||||
description="Статус отчёта: 'ready' (прочитан) | 'pending' (ещё нет)",
|
||||
)
|
||||
15
backend/app/services/chat/__init__.py
Normal file
15
backend/app/services/chat/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Чат по §22-форсайту участка (#957). Step 1 — ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM.
|
||||
|
||||
Два под-модуля:
|
||||
• retrieval — read-only достаёт персистентный SiteFinderReport участка.
|
||||
• intents — роутинг RU-вопроса в intent + сборка шаблонного RU-ответа из секций.
|
||||
|
||||
LLM-композиция (Step 2) ляжет поверх этого слоя; здесь LLM НЕ участвует.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.chat.intents import render_answer, route_intent
|
||||
from app.services.chat.retrieval import get_report_for_chat
|
||||
|
||||
__all__ = ["get_report_for_chat", "render_answer", "route_intent"]
|
||||
398
backend/app/services/chat/intents.py
Normal file
398
backend/app/services/chat/intents.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
"""Роутинг RU-вопроса в intent + сборка шаблонного RU-ответа (#957, Step 1, БЕЗ LLM).
|
||||
|
||||
ДЕТЕРМИНИРОВАННО:
|
||||
• route_intent — явный intent (если задан) ИЛИ keyword/regex-матч RU-вопроса; нет
|
||||
совпадения → ChatIntent.unknown.
|
||||
• render_answer — тянет нужную(ые) секцию(и) `SiteFinderReport.as_dict()` и собирает
|
||||
краткий RU-ответ с числами движка ВЕРБАТИМ (числа берутся ИЗ отчёта, НИКОГДА не
|
||||
фабрикуются). Возвращает (answer_text, sections_used).
|
||||
|
||||
ROBUST к частичному/пустому отчёту (отчёт может быть тонким — каждая секция Optional):
|
||||
никаких KeyError — отсутствует секция → честно говорим «данных по X в отчёте нет», НЕ
|
||||
выдумываем. Микрокопирайт: RU, нейтральный бизнес-тон (ui-microcopy.md), `₽/м²` с
|
||||
пробелами, тысячи через пробел, без emoji/«Готово!». Каждый ответ завершается advisory-
|
||||
оговоркой (отчёт советующий, не основание для инвест-решения).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.chat import ChatIntent
|
||||
|
||||
# Неразрывный пробел — разделитель тысяч (зеркало toLocaleString("ru"), ui-microcopy.md:
|
||||
# '6 832 540'). Явная константа, чтобы separator не зависел от способа ввода в исходнике.
|
||||
_NBSP = " "
|
||||
|
||||
# advisory-оговорка в конце КАЖДОГО ответа (зеркало report.advisory). Полное
|
||||
# предложение, не аббревиатура (ui-microcopy.md).
|
||||
_ADVISORY_NOTE = (
|
||||
"Отчёт носит советующий характер и не является основанием для инвестиционного решения."
|
||||
)
|
||||
|
||||
# Сообщение, когда конкретной секции в отчёте нет (честный graceful, не фабрикуем).
|
||||
_NO_SECTION_TMPL = "Данных по разделу «{name}» в отчёте нет."
|
||||
|
||||
# RU-уровни уверенности (movement из low/medium/high в человекочитаемое).
|
||||
_CONFIDENCE_RU = {
|
||||
"high": "высокая",
|
||||
"medium": "средняя",
|
||||
"low": "низкая",
|
||||
}
|
||||
|
||||
# RU-метки сценариев.
|
||||
_SCENARIO_RU = {
|
||||
"conservative": "консервативный",
|
||||
"base": "базовый",
|
||||
"aggressive": "агрессивный",
|
||||
}
|
||||
|
||||
# RU-метки ключей §25 спец-индексов (для intent=risks).
|
||||
_INDEX_RU = {
|
||||
"launch_window": "окно запуска",
|
||||
"product_void": "продуктовое «белое пятно»",
|
||||
"cannibalization": "каннибализация портфеля",
|
||||
"competitor_strength": "сила конкурентов",
|
||||
"artificial_demand": "искусственный (ипотечный) спрос",
|
||||
"cost_of_error": "цена ошибки выбора продукта",
|
||||
}
|
||||
|
||||
# ── Keyword-роутинг intent'ов ───────────────────────────────────────────────────
|
||||
# Каждый intent → список regex'ов по строчному RU-вопросу. Порядок проверки задан
|
||||
# _INTENT_ORDER (specific → general): «почему такой прогноз» матчим РАНЬШЕ голого
|
||||
# «прогноз», иначе why_forecast перехватится более общим правилом.
|
||||
_INTENT_PATTERNS: dict[ChatIntent, list[re.Pattern[str]]] = {
|
||||
ChatIntent.what_to_build: [
|
||||
re.compile(r"что\s+(тут|здесь|строить|возвод)"),
|
||||
re.compile(r"какой\s+продукт"),
|
||||
re.compile(r"квартирограф|нарезк[аеуи]\b"),
|
||||
re.compile(r"какой\s+класс"),
|
||||
re.compile(r"\bтз\b|техзадан"),
|
||||
],
|
||||
ChatIntent.why_forecast: [
|
||||
re.compile(r"почему\s+(такой|так)\b"),
|
||||
re.compile(r"на\s+чём\s+основан"),
|
||||
re.compile(r"откуда\s+(такой|эти|цифр|прогноз)"),
|
||||
re.compile(r"чем\s+обоснован"),
|
||||
re.compile(r"(почему|обоснуй).{0,20}прогноз"),
|
||||
],
|
||||
ChatIntent.risks: [
|
||||
re.compile(r"риск"),
|
||||
re.compile(r"опасн|угроз"),
|
||||
re.compile(r"каннибал"),
|
||||
re.compile(r"подводн|что\s+может\s+пойти"),
|
||||
re.compile(r"спец.?индекс|индекс[ыаов]\b"),
|
||||
],
|
||||
ChatIntent.scenarios: [
|
||||
re.compile(r"сценари"),
|
||||
re.compile(r"консерватив|агрессивн|базов"),
|
||||
re.compile(r"разброс|варианты\s+развит"),
|
||||
],
|
||||
ChatIntent.summary: [
|
||||
re.compile(r"резюме|итог|кратк|сводк|вывод"),
|
||||
re.compile(r"вердикт|общая\s+картин"),
|
||||
re.compile(r"расскажи\s+про\s+участ|стоит\s+ли"),
|
||||
],
|
||||
}
|
||||
|
||||
# Порядок проверки: более СПЕЦИФИЧНЫЕ intent'ы раньше общих.
|
||||
_INTENT_ORDER: tuple[ChatIntent, ...] = (
|
||||
ChatIntent.why_forecast,
|
||||
ChatIntent.what_to_build,
|
||||
ChatIntent.risks,
|
||||
ChatIntent.scenarios,
|
||||
ChatIntent.summary,
|
||||
)
|
||||
|
||||
|
||||
def route_intent(message: str, explicit: ChatIntent | None) -> ChatIntent:
|
||||
"""Определить intent: явный (приоритет) ИЛИ keyword-матч; нет совпадения → unknown.
|
||||
|
||||
Явный intent уважаем как есть (фронт/клиент знает лучше). Иначе нормализуем текст
|
||||
в нижний регистр и идём по _INTENT_ORDER (specific → general): первый сматчившийся
|
||||
pattern выигрывает. Ничего не сматчилось → ChatIntent.unknown (отдадим меню тем).
|
||||
"""
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
|
||||
text = message.lower().strip()
|
||||
if not text:
|
||||
return ChatIntent.unknown
|
||||
|
||||
for intent in _INTENT_ORDER:
|
||||
for pattern in _INTENT_PATTERNS[intent]:
|
||||
if pattern.search(text):
|
||||
return intent
|
||||
return ChatIntent.unknown
|
||||
|
||||
|
||||
# ── Хелперы форматирования (RU-микрокопирайт) ────────────────────────────────────
|
||||
|
||||
|
||||
def _fmt_thousands(value: float | int) -> str:
|
||||
"""Целое с разделителем-NBSP тысяч ('6 832 540', зеркало ru-locale). PURE."""
|
||||
return f"{round(value):,}".replace(",", _NBSP)
|
||||
|
||||
|
||||
def _fmt_number(value: Any) -> str | None:
|
||||
"""Привести число к RU-строке (тысячи NBSP), не-числа → str() или None.
|
||||
|
||||
None → None (вызывающий пропустит поле). int → тысячи NBSP. float: целозначный →
|
||||
тысячи NBSP; дробный → запятая-десятичный БЕЗ потери точности (число из отчёта уже
|
||||
округлено движком — НЕ пере-округляем, лишь меняем '.'→','). Прочее (готовая строка-
|
||||
метка) → как есть.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool): # bool — подтип int, не форматируем как число
|
||||
return str(value)
|
||||
if isinstance(value, int):
|
||||
return _fmt_thousands(value)
|
||||
if isinstance(value, float):
|
||||
if value == int(value):
|
||||
return _fmt_thousands(value)
|
||||
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
|
||||
return repr(value).replace(".", ",")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _confidence_ru(level: Any) -> str | None:
|
||||
"""RU-метка уровня уверенности или None (если нет/неизвестный). PURE."""
|
||||
if not isinstance(level, str):
|
||||
return None
|
||||
return _CONFIDENCE_RU.get(level.lower())
|
||||
|
||||
|
||||
def _assemble(lines: list[str]) -> str:
|
||||
"""Склеить непустые строки-абзацы в ответ + advisory-оговорка в конце. PURE.
|
||||
|
||||
Пустые/None-строки выкидываем (graceful: пропущенные поля не оставляют дыр).
|
||||
advisory-оговорка добавляется ВСЕГДА последней строкой.
|
||||
"""
|
||||
body = [ln for ln in lines if ln]
|
||||
body.append(_ADVISORY_NOTE)
|
||||
return "\n".join(body)
|
||||
|
||||
|
||||
# ── Рендереры по секциям ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _render_summary(report: dict[str, Any]) -> tuple[str, list[str]]:
|
||||
"""summary → exec_summary (headline / verdict / key_numbers / overall_confidence)."""
|
||||
section = report.get("exec_summary")
|
||||
if not isinstance(section, dict):
|
||||
return _assemble([_NO_SECTION_TMPL.format(name="резюме")]), []
|
||||
|
||||
lines: list[str] = []
|
||||
headline = section.get("headline")
|
||||
if headline:
|
||||
lines.append(f"Вердикт: {headline}")
|
||||
verdict = section.get("verdict")
|
||||
if verdict:
|
||||
lines.append(str(verdict))
|
||||
|
||||
key_numbers = section.get("key_numbers")
|
||||
if isinstance(key_numbers, dict) and key_numbers:
|
||||
parts = []
|
||||
for label, raw in key_numbers.items():
|
||||
shown = _fmt_number(raw)
|
||||
if shown is not None:
|
||||
parts.append(f"{label}: {shown}")
|
||||
if parts:
|
||||
lines.append("Ключевые числа — " + "; ".join(parts) + ".")
|
||||
|
||||
conf = _confidence_ru(section.get("overall_confidence"))
|
||||
if conf:
|
||||
lines.append(f"Общая уверенность: {conf}.")
|
||||
|
||||
if not lines:
|
||||
lines.append("В отчёте пока нет заполненного резюме по участку.")
|
||||
return _assemble(lines), ["exec_summary"]
|
||||
|
||||
|
||||
def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
|
||||
"""what_to_build → product_tz (класс / mix / коммерция / USP) + exec_summary.headline."""
|
||||
section = report.get("product_tz")
|
||||
sections_used: list[str] = []
|
||||
lines: list[str] = []
|
||||
|
||||
exec_summary = report.get("exec_summary")
|
||||
if isinstance(exec_summary, dict) and exec_summary.get("headline"):
|
||||
lines.append(f"Вердикт: {exec_summary['headline']}")
|
||||
sections_used.append("exec_summary")
|
||||
|
||||
if not isinstance(section, dict):
|
||||
lines.append(_NO_SECTION_TMPL.format(name="рекомендация продукта"))
|
||||
return _assemble(lines), sections_used
|
||||
|
||||
sections_used.append("product_tz")
|
||||
|
||||
obj_class = section.get("obj_class")
|
||||
if obj_class:
|
||||
lines.append(f"Рекомендованный класс: {obj_class}.")
|
||||
|
||||
mix = section.get("mix")
|
||||
if isinstance(mix, list) and mix:
|
||||
lines.append(f"Квартирография проработана ({len(mix)} форматов).")
|
||||
|
||||
commercial = section.get("commercial")
|
||||
if isinstance(commercial, dict) and commercial:
|
||||
lines.append("Заложен коммерческий блок (см. раздел продукта).")
|
||||
|
||||
usp = section.get("usp")
|
||||
if isinstance(usp, list) and usp:
|
||||
lines.append(f"Выделено USP-преимуществ: {len(usp)}.")
|
||||
|
||||
summary = section.get("summary")
|
||||
if summary:
|
||||
lines.append(str(summary))
|
||||
|
||||
if len(sections_used) == 1 and not any(
|
||||
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
|
||||
):
|
||||
lines.append("Раздел рекомендации продукта в отчёте пуст.")
|
||||
return _assemble(lines), sections_used
|
||||
|
||||
|
||||
def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
|
||||
"""why_forecast → future_market (по горизонтам + future-supply) + product_tz.reasons +
|
||||
confidence.rationale."""
|
||||
sections_used: list[str] = []
|
||||
lines: list[str] = []
|
||||
|
||||
future = report.get("future_market")
|
||||
if isinstance(future, dict):
|
||||
sections_used.append("future_market")
|
||||
horizons = future.get("forecasts_by_horizon")
|
||||
if isinstance(horizons, list) and horizons:
|
||||
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
|
||||
future_supply = future.get("future_supply")
|
||||
if isinstance(future_supply, dict) and future_supply:
|
||||
lines.append("Учтено давление будущего предложения (выходящие проекты).")
|
||||
summary = future.get("summary")
|
||||
if summary:
|
||||
lines.append(str(summary))
|
||||
else:
|
||||
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))
|
||||
|
||||
product = report.get("product_tz")
|
||||
if isinstance(product, dict):
|
||||
reasons = product.get("reasons")
|
||||
if isinstance(reasons, list) and reasons:
|
||||
lines.append(f"Рекомендация опирается на {len(reasons)} обоснований (§16).")
|
||||
sections_used.append("product_tz")
|
||||
|
||||
confidence = report.get("confidence")
|
||||
if isinstance(confidence, dict):
|
||||
rationale = confidence.get("rationale")
|
||||
if rationale:
|
||||
lines.append(f"Обоснование уверенности: {rationale}")
|
||||
sections_used.append("confidence")
|
||||
|
||||
if not lines or all(ln.startswith("Данных по") for ln in lines):
|
||||
lines.append("Детали обоснования прогноза в отчёте пока не заполнены.")
|
||||
return _assemble(lines), sections_used
|
||||
|
||||
|
||||
def _render_risks(report: dict[str, Any]) -> tuple[str, list[str]]:
|
||||
"""risks → scoring.special_indices (§25, вкл. каннибализацию) + confidence (уровень +
|
||||
тянущие факторы)."""
|
||||
sections_used: list[str] = []
|
||||
lines: list[str] = []
|
||||
|
||||
scoring = report.get("scoring")
|
||||
special = scoring.get("special_indices") if isinstance(scoring, dict) else None
|
||||
if isinstance(special, dict):
|
||||
indices = special.get("indices")
|
||||
if isinstance(indices, dict) and indices:
|
||||
sections_used.append("scoring")
|
||||
parts = []
|
||||
for key, idx in indices.items():
|
||||
if not isinstance(idx, dict):
|
||||
continue
|
||||
name = _INDEX_RU.get(key, key)
|
||||
value = idx.get("value")
|
||||
label = idx.get("label")
|
||||
if value is None and not label:
|
||||
continue # индекс не посчитан (тонкие данные) — пропускаем честно
|
||||
shown = label if label else _fmt_number(value)
|
||||
if shown is not None:
|
||||
parts.append(f"{name}: {shown}")
|
||||
if parts:
|
||||
lines.append("Специальные индексы §25 — " + "; ".join(parts) + ".")
|
||||
if not lines:
|
||||
lines.append("Специальные индексы §25 в отчёте не посчитаны (тонкие данные).")
|
||||
else:
|
||||
lines.append(_NO_SECTION_TMPL.format(name="специальные индексы §25"))
|
||||
|
||||
confidence = report.get("confidence")
|
||||
if isinstance(confidence, dict):
|
||||
level = _confidence_ru(confidence.get("level"))
|
||||
if level:
|
||||
lines.append(f"Уверенность отчёта: {level}.")
|
||||
sections_used.append("confidence")
|
||||
factors = confidence.get("factors")
|
||||
if isinstance(factors, dict) and factors:
|
||||
lines.append(f"Факторы, влияющие на уверенность: {len(factors)}.")
|
||||
if "confidence" not in sections_used:
|
||||
sections_used.append("confidence")
|
||||
|
||||
return _assemble(lines), sections_used
|
||||
|
||||
|
||||
def _render_scenarios(report: dict[str, Any]) -> tuple[str, list[str]]:
|
||||
"""scenarios → scenarios (by_scenario + summary)."""
|
||||
section = report.get("scenarios")
|
||||
if not isinstance(section, dict):
|
||||
return _assemble([_NO_SECTION_TMPL.format(name="сценарии")]), []
|
||||
|
||||
lines: list[str] = []
|
||||
by_scenario = section.get("by_scenario")
|
||||
if isinstance(by_scenario, dict) and by_scenario:
|
||||
names: list[str] = [_SCENARIO_RU.get(str(k), str(k)) for k in by_scenario]
|
||||
lines.append("Проработаны сценарии: " + ", ".join(names) + ".")
|
||||
else:
|
||||
lines.append("Разбивка по сценариям в отчёте не заполнена.")
|
||||
|
||||
summary = section.get("summary")
|
||||
if summary:
|
||||
lines.append(str(summary))
|
||||
return _assemble(lines), ["scenarios"]
|
||||
|
||||
|
||||
def _render_unknown(report: dict[str, Any]) -> tuple[str, list[str]]:
|
||||
"""unknown → graceful RU-меню поддерживаемых типов вопросов (секции не трогаем)."""
|
||||
menu = _assemble(
|
||||
[
|
||||
"Я отвечаю по форсайт-отчёту участка. Могу помочь с такими вопросами:",
|
||||
"• что здесь строить и какой продукт рекомендуется;",
|
||||
"• почему такой прогноз и на чём он основан;",
|
||||
"• какие риски и специальные индексы (включая каннибализацию);",
|
||||
"• как различаются сценарии (консервативный / базовый / агрессивный);",
|
||||
"• краткое резюме и общий вердикт по участку.",
|
||||
]
|
||||
)
|
||||
return menu, []
|
||||
|
||||
|
||||
# Диспетч intent → рендерер. unknown не зависит от отчёта.
|
||||
_RENDERERS = {
|
||||
ChatIntent.summary: _render_summary,
|
||||
ChatIntent.what_to_build: _render_what_to_build,
|
||||
ChatIntent.why_forecast: _render_why_forecast,
|
||||
ChatIntent.risks: _render_risks,
|
||||
ChatIntent.scenarios: _render_scenarios,
|
||||
ChatIntent.unknown: _render_unknown,
|
||||
}
|
||||
|
||||
|
||||
def render_answer(intent: ChatIntent, report: dict[str, Any]) -> tuple[str, list[str]]:
|
||||
"""Собрать RU-ответ под intent из секции(й) отчёта → (answer_text, sections_used).
|
||||
|
||||
Числа берутся ВЕРБАТИМ из `report` (никогда не фабрикуются). ROBUST к частичному
|
||||
отчёту: отсутствует секция → честное «данных по X нет», без KeyError. Ответ всегда
|
||||
заканчивается advisory-оговоркой (см. _assemble). Неизвестный intent → меню тем.
|
||||
"""
|
||||
renderer = _RENDERERS.get(intent, _render_unknown)
|
||||
return renderer(report)
|
||||
59
backend/app/services/chat/retrieval.py
Normal file
59
backend/app/services/chat/retrieval.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Read-only достаёт персистентный §22-форсайт участка для чата (#957, Step 1).
|
||||
|
||||
Тонкая обёртка над `latest_run_for(..., schema_version="1.0")` — ровно тот же seam,
|
||||
что читает `GET /{cad_num}/forecast` (parcels.py): последний ран ИМЕННО §22-схемы
|
||||
('1.0', НЕ analyze-1.0 inline-dict). `run.result` — это `SiteFinderReport.as_dict()`
|
||||
(стабильный 8-секционный контракт), `run.id` — run_id для grounding.
|
||||
|
||||
Q2-РЕШЕНИЕ = READ-ONLY: рана нет → возвращаем (None, None). Эндпоинт по этому отдаёт
|
||||
report_status='pending' + детерминированный RU-ответ «отчёт ещё не готов, запустите
|
||||
анализ участка». НИЧЕГО не считаем, НЕ enqueue'им, НЕ пере-вычисляем (чат не триггерит
|
||||
форсайт — это делает POST /analyze). Сбой БД пробрасываем НЕ из этого модуля —
|
||||
гасит/логирует его вызывающий эндпоинт (как get_parcel_forecast → pending).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.analysis_runs.repository import latest_run_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Версия схемы §22-форсайта (зеркало _FORECAST_SCHEMA_VERSION в parcels.py). Читаем
|
||||
# именно её, а не analyze-1.0: view вернул бы §22-ран поверх analyze, но фильтр по
|
||||
# схеме явный и устойчивый.
|
||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||
|
||||
|
||||
def get_report_for_chat(
|
||||
db: Session,
|
||||
cad_num: str,
|
||||
) -> tuple[dict[str, Any] | None, int | None]:
|
||||
"""Достать последний §22-отчёт участка как (report_dict, run_id) или (None, None).
|
||||
|
||||
READ-ONLY (Q2): рана нужной схемы нет → (None, None), БЕЗ enqueue/пере-расчёта.
|
||||
`run.result` — `SiteFinderReport.as_dict()` (8 секций). Если result почему-то не
|
||||
dict (мусор/частичная запись) — логируем и отдаём (None, None) как «нет отчёта»,
|
||||
чтобы чат не упал (graceful).
|
||||
"""
|
||||
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||
if run is None:
|
||||
return None, None
|
||||
|
||||
result = run.result
|
||||
if not isinstance(result, dict):
|
||||
# Ран есть, но result не похож на отчёт (старая/битая запись) — не валим чат,
|
||||
# трактуем как «отчёта ещё нет» (pending). Логируем для диагностики.
|
||||
logger.warning(
|
||||
"chat: run %s for cad=%s has non-dict result (%s) — treating as pending",
|
||||
run.id,
|
||||
cad_num,
|
||||
type(result).__name__,
|
||||
)
|
||||
return None, None
|
||||
|
||||
return result, run.id
|
||||
336
backend/tests/api/v1/test_chat.py
Normal file
336
backend/tests/api/v1/test_chat.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
"""Тесты эндпоинта чата `POST /api/v1/chat/ask` (#957, Step 1, ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM).
|
||||
|
||||
Покрывает:
|
||||
• Каждый intent отдаёт корректный шаблонный RU-ответ для фикстуры-отчёта.
|
||||
• Нет рана → report_status='pending' + RU-подсказка «запустите анализ» (READ-ONLY).
|
||||
• unknown-вопрос → меню тем (fallback_reason='intent_unknown').
|
||||
• llm_used всегда False; grounded_in несёт run_id/schema_version/sections.
|
||||
• 401 без X-Authenticated-User (mini-RBAC-app, как test_rbac.py — реальный app в
|
||||
тестах бьётся мимо Caddy с settings.testing=True, поэтому RBAC проверяем на копии
|
||||
middleware).
|
||||
|
||||
Стратегия: DB — через app.dependency_overrides[get_db] (MagicMock-сессия); сам отчёт
|
||||
контролируем патчем latest_run_for (никакой прод-БД).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import chat as chat_router
|
||||
from app.core import auth as auth_mod
|
||||
from app.core.db import get_db
|
||||
from app.main import app
|
||||
|
||||
_CAD = "66:41:0204016:10"
|
||||
# Разделитель тысяч — неразрывный пробел (зеркало ru-locale, _NBSP в intents.py).
|
||||
_NBSP = " "
|
||||
|
||||
|
||||
# ── Фикстуры ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _report() -> dict[str, Any]:
|
||||
"""Полный отчёт (форма SiteFinderReport.as_dict()) с узнаваемыми числами."""
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"advisory": True,
|
||||
"exec_summary": {
|
||||
"headline": "Комфорт-класс, 2-3-комнатные",
|
||||
"verdict": "Участок подходит под комфорт-класс.",
|
||||
"key_numbers": {"цена_руб_м2": 250000},
|
||||
"overall_confidence": "medium",
|
||||
},
|
||||
"market_now": {"summary": "Рынок активен."},
|
||||
"future_market": {
|
||||
"forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}],
|
||||
"future_supply": {"pressure": 0.4},
|
||||
"summary": "Ожидается дефицит на горизонте 12 мес.",
|
||||
},
|
||||
"product_tz": {
|
||||
"obj_class": "комфорт",
|
||||
"mix": [{"fmt": "2k"}],
|
||||
"reasons": [{"why": "дефицит формата"}],
|
||||
"summary": "Рекомендуется комфорт-класс.",
|
||||
},
|
||||
"scenarios": {
|
||||
"by_scenario": {"conservative": {}, "base": {}, "aggressive": {}},
|
||||
"summary": "Разброс умеренный.",
|
||||
},
|
||||
"scoring": {
|
||||
"special_indices": {
|
||||
"indices": {"cannibalization": {"value": 0.31, "label": None}}
|
||||
},
|
||||
},
|
||||
"confidence": {
|
||||
"level": "medium",
|
||||
"rationale": "Прогноз спроса — прокси.",
|
||||
"factors": {"deals": "ok"},
|
||||
},
|
||||
"meta": {"cad_num": _CAD, "schema_version": "1.0"},
|
||||
}
|
||||
|
||||
|
||||
def _make_run(report: dict[str, Any], run_id: int = 42) -> MagicMock:
|
||||
"""Замокать Row из latest_run_for (.result — отчёт, .id — run_id)."""
|
||||
run = MagicMock()
|
||||
run.result = report
|
||||
run.id = run_id
|
||||
return run
|
||||
|
||||
|
||||
def _client_with_db() -> TestClient:
|
||||
"""TestClient с override get_db на MagicMock-сессию (реальная БД не трогается)."""
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── Happy-path: intents возвращают шаблонный ответ ───────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "expected_substrings", "expected_sections"),
|
||||
[
|
||||
("Дай резюме по участку", ["Комфорт-класс", f"250{_NBSP}000"], ["exec_summary"]),
|
||||
("Что здесь строить?", ["комфорт"], ["exec_summary", "product_tz"]),
|
||||
("Почему такой прогноз?", ["горизонт", "прокси"], ["future_market"]),
|
||||
("Какие риски?", ["каннибализация портфеля", "0,31"], ["scoring"]),
|
||||
("Покажи сценарии", ["консервативный", "базовый"], ["scenarios"]),
|
||||
],
|
||||
)
|
||||
def test_ask_intent_returns_templated_answer(
|
||||
message: str,
|
||||
expected_substrings: list[str],
|
||||
expected_sections: list[str],
|
||||
) -> None:
|
||||
"""Каждый intent → корректный RU-ответ; числа из отчёта; grounded_in заполнен."""
|
||||
client = _client_with_db()
|
||||
with patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(_report()),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": message},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["report_status"] == "ready"
|
||||
assert body["llm_used"] is False
|
||||
assert body["advisory"] is True
|
||||
for sub in expected_substrings:
|
||||
assert sub in body["answer"], f"{sub!r} not in answer: {body['answer']}"
|
||||
assert body["grounded_in"]["run_id"] == 42
|
||||
assert body["grounded_in"]["schema_version"] == "1.0"
|
||||
for sect in expected_sections:
|
||||
assert sect in body["grounded_in"]["sections"]
|
||||
|
||||
|
||||
def test_ask_explicit_intent_is_respected() -> None:
|
||||
"""Явный intent уважается поверх текста (риск-текст, но intent=summary)."""
|
||||
client = _client_with_db()
|
||||
with patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(_report()),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "какие риски?", "intent": "summary"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["grounded_in"]["sections"] == ["exec_summary"]
|
||||
|
||||
|
||||
def test_ask_answer_always_has_advisory_caveat() -> None:
|
||||
"""Каждый ответ заканчивается advisory-оговоркой."""
|
||||
client = _client_with_db()
|
||||
with patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(_report()),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "резюме"},
|
||||
)
|
||||
assert "советующий характер" in resp.json()["answer"]
|
||||
|
||||
|
||||
def test_ask_history_accepted_but_unused() -> None:
|
||||
"""history принимается (200), но в Step 1 не влияет на ответ (llm_used=False)."""
|
||||
client = _client_with_db()
|
||||
with patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(_report()),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={
|
||||
"cad_num": _CAD,
|
||||
"message": "резюме",
|
||||
"history": [{"role": "user", "content": "привет"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["llm_used"] is False
|
||||
|
||||
|
||||
# ── unknown intent → меню ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ask_unknown_intent_returns_menu() -> None:
|
||||
"""Нераспознанный вопрос → меню тем + fallback_reason='intent_unknown'."""
|
||||
client = _client_with_db()
|
||||
with patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(_report()),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "какая погода в Москве?"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["report_status"] == "ready"
|
||||
assert body["fallback_reason"] == "intent_unknown"
|
||||
assert body["grounded_in"]["sections"] == []
|
||||
assert "что здесь строить" in body["answer"].lower()
|
||||
|
||||
|
||||
# ── pending: рана нет ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ask_no_run_returns_pending() -> None:
|
||||
"""Рана нет → report_status='pending' + RU-подсказка «запустите анализ»."""
|
||||
client = _client_with_db()
|
||||
with patch("app.services.chat.retrieval.latest_run_for", return_value=None):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "резюме"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["report_status"] == "pending"
|
||||
assert body["grounded_in"] is None
|
||||
assert body["llm_used"] is False
|
||||
assert body["fallback_reason"] == "report_pending"
|
||||
assert "Запустите анализ участка" in body["answer"]
|
||||
|
||||
|
||||
def test_ask_db_error_returns_pending_not_500() -> None:
|
||||
"""Сбой БД на read-only → pending (не 500), клиент может повторить."""
|
||||
client = _client_with_db()
|
||||
with patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
side_effect=RuntimeError("db down"),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "резюме"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["report_status"] == "pending"
|
||||
assert body["fallback_reason"] == "report_unavailable"
|
||||
|
||||
|
||||
# ── Валидация запроса ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ask_missing_message_422() -> None:
|
||||
"""message обязателен → 422."""
|
||||
client = _client_with_db()
|
||||
resp = client.post("/api/v1/chat/ask", json={"cad_num": _CAD})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_ask_empty_cad_num_422() -> None:
|
||||
"""Пустой cad_num → 422 (min_length=1)."""
|
||||
client = _client_with_db()
|
||||
resp = client.post("/api/v1/chat/ask", json={"cad_num": "", "message": "резюме"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── RBAC: 401 без X-Authenticated-User ───────────────────────────────────────────
|
||||
# Реальный app в тестах бьётся мимо Caddy с settings.testing=True → rbac_guard
|
||||
# пропускает. RBAC проверяем на КОПИИ middleware (как tests/test_rbac.py), смонтировав
|
||||
# тот же chat-роутер под /api/v1/chat.
|
||||
|
||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
|
||||
|
||||
def _build_rbac_test_app() -> FastAPI:
|
||||
"""FastAPI с копией rbac_guard из app/main.py + смонтированным chat-роутером."""
|
||||
rbac_app = FastAPI()
|
||||
|
||||
@rbac_app.middleware("http")
|
||||
async def rbac_guard( # type: ignore[unused-ignore]
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
path = request.url.path
|
||||
if path in _PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
username = request.headers.get("X-Authenticated-User")
|
||||
if not username:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
||||
)
|
||||
try:
|
||||
role = auth_mod.get_role(username)
|
||||
except KeyError:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "user not in roles config"},
|
||||
)
|
||||
if _ADMIN_API_RE.match(path) and role != "admin":
|
||||
return JSONResponse(status_code=403, content={"detail": "admin only"})
|
||||
return await call_next(request)
|
||||
|
||||
rbac_app.include_router(chat_router.router, prefix="/api/v1/chat", tags=["chat"])
|
||||
rbac_app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
return rbac_app
|
||||
|
||||
|
||||
def test_ask_no_auth_header_returns_401() -> None:
|
||||
"""Без X-Authenticated-User → 401 (rbac_guard, чат смонтирован под /api/v1)."""
|
||||
client = TestClient(_build_rbac_test_app())
|
||||
resp = client.post("/api/v1/chat/ask", json={"cad_num": _CAD, "message": "резюме"})
|
||||
assert resp.status_code == 401
|
||||
assert "no authenticated user" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_ask_unknown_user_returns_403() -> None:
|
||||
"""Юзер не в roles.yaml → 403 (rbac_guard)."""
|
||||
client = TestClient(_build_rbac_test_app())
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "резюме"},
|
||||
headers={"X-Authenticated-User": "nobody-unknown"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_ask_known_user_passes_rbac() -> None:
|
||||
"""Известный юзер (admin) проходит rbac_guard → доходит до хендлера (200)."""
|
||||
client = TestClient(_build_rbac_test_app())
|
||||
with patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(_report()),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "резюме"},
|
||||
headers={"X-Authenticated-User": "admin"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
0
backend/tests/services/chat/__init__.py
Normal file
0
backend/tests/services/chat/__init__.py
Normal file
244
backend/tests/services/chat/test_intents.py
Normal file
244
backend/tests/services/chat/test_intents.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""Юнит-тесты роутинга intent'ов + рендеринга RU-ответов чата (#957, Step 1, БЕЗ LLM).
|
||||
|
||||
Покрывает:
|
||||
• route_intent — явный intent уважается; keyword-матч RU-вопроса; нет матча → unknown.
|
||||
• render_answer — корректная секция(и) на intent; числа ВЕРБАТИМ из отчёта (не
|
||||
фабрикуются); advisory-оговорка в каждом ответе; graceful на отсутствующей секции
|
||||
(«данных по X нет», без KeyError); unknown → меню тем.
|
||||
|
||||
Никакой БД — работаем с фикстурой-dict (форма SiteFinderReport.as_dict()).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.schemas.chat import ChatIntent
|
||||
from app.services.chat.intents import _ADVISORY_NOTE, render_answer, route_intent
|
||||
|
||||
# Разделитель тысяч — неразрывный пробел (зеркало ru-locale, _NBSP в intents.py).
|
||||
_NBSP = " "
|
||||
|
||||
|
||||
def _full_report() -> dict[str, Any]:
|
||||
"""Полный 8-секционный отчёт (форма SiteFinderReport.as_dict()) с числами-маркерами.
|
||||
|
||||
Числа подобраны узнаваемыми (7654321, 250000), чтобы тест мог утверждать: они
|
||||
приходят ИМЕННО из отчёта, а не выдуманы рендерером.
|
||||
"""
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"advisory": True,
|
||||
"exec_summary": {
|
||||
"headline": "Комфорт-класс, 2-3-комнатные",
|
||||
"verdict": "Участок подходит под жильё комфорт-класса.",
|
||||
"key_numbers": {"площадь_м2": 7654321, "цена_руб_м2": 250000},
|
||||
"overall_confidence": "medium",
|
||||
},
|
||||
"market_now": {
|
||||
"market_metrics": {"absorption": 12.0},
|
||||
"competitors": [{"name": "ЖК А"}],
|
||||
"supply_layers": None,
|
||||
"summary": "Рынок активен.",
|
||||
},
|
||||
"future_market": {
|
||||
"forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}],
|
||||
"future_supply": {"pressure": 0.4},
|
||||
"future_competitors": [],
|
||||
"scenarios_summary": {"spread": "wide"},
|
||||
"summary": "Ожидается дефицит на горизонте 12 мес.",
|
||||
},
|
||||
"product_tz": {
|
||||
"obj_class": "комфорт",
|
||||
"mix": [{"fmt": "1k"}, {"fmt": "2k"}, {"fmt": "3k"}],
|
||||
"commercial": {"gla_m2": 1200},
|
||||
"usp": [{"usp": "видовые квартиры"}],
|
||||
"reasons": [{"why": "дефицит формата"}, {"why": "ценовое окно"}],
|
||||
"summary": "Рекомендуется комфорт-класс с акцентом на 2-3-комнатные.",
|
||||
},
|
||||
"scenarios": {
|
||||
"by_scenario": {
|
||||
"conservative": {"npv": 1},
|
||||
"base": {"npv": 2},
|
||||
"aggressive": {"npv": 3},
|
||||
},
|
||||
"summary": "Разброс сценариев умеренный.",
|
||||
},
|
||||
"scoring": {
|
||||
"product_scores": {"overall": 0.72},
|
||||
"special_indices": {
|
||||
"indices": {
|
||||
"launch_window": {"value": None, "label": "18 мес"},
|
||||
"cannibalization": {"value": 0.31, "label": None},
|
||||
"competitor_strength": {"value": None, "label": None},
|
||||
}
|
||||
},
|
||||
"overall": 0.72,
|
||||
},
|
||||
"confidence": {
|
||||
"level": "medium",
|
||||
"rationale": "Данные по сделкам полные, прогноз спроса — прокси.",
|
||||
"factors": {"deals": "ok", "supply": "proxy"},
|
||||
},
|
||||
"meta": {
|
||||
"cad_num": "66:41:0204016:10",
|
||||
"district": "Екатеринбург",
|
||||
"segment": {},
|
||||
"horizons": [12, 24],
|
||||
"schema_version": "1.0",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── route_intent ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_route_intent_explicit_wins_over_text() -> None:
|
||||
"""Явный intent уважается, даже если текст намекает на другое."""
|
||||
assert route_intent("какие тут риски?", ChatIntent.summary) == ChatIntent.summary
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "expected"),
|
||||
[
|
||||
("Что здесь строить?", ChatIntent.what_to_build),
|
||||
("Какой продукт рекомендуете?", ChatIntent.what_to_build),
|
||||
("Почему такой прогноз?", ChatIntent.why_forecast),
|
||||
("На чём основан прогноз?", ChatIntent.why_forecast),
|
||||
("Какие риски у участка?", ChatIntent.risks),
|
||||
("Есть ли каннибализация портфеля?", ChatIntent.risks),
|
||||
("Покажи сценарии развития", ChatIntent.scenarios),
|
||||
("Дай краткое резюме по участку", ChatIntent.summary),
|
||||
("Какой вердикт?", ChatIntent.summary),
|
||||
],
|
||||
)
|
||||
def test_route_intent_keyword_match(message: str, expected: ChatIntent) -> None:
|
||||
"""Keyword/regex-матч RU-вопроса → правильный intent."""
|
||||
assert route_intent(message, None) == expected
|
||||
|
||||
|
||||
def test_route_intent_no_match_returns_unknown() -> None:
|
||||
"""Нераспознанный вопрос → unknown."""
|
||||
assert route_intent("Какая сегодня погода в Москве?", None) == ChatIntent.unknown
|
||||
|
||||
|
||||
def test_route_intent_empty_message_unknown() -> None:
|
||||
"""Пустое сообщение → unknown (не падаем)."""
|
||||
assert route_intent(" ", None) == ChatIntent.unknown
|
||||
|
||||
|
||||
# ── render_answer: секции + числа из отчёта ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_render_summary_uses_exec_summary_and_verbatim_numbers() -> None:
|
||||
"""summary тянет exec_summary; числа приходят ВЕРБАТИМ из отчёта (форматированы)."""
|
||||
answer, sections = render_answer(ChatIntent.summary, _full_report())
|
||||
assert sections == ["exec_summary"]
|
||||
assert "Комфорт-класс, 2-3-комнатные" in answer
|
||||
# Числа приходят ВЕРБАТИМ из отчёта (тысячи — неразрывным пробелом, ru-locale).
|
||||
assert f"7{_NBSP}654{_NBSP}321" in answer
|
||||
assert f"250{_NBSP}000" in answer
|
||||
assert "средняя" in answer # overall_confidence=medium → RU
|
||||
assert _ADVISORY_NOTE in answer
|
||||
|
||||
|
||||
def test_render_what_to_build_uses_product_tz_and_headline() -> None:
|
||||
"""what_to_build тянет product_tz + exec_summary.headline."""
|
||||
answer, sections = render_answer(ChatIntent.what_to_build, _full_report())
|
||||
assert "product_tz" in sections
|
||||
assert "exec_summary" in sections
|
||||
assert "комфорт" in answer
|
||||
assert _ADVISORY_NOTE in answer
|
||||
|
||||
|
||||
def test_render_why_forecast_uses_future_and_reasons_and_confidence() -> None:
|
||||
"""why_forecast тянет future_market + product_tz.reasons + confidence.rationale."""
|
||||
answer, sections = render_answer(ChatIntent.why_forecast, _full_report())
|
||||
assert "future_market" in sections
|
||||
assert "product_tz" in sections
|
||||
assert "confidence" in sections
|
||||
assert "2 горизонт" in answer # 2 forecasts_by_horizon
|
||||
assert "прокси" in answer # из confidence.rationale
|
||||
assert _ADVISORY_NOTE in answer
|
||||
|
||||
|
||||
def test_render_risks_uses_special_indices_and_confidence() -> None:
|
||||
"""risks тянет scoring.special_indices (вкл. каннибализацию) + confidence."""
|
||||
answer, sections = render_answer(ChatIntent.risks, _full_report())
|
||||
assert "scoring" in sections
|
||||
assert "confidence" in sections
|
||||
assert "каннибализация портфеля" in answer
|
||||
assert "0,31" in answer # value=0.31 → RU-десятичная из отчёта
|
||||
assert "18 мес" in answer # launch_window label из отчёта
|
||||
assert _ADVISORY_NOTE in answer
|
||||
|
||||
|
||||
def test_render_risks_skips_uncomputed_index() -> None:
|
||||
"""Индекс с value=None и label=None пропускается честно (не фабрикуем)."""
|
||||
answer, _ = render_answer(ChatIntent.risks, _full_report())
|
||||
# competitor_strength был None/None → его RU-метки в ответе быть не должно.
|
||||
assert "сила конкурентов" not in answer
|
||||
|
||||
|
||||
def test_render_scenarios_uses_scenarios_section() -> None:
|
||||
"""scenarios тянет by_scenario + summary, метки сценариев → RU."""
|
||||
answer, sections = render_answer(ChatIntent.scenarios, _full_report())
|
||||
assert sections == ["scenarios"]
|
||||
assert "консервативный" in answer
|
||||
assert "базовый" in answer
|
||||
assert "агрессивный" in answer
|
||||
assert _ADVISORY_NOTE in answer
|
||||
|
||||
|
||||
def test_render_unknown_returns_menu() -> None:
|
||||
"""unknown → меню поддерживаемых тем, секции не используются."""
|
||||
answer, sections = render_answer(ChatIntent.unknown, _full_report())
|
||||
assert sections == []
|
||||
assert "что здесь строить" in answer.lower()
|
||||
assert "сценарии" in answer.lower()
|
||||
assert _ADVISORY_NOTE in answer
|
||||
|
||||
|
||||
# ── render_answer: graceful на частичном/пустом отчёте ───────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"intent",
|
||||
[
|
||||
ChatIntent.summary,
|
||||
ChatIntent.what_to_build,
|
||||
ChatIntent.why_forecast,
|
||||
ChatIntent.risks,
|
||||
ChatIntent.scenarios,
|
||||
ChatIntent.unknown,
|
||||
],
|
||||
)
|
||||
def test_render_empty_report_never_raises_and_has_advisory(intent: ChatIntent) -> None:
|
||||
"""Пустой отчёт ({}) — никакого KeyError, ответ непустой + advisory-оговорка."""
|
||||
answer, sections = render_answer(intent, {})
|
||||
assert isinstance(answer, str)
|
||||
assert answer.strip()
|
||||
assert _ADVISORY_NOTE in answer
|
||||
assert isinstance(sections, list)
|
||||
|
||||
|
||||
def test_render_summary_missing_section_says_honestly() -> None:
|
||||
"""Нет exec_summary → честное «данных по разделу резюме нет», не выдумка."""
|
||||
answer, sections = render_answer(ChatIntent.summary, {"market_now": {}})
|
||||
assert sections == []
|
||||
assert "Данных по разделу «резюме» в отчёте нет." in answer
|
||||
|
||||
|
||||
def test_render_risks_missing_special_indices_says_honestly() -> None:
|
||||
"""Нет scoring.special_indices → честное сообщение об отсутствии §25-индексов."""
|
||||
answer, _ = render_answer(ChatIntent.risks, {"scoring": {"product_scores": {}}})
|
||||
assert "специальные индексы §25" in answer.lower()
|
||||
|
||||
|
||||
def test_render_does_not_fabricate_numbers_on_empty() -> None:
|
||||
"""На пустом отчёте в ответе нет «выдуманных» числовых маркеров фикстуры."""
|
||||
answer, _ = render_answer(ChatIntent.summary, {})
|
||||
assert f"7{_NBSP}654{_NBSP}321" not in answer
|
||||
assert f"250{_NBSP}000" not in answer
|
||||
Loading…
Add table
Reference in a new issue