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
244 lines
11 KiB
Python
244 lines
11 KiB
Python
"""Юнит-тесты роутинга 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
|