gendesign/backend/tests/services/forecasting/test_confidence_engine.py
Light1YT e82761964d
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m4s
CI / openapi-codegen-check (pull_request) Successful in 1m54s
CI / backend-tests (pull_request) Successful in 13m50s
fix(report): human RU microcopy in Site Finder Section 6 (#1963)
Replace dev-jargon with plain RU across the §22 forecast report (6.2/6.3/6.5
+ deficit/затоварка legend). Source of truth = backend reason strings + the
frontend RU maps; text/render-only, no scoring/forecast math touched.

Backend (source of truth):
- scenarios.py _COLLAPSE_REASON_LOW_BETA: «β rate-sensitivity не прошёл gate …»
  → «чувствительность к ставке не оценена на коротком ряде ЕКБ → один базовый
  сценарий вместо трёх».
- confidence_engine.py _coverage_factor: drop «domrf↔objective» jargon, say it
  affects будущее предложение/конкуренцию. New _history_factor: «глубина истории
  N мес» + на что влияет + связь с 6.2 (короткий ряд → один сценарий).

Frontend (both Section-6 families — live analysis page + ptica cockpit):
- Deficit legend: −1 затоварка / 0 баланс / +1 острый дефицит + actionable
  трактовка; MOI tied to «сколько месяцев район распродаёт предложение».
- 6.2 heading «Почему один сценарий, а не три» over the collapse reason.
- 6.3 render confidence.rationale + weakest-link rule («скорее завысим
  недоверие, чем недооценим риск»); FACTOR_RU gains confounded_window/
  advisory_cap; factor notes shown.
- 6.5 «Вес»→«Оценка»; overall verdict vs 0.5; «Риск избытка предложения»→
  «Запас по предложению»; §-refs moved from reason into tooltip; 6 special-
  index 1-line «что это + куда лучше» descriptions; 0.00-score reasons shown.

Tests: confidence_engine (history/coverage notes), stripSectionRefs vitest.

Refs #1963
2026-06-27 17:42:27 +05:00

414 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""Unit-тесты §15 движка отчётной уверенности (#990, 955-A4).
Чистые тесты — БЕЗ БД (движок PURE: берёт уже-посчитанные входы, ничего не считает
из §9.x и не ходит в БД):
• _level_from_value / _factor_from_count — каждый порог-бэнд (high/medium/low) +
RU-нота с РЕАЛЬНЫМ числом и оценочным словом; None → low + «нет данных».
• _coverage_factor — покрытие domrf↔objective в % в ноте (структурный §15-пример).
• _aggregate — weakest-link MIN (один low → общий low); пустой → low.
• _cap — advisory-потолок опускает high → medium.
• _build_rationale — называет тянущий-вниз фактор с его числом, «Low потому что N…»;
advisory-cap проговаривается; пусто → «недостаточно данных».
• compute_report_confidence — смешанные факторы → правильный level + структурная
причина; advisory НИКОГДА не отдаёт high; all-None → low + «недостаточно данных»;
as_dict() ложится в слот ReportConfidence (#987) и JSON-сериализуем.
Детерминированно, без LLM, без сети. DATABASE_URL до импорта app-модулей (зеркало
соседних тестов — на случай side-effect'ов импорта пакета forecasting).
"""
from __future__ import annotations
import json
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.forecasting.confidence_engine import (
_ANALOG_COUNT_HIGH,
_DEAL_COUNT_HIGH,
_DEAL_COUNT_LOW,
_HISTORY_MONTHS_HIGH,
ConfidenceFactor,
ReportConfidenceResult,
_aggregate,
_build_rationale,
_cap,
_coverage_factor,
_factor_from_count,
_history_factor,
_level_from_value,
compute_report_confidence,
)
def _factor(name: str, level: str) -> ConfidenceFactor:
"""Лёгкий фактор фиксированного уровня (для агрегатных/причинных тестов)."""
return ConfidenceFactor(name=name, value=None, level=level, note=f"{name}:{level}") # type: ignore[arg-type]
# ── _level_from_value — пороговые бэнды ────────────────────────────────────────
class TestLevelFromValue:
def test_high_band(self) -> None:
assert _level_from_value(50, high_at=50, low_below=15) == "high"
assert _level_from_value(120, high_at=50, low_below=15) == "high"
def test_medium_band(self) -> None:
# Между low_below (вкл.) и high_at (искл.) → medium.
assert _level_from_value(15, high_at=50, low_below=15) == "medium"
assert _level_from_value(30, high_at=50, low_below=15) == "medium"
def test_low_band(self) -> None:
assert _level_from_value(14, high_at=50, low_below=15) == "low"
assert _level_from_value(0, high_at=50, low_below=15) == "low"
def test_none_is_low(self) -> None:
# Нет сигнала ≠ хороший сигнал.
assert _level_from_value(None, high_at=50, low_below=15) == "low"
# ── _factor_from_count — бэнды + RU-нота с числом ──────────────────────────────
class TestFactorFromCount:
def test_low_count_note_has_number_and_word(self) -> None:
f = _factor_from_count(
"deal_count",
7,
high_at=_DEAL_COUNT_HIGH,
low_below=_DEAL_COUNT_LOW,
unit="сделок",
suffix="за 6 мес",
)
assert f.level == "low"
assert f.value == 7
# Структурный §15-стиль: реальное число + контекст + оценка.
assert "7 сделок" in f.note
assert "за 6 мес" in f.note
assert "мало" in f.note
def test_medium_count_band(self) -> None:
f = _factor_from_count(
"deal_count", 20, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
)
assert f.level == "medium"
assert "20 сделок" in f.note
assert "умеренно" in f.note
def test_high_count_band(self) -> None:
f = _factor_from_count(
"deal_count", 80, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
)
assert f.level == "high"
assert "80 сделок" in f.note
assert "достаточно" in f.note
def test_one_analog_is_low_with_number(self) -> None:
# ТЗ §15-пример «только 1 ЖК» — 1 аналог обязан давать low с числом в ноте.
f = _factor_from_count(
"analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога"
)
assert f.level == "low"
assert "1 ЖК-аналога" in f.note
def test_none_count_low_no_data_note(self) -> None:
f = _factor_from_count(
"deal_count", None, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
)
assert f.level == "low"
assert f.value is None
assert "нет данных" in f.note
def test_float_count_rendered_compactly(self) -> None:
# Целое-как-float → без хвоста; дробное → 1 знак.
f_int = _factor_from_count(
"history_months", 12.0, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории"
)
assert "12 мес истории" in f_int.note # не "12.0"
f_frac = _factor_from_count(
"history_months", 12.5, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории"
)
assert "12.5 мес истории" in f_frac.note
# ── _coverage_factor — покрытие domrf↔objective в % ────────────────────────────
class TestCoverageFactor:
def test_low_coverage_percent_in_note(self) -> None:
# Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15).
f = _coverage_factor(0.025)
assert f.level == "low"
assert f.value == 0.025
assert "2.5%" in f.note
# #1963: нота человеческая, без внутр.жаргона «domrf↔objective».
assert "domrf" not in f.note
assert "будущ" in f.note # говорит про будущее предложение/проекты
def test_high_coverage(self) -> None:
f = _coverage_factor(0.75)
assert f.level == "high"
assert "75.0%" in f.note
def test_none_coverage_low(self) -> None:
f = _coverage_factor(None)
assert f.level == "low"
assert "неизвестн" in f.note
assert "domrf" not in f.note
def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None:
# BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден).
# До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) —
# инфляция confidence в exactly near-zero кейсе, который §15 призван флагать.
f = _coverage_factor(0.008)
assert f.level == "low"
assert "0.8%" in f.note
# ── _history_factor — глубина истории + «на что влияет» + связь с 6.2 (#1963) ──
class TestHistoryFactor:
def test_short_history_low_with_impact_and_scenario_link(self) -> None:
f = _history_factor(6)
assert f.level == "low"
assert f.value == 6
assert "6 мес истории" in f.note
# Говорит на что влияет (ставка/тренды) и связывает с 6.2 (сценарии).
assert "ставк" in f.note
assert "6.2" in f.note
def test_long_history_high(self) -> None:
f = _history_factor(36)
assert f.level == "high"
assert "36 мес истории" in f.note
def test_none_history_low(self) -> None:
f = _history_factor(None)
assert f.level == "low"
assert f.value is None
assert "6.2" in f.note
# ── _aggregate — weakest-link MIN ──────────────────────────────────────────────
class TestAggregate:
def test_one_low_drags_overall_to_low(self) -> None:
factors = [_factor("a", "high"), _factor("b", "medium"), _factor("c", "low")]
assert _aggregate(factors) == "low"
def test_all_high_stays_high(self) -> None:
assert _aggregate([_factor("a", "high"), _factor("b", "high")]) == "high"
def test_medium_floor_when_no_low(self) -> None:
assert _aggregate([_factor("a", "high"), _factor("b", "medium")]) == "medium"
def test_empty_is_low(self) -> None:
# Нет ни одного сигнала → low (graceful).
assert _aggregate([]) == "low"
# ── _cap — advisory-потолок ────────────────────────────────────────────────────
class TestCap:
def test_high_capped_to_medium(self) -> None:
assert _cap("high", "medium") == "medium"
def test_low_unchanged_below_ceiling(self) -> None:
assert _cap("low", "medium") == "low"
def test_medium_unchanged_at_ceiling(self) -> None:
assert _cap("medium", "medium") == "medium"
# ── _build_rationale — структурная §15-причина ─────────────────────────────────
class TestBuildRationale:
def test_low_names_dragging_factor_with_number(self) -> None:
# «Low потому что 7 сделок …» — называет виновника с его числом.
deal = _factor_from_count(
"deal_count",
7,
high_at=_DEAL_COUNT_HIGH,
low_below=_DEAL_COUNT_LOW,
unit="сделок",
suffix="за 6 мес",
)
good = _factor("history_months", "high")
text = _build_rationale("low", [deal, good], advisory_capped=False)
assert text.startswith("Low потому что")
assert "7 сделок" in text
# Не-тянущий (high) фактор НЕ называется.
assert "history_months" not in text
def test_low_lists_multiple_dragging_factors(self) -> None:
deal = _factor_from_count(
"deal_count", 7, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
)
analog = _factor_from_count(
"analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога"
)
cov = _coverage_factor(0.025)
text = _build_rationale("low", [deal, analog, cov], advisory_capped=False)
assert "7 сделок" in text
assert "1 ЖК-аналога" in text
assert "2.5%" in text
assert " / " in text # перечисление виновников
def test_advisory_cap_spoken_in_rationale(self) -> None:
# Уровень упёрся в advisory-потолок (не данные) → причина это проговаривает.
text = _build_rationale("medium", [_factor("x", "medium")], advisory_capped=True)
assert "советующий" in text
assert "medium" in text
def test_empty_factors_insufficient(self) -> None:
assert "недостаточно данных" in _build_rationale("low", [], advisory_capped=False)
# ── compute_report_confidence — интеграция (PURE, без БД) ──────────────────────
class TestComputeReportConfidence:
def test_mixed_factors_weakest_link_low(self) -> None:
# Сильная история, но 7 сделок и 1 ЖК → weakest-link low; причина структурная.
res = compute_report_confidence(
component_confidences=["medium", "high"],
deal_count=7,
analog_count=1,
domrf_coverage=0.4,
history_months=36,
confounded=False,
advisory=True,
)
assert isinstance(res, ReportConfidenceResult)
assert res.level == "low"
assert res.rationale.startswith("Low потому что")
assert "7 сделок" in res.rationale
assert "1 ЖК-аналога" in res.rationale
def test_advisory_never_high(self) -> None:
# Все сигналы отличные, но advisory=True → НИКОГДА не high (потолок medium).
res = compute_report_confidence(
component_confidences=["high", "high"],
deal_count=200,
analog_count=10,
domrf_coverage=0.9,
history_months=48,
confounded=False,
advisory=True,
)
assert res.level == "medium"
assert res.advisory_capped is True
assert "советующий" in res.rationale
def test_non_advisory_allows_high(self) -> None:
# advisory=False (гипотетически провалидированный стек) → high достижим.
res = compute_report_confidence(
component_confidences=["high"],
deal_count=200,
analog_count=10,
domrf_coverage=0.9,
history_months=48,
confounded=False,
advisory=False,
)
assert res.level == "high"
assert res.advisory_capped is False
def test_confounded_window_drags_down(self) -> None:
# Шок-окно → low даже при прочих хороших сигналах; названо в причине.
res = compute_report_confidence(
component_confidences=["high"],
deal_count=200,
analog_count=10,
domrf_coverage=0.9,
history_months=48,
confounded=True,
advisory=True,
)
assert res.level == "low"
assert "шок" in res.rationale
def test_all_none_is_low_insufficient(self) -> None:
# Граничный graceful: ни одного входа → low + «недостаточно данных».
res = compute_report_confidence()
assert res.level == "low"
assert "недостаточно данных" in res.rationale
assert res.factors == []
assert res.advisory_capped is False
def test_clean_inputs_medium_under_advisory(self) -> None:
# Рабочие (medium-бэнд) счётчики без low-факторов → medium под advisory-cap.
res = compute_report_confidence(
component_confidences=["medium"],
deal_count=20,
analog_count=2,
domrf_coverage=0.4,
history_months=18,
advisory=True,
)
assert res.level == "medium"
# Здесь потолок не понижал (raw уже medium) → не помечаем capped.
assert res.advisory_capped is False
def test_as_dict_fits_report_confidence_slot(self) -> None:
# as_dict() ложится в слот ReportConfidence (#987): {level, rationale, factors}.
res = compute_report_confidence(deal_count=7, analog_count=1, advisory=True)
d = res.as_dict()
assert set(d.keys()) == {"level", "rationale", "factors"}
assert d["level"] == "low"
assert isinstance(d["rationale"], str)
assert isinstance(d["factors"], dict)
# Факторы по именам + флаг advisory_capped внутри.
assert "deal_count" in d["factors"]
assert d["factors"]["deal_count"]["value"] == 7
assert "advisory_capped" in d["factors"]
# Полностью JSON-сериализуем (контракт для экспортёров/чата).
assert json.loads(json.dumps(d, ensure_ascii=False)) == d
def test_deal_count_note_carries_window_months(self) -> None:
# #1637: deal_count_months → нота «за N мес» в факторе (и в rationale).
res = compute_report_confidence(
deal_count=7,
deal_count_months=6,
advisory=False,
)
dc_factor = next(f for f in res.factors if f.name == "deal_count")
assert "за 6 мес" in dc_factor.note
assert "7 сделок" in dc_factor.note
# Структурная причина тоже содержит период (через ноту фактора-виновника).
assert "за 6 мес" in res.rationale
def test_deal_count_note_without_window_has_no_period(self) -> None:
# deal_count_months=None (по умолчанию) → нота без «за N мес» (backward compat).
res = compute_report_confidence(deal_count=7, advisory=False)
dc_factor = next(f for f in res.factors if f.name == "deal_count")
assert "7 сделок" in dc_factor.note
assert "за" not in dc_factor.note
def test_ignores_garbage_component_confidence(self) -> None:
# Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
res = compute_report_confidence(
component_confidences=["high", "bogus"], # type: ignore[list-item]
deal_count=200,
analog_count=10,
domrf_coverage=0.9,
history_months=48,
advisory=False,
)
assert res.level == "high"
def test_as_dict_keeps_all_component_factors(self) -> None:
# Несколько component-факторов не теряются в плоском dict (суффиксуются).
res = compute_report_confidence(
component_confidences=["high", "medium", "low"], advisory=False
)
d = res.as_dict()
component_keys = [k for k in d["factors"] if k.startswith("component")]
assert len(component_keys) == 3 # ни один не перезаписан
assert json.loads(json.dumps(d, ensure_ascii=False)) == d