From f600663aa00a32e298f6f7e123b2e0dba49bdd7f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 21:21:38 +0000 Subject: [PATCH] =?UTF-8?q?docs(ptica):=20=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D1=83=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=82=D1=83=20=D0=B2=D0=B5=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B8=D0=BD=D1=83=20(#2464-H)=20(#2878)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/forecasting/confidence_engine.py | 55 ++++++++++++------- .../services/forecasting/report_assembler.py | 24 +++++--- .../forecasting/test_confidence_engine.py | 28 +++++++++- 3 files changed, 77 insertions(+), 30 deletions(-) diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 4ef5b203..3e4aaec3 100644 --- a/backend/app/services/forecasting/confidence_engine.py +++ b/backend/app/services/forecasting/confidence_engine.py @@ -3,10 +3,10 @@ #990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/ #985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов, -покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень +покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог / -покрытие domrf↔objective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987. +цена известна у 12% ближних ЖК»). Наполняет слот `ReportConfidence` отчёта #987. ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x — движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только @@ -28,8 +28,10 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, мало сделок → скоростные метрики статистически ненадёжны. • analog_count (ЖК-аналоги, = market_metrics.obj_count) — high≥3 / medium≥2 / 1 → low (точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример). - • domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics - docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён. + • domrf_coverage — имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой + из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга + domrf↔objective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08: + медиана 40%, среднее 31.7%. Низкое покрытие → рынок и конкуренция оценены хуже. • history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий ряд → связь rate↔sales / тренды не установлены. • confounded — шок-окно (is_confounded_window, PR2): ряд пересекает структурный @@ -91,9 +93,11 @@ _DEAL_COUNT_LOW: int = 15 _ANALOG_COUNT_HIGH: int = 3 _ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low -# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%). -# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог -# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства). +# domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1] (имя ключа историческое, +# см. _coverage_factor). high — покрытие плотное; low — рынок оценён по меньшинству ЖК. +# medium-порог созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6. +# NB: пороги подбирались под ожидавшиеся ~2.5% покрытия маппинга, а реальная величина +# другого порядка (медиана 40%) — их стоит пересмотреть отдельно, замером, а не на глаз. _DOMRF_COVERAGE_HIGH: float = 0.6 _DOMRF_COVERAGE_LOW: float = 0.2 @@ -252,23 +256,36 @@ _QUALITY_WORD: dict[Confidence, str] = { def _coverage_factor(coverage: float | None) -> ConfidenceFactor: - """domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE. + """Покрытие рынка ценами Objective ∈ [0,1] → ConfidenceFactor с % в ноте. PURE. - Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ - (структурный §15-пример «покрытие domrf↔objective 2.5%»). None → low. + #2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие + маппинга domrf↔objective, но такого продьюсера НЕТ и не было: слот + `supply_layers.domrf_coverage` никто не заполняет (см. явную оговорку в + `orchestrator._summarize_supply_layers`), и значение ВСЕГДА приходит из + `analyze.market_data_coverage_pct` = `competitors_priced / competitors_total`, + то есть доля БЛИЖНИХ ЖК (3 км), у которых есть цена из Objective. + + Замер на проде 13.08: 2074 анализа, min 0% · медиана 40% · среднее 31.7% · + max 70%. Это не «~2.5% покрытия domrf↔objective», как было написано здесь + раньше, — другая величина другого порядка. + + Ключ фактора НЕ переименован намеренно: его читает фронт + (`ForecastConfidenceBlock`, `ConfidencePanel`) как стабильный контракт. + Порог и значение не меняются — правится только то, что читает человек. + None → low. """ level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW) if coverage is None: note = ( - "Доля будущих проектов с известными планировками и площадями неизвестна — " - "оценка будущего предложения и конкуренции менее надёжна" + "Доля ближних ЖК с известной ценой из Objective неизвестна — " + "оценка рынка и конкуренции менее надёжна" ) else: pct = round(float(coverage) * 100.0, 1) note = ( - f"Известные планировки и площади есть у {pct}% будущих проектов " - f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза " - "будущего предложения и конкуренции" + f"Цена из Objective известна у {pct}% ближних ЖК " + f"({_QUALITY_WORD[level]}) — от этого зависит точность оценки " + "рынка и конкуренции" ) return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note) @@ -294,9 +311,7 @@ def _history_factor(history_months: int | None) -> ConfidenceFactor: "ряде тренды и чувствительность спроса к ставке оцениваются хуже " "(поэтому в 6.2 может остаться один сценарий вместо трёх)" ) - return ConfidenceFactor( - name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note - ) + return ConfidenceFactor(name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note) def _confounded_factor(confounded: bool) -> ConfidenceFactor: @@ -479,7 +494,9 @@ def compute_report_confidence( deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес» в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода. analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count). - domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта). + domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1]. Имя ключа + историческое — про маппинг domrf↔objective, продьюсера для которого + нет и не было (#2464-H, см. _coverage_factor). history_months: глубина ряда (мес). confounded: True, если окно ряда пересекает шок-период (PR2). advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда). diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index 33c85dd5..9a5d6817 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -203,15 +203,23 @@ def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None: - """Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE. + """Покрытие рынка ценами Objective ∈ [0,1] — для фактора domrf_coverage. PURE. - Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ - per-branch — НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8% - спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе, - который §15 призван флагать): - • `supply_layers.domrf_coverage` — уже ДОЛЯ ∈ [0,1] (0.025) → берём как есть. - • `analyze.market_data_coverage_pct` — всегда ПРОЦЕНТ (2.5 == 2.5%) → /100 → доля. - Нет сигнала → None (#990 → тянет в low: слой §9.3 недооценён). + Источники по приоритету (единица ЯВНАЯ per-branch — НЕ угадываем по величине, + иначе настоящий sub-1% процент типа 0.8% спутался бы с долей 0.8 = 80%): + • `supply_layers.domrf_coverage` — ДОЛЯ ∈ [0,1] → берём как есть. + • `analyze.market_data_coverage_pct` — ПРОЦЕНТ (40 == 40%) → /100 → доля. + Нет сигнала → None. + + #2464-H, важно для читающего: **первая ветка не исполнялась ни разу**. Слот + `supply_layers.domrf_coverage` никто не заполняет — `_summarize_supply_layers` + в orchestrator это прямо оговаривает («domrf_coverage здесь НЕ выводим — нет + дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и + величина у неё другая: не «покрытие маппинга domrf↔objective ~2.5%», как + было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective — + замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%. + + Порядок веток оставлен: если продьюсер появится, приоритет у него. """ if supply_layers is not None: coverage = supply_layers.get("domrf_coverage") diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py index 49c48a8d..b946e28f 100644 --- a/backend/tests/services/forecasting/test_confidence_engine.py +++ b/backend/tests/services/forecasting/test_confidence_engine.py @@ -133,19 +133,33 @@ class TestFactorFromCount: assert "12.5 мес истории" in f_frac.note -# ── _coverage_factor — покрытие domrf↔objective в % ──────────────────────────── +# ── _coverage_factor — покрытие рынка ценами Objective в % ───────────────────── class TestCoverageFactor: def test_low_coverage_percent_in_note(self) -> None: - # Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15). + # 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_note_names_what_is_actually_measured(self) -> None: + """#2464-H: нота называет ближние ЖК и цену, а не «будущие проекты». + + Значение фактора ВСЕГДА приходит из `analyze.market_data_coverage_pct` + = competitors_priced / competitors_total, то есть доля ближних ЖК (3 км) + с ценой из Objective. Слот `supply_layers.domrf_coverage`, под который + писалась старая формулировка, никто не заполняет. + """ + f = _coverage_factor(0.4) + assert "ближних ЖК" in f.note, f.note + assert "Objective" in f.note, f.note + assert ( + "будущ" not in f.note + ), "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами" def test_high_coverage(self) -> None: f = _coverage_factor(0.75) @@ -158,6 +172,14 @@ class TestCoverageFactor: assert "неизвестн" in f.note assert "domrf" not in f.note + def test_factor_key_unchanged(self) -> None: + """Ключ фактора остаётся `domrf_coverage` — его читает фронт. + + Контроль к правке #2464-H: меняем только человеческий текст, не контракт + (ForecastConfidenceBlock / ConfidencePanel маппят имя в RU-подпись). + """ + assert _coverage_factor(0.4).name == "domrf_coverage" + 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% покрытия) —