feat(forecasting): §10.2/10.4/10.5 + §16 reasons on forecast-overlay (#983, 953-B)
Additively extend #982 build_forecast_overlay: class recommendation (§10.2, strongest aggregate deficit per class), USP-from-deficits (§10.5, top-3), degraded-honest commercial signal (§10.4, premise_kind=нежилое → available:False +caveat when thin, never fabricates), and a §16 structural reason (why/drivers/rejected/what_would_change/confidence/advisory) on every ranked segment. §16 rejected-alternatives derived from the what_to_build ranking runner-ups. Deterministic, no LLM, advisory, no new SQL. All #982 keys/behavior preserved; schema extended additively (RecommendReason + optional fields). 31 new tests (86 total recommendation+velocity green).
This commit is contained in:
parent
72e9b24f2c
commit
bba165a5f2
3 changed files with 951 additions and 10 deletions
|
|
@ -62,13 +62,36 @@ class RecommendComparable(BaseModel):
|
||||||
buildings_n: int | None = None
|
buildings_n: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendReason(BaseModel):
|
||||||
|
"""§16 структурная причина одного советующего пункта overlay (#983, 953-B). ADVISORY.
|
||||||
|
|
||||||
|
Объясняет, ПОЧЕМУ пункт попал в совет, ЧТО на него повлияло и ЧТО его изменило бы:
|
||||||
|
• `why` — RU-предложение (сегмент, deficit_index на горизонте, спрос/предложение).
|
||||||
|
• `drivers` — драйверы сигнала ([{factor, value, direction}]).
|
||||||
|
• `rejected` — отвергнутые альтернативы из РАНКИНГА (runner-up'ы / негатив-дефицит).
|
||||||
|
• `what_would_change` — рычаги §9.x, которые сдвинули бы индекс (ставка/предложение/
|
||||||
|
горизонт), полными RU-предложениями.
|
||||||
|
`advisory` ВСЕГДА True (наследует advisory-статус §9.x). Все поля схемы — для
|
||||||
|
explainability, НЕ основание для инвест-решения. Это «контракт-намёк»: overlay
|
||||||
|
пробрасывает причины как открытые dict'ы (см. RecommendForecastSegment.reason).
|
||||||
|
"""
|
||||||
|
|
||||||
|
why: str
|
||||||
|
drivers: list[dict[str, Any]] = []
|
||||||
|
rejected: list[dict[str, Any]] = []
|
||||||
|
what_would_change: list[str] = []
|
||||||
|
confidence: Literal["high", "medium", "low"]
|
||||||
|
advisory: bool
|
||||||
|
|
||||||
|
|
||||||
class RecommendForecastSegment(BaseModel):
|
class RecommendForecastSegment(BaseModel):
|
||||||
"""Одна ранжированная ячейка §9.7 forecast-overlay (#982). ADVISORY.
|
"""Одна ранжированная ячейка §9.7 forecast-overlay (#982). ADVISORY.
|
||||||
|
|
||||||
`deficit_index`: в demand_supply-режиме ∈ [−1,+1] (>0 строить / <0 затоварка,
|
`deficit_index`: в demand_supply-режиме ∈ [−1,+1] (>0 строить / <0 затоварка,
|
||||||
supply-based); в demand_only-режиме ∈ (0,1] = ПРОКСИ относительной силы спроса
|
supply-based); в demand_only-режиме ∈ (0,1] = ПРОКСИ относительной силы спроса
|
||||||
(NOT supply-based — геометрии участка нет). `balance_units` (demand−supply)
|
(NOT supply-based — геометрии участка нет). `balance_units` (demand−supply)
|
||||||
None в demand_only (предложение неизмеримо без cad_num).
|
None в demand_only (предложение неизмеримо без cad_num). `reason` (#983, §16) —
|
||||||
|
структурная причина ячейки (RecommendReason-shaped dict) или None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
bucket: str
|
bucket: str
|
||||||
|
|
@ -76,16 +99,25 @@ class RecommendForecastSegment(BaseModel):
|
||||||
deficit_index: float
|
deficit_index: float
|
||||||
balance_units: float | None = None
|
balance_units: float | None = None
|
||||||
confidence: Literal["high", "medium", "low"]
|
confidence: Literal["high", "medium", "low"]
|
||||||
|
reason: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class RecommendForecastOverlay(BaseModel):
|
class RecommendForecastOverlay(BaseModel):
|
||||||
"""§9.7 СОВЕТУЮЩИЙ forecast-overlay поверх живого микса (#982, 953-A).
|
"""§9.7 СОВЕТУЮЩИЙ forecast-overlay поверх живого микса (#982, 953-A; #983, 953-B).
|
||||||
|
|
||||||
Кладётся ТОЛЬКО под scope["forecast"] при заданном horizon_months; живой микс
|
Кладётся ТОЛЬКО под scope["forecast"] при заданном horizon_months; живой микс
|
||||||
(4 поля RecommendMixOutput) НЕ затрагивается. `advisory` ВСЕГДА True (наследует
|
(4 поля RecommendMixOutput) НЕ затрагивается. `advisory` ВСЕГДА True (наследует
|
||||||
advisory-статус §9.x — не основание для инвест-решения). `mode`: demand_supply
|
advisory-статус §9.x — не основание для инвест-решения). `mode`: demand_supply
|
||||||
(cad_num задан — supply/конкуренты учтены) или demand_only (cad_num=None — только
|
(cad_num задан — supply/конкуренты учтены) или demand_only (cad_num=None — только
|
||||||
темп спроса; см. warnings).
|
темп спроса; см. warnings).
|
||||||
|
|
||||||
|
#983 (953-B) — ADDITIVE-расширения поверх #982 (старые поля/поведение НЕ тронуты):
|
||||||
|
• `class_reco` — §10.2 рекомендация класса (сильнейший агрегатный дефицит) + §16
|
||||||
|
reason, либо None (нет ранжированных данных).
|
||||||
|
• `usp` — §10.5 USP-из-дефицитов: top-K самых недообеспеченных сегментов как
|
||||||
|
«дефицит формата X — стройте его» (пустой при отсутствии данных).
|
||||||
|
• `commercial` — §10.4 советующий коммерческий сигнал (доля коммерции) или
|
||||||
|
degraded-honest {available: False, caveat, advisory} при тонких данных, либо None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
horizon_months: int
|
horizon_months: int
|
||||||
|
|
@ -93,6 +125,9 @@ class RecommendForecastOverlay(BaseModel):
|
||||||
advisory: bool
|
advisory: bool
|
||||||
ranked_segments: list[RecommendForecastSegment]
|
ranked_segments: list[RecommendForecastSegment]
|
||||||
warnings: list[str]
|
warnings: list[str]
|
||||||
|
class_reco: dict[str, Any] | None = None
|
||||||
|
usp: list[dict[str, Any]] = []
|
||||||
|
commercial: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class RecommendMixOutput(BaseModel):
|
class RecommendMixOutput(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
"""§9.7 forecast-overlay для живого квартирография-рекомендатора (#982, 953-A).
|
"""§9.7 forecast-overlay для живого квартирография-рекомендатора (#982, 953-A; #983, 953-B).
|
||||||
|
|
||||||
Это ТОНКИЙ СОВЕТУЮЩИЙ мост между ЖИВЫМ `analytics_queries.recommend_mix` и
|
Это ТОНКИЙ СОВЕТУЮЩИЙ мост между ЖИВЫМ `analytics_queries.recommend_mix` и
|
||||||
форсайт-движком §9.x (#980/#981). Своей §9.x-математики НЕ пересобирает — собирает
|
форсайт-движком §9.x (#980/#981). Своей §9.x-математики НЕ пересобирает — собирает
|
||||||
ответ из уже-смерженных сервисов и форматирует под schemas.recommend overlay-контракт.
|
ответ из уже-смерженных сервисов и форматирует под schemas.recommend overlay-контракт.
|
||||||
Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM; своего SQL НЕТ.
|
Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM; своего SQL НЕТ.
|
||||||
|
|
||||||
|
#983 (953-B, EPIC 9) ADDITIVE-расширяет overlay поверх #982 (старые поля/поведение
|
||||||
|
НЕ тронуты): §10.2 рекомендация класса (`class_reco`), §10.5 USP-из-дефицитов (`usp`),
|
||||||
|
§10.4 советующий коммерческий сигнал (`commercial`, degraded-honest) и §16 структурная
|
||||||
|
причина (`reason`) на КАЖДОМ ранжированном сегменте + на class_reco/usp. §16-причины
|
||||||
|
ДЕРИВИРУЮТСЯ из ранкинга what_to_build (отвергнутые альтернативы = runner-up'ы /
|
||||||
|
негатив-дефицит ячейки) — поэтому маршрут через rank_segments корректен и для §16.
|
||||||
|
|
||||||
ADVISORY (КРИТИЧНО — честность, зеркало what_to_build.py): overlay наследует
|
ADVISORY (КРИТИЧНО — честность, зеркало what_to_build.py): overlay наследует
|
||||||
advisory-статус §9.x (движок не провалидирован до бэктеста #951). Поэтому
|
advisory-статус §9.x (движок не провалидирован до бэктеста #951). Поэтому
|
||||||
`advisory` ВСЕГДА True, confidence ≤ 'medium' в demand_supply-режиме и 'low' в
|
`advisory` ВСЕГДА True, confidence ≤ 'medium' в demand_supply-режиме и 'low' в
|
||||||
|
|
@ -72,6 +79,34 @@ _CLASS_TO_FORECAST: dict[str, str] = {
|
||||||
"Economy": "эконом",
|
"Economy": "эконом",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── #983 named-константы (§10/§16) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
# §10.5 USP-из-дефицитов: сколько самых недообеспеченных сегментов превращаем в
|
||||||
|
# USP-пункты «дефицит формата X — стройте его». 3 = верхушка сигнала, не «простыня».
|
||||||
|
_USP_TOP_K: int = 3
|
||||||
|
|
||||||
|
# §10.4 коммерция: premise_kind, которым пробуем измерить нежилой сток (вокабуляр
|
||||||
|
# objective_lots — «нежилое»). objective покрывает в основном жильё, поэтому обычно
|
||||||
|
# выборка тонкая → degraded-honest путь (см. _commercial_signal). НЕ фабрикуем число.
|
||||||
|
_COMMERCIAL_PREMISE_KIND: str = "нежилое"
|
||||||
|
|
||||||
|
# §10.4: минимум нежилых лотов в выборке, ниже которого сигнал считаем недостаточным
|
||||||
|
# (degraded-honest). Зеркало духа market_metrics порогов: мало лотов → ненадёжно.
|
||||||
|
_COMMERCIAL_MIN_LOTS: int = 30
|
||||||
|
|
||||||
|
# §16 «что изменило бы индекс» — рычаги §9.x полными RU-предложениями (микрокопия:
|
||||||
|
# п.п., ≥). Templated-константы (НЕ LLM): ставка §9.4/§9.6, скрытый запас §9.3 Layer2,
|
||||||
|
# горизонт. Шаблон с {horizon} подставляет текущий горизонт вызова.
|
||||||
|
_LEVER_RATE: str = (
|
||||||
|
"Рост ключевой ставки на ≥1 п.п. снижает нормализованный спрос (§9.4) → индекс падает."
|
||||||
|
)
|
||||||
|
_LEVER_SUPPLY: str = (
|
||||||
|
"Выход скрытого запаса (Layer2, §9.3) в радиусе участка → предложение растёт → индекс падает."
|
||||||
|
)
|
||||||
|
_LEVER_HORIZON_TMPL: str = (
|
||||||
|
"Сужение горизонта с {horizon} до 6 мес → меньше поглощённого спроса → индекс может вырасти."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def map_room_bucket(forecast_bucket: str | None) -> str | None:
|
def map_room_bucket(forecast_bucket: str | None) -> str | None:
|
||||||
"""Forecast room-bucket → live bucket-id. PURE. Неизвестное/None → None."""
|
"""Forecast room-bucket → live bucket-id. PURE. Неизвестное/None → None."""
|
||||||
|
|
@ -99,6 +134,271 @@ def map_class(target_class: str | None) -> str | None:
|
||||||
return _CLASS_TO_FORECAST.get(target_class)
|
return _CLASS_TO_FORECAST.get(target_class)
|
||||||
|
|
||||||
|
|
||||||
|
# ── #983 PURE-билдеры §10/§16 — без БД, полностью юнит-тестируемы ──────────────
|
||||||
|
# Работают на live segment-dict'ах (shape _demand_*_overlay: bucket / obj_class /
|
||||||
|
# deficit_index / balance_units / confidence). `all_ranked` — DESC-проекция ранкинга
|
||||||
|
# what_to_build; из неё §16 деривирует отвергнутые альтернативы (runner-up'ы).
|
||||||
|
|
||||||
|
|
||||||
|
def _segment_label(segment: dict[str, Any]) -> str:
|
||||||
|
"""Человекочитаемая метка ячейки для RU-микрокопии §16/§10. PURE.
|
||||||
|
|
||||||
|
«<bucket> (<класс>)» если класс задан, иначе «<bucket>». bucket — уже живой
|
||||||
|
pretty-id (например «3-2-к»); None-bucket → «формат» (не падаем на мусоре).
|
||||||
|
"""
|
||||||
|
bucket = segment.get("bucket") or "формат"
|
||||||
|
obj_class = segment.get("obj_class")
|
||||||
|
return f"{bucket} ({obj_class})" if obj_class else str(bucket)
|
||||||
|
|
||||||
|
|
||||||
|
def _signed(value: float) -> str:
|
||||||
|
"""'+0.62' / '−0.18' — знак для RU-микрокопии индекса (минус — типографский). PURE."""
|
||||||
|
return f"+{value:.2f}" if value >= 0 else f"−{abs(value):.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _direction(value: float) -> str:
|
||||||
|
"""Направление драйвера для §16 ('+' рост сигнала / '−' давление вниз). PURE."""
|
||||||
|
return "+" if value >= 0 else "−"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reason(
|
||||||
|
segment: dict[str, Any],
|
||||||
|
deficit_index: float,
|
||||||
|
all_ranked: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
horizon_months: int,
|
||||||
|
demand_only: bool = False,
|
||||||
|
demand: float | None = None,
|
||||||
|
supply: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""§16 структурная причина одной ячейки (RecommendReason-shaped dict). PURE.
|
||||||
|
|
||||||
|
Собирает why/drivers/rejected/what_would_change/confidence/advisory:
|
||||||
|
• why — RU-предложение с deficit_index на горизонте (+ спрос/предложение если
|
||||||
|
известны; в demand_only оговаривает, что предложение НЕ учтено).
|
||||||
|
• drivers — deficit_index (всегда) + balance_units (если не None) с direction.
|
||||||
|
• rejected — ДЕРИВИРУЕТСЯ из `all_ranked`: прочие ячейки (runner-up'ы +
|
||||||
|
негатив-дефицит) — вот ПОЧЕМУ маршрут через rank_segments корректен для §16.
|
||||||
|
Негативный дефицит помечаем «затоварка», прочее — «слабее сигнал».
|
||||||
|
• what_would_change — templated рычаги §9.x (ставка/предложение/горизонт).
|
||||||
|
`confidence` НАСЛЕДУЕТСЯ из самой ячейки (передаётся в segment). advisory=True.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
segment: live segment-dict рассматриваемой ячейки (с confidence/bucket/класс).
|
||||||
|
deficit_index: индекс ячейки (для why/drivers — отдельным аргументом, т.к.
|
||||||
|
может быть уже округлён вызывающим).
|
||||||
|
all_ranked: DESC-проекция всего ранкинга (для деривации rejected).
|
||||||
|
horizon_months: горизонт прогноза (мес) — для why и horizon-рычага.
|
||||||
|
demand_only: True → why оговаривает, что supply не учтён (demand_only-режим).
|
||||||
|
demand: оценка спроса (ед.) если известна — иначе опускаем из why.
|
||||||
|
supply: оценка предложения (ед.) если известна — иначе опускаем из why.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RecommendReason-shaped dict.
|
||||||
|
"""
|
||||||
|
label = _segment_label(segment)
|
||||||
|
why = (
|
||||||
|
f"Сегмент {label}: deficit_index {_signed(deficit_index)} на горизонте {horizon_months} мес"
|
||||||
|
)
|
||||||
|
if demand is not None and supply is not None:
|
||||||
|
why += f" (спрос ~{demand:.0f}, предложение ~{supply:.0f})"
|
||||||
|
elif demand_only:
|
||||||
|
why += " (прокси силы спроса; предложение участка НЕ учтено — нет геометрии)"
|
||||||
|
why += "."
|
||||||
|
|
||||||
|
drivers: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"factor": "deficit_index",
|
||||||
|
"value": round(deficit_index, 2),
|
||||||
|
"direction": _direction(deficit_index),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
balance_units = segment.get("balance_units")
|
||||||
|
if balance_units is not None:
|
||||||
|
drivers.append(
|
||||||
|
{
|
||||||
|
"factor": "balance_units",
|
||||||
|
"value": round(float(balance_units), 1),
|
||||||
|
"direction": _direction(float(balance_units)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
rejected = _rejected_alternatives(segment, all_ranked)
|
||||||
|
|
||||||
|
what_would_change = [
|
||||||
|
_LEVER_RATE,
|
||||||
|
_LEVER_SUPPLY,
|
||||||
|
_LEVER_HORIZON_TMPL.format(horizon=horizon_months),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"why": why,
|
||||||
|
"drivers": drivers,
|
||||||
|
"rejected": rejected,
|
||||||
|
"what_would_change": what_would_change,
|
||||||
|
"confidence": segment.get("confidence", "low"),
|
||||||
|
"advisory": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rejected_alternatives(
|
||||||
|
chosen: dict[str, Any], all_ranked: list[dict[str, Any]]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""§16 отвергнутые альтернативы из РАНКИНГА (всё, кроме выбранной ячейки). PURE.
|
||||||
|
|
||||||
|
Деривируем из `all_ranked` (DESC): каждая ДРУГАЯ ячейка — отвергнутая альтернатива.
|
||||||
|
Идентичность ячейки — по (bucket, obj_class) (live-ключ overlay). reason-ярлык:
|
||||||
|
deficit_index < 0 → «затоварка», иначе «слабее сигнал». Сохраняем DESC-порядок
|
||||||
|
ранкинга (детерминированно). Самоисключение по ключу — НЕ по object identity.
|
||||||
|
"""
|
||||||
|
chosen_key = (chosen.get("bucket"), chosen.get("obj_class"))
|
||||||
|
rejected: list[dict[str, Any]] = []
|
||||||
|
for seg in all_ranked:
|
||||||
|
if (seg.get("bucket"), seg.get("obj_class")) == chosen_key:
|
||||||
|
continue
|
||||||
|
di = seg.get("deficit_index")
|
||||||
|
di_f = float(di) if di is not None else 0.0
|
||||||
|
rejected.append(
|
||||||
|
{
|
||||||
|
"alternative": _segment_label(seg),
|
||||||
|
"deficit_index": round(di_f, 2),
|
||||||
|
"reason": "затоварка" if di_f < 0 else "слабее сигнал",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rejected
|
||||||
|
|
||||||
|
|
||||||
|
def _recommend_class(
|
||||||
|
all_ranked: list[dict[str, Any]], *, horizon_months: int, demand_only: bool = False
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""§10.2 рекомендация класса — сильнейший агрегатный дефицит по классам. PURE.
|
||||||
|
|
||||||
|
Агрегируем `deficit_index` per `obj_class` (среднее по его room-bucket'ам),
|
||||||
|
выбираем класс с сильнейшим средним дефицитом. К рекомендации прикрепляем §16
|
||||||
|
`_build_reason` (как для синтетической class-level ячейки; rejected — прочие
|
||||||
|
классы тем же механизмом). None если нет ранжированных данных или ни у одной
|
||||||
|
ячейки нет obj_class (нечего агрегировать — НЕ фабрикуем класс).
|
||||||
|
|
||||||
|
Детерминированно: tie-break по obj_class ASC (стабильно при равных средних).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
all_ranked: DESC-проекция ранкинга (live segment-dict'ы).
|
||||||
|
horizon_months: горизонт (для §16 reason).
|
||||||
|
demand_only: проброс в reason (оговорка supply-excluded).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{obj_class, mean_deficit_index, n_segments, reason} либо None.
|
||||||
|
"""
|
||||||
|
if not all_ranked:
|
||||||
|
return None
|
||||||
|
|
||||||
|
by_class: dict[str, list[float]] = {}
|
||||||
|
for seg in all_ranked:
|
||||||
|
obj_class = seg.get("obj_class")
|
||||||
|
di = seg.get("deficit_index")
|
||||||
|
if obj_class is None or di is None:
|
||||||
|
continue
|
||||||
|
by_class.setdefault(obj_class, []).append(float(di))
|
||||||
|
if not by_class:
|
||||||
|
return None
|
||||||
|
|
||||||
|
means = {cls: sum(vals) / len(vals) for cls, vals in by_class.items()}
|
||||||
|
# Сильнейший средний дефицит; tie-break — obj_class ASC (детерминизм).
|
||||||
|
best_class = max(means, key=lambda c: (means[c], _neg_str(c)))
|
||||||
|
best_mean = means[best_class]
|
||||||
|
|
||||||
|
# Синтетическая class-level ячейка для §16: confidence наследуем как лучший
|
||||||
|
# (max) среди ячеек класса — class-сигнал не слабее своей сильнейшей ячейки.
|
||||||
|
class_segments = [s for s in all_ranked if s.get("obj_class") == best_class]
|
||||||
|
confidence = _best_confidence(s.get("confidence") for s in class_segments)
|
||||||
|
synthetic = {"bucket": "класс", "obj_class": best_class, "confidence": confidence}
|
||||||
|
# rejected — прочие КЛАССЫ (агрегатные ячейки), не room-bucket'ы.
|
||||||
|
class_cells = [
|
||||||
|
{"bucket": "класс", "obj_class": cls, "deficit_index": m, "confidence": confidence}
|
||||||
|
for cls, m in means.items()
|
||||||
|
]
|
||||||
|
reason = _build_reason(
|
||||||
|
synthetic,
|
||||||
|
best_mean,
|
||||||
|
class_cells,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
demand_only=demand_only,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"obj_class": best_class,
|
||||||
|
"mean_deficit_index": round(best_mean, 3),
|
||||||
|
"n_segments": len(class_segments),
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _usp_from_deficits(
|
||||||
|
all_ranked: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
horizon_months: int,
|
||||||
|
top_k: int = _USP_TOP_K,
|
||||||
|
demand_only: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""§10.5 USP-из-дефицитов — top-K самых недообеспеченных сегментов. PURE.
|
||||||
|
|
||||||
|
`all_ranked` уже DESC по deficit_index → первые top_k = сильнейшая недообеспеченность.
|
||||||
|
Каждый → USP-пункт «Дефицит формата X — стройте его» + §16 reason. Пустой вход →
|
||||||
|
пустой список (НЕ фабрикуем USP). Берём только ячейки с deficit_index не None.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
all_ranked: DESC-проекция ранкинга (live segment-dict'ы).
|
||||||
|
horizon_months: горизонт (для §16 reason).
|
||||||
|
top_k: сколько верхних сегментов превратить в USP (по умолчанию _USP_TOP_K).
|
||||||
|
demand_only: проброс в reason (оговорка supply-excluded).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список USP-пунктов {segment, obj_class, deficit_index, usp_text, reason}.
|
||||||
|
"""
|
||||||
|
usp: list[dict[str, Any]] = []
|
||||||
|
for seg in all_ranked[:top_k]:
|
||||||
|
di = seg.get("deficit_index")
|
||||||
|
if di is None:
|
||||||
|
continue
|
||||||
|
usp.append(
|
||||||
|
{
|
||||||
|
"segment": seg.get("bucket"),
|
||||||
|
"obj_class": seg.get("obj_class"),
|
||||||
|
"deficit_index": round(float(di), 3),
|
||||||
|
"usp_text": f"Дефицит формата «{_segment_label(seg)}» — стройте его.",
|
||||||
|
"reason": _build_reason(
|
||||||
|
seg,
|
||||||
|
float(di),
|
||||||
|
all_ranked,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
demand_only=demand_only,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return usp
|
||||||
|
|
||||||
|
|
||||||
|
def _neg_str(value: str) -> tuple[int, ...]:
|
||||||
|
"""Ключ для ASC-tie-break внутри max() (max берёт «больший» → инвертируем). PURE.
|
||||||
|
|
||||||
|
max(..., key) выбирает максимум; чтобы при равном primary взять лексикографически
|
||||||
|
МЕНЬШИЙ obj_class, отдаём покодовый кортеж с отрицанием (меньшая строка → больший
|
||||||
|
ключ). Детерминированно, без локали.
|
||||||
|
"""
|
||||||
|
return tuple(-ord(ch) for ch in value)
|
||||||
|
|
||||||
|
|
||||||
|
def _best_confidence(values: Any) -> str:
|
||||||
|
"""MAX confidence среди ячеек (class-сигнал не слабее сильнейшей ячейки). PURE.
|
||||||
|
|
||||||
|
Зеркало _CONFIDENCE-vocab (low<medium<high). Пустой/мусорный вход → 'low'.
|
||||||
|
"""
|
||||||
|
rank = {"low": 0, "medium": 1, "high": 2}
|
||||||
|
inv = {0: "low", 1: "medium", 2: "high"}
|
||||||
|
ranks = [rank[v] for v in values if v in rank]
|
||||||
|
return inv[max(ranks)] if ranks else "low"
|
||||||
|
|
||||||
|
|
||||||
# ── DB-оркестратор overlay — тонкий, graceful, live-safe ──────────────────────
|
# ── DB-оркестратор overlay — тонкий, graceful, live-safe ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -200,7 +500,15 @@ def _demand_supply_overlay(
|
||||||
"demand_supply: ранкинг недоступен (нет геометрии участка или тонкие "
|
"demand_supply: ранкинг недоступен (нет геометрии участка или тонкие "
|
||||||
"данные) — сегменты не ранжированы."
|
"данные) — сегменты не ранжированы."
|
||||||
)
|
)
|
||||||
return _overlay(horizon_months, "demand_supply", [], warnings)
|
return _enrich_overlay(
|
||||||
|
db,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
mode="demand_supply",
|
||||||
|
ranked_segments=[],
|
||||||
|
warnings=warnings,
|
||||||
|
district=district,
|
||||||
|
demand_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
ranked_segments: list[dict[str, Any]] = []
|
ranked_segments: list[dict[str, Any]] = []
|
||||||
for seg in ranking.ranked: # уже DESC по deficit_index
|
for seg in ranking.ranked: # уже DESC по deficit_index
|
||||||
|
|
@ -231,7 +539,15 @@ def _demand_supply_overlay(
|
||||||
horizon_months,
|
horizon_months,
|
||||||
len(ranked_segments),
|
len(ranked_segments),
|
||||||
)
|
)
|
||||||
return _overlay(horizon_months, "demand_supply", ranked_segments, warnings)
|
return _enrich_overlay(
|
||||||
|
db,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
mode="demand_supply",
|
||||||
|
ranked_segments=ranked_segments,
|
||||||
|
warnings=warnings,
|
||||||
|
district=district,
|
||||||
|
demand_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _demand_only_overlay(
|
def _demand_only_overlay(
|
||||||
|
|
@ -273,7 +589,15 @@ def _demand_only_overlay(
|
||||||
warnings.append(
|
warnings.append(
|
||||||
"demand-only: нет наблюдаемого темпа продаж (§9.2) — сегменты не ранжированы."
|
"demand-only: нет наблюдаемого темпа продаж (§9.2) — сегменты не ранжированы."
|
||||||
)
|
)
|
||||||
return _overlay(horizon_months, "demand_only", [], warnings)
|
return _enrich_overlay(
|
||||||
|
db,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
mode="demand_only",
|
||||||
|
ranked_segments=[],
|
||||||
|
warnings=warnings,
|
||||||
|
district=district,
|
||||||
|
demand_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
paces: list[tuple[str, str | None, float]] = [] # (live_bucket, obj_class, pace)
|
paces: list[tuple[str, str | None, float]] = [] # (live_bucket, obj_class, pace)
|
||||||
for live_bucket, forecast_bucket in _LIVE_TO_FORECAST_BUCKET.items():
|
for live_bucket, forecast_bucket in _LIVE_TO_FORECAST_BUCKET.items():
|
||||||
|
|
@ -298,7 +622,15 @@ def _demand_only_overlay(
|
||||||
max_pace = max((p for _, _, p in paces), default=0.0)
|
max_pace = max((p for _, _, p in paces), default=0.0)
|
||||||
if max_pace <= 0.0:
|
if max_pace <= 0.0:
|
||||||
warnings.append("demand-only: темп спроса по всем форматам ≤0 — сегменты не ранжированы.")
|
warnings.append("demand-only: темп спроса по всем форматам ≤0 — сегменты не ранжированы.")
|
||||||
return _overlay(horizon_months, "demand_only", [], warnings)
|
return _enrich_overlay(
|
||||||
|
db,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
mode="demand_only",
|
||||||
|
ranked_segments=[],
|
||||||
|
warnings=warnings,
|
||||||
|
district=district,
|
||||||
|
demand_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1] (ПРОКСИ, НЕ supply-based).
|
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1] (ПРОКСИ, НЕ supply-based).
|
||||||
paces.sort(key=lambda t: t[2], reverse=True)
|
paces.sort(key=lambda t: t[2], reverse=True)
|
||||||
|
|
@ -321,7 +653,15 @@ def _demand_only_overlay(
|
||||||
len(ranked_segments),
|
len(ranked_segments),
|
||||||
base_pace,
|
base_pace,
|
||||||
)
|
)
|
||||||
return _overlay(horizon_months, "demand_only", ranked_segments, warnings)
|
return _enrich_overlay(
|
||||||
|
db,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
mode="demand_only",
|
||||||
|
ranked_segments=ranked_segments,
|
||||||
|
warnings=warnings,
|
||||||
|
district=district,
|
||||||
|
demand_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _overlay(
|
def _overlay(
|
||||||
|
|
@ -329,12 +669,187 @@ def _overlay(
|
||||||
mode: str,
|
mode: str,
|
||||||
ranked_segments: list[dict[str, Any]],
|
ranked_segments: list[dict[str, Any]],
|
||||||
warnings: list[str],
|
warnings: list[str],
|
||||||
|
*,
|
||||||
|
class_reco: dict[str, Any] | None = None,
|
||||||
|
usp: list[dict[str, Any]] | None = None,
|
||||||
|
commercial: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Собрать RecommendForecastOverlay-shaped dict. advisory ВСЕГДА True. PURE."""
|
"""Собрать RecommendForecastOverlay-shaped dict. advisory ВСЕГДА True. PURE.
|
||||||
|
|
||||||
|
#982-поля (horizon_months/mode/advisory/ranked_segments/warnings) — без изменений.
|
||||||
|
#983 ADDITIVE-поля (class_reco §10.2 / usp §10.5 / commercial §10.4) — опциональны,
|
||||||
|
дефолты сохраняют #982-байт-в-байт-форму при пустом расширении (None/[]).
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"horizon_months": horizon_months,
|
"horizon_months": horizon_months,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"advisory": True,
|
"advisory": True,
|
||||||
"ranked_segments": ranked_segments,
|
"ranked_segments": ranked_segments,
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
|
"class_reco": class_reco,
|
||||||
|
"usp": usp if usp is not None else [],
|
||||||
|
"commercial": commercial,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _commercial_signal(
|
||||||
|
db: Session, district: str | None, horizon_months: int
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful.
|
||||||
|
|
||||||
|
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
|
||||||
|
"нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда
|
||||||
|
возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число.
|
||||||
|
Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка доли
|
||||||
|
коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason.
|
||||||
|
НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: SQLAlchemy sync Session.
|
||||||
|
district: район (None → ЕКБ-wide).
|
||||||
|
horizon_months: горизонт (для контекста caveat/reason).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Советующий dict {available, ...} либо None при полном отсутствии движка.
|
||||||
|
"""
|
||||||
|
# Локальный импорт — зеркало остального модуля (избегаем import-cycle).
|
||||||
|
from app.services.site_finder.market_metrics import compute_market_metrics
|
||||||
|
|
||||||
|
caveat = (
|
||||||
|
f"коммерция: нет достаточных данных (premise_kind={_COMMERCIAL_PREMISE_KIND}, "
|
||||||
|
f"горизонт {horizon_months} мес)"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
metrics = compute_market_metrics(
|
||||||
|
db, district=district, premise_kind=_COMMERCIAL_PREMISE_KIND
|
||||||
|
)
|
||||||
|
n_lots = _as_int(getattr(metrics, "n_lots", None))
|
||||||
|
sell_through = _as_float(getattr(metrics, "sell_through_pct", None))
|
||||||
|
confidence = getattr(metrics, "confidence", "low")
|
||||||
|
except Exception:
|
||||||
|
# Движок не поддерживает коммерческий premise_kind / сбой — честный degrade.
|
||||||
|
logger.exception(
|
||||||
|
"forecast-overlay commercial: market_metrics failed (district=%s) → degraded",
|
||||||
|
district,
|
||||||
|
)
|
||||||
|
return {"available": False, "caveat": caveat, "advisory": True}
|
||||||
|
|
||||||
|
# Тонкая выборка (objective ~ жильё) / неизмеримая доля → degraded-honest, без
|
||||||
|
# фабрикации числа. n_lots None (нечисловой/сбой) тоже трактуем как недостаток.
|
||||||
|
if n_lots is None or n_lots < _COMMERCIAL_MIN_LOTS or sell_through is None:
|
||||||
|
logger.info(
|
||||||
|
"forecast-overlay commercial: thin (district=%s n_lots=%s) → degraded (ADVISORY)",
|
||||||
|
district,
|
||||||
|
n_lots,
|
||||||
|
)
|
||||||
|
return {"available": False, "caveat": caveat, "advisory": True}
|
||||||
|
|
||||||
|
# Достаточно данных: советующая оценка реализованной доли коммерции (прокси).
|
||||||
|
share_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": share_pct,
|
||||||
|
"n_lots": n_lots,
|
||||||
|
"confidence": confidence,
|
||||||
|
"reason": {
|
||||||
|
"why": (
|
||||||
|
f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} "
|
||||||
|
f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)."
|
||||||
|
),
|
||||||
|
"drivers": [
|
||||||
|
{"factor": "sell_through_pct", "value": share_pct, "direction": "+"},
|
||||||
|
{"factor": "n_lots", "value": n_lots, "direction": "+"},
|
||||||
|
],
|
||||||
|
"rejected": [],
|
||||||
|
"what_would_change": [_LEVER_SUPPLY],
|
||||||
|
"confidence": confidence,
|
||||||
|
"advisory": True,
|
||||||
|
},
|
||||||
|
"advisory": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: Any) -> int | None:
|
||||||
|
"""Безопасно привести значение к int (None/нечисловое → None). Graceful. PURE.
|
||||||
|
|
||||||
|
Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой
|
||||||
|
нечисловой/bool/сбойный вход → None → degraded-honest путь, без падения.
|
||||||
|
"""
|
||||||
|
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)):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_overlay(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
horizon_months: int,
|
||||||
|
mode: str,
|
||||||
|
ranked_segments: list[dict[str, Any]],
|
||||||
|
warnings: list[str],
|
||||||
|
district: str | None,
|
||||||
|
demand_only: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""#983 ADDITIVE-сборка: §16 reason на каждом сегменте + §10.2/§10.5/§10.4. Graceful.
|
||||||
|
|
||||||
|
Мутирует `ranked_segments` IN-PLACE, добавляя `reason` (§16) к каждой ячейке
|
||||||
|
(rejected деривируется из всего списка). Затем считает class_reco (§10.2),
|
||||||
|
usp (§10.5) и commercial (§10.4) и собирает overlay через `_overlay`. Расширения
|
||||||
|
§10.2/§10.5 только при НАЛИЧИИ ранжированных данных (иначе None/[]); commercial
|
||||||
|
(§10.4) пробуем всегда (он независим от ранкинга, сам degraded-honest). Все
|
||||||
|
#982-поля сохраняются _overlay'ем без изменений.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: SQLAlchemy sync Session (для §10.4 коммерции).
|
||||||
|
horizon_months: горизонт прогноза (мес).
|
||||||
|
mode: "demand_supply" | "demand_only".
|
||||||
|
ranked_segments: live segment-dict'ы (мутируются — добавляется reason).
|
||||||
|
warnings: #982-варнинги (пробрасываются как есть).
|
||||||
|
district: район (для §10.4).
|
||||||
|
demand_only: True → reason'ы оговаривают, что supply не учтён.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RecommendForecastOverlay-shaped dict с #983-расширениями.
|
||||||
|
"""
|
||||||
|
for seg in ranked_segments:
|
||||||
|
di = seg.get("deficit_index")
|
||||||
|
if di is None:
|
||||||
|
continue
|
||||||
|
seg["reason"] = _build_reason(
|
||||||
|
seg,
|
||||||
|
float(di),
|
||||||
|
ranked_segments,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
demand_only=demand_only,
|
||||||
|
)
|
||||||
|
|
||||||
|
class_reco = (
|
||||||
|
_recommend_class(ranked_segments, horizon_months=horizon_months, demand_only=demand_only)
|
||||||
|
if ranked_segments
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
usp = (
|
||||||
|
_usp_from_deficits(ranked_segments, horizon_months=horizon_months, demand_only=demand_only)
|
||||||
|
if ranked_segments
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
commercial = _commercial_signal(db, district, horizon_months)
|
||||||
|
|
||||||
|
return _overlay(
|
||||||
|
horizon_months,
|
||||||
|
mode,
|
||||||
|
ranked_segments,
|
||||||
|
warnings,
|
||||||
|
class_reco=class_reco,
|
||||||
|
usp=usp,
|
||||||
|
commercial=commercial,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Tests для §9.7 forecast-overlay моста (#982, 953-A) recommendation.py.
|
"""Tests для §9.7 forecast-overlay моста (#982, 953-A; #983, 953-B) recommendation.py.
|
||||||
|
|
||||||
Покрывает:
|
Покрывает:
|
||||||
• PURE bridge-таблицы map_room_bucket / map_room_bucket_inverse — обе стороны +
|
• PURE bridge-таблицы map_room_bucket / map_room_bucket_inverse — обе стороны +
|
||||||
|
|
@ -12,6 +12,14 @@
|
||||||
flips, обязательный warning, balance_units None, supply НЕ фабрикуется.
|
flips, обязательный warning, balance_units None, supply НЕ фабрикуется.
|
||||||
- graceful: rank_segments бросает ValueError → пустой ranked_segments + warning,
|
- graceful: rank_segments бросает ValueError → пустой ranked_segments + warning,
|
||||||
НЕ исключение.
|
НЕ исключение.
|
||||||
|
• #983 (953-B) PURE-билдеры §10/§16:
|
||||||
|
- _build_reason — why/drivers/rejected(из runner-up'ов)/what_would_change shape,
|
||||||
|
confidence inherited, advisory True.
|
||||||
|
- _recommend_class — сильнейший агрегатный дефицит; None на пустом.
|
||||||
|
- _usp_from_deficits — top-K по deficit, RU usp_text.
|
||||||
|
- _commercial_signal — degraded → available False + caveat, НИКОГДА не crash.
|
||||||
|
- build_forecast_overlay теперь несёт class_reco/usp/commercial + per-segment reason,
|
||||||
|
а #982-поведение (ключи/порядок/режимы) НЕ сломано.
|
||||||
|
|
||||||
Mock-based — живой БД не требуют (db = MagicMock; §9.x вызовы патчатся по месту
|
Mock-based — живой БД не требуют (db = MagicMock; §9.x вызовы патчатся по месту
|
||||||
ЛОКАЛЬНОГО импорта внутри функций recommendation.py).
|
ЛОКАЛЬНОГО импорта внутри функций recommendation.py).
|
||||||
|
|
@ -26,6 +34,11 @@ import pytest
|
||||||
|
|
||||||
from app.services.forecasting.recommendation import (
|
from app.services.forecasting.recommendation import (
|
||||||
_FORECAST_TO_LIVE_BUCKET,
|
_FORECAST_TO_LIVE_BUCKET,
|
||||||
|
_USP_TOP_K,
|
||||||
|
_build_reason,
|
||||||
|
_commercial_signal,
|
||||||
|
_recommend_class,
|
||||||
|
_usp_from_deficits,
|
||||||
build_forecast_overlay,
|
build_forecast_overlay,
|
||||||
map_class,
|
map_class,
|
||||||
map_room_bucket,
|
map_room_bucket,
|
||||||
|
|
@ -479,3 +492,381 @@ class TestDemandOnlyOverlay:
|
||||||
model = RecommendForecastOverlay.model_validate(out)
|
model = RecommendForecastOverlay.model_validate(out)
|
||||||
assert model.mode == "demand_only"
|
assert model.mode == "demand_only"
|
||||||
assert model.advisory is True
|
assert model.advisory is True
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# #983 (953-B) PURE-билдеры §10/§16
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _seg(
|
||||||
|
bucket: str,
|
||||||
|
deficit_index: float,
|
||||||
|
*,
|
||||||
|
obj_class: str | None = "комфорт",
|
||||||
|
balance_units: float | None = 12.0,
|
||||||
|
confidence: str = "medium",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Live segment-dict (shape _demand_*_overlay) для PURE-билдеров #983."""
|
||||||
|
return {
|
||||||
|
"bucket": bucket,
|
||||||
|
"obj_class": obj_class,
|
||||||
|
"deficit_index": deficit_index,
|
||||||
|
"balance_units": balance_units,
|
||||||
|
"confidence": confidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── §16: _build_reason ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildReason:
|
||||||
|
def _ranked(self) -> list[dict[str, Any]]:
|
||||||
|
# Фиксированный ранкинг-фикстура (DESC): топ + runner-up + негатив-дефицит.
|
||||||
|
return [
|
||||||
|
_seg("1-Студия", 0.62, balance_units=62.0, confidence="medium"),
|
||||||
|
_seg("3-2-к", 0.20, obj_class="бизнес", confidence="low"),
|
||||||
|
_seg("4-3-к", -0.18, confidence="medium"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_why_mentions_segment_index_and_horizon(self) -> None:
|
||||||
|
ranked = self._ranked()
|
||||||
|
r = _build_reason(ranked[0], 0.62, ranked, horizon_months=12)
|
||||||
|
assert "1-Студия" in r["why"]
|
||||||
|
assert "+0.62" in r["why"]
|
||||||
|
assert "12 мес" in r["why"]
|
||||||
|
|
||||||
|
def test_drivers_deficit_and_balance_with_direction(self) -> None:
|
||||||
|
ranked = self._ranked()
|
||||||
|
r = _build_reason(ranked[0], 0.62, ranked, horizon_months=12)
|
||||||
|
factors = {d["factor"]: d for d in r["drivers"]}
|
||||||
|
assert factors["deficit_index"]["value"] == 0.62
|
||||||
|
assert factors["deficit_index"]["direction"] == "+"
|
||||||
|
assert factors["balance_units"]["value"] == 62.0
|
||||||
|
assert factors["balance_units"]["direction"] == "+"
|
||||||
|
|
||||||
|
def test_balance_units_omitted_when_none(self) -> None:
|
||||||
|
# demand_only-ячейка (balance_units None) → драйвера balance_units нет.
|
||||||
|
seg = _seg("1-Студия", 0.8, balance_units=None, confidence="low")
|
||||||
|
r = _build_reason(seg, 0.8, [seg], horizon_months=12, demand_only=True)
|
||||||
|
factors = {d["factor"] for d in r["drivers"]}
|
||||||
|
assert "balance_units" not in factors
|
||||||
|
assert "deficit_index" in factors
|
||||||
|
|
||||||
|
def test_rejected_derived_from_runner_ups(self) -> None:
|
||||||
|
# rejected = ВСЕ прочие ячейки ранкинга (runner-up + негатив-дефицит).
|
||||||
|
ranked = self._ranked()
|
||||||
|
r = _build_reason(ranked[0], 0.62, ranked, horizon_months=12)
|
||||||
|
alts = {x["alternative"] for x in r["rejected"]}
|
||||||
|
assert "3-2-к (бизнес)" in alts
|
||||||
|
assert "4-3-к (комфорт)" in alts
|
||||||
|
# Сама выбранная ячейка не попадает в отвергнутые.
|
||||||
|
assert "1-Студия (комфорт)" not in alts
|
||||||
|
|
||||||
|
def test_rejected_labels_overstock_vs_weaker(self) -> None:
|
||||||
|
ranked = self._ranked()
|
||||||
|
r = _build_reason(ranked[0], 0.62, ranked, horizon_months=12)
|
||||||
|
by_alt = {x["alternative"]: x["reason"] for x in r["rejected"]}
|
||||||
|
assert by_alt["3-2-к (бизнес)"] == "слабее сигнал" # положительный, но ниже
|
||||||
|
assert by_alt["4-3-к (комфорт)"] == "затоварка" # негатив-дефицит
|
||||||
|
|
||||||
|
def test_what_would_change_has_three_levers_with_horizon(self) -> None:
|
||||||
|
ranked = self._ranked()
|
||||||
|
r = _build_reason(ranked[0], 0.62, ranked, horizon_months=9)
|
||||||
|
wwc = r["what_would_change"]
|
||||||
|
assert len(wwc) == 3
|
||||||
|
assert any("ключевой ставки" in s and "п.п." in s for s in wwc)
|
||||||
|
assert any("Layer2" in s for s in wwc)
|
||||||
|
assert any("9 до 6 мес" in s for s in wwc) # horizon подставлен
|
||||||
|
|
||||||
|
def test_confidence_inherited_from_segment(self) -> None:
|
||||||
|
seg = _seg("1-Студия", 0.5, confidence="low")
|
||||||
|
r = _build_reason(seg, 0.5, [seg], horizon_months=12)
|
||||||
|
assert r["confidence"] == "low"
|
||||||
|
|
||||||
|
def test_advisory_always_true(self) -> None:
|
||||||
|
seg = _seg("1-Студия", 0.5)
|
||||||
|
r = _build_reason(seg, 0.5, [seg], horizon_months=12)
|
||||||
|
assert r["advisory"] is True
|
||||||
|
|
||||||
|
def test_demand_only_why_notes_supply_excluded(self) -> None:
|
||||||
|
seg = _seg("1-Студия", 0.9, balance_units=None, confidence="low")
|
||||||
|
r = _build_reason(seg, 0.9, [seg], horizon_months=12, demand_only=True)
|
||||||
|
assert "предложение участка НЕ учтено" in r["why"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── §10.2: _recommend_class ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecommendClass:
|
||||||
|
def test_picks_strongest_aggregate(self) -> None:
|
||||||
|
# комфорт: mean(0.6, -0.2)=0.2 ; бизнес: mean(0.5, 0.5)=0.5 → бизнес сильнее.
|
||||||
|
ranked = [
|
||||||
|
_seg("1-Студия", 0.6, obj_class="комфорт"),
|
||||||
|
_seg("3-2-к", -0.2, obj_class="комфорт"),
|
||||||
|
_seg("4-3-к", 0.5, obj_class="бизнес"),
|
||||||
|
_seg("5-80+ м²", 0.5, obj_class="бизнес"),
|
||||||
|
]
|
||||||
|
out = _recommend_class(ranked, horizon_months=12)
|
||||||
|
assert out is not None
|
||||||
|
assert out["obj_class"] == "бизнес"
|
||||||
|
assert out["mean_deficit_index"] == pytest.approx(0.5)
|
||||||
|
assert out["n_segments"] == 2
|
||||||
|
assert out["reason"]["advisory"] is True
|
||||||
|
|
||||||
|
def test_none_on_empty(self) -> None:
|
||||||
|
assert _recommend_class([], horizon_months=12) is None
|
||||||
|
|
||||||
|
def test_none_when_no_obj_class(self) -> None:
|
||||||
|
# Ни у одной ячейки нет класса — агрегировать нечего → None (НЕ фабрикуем).
|
||||||
|
ranked = [_seg("1-Студия", 0.6, obj_class=None)]
|
||||||
|
assert _recommend_class(ranked, horizon_months=12) is None
|
||||||
|
|
||||||
|
def test_reason_rejected_lists_other_classes(self) -> None:
|
||||||
|
# комфорт (0.6) > бизнес (0.5) → выбран комфорт, отвергнут бизнес.
|
||||||
|
ranked = [
|
||||||
|
_seg("1-Студия", 0.6, obj_class="комфорт"),
|
||||||
|
_seg("4-3-к", 0.5, obj_class="бизнес"),
|
||||||
|
]
|
||||||
|
out = _recommend_class(ranked, horizon_months=12)
|
||||||
|
assert out is not None
|
||||||
|
assert out["obj_class"] == "комфорт"
|
||||||
|
alts = {x["alternative"] for x in out["reason"]["rejected"]}
|
||||||
|
# Отвергнут другой КЛАСС (агрегатная class-level ячейка), не выбранный.
|
||||||
|
assert any("бизнес" in a for a in alts)
|
||||||
|
assert not any("комфорт" in a for a in alts)
|
||||||
|
|
||||||
|
|
||||||
|
# ── §10.5: _usp_from_deficits ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestUspFromDeficits:
|
||||||
|
def test_top_k_by_deficit(self) -> None:
|
||||||
|
ranked = [
|
||||||
|
_seg("1-Студия", 0.9),
|
||||||
|
_seg("2-1-к", 0.7),
|
||||||
|
_seg("3-2-к", 0.5),
|
||||||
|
_seg("4-3-к", 0.3),
|
||||||
|
_seg("5-80+ м²", 0.1),
|
||||||
|
]
|
||||||
|
usp = _usp_from_deficits(ranked, horizon_months=12, top_k=3)
|
||||||
|
assert len(usp) == 3
|
||||||
|
# Сохранён DESC-порядок верхушки.
|
||||||
|
assert [u["segment"] for u in usp] == ["1-Студия", "2-1-к", "3-2-к"]
|
||||||
|
|
||||||
|
def test_default_top_k_is_module_const(self) -> None:
|
||||||
|
ranked = [_seg(f"b{i}", 1.0 - i * 0.1) for i in range(10)]
|
||||||
|
usp = _usp_from_deficits(ranked, horizon_months=12)
|
||||||
|
assert len(usp) == _USP_TOP_K
|
||||||
|
|
||||||
|
def test_usp_text_is_russian_deficit_phrasing(self) -> None:
|
||||||
|
usp = _usp_from_deficits([_seg("1-Студия", 0.9)], horizon_months=12)
|
||||||
|
assert usp[0]["usp_text"] == "Дефицит формата «1-Студия (комфорт)» — стройте его."
|
||||||
|
|
||||||
|
def test_each_usp_carries_reason(self) -> None:
|
||||||
|
usp = _usp_from_deficits([_seg("1-Студия", 0.9)], horizon_months=12)
|
||||||
|
assert usp[0]["reason"]["advisory"] is True
|
||||||
|
assert "deficit_index" in {d["factor"] for d in usp[0]["reason"]["drivers"]}
|
||||||
|
|
||||||
|
def test_empty_input_empty_list(self) -> None:
|
||||||
|
assert _usp_from_deficits([], horizon_months=12) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── §10.4: _commercial_signal (degraded-honest, never crash) ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_commercial_metrics(
|
||||||
|
n_lots: int, sell_through_pct: float | None, confidence: str = "medium"
|
||||||
|
) -> MagicMock:
|
||||||
|
m = MagicMock()
|
||||||
|
m.n_lots = n_lots
|
||||||
|
m.sell_through_pct = sell_through_pct
|
||||||
|
m.confidence = confidence
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommercialSignal:
|
||||||
|
def test_thin_data_degraded_with_caveat(self) -> None:
|
||||||
|
# Мало нежилых лотов → degraded-honest, БЕЗ фабрикации доли.
|
||||||
|
with patch(_METRICS, return_value=_mk_commercial_metrics(5, None)):
|
||||||
|
out = _commercial_signal(MagicMock(), "Ленинский", 12)
|
||||||
|
assert out is not None
|
||||||
|
assert out["available"] is False
|
||||||
|
assert "нет достаточных данных" in out["caveat"]
|
||||||
|
assert "нежилое" in out["caveat"]
|
||||||
|
assert out["advisory"] is True
|
||||||
|
|
||||||
|
def test_engine_exception_degraded_never_crash(self) -> None:
|
||||||
|
# Движок бросает (не поддерживает commercial premise_kind) → честный degrade.
|
||||||
|
with patch(_METRICS, side_effect=RuntimeError("no premise")):
|
||||||
|
out = _commercial_signal(MagicMock(), "Ленинский", 12)
|
||||||
|
assert out is not None
|
||||||
|
assert out["available"] is False
|
||||||
|
assert out["advisory"] is True
|
||||||
|
|
||||||
|
def test_magicmock_metrics_does_not_crash(self) -> None:
|
||||||
|
# Нечисловой n_lots (MagicMock-атрибут) НЕ должен бросать TypeError.
|
||||||
|
with patch(_METRICS, return_value=MagicMock()):
|
||||||
|
out = _commercial_signal(MagicMock(), "Ленинский", 12)
|
||||||
|
assert out is not None
|
||||||
|
assert out["available"] is False # нечисловой → недостаток данных
|
||||||
|
|
||||||
|
def test_sufficient_data_advisory_share(self) -> None:
|
||||||
|
# Достаточно лотов + измеримая доля → советующая оценка + reason (НЕ crash).
|
||||||
|
with patch(_METRICS, return_value=_mk_commercial_metrics(120, 42.5, "medium")):
|
||||||
|
out = _commercial_signal(MagicMock(), "Ленинский", 12)
|
||||||
|
assert out is not None
|
||||||
|
assert out["available"] is True
|
||||||
|
assert out["commercial_share_pct"] == 42.5
|
||||||
|
assert out["n_lots"] == 120
|
||||||
|
assert out["confidence"] == "medium"
|
||||||
|
assert out["reason"]["advisory"] is True
|
||||||
|
assert out["advisory"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_forecast_overlay: #983 ADDITIVE-расширения присутствуют ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestOverlayForecast983Additions:
|
||||||
|
def _ranking(self) -> WhatToBuildRanking:
|
||||||
|
return _ranking(
|
||||||
|
[
|
||||||
|
_ranked(room_bucket="Студии 15-30", deficit_index=0.6, obj_class="комфорт"),
|
||||||
|
_ranked(room_bucket="2-к 45-60", deficit_index=0.2, obj_class="бизнес"),
|
||||||
|
_ranked(room_bucket="3-к 60-80", deficit_index=-0.3, obj_class="комфорт"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overlay_has_new_keys(self) -> None:
|
||||||
|
with patch(_RANK, return_value=self._ranking()):
|
||||||
|
out = build_forecast_overlay(
|
||||||
|
MagicMock(),
|
||||||
|
district="Ленинский",
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
horizon_months=12,
|
||||||
|
target_class=None,
|
||||||
|
)
|
||||||
|
assert "class_reco" in out
|
||||||
|
assert "usp" in out
|
||||||
|
assert "commercial" in out
|
||||||
|
|
||||||
|
def test_each_ranked_segment_has_reason(self) -> None:
|
||||||
|
with patch(_RANK, return_value=self._ranking()):
|
||||||
|
out = build_forecast_overlay(
|
||||||
|
MagicMock(),
|
||||||
|
district="Ленинский",
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
horizon_months=12,
|
||||||
|
target_class=None,
|
||||||
|
)
|
||||||
|
assert out["ranked_segments"]
|
||||||
|
for seg in out["ranked_segments"]:
|
||||||
|
assert seg["reason"]["advisory"] is True
|
||||||
|
assert "why" in seg["reason"]
|
||||||
|
assert "rejected" in seg["reason"]
|
||||||
|
|
||||||
|
def test_class_reco_present_with_ranked_data(self) -> None:
|
||||||
|
with patch(_RANK, return_value=self._ranking()):
|
||||||
|
out = build_forecast_overlay(
|
||||||
|
MagicMock(),
|
||||||
|
district="Ленинский",
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
horizon_months=12,
|
||||||
|
target_class=None,
|
||||||
|
)
|
||||||
|
assert out["class_reco"] is not None
|
||||||
|
assert out["class_reco"]["obj_class"] in ("комфорт", "бизнес")
|
||||||
|
|
||||||
|
def test_usp_top_k_with_ranked_data(self) -> None:
|
||||||
|
with patch(_RANK, return_value=self._ranking()):
|
||||||
|
out = build_forecast_overlay(
|
||||||
|
MagicMock(),
|
||||||
|
district="Ленинский",
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
horizon_months=12,
|
||||||
|
target_class=None,
|
||||||
|
)
|
||||||
|
assert 1 <= len(out["usp"]) <= _USP_TOP_K
|
||||||
|
assert out["usp"][0]["segment"] == "1-Студия" # сильнейший дефицит
|
||||||
|
|
||||||
|
def test_commercial_degraded_on_mock_db(self) -> None:
|
||||||
|
# MagicMock-db → нет реальных нежилых лотов → degraded-honest commercial.
|
||||||
|
with patch(_RANK, return_value=self._ranking()):
|
||||||
|
out = build_forecast_overlay(
|
||||||
|
MagicMock(),
|
||||||
|
district="Ленинский",
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
horizon_months=12,
|
||||||
|
target_class=None,
|
||||||
|
)
|
||||||
|
assert out["commercial"]["available"] is False
|
||||||
|
assert out["commercial"]["advisory"] is True
|
||||||
|
|
||||||
|
def test_empty_ranking_class_reco_none_usp_empty(self) -> None:
|
||||||
|
# Пустой ранкинг → class_reco None, usp [], но overlay не падает + commercial есть.
|
||||||
|
with patch(_RANK, return_value=_ranking([])):
|
||||||
|
out = build_forecast_overlay(
|
||||||
|
MagicMock(),
|
||||||
|
district="Ленинский",
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
horizon_months=12,
|
||||||
|
target_class=None,
|
||||||
|
)
|
||||||
|
assert out["class_reco"] is None
|
||||||
|
assert out["usp"] == []
|
||||||
|
assert out["commercial"] is not None
|
||||||
|
|
||||||
|
def test_982_keys_still_present_and_unchanged(self) -> None:
|
||||||
|
# #982-инвариант: исходные поля overlay на месте + advisory True + mode корректен.
|
||||||
|
with patch(_RANK, return_value=self._ranking()):
|
||||||
|
out = build_forecast_overlay(
|
||||||
|
MagicMock(),
|
||||||
|
district="Ленинский",
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
horizon_months=12,
|
||||||
|
target_class=None,
|
||||||
|
)
|
||||||
|
for key in ("horizon_months", "mode", "advisory", "ranked_segments", "warnings"):
|
||||||
|
assert key in out
|
||||||
|
assert out["mode"] == "demand_supply"
|
||||||
|
assert out["advisory"] is True
|
||||||
|
assert out["horizon_months"] == 12
|
||||||
|
|
||||||
|
def test_demand_only_reasons_note_supply_excluded(self) -> None:
|
||||||
|
# demand_only: per-segment reason оговаривает, что предложение не учтено.
|
||||||
|
with (
|
||||||
|
patch(_METRICS, return_value=_mk_metrics(4.0)),
|
||||||
|
patch(_GET_MACRO, return_value=[]),
|
||||||
|
patch(_HOLD, return_value={12: 18.0}),
|
||||||
|
patch(_NORM, return_value=_mk_coef(1.0)),
|
||||||
|
patch(_MACRO, return_value=_mk_coef(1.0)),
|
||||||
|
):
|
||||||
|
out = build_forecast_overlay(
|
||||||
|
MagicMock(),
|
||||||
|
district="Ленинский",
|
||||||
|
cad_num=None,
|
||||||
|
horizon_months=12,
|
||||||
|
target_class=None,
|
||||||
|
)
|
||||||
|
assert out["ranked_segments"]
|
||||||
|
assert all(
|
||||||
|
"предложение участка НЕ учтено" in s["reason"]["why"]
|
||||||
|
for s in out["ranked_segments"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overlay_with_983_fields_validates_against_schema(self) -> None:
|
||||||
|
from app.schemas.recommend import RecommendForecastOverlay
|
||||||
|
|
||||||
|
with patch(_RANK, return_value=self._ranking()):
|
||||||
|
out: dict[str, Any] = build_forecast_overlay(
|
||||||
|
MagicMock(),
|
||||||
|
district="Ленинский",
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
horizon_months=12,
|
||||||
|
target_class=None,
|
||||||
|
)
|
||||||
|
model = RecommendForecastOverlay.model_validate(out)
|
||||||
|
assert model.class_reco is not None
|
||||||
|
assert model.usp
|
||||||
|
assert model.commercial is not None
|
||||||
|
assert model.ranked_segments[0].reason is not None
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue