From 4847a2eb01c7fcf295612657ac7a3573dd5aebe5 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Mon, 8 Jun 2026 17:21:51 +0500 Subject: [PATCH] =?UTF-8?q?feat(chat):=20deterministic=20=C2=A722=20chat?= =?UTF-8?q?=20foundation=20=E2=80=94=20POST=20/api/v1/chat/ask=20(no=20LLM?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/v1/chat.py | 99 +++++ backend/app/main.py | 2 + backend/app/schemas/chat.py | 122 ++++++ backend/app/services/chat/__init__.py | 15 + backend/app/services/chat/intents.py | 398 ++++++++++++++++++++ backend/app/services/chat/retrieval.py | 59 +++ backend/tests/api/v1/test_chat.py | 336 +++++++++++++++++ backend/tests/services/chat/__init__.py | 0 backend/tests/services/chat/test_intents.py | 244 ++++++++++++ 9 files changed, 1275 insertions(+) create mode 100644 backend/app/api/v1/chat.py create mode 100644 backend/app/schemas/chat.py create mode 100644 backend/app/services/chat/__init__.py create mode 100644 backend/app/services/chat/intents.py create mode 100644 backend/app/services/chat/retrieval.py create mode 100644 backend/tests/api/v1/test_chat.py create mode 100644 backend/tests/services/chat/__init__.py create mode 100644 backend/tests/services/chat/test_intents.py diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py new file mode 100644 index 00000000..a69cffd4 --- /dev/null +++ b/backend/app/api/v1/chat.py @@ -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", + ) diff --git a/backend/app/main.py b/backend/app/main.py index 43ca2548..2604c737 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py new file mode 100644 index 00000000..fdd625f7 --- /dev/null +++ b/backend/app/schemas/chat.py @@ -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' (ещё нет)", + ) diff --git a/backend/app/services/chat/__init__.py b/backend/app/services/chat/__init__.py new file mode 100644 index 00000000..8802281a --- /dev/null +++ b/backend/app/services/chat/__init__.py @@ -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"] diff --git a/backend/app/services/chat/intents.py b/backend/app/services/chat/intents.py new file mode 100644 index 00000000..221b1829 --- /dev/null +++ b/backend/app/services/chat/intents.py @@ -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) diff --git a/backend/app/services/chat/retrieval.py b/backend/app/services/chat/retrieval.py new file mode 100644 index 00000000..afeb7f1c --- /dev/null +++ b/backend/app/services/chat/retrieval.py @@ -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 diff --git a/backend/tests/api/v1/test_chat.py b/backend/tests/api/v1/test_chat.py new file mode 100644 index 00000000..3f4aa13b --- /dev/null +++ b/backend/tests/api/v1/test_chat.py @@ -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 diff --git a/backend/tests/services/chat/__init__.py b/backend/tests/services/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/services/chat/test_intents.py b/backend/tests/services/chat/test_intents.py new file mode 100644 index 00000000..30f782ad --- /dev/null +++ b/backend/tests/services/chat/test_intents.py @@ -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