From afa7d80ecf204751b9cb99ca6abc7f1b6c4af3fe Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:38:00 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(forecasting):=20deal=5Fcount=20confiden?= =?UTF-8?q?ce=20note=20carries=20=C2=AB=D0=B7=D0=B0=20N=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=C2=BB=20window=20(#1637)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../services/forecasting/confidence_engine.py | 5 +++++ .../forecasting/test_confidence_engine.py | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 16fd9399..64192458 100644 --- a/backend/app/services/forecasting/confidence_engine.py +++ b/backend/app/services/forecasting/confidence_engine.py @@ -375,6 +375,7 @@ def compute_report_confidence( *, component_confidences: list[Confidence] | None = None, deal_count: int | None = None, + deal_count_months: int | None = None, analog_count: int | None = None, domrf_coverage: float | None = None, history_months: int | None = None, @@ -403,6 +404,8 @@ def compute_report_confidence( component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→ нет вкладывающих компонентов. deal_count: число сделок за окно (None → нет данных, тянет в low). + deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес» + в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода. analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count). domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта). history_months: глубина ряда (мес). @@ -417,6 +420,7 @@ def compute_report_confidence( # ── 1. Сырые счётчики качества данных → факторы (только заданные) ────────── if deal_count is not None: + _deal_suffix = f"за {deal_count_months} мес" if deal_count_months is not None else "" factors.append( _factor_from_count( _F_DEAL_COUNT, @@ -424,6 +428,7 @@ def compute_report_confidence( high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок", + suffix=_deal_suffix, ) ) if analog_count is not None: diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py index f14c5991..df6718b5 100644 --- a/backend/tests/services/forecasting/test_confidence_engine.py +++ b/backend/tests/services/forecasting/test_confidence_engine.py @@ -342,6 +342,26 @@ class TestComputeReportConfidence: # Полностью 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( From ac47cd73a6f21a419bc62082cc20353c2e90bf1e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:57:05 +0300 Subject: [PATCH 2/2] fix(forecasting): wire deal_count_months from market window into confidence note (#1637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_build_confidence` was calling `compute_report_confidence` without `deal_count_months`, so the «за N мес» suffix never appeared in production. Add `_deal_count_months(market_metrics)` extractor (reads `window_months`, same key as `_history_months`) and pass it. Also fix pre-existing UP038 violations (isinstance tuple → X | Y). --- .../services/forecasting/report_assembler.py | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index 9dd27051..44ed966e 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -189,10 +189,10 @@ def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | Non """ if supply_layers is not None: coverage = supply_layers.get("domrf_coverage") - if isinstance(coverage, (int, float)) and not isinstance(coverage, bool): + if isinstance(coverage, int | float) and not isinstance(coverage, bool): return _clamp_fraction(float(coverage)) pct = analyze.get("market_data_coverage_pct") - if isinstance(pct, (int, float)) and not isinstance(pct, bool): + if isinstance(pct, int | float) and not isinstance(pct, bool): return _clamp_fraction(float(pct) / 100.0) return None @@ -223,6 +223,19 @@ def _history_months( return None +def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None: + """Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE. + + Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months` — + именно за это окно считается n_sold. Нет → None (#990 пропускает суффикс «за N мес»). + """ + if market_metrics is not None: + window = market_metrics.get("window_months") + if isinstance(window, int) and window > 0: + return window + return None + + def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool: """Пересекает ли окно прогноза шок-период — для confounded #990. PURE. @@ -293,10 +306,10 @@ def _primary_deficit_index(forecasts: Sequence[dict[str, Any]]) -> float | None: ) if primary is not None and primary.get("deficit_index") is not None: di = primary["deficit_index"] - return float(di) if isinstance(di, (int, float)) and not isinstance(di, bool) else None + return float(di) if isinstance(di, int | float) and not isinstance(di, bool) else None for f in forecasts: di = f.get("deficit_index") - if isinstance(di, (int, float)) and not isinstance(di, bool): + if isinstance(di, int | float) and not isinstance(di, bool): return float(di) return None @@ -314,10 +327,10 @@ def _primary_months_of_inventory(forecasts: Sequence[dict[str, Any]]) -> float | ) if primary is not None and primary.get("months_of_inventory") is not None: moi = primary["months_of_inventory"] - return float(moi) if isinstance(moi, (int, float)) and not isinstance(moi, bool) else None + return float(moi) if isinstance(moi, int | float) and not isinstance(moi, bool) else None for f in forecasts: moi = f.get("months_of_inventory") - if isinstance(moi, (int, float)) and not isinstance(moi, bool): + if isinstance(moi, int | float) and not isinstance(moi, bool): return float(moi) return None @@ -342,7 +355,7 @@ def _overall_score(product_scores: dict[str, Any] | None) -> float | None: if not isinstance(product_scores, dict): return None overall = product_scores.get("overall") - if isinstance(overall, (int, float)) and not isinstance(overall, bool): + if isinstance(overall, int | float) and not isinstance(overall, bool): return float(overall) return None @@ -388,10 +401,10 @@ def _market_now_summary( parts: list[str] = [] if market_metrics is not None: velocity = market_metrics.get("unit_velocity") - if isinstance(velocity, (int, float)) and not isinstance(velocity, bool): + if isinstance(velocity, int | float) and not isinstance(velocity, bool): parts.append(f"абсорбция ~{round(float(velocity), 1)} ед./мес") avg_price = analyze.get("market_avg_price_per_m2") - if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool): + if isinstance(avg_price, int | float) and not isinstance(avg_price, bool): parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " ")) # #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во # всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом». @@ -618,6 +631,7 @@ def _build_confidence( market_metrics, future_supply, forecasts, product_scores, special_indices ), deal_count=_deal_count(analyze, market_metrics), + deal_count_months=_deal_count_months(market_metrics), analog_count=_analog_count(analyze, market_metrics), domrf_coverage=_domrf_coverage(analyze, supply_layers), history_months=_history_months(market_metrics, forecasts),