diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 69ed8d7d..09bd58e7 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -5,14 +5,13 @@ from logging.config import fileConfig from sqlalchemy import engine_from_config, pool +# Import the models package so every ORM model registers on Base.metadata. +# Add new model modules in app/models/__init__.py as they appear. +import app.models # noqa: F401 from alembic import context from app.core.config import settings from app.core.db import Base -# Import models so they register on Base.metadata. -# Add new model modules here as they appear. -from app.models import job_settings, parcel # noqa: F401 - config = context.config # Inject runtime DB URL. diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e69de29b..ce2a7c55 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -0,0 +1,15 @@ +"""ORM model registry. + +Importing this package loads every SQLAlchemy model module so the classes +register on `Base.metadata`. Alembic (alembic/env.py) imports `app.models` +to populate `target_metadata` for autogenerate / drift detection. + +Add new ORM model modules to the imports below as they appear. Tables managed +by raw SQL (data/sql/*.sql, e.g. cad_parcels / cad_parcels_geom) have no ORM +model by design and intentionally stay out of Base.metadata. +""" + +from app.core.db import Base +from app.models import job_settings, parcel # noqa: F401 + +__all__ = ["Base"] diff --git a/backend/app/services/exporters/report_docx.py b/backend/app/services/exporters/report_docx.py index d7e95ac8..96663941 100644 --- a/backend/app/services/exporters/report_docx.py +++ b/backend/app/services/exporters/report_docx.py @@ -10,7 +10,7 @@ DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из `report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря, `_fmt` — строковое форматирование ячейки/None→"—", `_level_ru`, `_as_dict`/`_as_list`, -`_future_supply_pairs`, `_scenario_deficit_index`) + переиспользуем его named-константы +`_future_supply_pairs`, `_scenario_deficit_cell`) + переиспользуем его named-константы (`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные микро-билдеры (заголовки/абзацы/таблицы python-docx). @@ -44,7 +44,7 @@ from app.services.exporters.report_pdf import ( _future_supply_pairs, _level_ru, _normalize, - _scenario_deficit_index, + _scenario_deficit_cell, ) if TYPE_CHECKING: @@ -76,7 +76,8 @@ _TABLE_STYLE: str = "Table Grid" _COMPETITORS_TOP_N: int = 5 # Основной продуктовый горизонт (мес) — подпись столбца сводного дефицита сценария -# (зеркало report_md._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index). +# (зеркало report_md._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell — +# при fallback на чужой горизонт ячейка несёт «(гор. N мес)», #1590). _PRIMARY_HORIZON_MONTHS: int = 12 @@ -272,7 +273,7 @@ def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None: rate_str = ( ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None ) - rows.append([name, _scenario_deficit_index(data), rate_str, data.get("advisory")]) + rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")]) headers = [ "Сценарий", diff --git a/backend/app/services/exporters/report_md.py b/backend/app/services/exporters/report_md.py index 3adeba6f..1c49a0b1 100644 --- a/backend/app/services/exporters/report_md.py +++ b/backend/app/services/exporters/report_md.py @@ -39,6 +39,7 @@ from app.services.exporters.report_pdf import ( _future_supply_pairs, _level_ru, _normalize, + _scenario_deficit_horizon, _scenario_deficit_index, ) @@ -109,10 +110,7 @@ def _md_table(headers: list[str], rows: list[list[Any]]) -> str: if not rows: empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |" return "\n".join([head, sep, empty]) - body = [ - "| " + " | ".join(_md_cell(cell) for cell in row) + " |" - for row in rows - ] + body = ["| " + " | ".join(_md_cell(cell) for cell in row) + " |" for row in rows] return "\n".join([head, sep, *body]) @@ -250,6 +248,22 @@ def _build_future_market(report: dict[str, Any]) -> str: ) +def _scenario_deficit_cell(payload: dict[str, Any]) -> Any: + """Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта. + + PURE. Зеркало report_pdf._scenario_deficit_cell: при fallback `_scenario_deficit_index` + на чужой горизонт (нет 12-мес прогноза) помечаем число реальным горизонтом, чтобы под + общей шапкой «(12 мес)» оно не читалось как 12-месячное. Дефицит None → как есть (→ "—"). + """ + deficit = _scenario_deficit_index(payload) + if deficit is None: + return deficit + horizon = _scenario_deficit_horizon(payload) + if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS: + return f"{_fmt(deficit)} (гор. {horizon} мес)" + return deficit + + def _build_scenarios(report: dict[str, Any]) -> str: """§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path.""" scenarios = _as_dict(report.get("scenarios")) @@ -259,12 +273,8 @@ def _build_scenarios(report: dict[str, Any]) -> str: for name, payload in by_scenario.items(): data = _as_dict(payload) rate_path = _as_dict(data.get("rate_path")) - rate_str = ( - ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None - ) - rows.append( - [name, _scenario_deficit_index(data), rate_str, data.get("advisory")] - ) + rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None + rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")]) headers = [ "Сценарий", @@ -273,9 +283,7 @@ def _build_scenarios(report: dict[str, Any]) -> str: "Advisory", ] return ( - f"## {_TITLE_SCENARIOS}\n\n" - f"{_fmt(scenarios.get('summary'))}\n\n" - f"{_md_table(headers, rows)}" + f"## {_TITLE_SCENARIOS}\n\n{_fmt(scenarios.get('summary'))}\n\n{_md_table(headers, rows)}" ) @@ -297,9 +305,7 @@ def _build_product_tz(report: dict[str, Any]) -> str: if isinstance(m, dict) ] usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)] - reason_rows = [ - [r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict) - ] + reason_rows = [[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)] return ( f"## {_TITLE_PRODUCT_TZ}\n\n" @@ -451,7 +457,9 @@ def _tg_scenario_deficit_line(report: dict[str, Any]) -> str | None: parts: list[str] = [] for name in ordered: label = _TG_SCENARIO_RU.get(name, str(name)) - deficit = _scenario_deficit_index(_as_dict(by_scenario.get(name))) + # _scenario_deficit_cell сам помечает «(гор. N мес)» при fallback на чужой горизонт, + # чтобы метка периода в чате не лгала (см. _build_scenarios). + deficit = _scenario_deficit_cell(_as_dict(by_scenario.get(name))) parts.append(f"{label} {_fmt(deficit)}") return " · ".join(parts) if parts else None @@ -511,8 +519,20 @@ def render_report_telegram_summary(report: Any) -> str: # Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии). scenario_line = _tg_scenario_deficit_line(data) if scenario_line is not None: - horizons = _as_list(meta.get("horizons")) - horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if horizons else "" + # Метку «(гор. 12 мес)» ставим, только если ВСЕ выведенные дефициты реально взяты + # с основного 12-мес горизонта; при fallback на чужой горизонт у сценария метка + # «(гор. N мес)» уже стоит в самой строке (см. _scenario_deficit_cell), а общая + # метка молчит, чтобы не лгать про период (баг #1590). + by_scenario = _as_dict(_as_dict(data.get("scenarios")).get("by_scenario")) + with_deficit = [ + _as_dict(payload) + for payload in by_scenario.values() + if _scenario_deficit_index(_as_dict(payload)) is not None + ] + all_primary = bool(with_deficit) and all( + _scenario_deficit_horizon(p) == _PRIMARY_HORIZON_MONTHS for p in with_deficit + ) + horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if all_primary else "" lines.append(f"• Дефицит по сценариям{horizon_note}: {scenario_line}") # Рекомендованный класс продукта (если задан). diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py index aa6a3370..3a9faefd 100644 --- a/backend/app/services/exporters/report_pdf.py +++ b/backend/app/services/exporters/report_pdf.py @@ -479,12 +479,14 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any: return None -def _scenario_deficit_horizon(payload: dict[str, Any]) -> Any: - """Фактический горизонт (мес), из которого взят `_scenario_deficit_index`. PURE. +def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None: + """Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE. - Зеркалит выбор `_scenario_deficit_index`: основной горизонт, иначе первый с не-None - дефицитом. Нужен, чтобы НЕ врать подписью «(12 мес)» при fallback на чужой горизонт - (#1590). Нет дефицита → None. + Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес → первый с не-None + дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала + при fallback на чужой горизонт. Нет дефицита → None. NB: сигнатура + `_scenario_deficit_index` (импортируется в report_md/docx/pptx) НЕ меняется — это + отдельная чистая функция-зеркало. """ forecasts = _as_list(payload.get("forecasts")) primary = next( @@ -499,10 +501,27 @@ def _scenario_deficit_horizon(payload: dict[str, Any]) -> Any: return _PRIMARY_HORIZON_MONTHS for f in forecasts: if isinstance(f, dict) and f.get("deficit_index") is not None: - return f.get("horizon_months") + h = f.get("horizon_months") + return h if isinstance(h, int) else None return None +def _scenario_deficit_cell(payload: dict[str, Any]) -> Any: + """Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта. + + PURE. При отсутствии 12-мес прогноза `_scenario_deficit_index` делает fallback на чужой + горизонт — помечаем ячейку реальным горизонтом, чтобы число под общей шапкой + «(12 мес)» не читалось как 12-месячное. Дефицит None → отдаём как есть (→ "—"). + """ + deficit = _scenario_deficit_index(payload) + if deficit is None: + return deficit + horizon = _scenario_deficit_horizon(payload) + if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS: + return f"{_fmt(deficit)} (гор. {horizon} мес)" + return deficit + + def _build_scenarios(report: dict[str, Any]) -> str: """Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful.""" scenarios = _as_dict(report.get("scenarios")) @@ -511,19 +530,9 @@ def _build_scenarios(report: dict[str, Any]) -> str: rows: list[list[Any]] = [] for name, payload in by_scenario.items(): data = _as_dict(payload) - deficit = _scenario_deficit_index(data) - horizon = _scenario_deficit_horizon(data) - # Подпись столбца жёстко «(12 мес)» — если значение от другого горизонта - # (fallback), помечаем ячейку фактическим горизонтом, чтобы не врать (#1590). - if deficit is not None and horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS: - deficit = f"{_fmt(deficit)} (гор. {horizon} мес)" - rows.append([name, deficit, data.get("advisory")]) + rows.append([name, _scenario_deficit_cell(data), data.get("advisory")]) - headers = [ - "Сценарий", - f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", - "Advisory", - ] + headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"] return f"""

{html.escape(_TITLE_SCENARIOS)}

diff --git a/backend/app/services/exporters/report_pptx.py b/backend/app/services/exporters/report_pptx.py index bffd34c0..2390ffde 100644 --- a/backend/app/services/exporters/report_pptx.py +++ b/backend/app/services/exporters/report_pptx.py @@ -12,7 +12,7 @@ DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из `report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря, `_fmt` — строковое форматирование значения/None→"—", `_level_ru`, `_as_dict`/`_as_list`, -`_scenario_deficit_index`) + переиспользуем его named-константы +`_scenario_deficit_cell`) + переиспользуем его named-константы (`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные микро-билдеры (слайды/буллеты/таблица python-pptx). @@ -46,7 +46,7 @@ from app.services.exporters.report_pdf import ( _fmt, _level_ru, _normalize, - _scenario_deficit_index, + _scenario_deficit_cell, ) if TYPE_CHECKING: @@ -78,7 +78,8 @@ _LAYOUT_TITLE_CONTENT: int = 1 _LAYOUT_TITLE_ONLY: int = 5 # Основной продуктовый горизонт (мес) — подпись сводного дефицита сценария -# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index). +# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell — +# при fallback на чужой горизонт буллет несёт «(гор. N мес)», #1590). _PRIMARY_HORIZON_MONTHS: int = 12 # Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно). @@ -235,7 +236,7 @@ def _build_scenarios(prs: _PptxPresentation, report: dict[str, Any]) -> None: rate_str = ( ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH ) - deficit = _fmt(_scenario_deficit_index(data)) + deficit = _fmt(_scenario_deficit_cell(data)) bullets.append( (str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}") ) diff --git a/backend/app/services/forecasting/product_scoring.py b/backend/app/services/forecasting/product_scoring.py index 15ab89ea..aa19d5d5 100644 --- a/backend/app/services/forecasting/product_scoring.py +++ b/backend/app/services/forecasting/product_scoring.py @@ -174,9 +174,10 @@ _MORTG_MAX_X_PCT: float = 25.0 # дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш). _DIFF_TARGET_USP: float = 3.0 -# commercial: commercial_share_pct ∈ [0,100] (§10.4 реализованная доля нежилого) → /100. -# Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): overlay.commercial.available=False -# → скор None (НЕ 0). available=True → share/100 как прокси силы спроса на коммерцию. +# commercial: commercial_sell_through_pct ∈ [0,100] (§10.4 темп распродажи нежилого, +# sell-through, прокси спроса) → /100. Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): +# overlay.commercial.available=False → скор None (НЕ 0). available=True → sell_through/100 +# как прокси силы спроса на коммерцию. # confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] → # усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 — @@ -463,21 +464,24 @@ def _score_differentiation( def _score_commercial( commercial: dict[str, Any] | None, ) -> tuple[float | None, Confidence, str]: - """commercial ← recommendation §10.4 сигнал → share_pct/100. ДЕГРАДИРУЕТ в None. PURE. + """commercial ← recommendation §10.4 сигнал → sell_through_pct/100. ДЕГРАДИРУЕТ в None. PURE. - overlay.commercial.available=True → commercial_share_pct/100 (прокси спроса на - нежилое). available=False / None (objective ~ жильё, тонко) → None (НЕ 0-как-заглушка). - confidence наследуется из сигнала (или 'low'). + overlay.commercial.available=True → commercial_sell_through_pct/100 (темп распродажи + нежилого, sell-through, прокси спроса). available=False / None (objective ~ жильё, тонко) + → None (НЕ 0-как-заглушка). confidence наследуется из сигнала (или 'low'). """ if not commercial or not commercial.get("available"): return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)." - share_pct = commercial.get("commercial_share_pct") - if share_pct is None: - return None, "low", "Коммерческий сигнал §10.4 недоступен (доля не измерена)." + sell_through_pct = commercial.get("commercial_sell_through_pct") + if sell_through_pct is None: + return None, "low", "Коммерческий сигнал §10.4 недоступен (темп распродажи не измерен)." raw_conf = commercial.get("confidence", "low") conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low" - value = _clamp01(float(share_pct) / 100.0) - reason = f"Реализованная доля коммерции ~{round(float(share_pct), 1)}% (§10.4, прокси спроса)." + value = _clamp01(float(sell_through_pct) / 100.0) + reason = ( + f"Темп распродажи нежилого ~{round(float(sell_through_pct), 1)}% (§10.4, прокси " + "ликвидности/спроса на нежилое; НЕ доля нежилого в объёме застройки)." + ) return value, conf, reason diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index a1d56c55..07e592ff 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -28,9 +28,17 @@ LIVE-ENDPOINT-SAFE: НИКОГДА не бросает — на любом то • mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты НЕизмеримы → считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace ∈ - (0,1] — это ПРОКСИ относительной силы спроса, НЕ supply-based дефицит; - `balance_units=None`, confidence='low', обязательный warning что supply НЕ учтён. - НИКОГДА не фабрикуем предложение/конкурентов из воздуха. + (0,1]; `balance_units=None`, confidence='low', обязательный warning что supply + НЕ учтён. НИКОГДА не фабрикуем предложение/конкурентов из воздуха. + ВАЖНО (#1593): сегодня §9.2 unit_velocity (base_pace) и §9.4 norm.coefficient + ОДИНАКОВЫ для всех 5 room-bucket'ов (market_metrics.compute_market_metrics не + принимает room_bucket, а §9.4-β фитится district×obj_class без room_bucket), + поэтому оба постоянных множителя СОКРАЩАЮТСЯ в pace/max_pace и порядок форматов + определяется ИСКЛЮЧИТЕЛЬНО §9.5 macro_coef = segment_steepness(room_bucket). + Т.е. фактический deficit_index в demand_only = ПРОКСИ rate-крутизны нарезки, а + НЕ относительной силы спроса. Честный per-bucket §9.2/§9.4-сигнал требует + room_bucket-параметра в market_metrics + §9.4/§9.6 (другие файлы + доменное + решение, как мерить per-bucket спрос) — см. #1593. ИМПОРТЫ §9.x — ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет `forecasting/__init__` тянет `affordability`, который импортит @@ -565,9 +573,17 @@ def _demand_only_overlay( БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace ∈ - (0,1] — ПРОКСИ относительной силы спроса (НЕ supply-based дефицит). - balance_units=None, confidence='low', обязательный warning. НИКОГДА не + (0,1]. balance_units=None, confidence='low', обязательный warning. НИКОГДА не фабрикуем supply. Graceful → []. + + ВАЖНО (#1593, честность ранкинга): §9.2 base_pace и §9.4 norm.coefficient + сейчас ОДИНАКОВЫ для всех bucket'ов (нет room_bucket-фильтра в + market_metrics.compute_market_metrics; §9.4-β фитится district×obj_class без + room_bucket) и СОКРАЩАЮТСЯ в pace/max_pace. Поэтому фактический порядок + форматов и deficit_index определяются ИСКЛЮЧИТЕЛЬНО §9.5 macro_coef = + segment_steepness(room_bucket) — это ПРОКСИ rate-крутизны нарезки, НЕ силы + спроса. Полноценный per-bucket §9.2/§9.4-сигнал = другие файлы + доменное + решение (#1593); до тех пор не интерпретировать как «самый дефицитный спрос». """ # Локальные импорты — избегаем import-cycle (см. module docstring). from app.services.forecasting.demand_normalization import compute_demand_normalization @@ -640,7 +656,9 @@ def _demand_only_overlay( demand_only=True, ) - # DESC по темпу; deficit_index = pace/max_pace ∈ (0,1] (ПРОКСИ, НЕ supply-based). + # DESC по темпу; deficit_index = pace/max_pace ∈ (0,1]. NB #1593: base_pace и + # §9.4 norm одинаковы для всех bucket'ов и сокращаются → порядок = §9.5 + # segment_steepness (rate-крутизна), НЕ сила спроса. Per-bucket §9.2/§9.4 = TODO. paces.sort(key=lambda t: t[2], reverse=True) ranked_segments: list[dict[str, Any]] = [ { @@ -709,8 +727,9 @@ def _commercial_signal( "нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число. Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка ТЕМПА - РАСПРОДАЖИ нежилого стока (sell_through_pct = проданные ÷ (проданные+доступные)·100, - прокси ликвидности/спроса — НЕ доля нежилого в объёме застройки) + §16-подобный reason. + РАСПРОДАЖИ нежилого: sell_through_pct = проданные ÷ (проданные+доступные)·100 + (market_metrics §-определение) — прокси ликвидности/спроса на нежилое, НЕ доля + нежилого в объёме застройки + §16-подобный reason. НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал. Args: @@ -753,16 +772,16 @@ def _commercial_signal( ) return {"available": False, "caveat": caveat, "advisory": True} - # Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (sell_through_pct - # = проданные ÷ (проданные+доступные)·100 — ликвидность/спрос, НЕ доля застройки). - # NB: ключ commercial_share_pct мислейблит метрику; честное переименование требует - # согласованной правки product_scoring._score_commercial (другой файл) → не трогаем. + # Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (прокси + # ликвидности/спроса), НЕ доли нежилого в объёме застройки (#1635). sell_through_pct = round(sell_through, 1) confidence = confidence if confidence in ("high", "medium", "low") else "low" return { "available": True, "premise_kind": _COMMERCIAL_PREMISE_KIND, - "commercial_share_pct": sell_through_pct, + # #1635: ключ = ТЕМП РАСПРОДАЖИ нежилого (sell_through, прокси + # ликвидности/спроса), НЕ доля нежилого в объёме застройки. + "commercial_sell_through_pct": sell_through_pct, "n_lots": n_lots, "confidence": confidence, "reason": { @@ -790,14 +809,14 @@ def _as_int(value: Any) -> int | None: Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой нечисловой/bool/сбойный вход → None → degraded-honest путь, без падения. """ - if isinstance(value, bool) or not isinstance(value, (int, float)): + if isinstance(value, bool) or not isinstance(value, int | float): return None return int(value) def _as_float(value: Any) -> float | None: """Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE.""" - if isinstance(value, bool) or not isinstance(value, (int, float)): + if isinstance(value, bool) or not isinstance(value, int | float): return None return float(value) diff --git a/backend/app/services/llm/redaction.py b/backend/app/services/llm/redaction.py index 4fc6dc81..5ace6d7f 100644 --- a/backend/app/services/llm/redaction.py +++ b/backend/app/services/llm/redaction.py @@ -87,41 +87,51 @@ _PHONE_BARE_RE = re.compile(r"(? bool: - """Проверить контрольные цифры ИНН (официальный алгоритм ФНС). +def _inn_checksum_valid(token: str) -> bool: + """Проверить контрольные цифры ИНН по алгоритму ФНС. - 10-значный (юрлицо): одна контрольная цифра. 12-значный (физлицо/ИП): две. - Вес-коэффициенты фиксированы стандартом. Любая слитная группа 10/12 цифр, НЕ - проходящая checksum (напр. круглая сумма «1000000000»), считается НЕ-ИНН и не - редактируется — это и закрывает ложные срабатывания #1640. + 10-значный (юрлицо): одна контрольная цифра (последняя). + 12-значный (физлицо/ИП): две контрольные цифры (11-я и 12-я). + Веса — стандартные коэффициенты ФНС. - NB: checksum резко снижает false-positive rate, но не доводит его до нуля — - ~1/11 случайных 10-значных чисел совпадает с валидным ИНН по контрольной цифре. - Это приемлемо для вторичной (belt-and-suspenders) защиты. + Назначение — сузить вторичный regex-scrub так, чтобы случайные слитные + 10/12-значные числа (крупные суммы в рублях) НЕ принимались за ИНН (#1640). + + Ограничение (accepted limit belt-and-suspenders слоя): контроль-сумма НЕ + разрешает полностью неоднозначность «сумма vs ИНН» — примерно 1/11 произвольных + 10-значных чисел случайно проходит ИНН-10 контроль (в т.ч. буквальный пример + аудита 1200000000, который остаётся под редактированием). Полное устранение + false-positive на суммах (требовать рядом якорь «ИНН» либо вовсе убрать + INN-scrub из chat free-text) — бизнес-решение, оставлено на Leha. """ + digits = [int(c) for c in token] if len(digits) == 10: weights = (2, 4, 10, 3, 5, 9, 4, 6, 8) - control = sum(int(digits[i]) * weights[i] for i in range(9)) % 11 % 10 - return control == int(digits[9]) + control = sum(w * d for w, d in zip(weights, digits[:9], strict=True)) % 11 % 10 + return control == digits[9] if len(digits) == 12: w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8) w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) - c1 = sum(int(digits[i]) * w1[i] for i in range(10)) % 11 % 10 - c2 = sum(int(digits[i]) * w2[i] for i in range(11)) % 11 % 10 - return c1 == int(digits[10]) and c2 == int(digits[11]) + c1 = sum(w * d for w, d in zip(w1, digits[:10], strict=True)) % 11 % 10 + c2 = sum(w * d for w, d in zip(w2, digits[:11], strict=True)) % 11 % 10 + return c1 == digits[10] and c2 == digits[11] return False def _inn_repl(match: re.Match[str]) -> str: - """re.sub-callback: редактировать кандидат ТОЛЬКО если checksum валиден.""" + """re.sub callback: редактировать ИНН-кандидата только при валидной контроль-сумме. + + Иначе вернуть исходный токен без изменений — так слитные суммы в рублях + переживают scrub (#1640). + """ token = match.group(0) return "[REDACTED:inn]" if _inn_checksum_valid(token) else token @@ -191,7 +201,7 @@ def _scrub_value(value: Any) -> Any: return scrub_text(value) if isinstance(value, dict): return {k: _scrub_value(v) for k, v in value.items()} - if isinstance(value, (list, tuple)): + if isinstance(value, list | tuple): scrubbed = [_scrub_value(v) for v in value] return type(value)(scrubbed) return value diff --git a/backend/app/services/photos/thumbs.py b/backend/app/services/photos/thumbs.py index b7f69d4f..25820162 100644 --- a/backend/app/services/photos/thumbs.py +++ b/backend/app/services/photos/thumbs.py @@ -37,14 +37,24 @@ def make_thumbnail( ) -> Path | None: """Generate a WebP cover-thumbnail next to src. Returns thumb path on success. - Skips if thumb already exists and overwrite=False. Returns None on any error + Skips if thumb already exists, is not stale (dst mtime >= src mtime) and + overwrite=False. A stale thumb (src rewritten after thumb, e.g. original + re-downloaded on size mismatch) is regenerated. Returns None on any error (logged) so callers can keep going with the next file. """ if not src.exists(): return None dst = thumb_path_for(src) if dst.exists() and not overwrite: - return dst + # Existence alone is not freshness: if src was rewritten after dst was + # generated, the thumb is stale and must be regenerated. Only skip when + # the thumb is at least as new as the source. + try: + if dst.stat().st_mtime >= src.stat().st_mtime: + return dst + except OSError as e: + # stat failed (race / removed) — fall through and try to regenerate. + logger.warning("thumbnail freshness check %s failed: %s", src, e) try: with Image.open(src) as im: im = ImageOps.exif_transpose(im) diff --git a/backend/tests/services/forecasting/test_product_scoring.py b/backend/tests/services/forecasting/test_product_scoring.py index 4948d646..99e889c2 100644 --- a/backend/tests/services/forecasting/test_product_scoring.py +++ b/backend/tests/services/forecasting/test_product_scoring.py @@ -409,7 +409,7 @@ class TestScoreDifferentiation: class TestScoreCommercial: def test_available_share_scaled(self) -> None: - signal = {"available": True, "commercial_share_pct": 40.0, "confidence": "medium"} + signal = {"available": True, "commercial_sell_through_pct": 40.0, "confidence": "medium"} value, conf, _r = _score_commercial(signal) assert value == pytest.approx(0.4) assert conf == "medium" @@ -417,7 +417,7 @@ class TestScoreCommercial: def test_in_range(self) -> None: for share in (0.0, 25.0, 100.0): value, _c, _r = _score_commercial( - {"available": True, "commercial_share_pct": share, "confidence": "low"} + {"available": True, "commercial_sell_through_pct": share, "confidence": "low"} ) assert value is not None assert 0.0 <= value <= 1.0 @@ -439,7 +439,7 @@ class TestScoreCommercial: def test_bad_confidence_falls_back_low(self) -> None: value, conf, _r = _score_commercial( - {"available": True, "commercial_share_pct": 10.0, "confidence": "garbage"} + {"available": True, "commercial_sell_through_pct": 10.0, "confidence": "garbage"} ) assert value is not None assert conf == "low" @@ -728,7 +728,7 @@ def _patch_all( overlay: Any = None, ) -> Any: """Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом).""" - commercial = {"available": True, "commercial_share_pct": 30.0, "confidence": "medium"} + commercial = {"available": True, "commercial_sell_through_pct": 30.0, "confidence": "medium"} forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)] competitors_rv = ( competitors if competitors is not None else _competitors_response_stub([0.4, 0.2]) diff --git a/backend/tests/services/forecasting/test_recommendation.py b/backend/tests/services/forecasting/test_recommendation.py index bbde74f9..32686bef 100644 --- a/backend/tests/services/forecasting/test_recommendation.py +++ b/backend/tests/services/forecasting/test_recommendation.py @@ -744,7 +744,7 @@ class TestCommercialSignal: out = _commercial_signal(MagicMock(), "Ленинский", 12) assert out is not None assert out["available"] is True - assert out["commercial_share_pct"] == 42.5 + assert out["commercial_sell_through_pct"] == 42.5 assert out["n_lots"] == 120 assert out["confidence"] == "medium" assert out["reason"]["advisory"] is True diff --git a/frontend/src/app/%5F%5Fpreview/README.md b/frontend/src/app/%5F%5Fpreview/README.md new file mode 100644 index 00000000..40501b5e --- /dev/null +++ b/frontend/src/app/%5F%5Fpreview/README.md @@ -0,0 +1,68 @@ +# `/__preview/*` — mock-render маршруты для UI-аудита (issue #801) + +Dev/CI-only страницы, которые рендерят «тяжёлый» UI на **статичных fixture** +без единого сетевого запроса. Нужны потому, что live-фронт за auth-gate (Caddy +basic_auth), а dev без бэкенда отвечает `/api/v1/me` + `/estimate` 401 / +network-fail → реальные страницы рендерят пустой EmptyState / NoAccessScreen. +axe / lighthouse требуют **reachable rendered content** — его и даёт preview. + +## Маршруты + +| URL | Рендерит | Fixture | +|---|---|---| +| `/__preview/estimate` | `` — HeroSummary + price-range bar + таблицы аналогов/сделок («денежный экран» Trade-In) | `src/lib/mocks/trade-in-estimate.ts` | + +> Folder назван `%5F%5Fpreview` (URL-encoded `__preview`): App Router исключает +> из роутинга папки с leading `_`, поэтому underscore-сегмент задаётся `%5F`. + +## Гарды (preview не утекает в prod) + +1. **Страница** 404'ит (`notFound()`), если не выполнено + `NODE_ENV !== "production"` **или** `NEXT_PUBLIC_ENABLE_PREVIEW=true`. + В `next dev` доступна всегда; в собранном prod-build — только при явном флаге. +2. **RouteGuard** (`components/auth/RouteGuard.tsx`) пропускает `/__preview/*` + без вызова `/me`, иначе network-fail подменил бы content на ``. + +## Локальный прогон аудита + +```bash +# 1. Поднять preview без бэкенда (NODE_ENV=development → preview включён) +cd frontend && npm run dev # http://localhost:3000/__preview/estimate + +# 2a. axe (a11y) — через @axe-core/cli или @axe-core/playwright +npx @axe-core/cli http://localhost:3000/__preview/estimate + +# 2b. lighthouse (CWV: LCP / CLS / TBT) +npx lighthouse http://localhost:3000/__preview/estimate \ + --only-categories=performance,accessibility \ + --preset=desktop --output=json --output-path=./lh-estimate.json +``` + +Для собранного build вместо `dev`: +```bash +NEXT_PUBLIC_ENABLE_PREVIEW=true npm run build && \ +NEXT_PUBLIC_ENABLE_PREVIEW=true npm run start +``` + +## Baseline-метрики (заполнить после первого реального прогона) + +Зафиксировать здесь, чтобы ловить регрессии (DoD #801). Прочерки — TODO +followup-issue: реальный axe/lighthouse прогон. + +| Метрика | Baseline | Дата | Порог регрессии | +|---|---|---|---| +| axe violations (critical/serious) | _TODO_ | — | 0 critical | +| lighthouse a11y score | _TODO_ | — | ≥ 90 | +| LCP (desktop) | _TODO_ | — | ≤ 2.5 s | +| CLS | _TODO_ | — | ≤ 0.1 | + +> Известные кандидаты-дефекты для followup-issue (UI-аудит 2026-05-30, #559): +> контраст `text-xs` / `#9ca3af` на белом, ARIA на sortable `` таблиц +> (`AnalogsTable`), LCP/CLS от Hero + фото-ячеек. + +## CI (опционально — вне scope #801, отдельный followup) + +Деп `@axe-core/playwright` + `lighthouse` не добавлены в `package.json` (правило: +изменение deps требует regen lockfile, иначе deploy aborts). Интеграция в +`.forgejo/workflows/ci.yml` — отдельная задача для devops-engineer: поднять +`npm run dev`, прогнать команды выше, fail job при превышении порогов. diff --git a/frontend/src/app/%5F%5Fpreview/estimate/page.tsx b/frontend/src/app/%5F%5Fpreview/estimate/page.tsx new file mode 100644 index 00000000..be839e01 --- /dev/null +++ b/frontend/src/app/%5F%5Fpreview/estimate/page.tsx @@ -0,0 +1,73 @@ +/** + * `/__preview/estimate` — dev/CI-only mock-render «денежного экрана» (issue #801). + * + * Зачем: без бэкенда `/api/v1/me` + `POST /estimate` отвечают 401 / network-fail, + * `/trade-in` рендерит пустой EmptyState → axe / lighthouse нечего аудировать + * (нет reachable rendered content). Этот маршрут рендерит реальный + * `` (HeroSummary + price-range bar + таблицы аналогов/сделок) + * на статичном fixture БЕЗ единого сетевого запроса. + * + * Folder назван `%5F%5Fpreview` (URL-encoded `__preview`): App Router исключает + * из роутинга папки с leading `_` (private folders), поэтому литеральный + * underscore-сегмент задаётся через `%5F`. Итоговый URL — `/__preview/estimate`. + * + * Production guard: страница 404'ит (`notFound()`) если `NEXT_PUBLIC_ENABLE_PREVIEW` + * не выставлен в `"true"`. В dev (`next dev`) preview включён всегда. Так + * preview-bundle не утекает в публичный prod (live за auth-gate Caddy). + * RouteGuard дополнительно пропускает `/__preview/*` без вызова `/me` + * (см. `components/auth/RouteGuard.tsx`). + */ + +import { notFound } from "next/navigation"; + +import { EstimateResult } from "@/components/trade-in/EstimateResult"; +import { + MOCK_ESTIMATE, + MOCK_ESTIMATE_INPUT, +} from "@/lib/mocks/trade-in-estimate"; + +/** Preview доступен в dev всегда; в собранном build — только при явном флаге. */ +function previewEnabled(): boolean { + return ( + process.env.NODE_ENV !== "production" || + process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "true" + ); +} + +export default function PreviewEstimatePage() { + if (!previewEnabled()) { + notFound(); + } + + return ( +
+
+

+ Trade-In Estimator +

+

+ Превью результата оценки на статичных данных (без бэкенда) — для + a11y / lighthouse аудита. +

+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/auth/RouteGuard.tsx b/frontend/src/components/auth/RouteGuard.tsx index 66f8c743..0455faa9 100644 --- a/frontend/src/components/auth/RouteGuard.tsx +++ b/frontend/src/components/auth/RouteGuard.tsx @@ -35,6 +35,21 @@ export function RouteGuard({ children }: RouteGuardProps) { const pathname = usePathname() ?? "/"; const { data, isLoading, error } = useMe(); + // #801: dev/CI-only mock-render маршрут `/__preview/*` — намеренно БЕЗ + // auth-gate. Без этого bypass `useMe()` без бэкенда падает network-fail'ом + // → ветка `if (error)` ниже подменяет content на , и + // axe / lighthouse аудируют экран «доступа нет» вместо «денежного экрана». + // Сама preview-страница 404'ит вне dev/CI (NEXT_PUBLIC_ENABLE_PREVIEW), + // и сам bypass дополнительно gated тем же env-условием — чтобы будущий + // `/__preview/*` route не оказался без auth в prod (defense-in-depth). + if ( + pathname.startsWith("/__preview") && + (process.env.NODE_ENV !== "production" || + process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "true") + ) { + return <>{children}; + } + // Initial load — короткое окно, рендерим пустоту чтобы не моргать UI. if (isLoading) return null; diff --git a/frontend/src/lib/mocks/trade-in-estimate.ts b/frontend/src/lib/mocks/trade-in-estimate.ts new file mode 100644 index 00000000..a68d43b8 --- /dev/null +++ b/frontend/src/lib/mocks/trade-in-estimate.ts @@ -0,0 +1,154 @@ +/** + * Trade-In estimate fixture — статичные данные «денежного экрана» для + * mock-render маршрута `/__preview/estimate` (issue #801). + * + * Зачем: без бэкенда `/api/v1/me` + `/estimate` отвечают 401/network-fail и + * `/trade-in` рендерит пустой EmptyState — нечего скормить axe / lighthouse. + * Этот fixture даёт реалистичный AggregatedEstimate (HeroSummary + две таблицы + * аналогов/сделок + price-range bar) для прогона a11y + CWV без сети. + * + * Контракт строго по `@/types/trade-in` (TS strict, без any). Если backend + * Pydantic-схема изменится → `npm run codegen` + поправить типы → этот fixture + * упадёт на type-check, что и нужно (early signal о contract drift). + * + * НЕ импортировать в prod-bundle: используется только preview-страницей, + * которая сама 404'ит вне dev/CI (см. `app/%5F%5Fpreview/estimate/page.tsx`). + */ + +import type { + AggregatedEstimate, + AnalogLot, + TradeInEstimateInput, +} from "@/types/trade-in"; + +/** Параметры оцениваемой квартиры (левая колонка / блок «Параметры объекта»). */ +export const MOCK_ESTIMATE_INPUT: TradeInEstimateInput = { + address: "Екатеринбург, ул. Малышева, 4Б", + area_m2: 58.4, + rooms: 2, + floor: 7, + total_floors: 16, + year_built: 2018, + house_type: "monolith_brick", + repair_state: "good", + has_balcony: true, +}; + +/** Объявления-аналоги (top-аналоги в радиусе/выборке). */ +const MOCK_ANALOGS: AnalogLot[] = [ + { + address: "Екатеринбург, ул. Малышева, 8", + area_m2: 56.2, + rooms: 2, + floor: 9, + total_floors: 16, + price_rub: 9_650_000, + price_per_m2: 171_708, + listing_date: "2026-05-12", + days_on_market: 34, + photo_url: null, + }, + { + address: "Екатеринбург, ул. Радищева, 33", + area_m2: 60.1, + rooms: 2, + floor: 5, + total_floors: 12, + price_rub: 10_200_000, + price_per_m2: 169_717, + listing_date: "2026-04-28", + days_on_market: 48, + photo_url: null, + }, + { + address: "Екатеринбург, ул. 8 Марта, 21", + area_m2: 54.8, + rooms: 2, + floor: 11, + total_floors: 18, + price_rub: 9_280_000, + price_per_m2: 169_343, + listing_date: "2026-05-30", + days_on_market: 16, + photo_url: null, + }, + { + address: "Екатеринбург, ул. Хохрякова, 74", + area_m2: 61.5, + rooms: 2, + floor: 3, + total_floors: 10, + price_rub: 10_750_000, + price_per_m2: 174_797, + listing_date: "2026-03-19", + days_on_market: 88, + photo_url: null, + }, + { + address: "Екатеринбург, ул. Шейнкмана, 90", + area_m2: 57.0, + rooms: 2, + floor: 14, + total_floors: 25, + price_rub: 9_900_000, + price_per_m2: 173_684, + listing_date: "2026-05-02", + days_on_market: 41, + photo_url: null, + }, +]; + +/** Фактические сделки (Росреестр, последние 12 мес). */ +const MOCK_DEALS: AnalogLot[] = [ + { + address: "Екатеринбург, ул. Малышева, 12", + area_m2: 57.8, + rooms: 2, + floor: 8, + total_floors: 16, + price_rub: 9_400_000, + price_per_m2: 162_630, + listing_date: "2026-02-14", + days_on_market: null, + photo_url: null, + }, + { + address: "Екатеринбург, ул. Энгельса, 36", + area_m2: 59.3, + rooms: 2, + floor: 6, + total_floors: 14, + price_rub: 9_750_000, + price_per_m2: 164_418, + listing_date: "2025-12-03", + days_on_market: null, + photo_url: null, + }, + { + address: "Екатеринбург, ул. Куйбышева, 48", + area_m2: 55.6, + rooms: 2, + floor: 4, + total_floors: 9, + price_rub: 8_950_000, + price_per_m2: 160_971, + listing_date: "2025-11-21", + days_on_market: null, + photo_url: null, + }, +]; + +/** Готовый AggregatedEstimate — то, что вернул бы POST /estimate. */ +export const MOCK_ESTIMATE: AggregatedEstimate = { + estimate_id: "00000000-0000-4000-8000-000000000801", + median_price_rub: 9_780_000, + range_low_rub: 9_120_000, + range_high_rub: 10_540_000, + median_price_per_m2: 167_466, + confidence: "high", + n_analogs: MOCK_ANALOGS.length, + period_months: 24, + analogs: MOCK_ANALOGS, + actual_deals: MOCK_DEALS, + expires_at: "2099-12-31T23:59:59Z", +};