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

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:
Light1YT 2026-06-08 17:21:51 +05:00
parent 347203dfda
commit 4847a2eb01
9 changed files with 1275 additions and 0 deletions

View 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",
)

View file

@ -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
View 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' (ещё нет)",
)

View 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"]

View 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)

View 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

View 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

View file

View 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