gendesign/backend/tests/services/forecasting/test_confidence_engine.py
bot-backend afa7d80ecf
Some checks failed
CI / changes (push) Waiting to run
CI / backend-tests (push) Blocked by required conditions
CI / frontend-tests (push) Blocked by required conditions
CI / openapi-codegen-check (push) Blocked by required conditions
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Failing after 8m54s
fix(forecasting): deal_count confidence note carries «за N мес» window (#1637)
Add `deal_count_months: int | None = None` to `compute_report_confidence`.
When provided, threads it as suffix into `_factor_from_count` so the
deal_count ConfidenceFactor note reads «7 сделок за 6 мес — мало» instead
of the windowless «7 сделок — мало». Existing callers unaffected (default None).

Tests: two new cases in TestComputeReportConfidence — with/without window.
2026-06-17 20:38:00 +03:00

385 lines
17 KiB
Python
Raw Permalink 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,
_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
assert "domrf↔objective" 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
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
# ── _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