gendesign/backend/tests/services/chat/test_intents.py
Light1YT 4847a2eb01
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
feat(chat): deterministic §22 chat foundation — POST /api/v1/chat/ask (no LLM)
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
2026-06-08 17:21:51 +05:00

244 lines
11 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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