feat(site-finder): per-competitor relevance_weight (#949 PR B) #1000
3 changed files with 600 additions and 15 deletions
|
|
@ -181,6 +181,10 @@ class CompetitorsRequest(BaseModel):
|
|||
time_window: TimeWindow = "last_quarter"
|
||||
obj_class_filter: ObjClassFilter | None = None
|
||||
exclude_obj_ids: list[int] = Field(default_factory=list)
|
||||
# #949 PR B (ТЗ §9.1): горизонт запуска (мес.) для horizon-aware
|
||||
# relevance_weight. Конкурент, который к нашему запуску будет распродан,
|
||||
# менее релевантен (см. stage_at_horizon в competitors.py).
|
||||
horizon_months: int = Field(default=12, ge=1, le=60)
|
||||
|
||||
|
||||
class Competitor(BaseModel):
|
||||
|
|
@ -200,6 +204,12 @@ class Competitor(BaseModel):
|
|||
velocity_per_month: float
|
||||
avg_price_per_m2: float | None
|
||||
is_active: bool
|
||||
# #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
|
||||
# взвешенная комбинация geo_proximity / class_similarity / price_similarity /
|
||||
# stage_at_horizon. None только если расчёт недоступен (backward-safe).
|
||||
# relevance_breakdown — explainability: 4 саб-скора (каждый 0..1).
|
||||
relevance_weight: float | None = None
|
||||
relevance_breakdown: dict[str, float] | None = None
|
||||
|
||||
|
||||
class CompetitorsSummary(BaseModel):
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ objective_complex_mapping. LEFT JOIN гарантирует velocity=0 (не о
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -40,6 +41,215 @@ _TIME_WINDOW_MONTHS: dict[str, float] = {
|
|||
# site_status значения, считающиеся «активными»
|
||||
_ACTIVE_STATUSES = frozenset({"sales", "construction"})
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# #949 PR B (ТЗ §9.1, §16) — relevance_weight: детерминированная (без LLM)
|
||||
# взвешенная оценка релевантности конкурента 0..1 + explainability-breakdown.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Веса саб-скоров для итогового relevance_weight (named constants — tunable).
|
||||
# Сумма = 1.0; geo и stage_at_horizon — главные сигналы (близость + «будет ли
|
||||
# конкурировать к нашему запуску»), class/price — корректировки сопоставимости.
|
||||
_W_GEO: float = 0.30
|
||||
_W_CLASS: float = 0.25
|
||||
_W_PRICE: float = 0.20
|
||||
_W_STAGE: float = 0.25
|
||||
|
||||
# Нейтральный саб-скор при недостатке данных (graceful: не штрафуем и не
|
||||
# завышаем конкурента, по которому нет сигнала — ТЗ §15 spirit).
|
||||
_NEUTRAL: float = 0.5
|
||||
|
||||
# Детерминированный порядок класса жилья (домрф): эконом < комфорт < комфорт+ <
|
||||
# бизнес < бизнес+ < премиум/элит. class_similarity считается по дистанции в этом
|
||||
# порядке. Ключи нормализуются через _normalize_class (lower + синонимы домрф/EN,
|
||||
# чтобы 'Комфорт'/'comfort'/'комфорт-класс' попадали в один индекс).
|
||||
_CLASS_ORDER: dict[str, int] = {
|
||||
"эконом": 0,
|
||||
"стандарт": 0, # домрф канон для эконома (см. domrf_kn._OBJ_CLASS_PATTERNS)
|
||||
"типовой": 0,
|
||||
"комфорт": 1,
|
||||
"комфорт+": 2,
|
||||
"бизнес": 3,
|
||||
"бизнес+": 4,
|
||||
"премиум": 5,
|
||||
"элит": 5,
|
||||
}
|
||||
|
||||
# class_similarity по дистанции шагов в _CLASS_ORDER: 0 шагов (тот же класс) → 1.0,
|
||||
# 1 шаг (соседний, комфорт↔комфорт+) → 0.6, 2 → 0.3, ≥3 → 0.1.
|
||||
_CLASS_SIM_BY_STEPS: dict[int, float] = {0: 1.0, 1: 0.6, 2: 0.3}
|
||||
_CLASS_SIM_FAR: float = 0.1
|
||||
|
||||
# Синонимы для нормализации obj_class к ключам _CLASS_ORDER. obj_class в данных
|
||||
# приходит и кириллицей ('Комфорт'), и латиницей (фильтр API economy/comfort/
|
||||
# business), и с суффиксами ('комфорт-класс'). Сводим к каноническому ключу.
|
||||
_CLASS_SYNONYMS: dict[str, str] = {
|
||||
"economy": "эконом",
|
||||
"econom": "эконом",
|
||||
"standard": "стандарт",
|
||||
"comfort": "комфорт",
|
||||
"comfortplus": "комфорт+",
|
||||
"business": "бизнес",
|
||||
"businessplus": "бизнес+",
|
||||
"premium": "премиум",
|
||||
"elite": "элит",
|
||||
"elit": "элит",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_class(obj_class: str | None) -> str | None:
|
||||
"""Привести obj_class к каноническому ключу _CLASS_ORDER.
|
||||
|
||||
Терпимо к регистру, латинице (economy/comfort/business из API-фильтра),
|
||||
суффиксам ('комфорт-класс', '«Бизнес»') и плюс-формам ('комфорт +',
|
||||
'комфорт плюс' → 'комфорт+'). Возвращает None если класс не распознан.
|
||||
"""
|
||||
if not obj_class:
|
||||
return None
|
||||
s = obj_class.strip().lower()
|
||||
# Унифицируем плюс-формы до проверки прямого вхождения.
|
||||
s = s.replace(" плюс", "+").replace("плюс", "+").replace(" +", "+")
|
||||
if s in _CLASS_ORDER:
|
||||
return s
|
||||
if s in _CLASS_SYNONYMS:
|
||||
return _CLASS_SYNONYMS[s]
|
||||
# Подстрочный матч по канон-ключам (длинные ключи первыми: 'комфорт+'
|
||||
# раньше 'комфорт', иначе 'комфорт-класс плюс' схлопнется в 'комфорт').
|
||||
for key in sorted(_CLASS_ORDER, key=len, reverse=True):
|
||||
if key in s:
|
||||
return key
|
||||
for syn, key in _CLASS_SYNONYMS.items():
|
||||
if syn in s:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _geo_proximity(distance_m: float | None, radius_m: float) -> float:
|
||||
"""geo_proximity ∈ [0,1] — линейный decay расстояния.
|
||||
|
||||
1.0 на участке (distance 0), → 0.0 на краю радиуса. За радиусом / без
|
||||
данных → нейтрально не нужно: расстояние всегда известно (PostGIS), но
|
||||
защищаемся от None/нулевого радиуса. clamp в [0,1].
|
||||
"""
|
||||
if distance_m is None or radius_m <= 0:
|
||||
return _NEUTRAL
|
||||
return max(0.0, min(1.0, 1.0 - distance_m / radius_m))
|
||||
|
||||
|
||||
def _class_similarity(competitor_class: str | None, reference_class: str | None) -> float:
|
||||
"""class_similarity ∈ [0,1] по дистанции в _CLASS_ORDER.
|
||||
|
||||
Тот же класс → 1.0, соседний (1 шаг) → 0.6, 2 шага → 0.3, дальше → 0.1.
|
||||
Если класс конкурента или эталон неизвестен/нераспознан → нейтрально 0.5
|
||||
(нет сигнала о сопоставимости — не штрафуем).
|
||||
|
||||
reference_class: эталон сравнения. На этой стадии целевой класс участка ещё
|
||||
не выбран (нет target), поэтому caller передаёт ЛОКАЛЬНЫЙ ДОМИНИРУЮЩИЙ класс
|
||||
(самый частый среди конкурентов в радиусе) — так скор остаётся осмысленным
|
||||
«насколько конкурент типичен для этой локации».
|
||||
"""
|
||||
c = _normalize_class(competitor_class)
|
||||
r = _normalize_class(reference_class)
|
||||
if c is None or r is None:
|
||||
return _NEUTRAL
|
||||
steps = abs(_CLASS_ORDER[c] - _CLASS_ORDER[r])
|
||||
return _CLASS_SIM_BY_STEPS.get(steps, _CLASS_SIM_FAR)
|
||||
|
||||
|
||||
def _price_similarity(competitor_ppm2: float | None, median_ppm2: float | None) -> float:
|
||||
"""price_similarity ∈ [0,1] — близость цены конкурента к локальной медиане.
|
||||
|
||||
1 - min(1, |c_price - median| / median): на медиане → 1.0, отклонение в
|
||||
100%+ → 0.0. Нет цены конкурента или невалидная медиана → нейтрально 0.5.
|
||||
"""
|
||||
if competitor_ppm2 is None or not median_ppm2 or median_ppm2 <= 0:
|
||||
return _NEUTRAL
|
||||
rel_diff = abs(competitor_ppm2 - median_ppm2) / median_ppm2
|
||||
return max(0.0, 1.0 - min(1.0, rel_diff))
|
||||
|
||||
|
||||
def _stage_at_horizon(
|
||||
available: float | None,
|
||||
velocity_per_month: float | None,
|
||||
horizon_months: int,
|
||||
flats_total: float | None,
|
||||
) -> float:
|
||||
"""stage_at_horizon ∈ [0,1] — horizon-aware ключевой фактор (ТЗ §9.1).
|
||||
|
||||
Проецируем распродажу конкурента на горизонт нашего запуска:
|
||||
projected_remaining = max(0, available - velocity * horizon_months)
|
||||
Скор = доля проектируемого остатка от изначального объёма
|
||||
(projected_remaining / flats_total). Конкурент, у которого к запуску ещё
|
||||
значимый остаток → высокий скор (он БУДЕТ конкурировать); проектируемо
|
||||
распроданный → низкий.
|
||||
|
||||
Graceful: если velocity или available/total неизвестны (тонкие данные) →
|
||||
нейтрально 0.5 — не переоцениваем и не недооцениваем (ТЗ §15). clamp [0,1].
|
||||
"""
|
||||
if available is None or velocity_per_month is None or flats_total is None or flats_total <= 0:
|
||||
return _NEUTRAL
|
||||
if velocity_per_month <= 0:
|
||||
# Нет продаж: остаток не уменьшается — конкурент останется на рынке
|
||||
# (макс. релевантность по этому фактору).
|
||||
return max(0.0, min(1.0, available / flats_total))
|
||||
projected_remaining = max(0.0, available - velocity_per_month * float(horizon_months))
|
||||
return max(0.0, min(1.0, projected_remaining / flats_total))
|
||||
|
||||
|
||||
def _relevance_weight(geo: float, class_sim: float, price: float, stage: float) -> float:
|
||||
"""Взвешенное среднее 4 саб-скоров → relevance_weight ∈ [0,1].
|
||||
|
||||
Веса — named constants (_W_GEO/_W_CLASS/_W_PRICE/_W_STAGE), сумма 1.0.
|
||||
Все входы предполагаются уже clamped в [0,1]; результат тоже clamp для
|
||||
устойчивости к будущей подстройке весов.
|
||||
"""
|
||||
weighted = geo * _W_GEO + class_sim * _W_CLASS + price * _W_PRICE + stage * _W_STAGE
|
||||
return max(0.0, min(1.0, weighted))
|
||||
|
||||
|
||||
def _dominant_class(obj_classes: list[str | None]) -> str | None:
|
||||
"""Локальный доминирующий класс — самый частый нормализованный obj_class.
|
||||
|
||||
Используется как эталон class_similarity, когда целевой класс участка ещё
|
||||
не задан. None если ни один класс не распознан. При ничьей берётся класс с
|
||||
наименьшим индексом в _CLASS_ORDER (детерминированно, без рандома).
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for raw in obj_classes:
|
||||
norm = _normalize_class(raw)
|
||||
if norm is not None:
|
||||
counts[norm] = counts.get(norm, 0) + 1
|
||||
if not counts:
|
||||
return None
|
||||
# Сортируем: больше частота → раньше; при равной частоте меньший order-индекс.
|
||||
return min(counts, key=lambda k: (-counts[k], _CLASS_ORDER[k]))
|
||||
|
||||
|
||||
def _median(values: list[float]) -> float | None:
|
||||
"""Детерминированная медиана непустого списка. Пусто → None."""
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
n = len(ordered)
|
||||
mid = n // 2
|
||||
if n % 2 == 1:
|
||||
return ordered[mid]
|
||||
return (ordered[mid - 1] + ordered[mid]) / 2.0
|
||||
|
||||
|
||||
def _row_get(row: Any, key: str) -> Any:
|
||||
"""Безопасно прочитать ключ из row-mapping → None если ключа нет.
|
||||
|
||||
Используем ТОЛЬКО __getitem__: и SQLAlchemy RowMapping, и тестовые
|
||||
MagicMock-строки реализуют его корректно и кидают KeyError на отсутствующий
|
||||
ключ. (Не используем .get(): на MagicMock это авто-атрибут-заглушка,
|
||||
возвращающая MagicMock вместо реального значения.) Нужно для backward-safe
|
||||
чтения новой колонки flats_sold — старые моки её просто не отдают."""
|
||||
try:
|
||||
return row[key]
|
||||
except (KeyError, TypeError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
# SQL для получения центроида участка
|
||||
_PARCEL_CENTROID_SQL = text("""
|
||||
SELECT ST_X(pt) AS lon, ST_Y(pt) AS lat
|
||||
|
|
@ -136,13 +346,23 @@ _COMPETITORS_SQL = text("""
|
|||
# Фильтр status='sold' убран: поле status в domrf_kn_flats заполнено в ~0.2% строк
|
||||
# (99.8% NULL) — фильтр давал 0 строк и avg_price_per_m2 всегда None (Issue #112/227).
|
||||
# AVG по всем квартирам с price_per_m2 IS NOT NULL даёт корректную среднюю цену ЖК.
|
||||
#
|
||||
# #949 PR B: добавлена колонка flats_sold (COUNT квартир со status LIKE '%прод%')
|
||||
# — нужна для stage_at_horizon (available = flat_count - flats_sold). status
|
||||
# заполнен слабо (~0.2%), поэтому flats_sold чаще будет 0/мало → stage_at_horizon
|
||||
# падает в нейтраль 0.5 (graceful, см. helper). Колонка добавлена в ТОТ ЖЕ запрос
|
||||
# (без 4-го db.execute), чтобы не ломать существующие callers/тесты.
|
||||
_AVG_PRICE_SQL = text("""
|
||||
SELECT
|
||||
f.obj_id,
|
||||
AVG(f.price_per_m2) AS avg_price_per_m2
|
||||
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
|
||||
AS avg_price_per_m2,
|
||||
COUNT(*) FILTER (
|
||||
WHERE LOWER(COALESCE(f.status, '')) LIKE '%прод%'
|
||||
OR LOWER(COALESCE(f.status, '')) = 'sold'
|
||||
) AS flats_sold
|
||||
FROM domrf_kn_flats f
|
||||
WHERE f.obj_id = ANY(:obj_ids)
|
||||
AND f.price_per_m2 IS NOT NULL
|
||||
GROUP BY f.obj_id
|
||||
""")
|
||||
|
||||
|
|
@ -245,22 +465,34 @@ def get_competitors(
|
|||
|
||||
obj_ids: list[int] = [int(r["obj_id"]) for r in rows]
|
||||
|
||||
# ── 4. Средняя цена м² (graceful — таблица может быть не заполнена) ──────
|
||||
# ── 4. Средняя цена м² + sold-count (graceful — таблица может быть пуста) ─
|
||||
# flats_sold нужен для stage_at_horizon (available = flat_count - flats_sold).
|
||||
# Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2,
|
||||
# отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage.
|
||||
avg_price_map: dict[int, float] = {}
|
||||
sold_count_map: dict[int, int] = {}
|
||||
try:
|
||||
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
||||
avg_price_map = {
|
||||
int(r["obj_id"]): float(r["avg_price_per_m2"])
|
||||
for r in price_rows
|
||||
if r["avg_price_per_m2"] is not None
|
||||
}
|
||||
for r in price_rows:
|
||||
oid = int(r["obj_id"])
|
||||
price = _row_get(r, "avg_price_per_m2")
|
||||
if price is not None:
|
||||
avg_price_map[oid] = float(price)
|
||||
sold = _row_get(r, "flats_sold")
|
||||
if sold is not None:
|
||||
sold_count_map[oid] = int(sold)
|
||||
except Exception:
|
||||
logger.warning("competitors: avg_price query failed, continuing without prices")
|
||||
|
||||
# ── 5. Сборка результата ─────────────────────────────────────────────────
|
||||
# flats_sold / sold_pct: не доступны из domrf_kn_objects (только flat_count).
|
||||
# Можно получить через COUNT(domrf_kn_flats WHERE status='sold') —
|
||||
# отложено за MVP, поля остаются None.
|
||||
# ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─
|
||||
radius_m = request.radius_km * 1000.0
|
||||
local_median_ppm2 = _median(list(avg_price_map.values()))
|
||||
dominant_class = _dominant_class([r["obj_class"] for r in rows])
|
||||
|
||||
# ── 5. Сборка результата + relevance_weight (ТЗ §9.1) ────────────────────
|
||||
# flats_sold: COUNT из domrf_kn_flats по status LIKE '%прод%' (заполнен слабо
|
||||
# ~0.2%, поэтому чаще 0/None). При known flats_sold считаем sold_pct и
|
||||
# available для stage_at_horizon; иначе оба поля None и stage → нейтраль 0.5.
|
||||
competitors: list[Competitor] = []
|
||||
for r in rows:
|
||||
obj_id = int(r["obj_id"])
|
||||
|
|
@ -269,6 +501,37 @@ def get_competitors(
|
|||
site_status = r["site_status"]
|
||||
is_active = site_status in _ACTIVE_STATUSES if site_status else False
|
||||
|
||||
velocity = round(float(r["velocity_per_month"]), 2)
|
||||
|
||||
# flats_sold / sold_pct / available — только если sold-count реально есть
|
||||
# И он осмыслен (не превышает total). Иначе None (graceful, не выдумываем).
|
||||
flats_sold: int | None = None
|
||||
sold_pct: float | None = None
|
||||
available: float | None = None
|
||||
raw_sold = sold_count_map.get(obj_id)
|
||||
if raw_sold is not None and flats_total and flats_total > 0 and raw_sold <= flats_total:
|
||||
flats_sold = raw_sold
|
||||
sold_pct = round(raw_sold / flats_total * 100.0, 1)
|
||||
available = float(flats_total - raw_sold)
|
||||
|
||||
# ── relevance саб-скоры (каждый 0..1) + взвешенный weight ─────────────
|
||||
geo = _geo_proximity(float(r["distance_m"]), radius_m)
|
||||
class_sim = _class_similarity(r["obj_class"], dominant_class)
|
||||
price = _price_similarity(avg_price_map.get(obj_id), local_median_ppm2)
|
||||
stage = _stage_at_horizon(
|
||||
available=available,
|
||||
velocity_per_month=velocity,
|
||||
horizon_months=request.horizon_months,
|
||||
flats_total=float(flats_total) if flats_total else None,
|
||||
)
|
||||
relevance_weight = _relevance_weight(geo, class_sim, price, stage)
|
||||
relevance_breakdown = {
|
||||
"geo_proximity": round(geo, 3),
|
||||
"class_similarity": round(class_sim, 3),
|
||||
"price_similarity": round(price, 3),
|
||||
"stage_at_horizon": round(stage, 3),
|
||||
}
|
||||
|
||||
competitors.append(
|
||||
Competitor(
|
||||
obj_id=obj_id,
|
||||
|
|
@ -280,14 +543,20 @@ def get_competitors(
|
|||
lng=float(r["longitude"]),
|
||||
stage=site_status,
|
||||
flats_total=flats_total,
|
||||
flats_sold=None,
|
||||
sold_pct=None,
|
||||
velocity_per_month=round(float(r["velocity_per_month"]), 2),
|
||||
flats_sold=flats_sold,
|
||||
sold_pct=sold_pct,
|
||||
velocity_per_month=velocity,
|
||||
avg_price_per_m2=avg_price_map.get(obj_id),
|
||||
is_active=is_active,
|
||||
relevance_weight=round(relevance_weight, 3),
|
||||
relevance_breakdown=relevance_breakdown,
|
||||
)
|
||||
)
|
||||
|
||||
# ── 5b. Сортировка по relevance_weight DESC (distance остаётся в полях) ────
|
||||
# tie-break по distance ASC (ближе — раньше), затем obj_id для стабильности.
|
||||
competitors.sort(key=lambda c: (-(c.relevance_weight or 0.0), c.distance_m, c.obj_id))
|
||||
|
||||
# ── 6. Summary ───────────────────────────────────────────────────────────
|
||||
active_count = sum(1 for c in competitors if c.is_active)
|
||||
total_velocity = sum(c.velocity_per_month for c in competitors)
|
||||
|
|
|
|||
306
backend/tests/services/site_finder/test_competitor_relevance.py
Normal file
306
backend/tests/services/site_finder/test_competitor_relevance.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"""Unit-тесты relevance_weight конкурентов (#949 PR B, ТЗ §9.1, §16).
|
||||
|
||||
Чистые тесты pure-хелперов (без живой БД, без app.main):
|
||||
• _normalize_class — нормализация obj_class (RU/EN/суффиксы/plus-формы).
|
||||
• _geo_proximity — линейный decay расстояния (parcel→1, край радиуса→0).
|
||||
• _class_similarity — карта по шагам в _CLASS_ORDER (same/adjacent/2/far),
|
||||
нейтраль 0.5 при неизвестном классе.
|
||||
• _price_similarity — близость к локальной медиане; нейтраль при None.
|
||||
• _stage_at_horizon — horizon-aware проекция распродажи (sold-out→low,
|
||||
активный→high, тонкие данные→0.5).
|
||||
• _relevance_weight — взвешенное среднее (named weights, сумма 1.0).
|
||||
• _dominant_class / _median — детерминированные эталоны.
|
||||
|
||||
Детерминированно, без LLM. Каждый граничный/тонкий кейс проверяет graceful
|
||||
поведение (нейтраль 0.5 / clamp, никогда не crash).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.site_finder.competitors import (
|
||||
_CLASS_ORDER,
|
||||
_NEUTRAL,
|
||||
_W_CLASS,
|
||||
_W_GEO,
|
||||
_W_PRICE,
|
||||
_W_STAGE,
|
||||
_class_similarity,
|
||||
_dominant_class,
|
||||
_geo_proximity,
|
||||
_median,
|
||||
_normalize_class,
|
||||
_price_similarity,
|
||||
_relevance_weight,
|
||||
_stage_at_horizon,
|
||||
)
|
||||
|
||||
# ── weights sanity ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_weights_sum_to_one() -> None:
|
||||
"""Named-веса саб-скоров образуют выпуклую комбинацию (сумма = 1.0)."""
|
||||
assert _W_GEO + _W_CLASS + _W_PRICE + _W_STAGE == pytest.approx(1.0)
|
||||
|
||||
|
||||
# ── _normalize_class ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeClass:
|
||||
def test_canonical_ru(self) -> None:
|
||||
assert _normalize_class("комфорт") == "комфорт"
|
||||
assert _normalize_class("Бизнес") == "бизнес"
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
assert _normalize_class("КОМФОРТ") == "комфорт"
|
||||
|
||||
def test_english_synonyms(self) -> None:
|
||||
# API-фильтр шлёт economy/comfort/business.
|
||||
assert _normalize_class("economy") == "эконом"
|
||||
assert _normalize_class("comfort") == "комфорт"
|
||||
assert _normalize_class("business") == "бизнес"
|
||||
assert _normalize_class("premium") == "премиум"
|
||||
|
||||
def test_plus_forms(self) -> None:
|
||||
assert _normalize_class("комфорт+") == "комфорт+"
|
||||
assert _normalize_class("комфорт плюс") == "комфорт+"
|
||||
assert _normalize_class("Бизнес +") == "бизнес+"
|
||||
|
||||
def test_substring_suffix(self) -> None:
|
||||
# «комфорт-класс», «класс «Бизнес»» из домрф aiDescription.
|
||||
assert _normalize_class("комфорт-класс") == "комфорт"
|
||||
assert _normalize_class("класс «Бизнес»") == "бизнес"
|
||||
|
||||
def test_plus_substring_not_swallowed_to_base(self) -> None:
|
||||
# 'комфорт+' должен распознаться раньше 'комфорт' (длинный ключ первым).
|
||||
assert _normalize_class("жильё комфорт+ класса") == "комфорт+"
|
||||
|
||||
def test_domrf_econom_synonyms(self) -> None:
|
||||
# домрф канон эконома: стандарт / типовой (см. _OBJ_CLASS_PATTERNS).
|
||||
assert _normalize_class("стандарт") == "стандарт"
|
||||
assert _normalize_class("типовой") == "типовой"
|
||||
|
||||
def test_none_and_empty(self) -> None:
|
||||
assert _normalize_class(None) is None
|
||||
assert _normalize_class("") is None
|
||||
assert _normalize_class(" ") is None
|
||||
|
||||
def test_unknown_returns_none(self) -> None:
|
||||
assert _normalize_class("люксовый-апарт") is None
|
||||
|
||||
|
||||
# ── _geo_proximity ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGeoProximity:
|
||||
def test_at_parcel_is_one(self) -> None:
|
||||
assert _geo_proximity(0.0, 1000.0) == 1.0
|
||||
|
||||
def test_at_radius_edge_is_zero(self) -> None:
|
||||
assert _geo_proximity(1000.0, 1000.0) == 0.0
|
||||
|
||||
def test_midpoint(self) -> None:
|
||||
assert _geo_proximity(500.0, 1000.0) == pytest.approx(0.5)
|
||||
|
||||
def test_beyond_radius_clamps_to_zero(self) -> None:
|
||||
# За радиусом (не должно случаться — SQL фильтрует) → clamp 0, не negative.
|
||||
assert _geo_proximity(1500.0, 1000.0) == 0.0
|
||||
|
||||
def test_none_distance_neutral(self) -> None:
|
||||
assert _geo_proximity(None, 1000.0) == _NEUTRAL
|
||||
|
||||
def test_zero_radius_neutral(self) -> None:
|
||||
assert _geo_proximity(100.0, 0.0) == _NEUTRAL
|
||||
|
||||
|
||||
# ── _class_similarity ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClassSimilarity:
|
||||
def test_same_class(self) -> None:
|
||||
assert _class_similarity("комфорт", "комфорт") == 1.0
|
||||
|
||||
def test_adjacent_class(self) -> None:
|
||||
# комфорт(1) ↔ комфорт+(2) = 1 шаг → 0.6.
|
||||
assert _class_similarity("комфорт", "комфорт+") == pytest.approx(0.6)
|
||||
assert _class_similarity("комфорт+", "комфорт") == pytest.approx(0.6)
|
||||
|
||||
def test_two_steps(self) -> None:
|
||||
# эконом(0) ↔ комфорт+(2) = 2 шага → 0.3.
|
||||
assert _class_similarity("эконом", "комфорт+") == pytest.approx(0.3)
|
||||
|
||||
def test_far_class(self) -> None:
|
||||
# эконом(0) ↔ премиум(5) = 5 шагов → 0.1.
|
||||
assert _class_similarity("эконом", "премиум") == pytest.approx(0.1)
|
||||
# бизнес(3) ↔ эконом(0) = 3 шага → far 0.1.
|
||||
assert _class_similarity("бизнес", "эконом") == pytest.approx(0.1)
|
||||
|
||||
def test_cross_language_match(self) -> None:
|
||||
# comp латиницей, эталон кириллицей → один класс → 1.0.
|
||||
assert _class_similarity("comfort", "Комфорт") == 1.0
|
||||
|
||||
def test_unknown_competitor_neutral(self) -> None:
|
||||
assert _class_similarity(None, "комфорт") == _NEUTRAL
|
||||
assert _class_similarity("неведомый", "комфорт") == _NEUTRAL
|
||||
|
||||
def test_unknown_reference_neutral(self) -> None:
|
||||
assert _class_similarity("комфорт", None) == _NEUTRAL
|
||||
|
||||
|
||||
# ── _price_similarity ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPriceSimilarity:
|
||||
def test_on_median_is_one(self) -> None:
|
||||
assert _price_similarity(100_000.0, 100_000.0) == 1.0
|
||||
|
||||
def test_fifty_pct_above(self) -> None:
|
||||
# |150k-100k|/100k = 0.5 → 1-0.5 = 0.5.
|
||||
assert _price_similarity(150_000.0, 100_000.0) == pytest.approx(0.5)
|
||||
|
||||
def test_fifty_pct_below_symmetric(self) -> None:
|
||||
assert _price_similarity(50_000.0, 100_000.0) == pytest.approx(0.5)
|
||||
|
||||
def test_double_or_more_clamps_to_zero(self) -> None:
|
||||
# +100% и больше → clamp 0 (не отрицательное).
|
||||
assert _price_similarity(200_000.0, 100_000.0) == 0.0
|
||||
assert _price_similarity(500_000.0, 100_000.0) == 0.0
|
||||
|
||||
def test_none_competitor_price_neutral(self) -> None:
|
||||
assert _price_similarity(None, 100_000.0) == _NEUTRAL
|
||||
|
||||
def test_none_or_zero_median_neutral(self) -> None:
|
||||
assert _price_similarity(100_000.0, None) == _NEUTRAL
|
||||
assert _price_similarity(100_000.0, 0.0) == _NEUTRAL
|
||||
|
||||
|
||||
# ── _stage_at_horizon (horizon-aware ключевой фактор) ──────────────────────────
|
||||
|
||||
|
||||
class TestStageAtHorizon:
|
||||
def test_projected_sold_out_is_low(self) -> None:
|
||||
# 10 доступно, velocity 5/мес × 12 = 60 → остаток 0 → score 0.
|
||||
assert _stage_at_horizon(10.0, 5.0, 12, 100.0) == 0.0
|
||||
|
||||
def test_active_competitor_high(self) -> None:
|
||||
# 80 доступно, velocity 1/мес × 12 = 12 → остаток 68 из 100 → 0.68.
|
||||
assert _stage_at_horizon(80.0, 1.0, 12, 100.0) == pytest.approx(0.68)
|
||||
|
||||
def test_partial_burn_down(self) -> None:
|
||||
# 60 доступно, velocity 2/мес × 12 = 24 → остаток 36 из 100 → 0.36.
|
||||
assert _stage_at_horizon(60.0, 2.0, 12, 100.0) == pytest.approx(0.36)
|
||||
|
||||
def test_zero_velocity_keeps_inventory(self) -> None:
|
||||
# Нет продаж → остаток не уменьшается → доля доступного (50/100=0.5).
|
||||
assert _stage_at_horizon(50.0, 0.0, 12, 100.0) == pytest.approx(0.5)
|
||||
|
||||
def test_thin_data_velocity_none_neutral(self) -> None:
|
||||
assert _stage_at_horizon(50.0, None, 12, 100.0) == _NEUTRAL
|
||||
|
||||
def test_thin_data_available_none_neutral(self) -> None:
|
||||
assert _stage_at_horizon(None, 5.0, 12, 100.0) == _NEUTRAL
|
||||
|
||||
def test_thin_data_total_none_neutral(self) -> None:
|
||||
assert _stage_at_horizon(50.0, 5.0, 12, None) == _NEUTRAL
|
||||
|
||||
def test_zero_total_neutral_no_crash(self) -> None:
|
||||
assert _stage_at_horizon(0.0, 5.0, 12, 0.0) == _NEUTRAL
|
||||
|
||||
def test_horizon_affects_projection(self) -> None:
|
||||
# Тот же конкурент: короткий горизонт → больше остаток → выше score.
|
||||
short = _stage_at_horizon(60.0, 2.0, 6, 100.0) # 60-12=48 → 0.48
|
||||
long = _stage_at_horizon(60.0, 2.0, 24, 100.0) # 60-48=12 → 0.12
|
||||
assert short > long
|
||||
assert short == pytest.approx(0.48)
|
||||
assert long == pytest.approx(0.12)
|
||||
|
||||
def test_remaining_clamped_non_negative(self) -> None:
|
||||
# velocity огромная → projected_remaining clamp 0 (не отрицательный score).
|
||||
assert _stage_at_horizon(5.0, 100.0, 12, 100.0) == 0.0
|
||||
|
||||
|
||||
# ── _relevance_weight (взвешенная комбинация) ──────────────────────────────────
|
||||
|
||||
|
||||
class TestRelevanceWeight:
|
||||
def test_all_ones(self) -> None:
|
||||
assert _relevance_weight(1.0, 1.0, 1.0, 1.0) == pytest.approx(1.0)
|
||||
|
||||
def test_all_zeros(self) -> None:
|
||||
assert _relevance_weight(0.0, 0.0, 0.0, 0.0) == 0.0
|
||||
|
||||
def test_geo_only_equals_geo_weight(self) -> None:
|
||||
assert _relevance_weight(1.0, 0.0, 0.0, 0.0) == pytest.approx(_W_GEO)
|
||||
|
||||
def test_stage_only_equals_stage_weight(self) -> None:
|
||||
assert _relevance_weight(0.0, 0.0, 0.0, 1.0) == pytest.approx(_W_STAGE)
|
||||
|
||||
def test_weighted_mix(self) -> None:
|
||||
# geo 0.30*1 + class 0.25*0.6 + price 0.20*0.5 + stage 0.25*0 = 0.55.
|
||||
expected = _W_GEO * 1.0 + _W_CLASS * 0.6 + _W_PRICE * 0.5 + _W_STAGE * 0.0
|
||||
assert _relevance_weight(1.0, 0.6, 0.5, 0.0) == pytest.approx(expected)
|
||||
|
||||
def test_result_clamped(self) -> None:
|
||||
# Даже при «грязных» входах результат остаётся в [0,1].
|
||||
assert 0.0 <= _relevance_weight(2.0, 2.0, 2.0, 2.0) <= 1.0
|
||||
assert _relevance_weight(2.0, 2.0, 2.0, 2.0) == 1.0
|
||||
|
||||
def test_neutral_all_subscores_gives_neutral(self) -> None:
|
||||
# Полностью тонкие данные (все 0.5) → итог 0.5 (не штраф, не завышение).
|
||||
assert _relevance_weight(_NEUTRAL, _NEUTRAL, _NEUTRAL, _NEUTRAL) == pytest.approx(0.5)
|
||||
|
||||
|
||||
# ── _dominant_class / _median ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDominantClass:
|
||||
def test_most_common(self) -> None:
|
||||
assert _dominant_class(["комфорт", "комфорт", "бизнес"]) == "комфорт"
|
||||
|
||||
def test_mixed_languages_counted_together(self) -> None:
|
||||
# comfort + комфорт = один нормализованный класс → доминирует.
|
||||
assert _dominant_class(["comfort", "комфорт", "бизнес"]) == "комфорт"
|
||||
|
||||
def test_ignores_unknown_and_none(self) -> None:
|
||||
assert _dominant_class([None, "ерунда", "бизнес"]) == "бизнес"
|
||||
|
||||
def test_all_unknown_returns_none(self) -> None:
|
||||
assert _dominant_class([None, "ерунда", ""]) is None
|
||||
|
||||
def test_empty_returns_none(self) -> None:
|
||||
assert _dominant_class([]) is None
|
||||
|
||||
def test_tie_breaks_by_lowest_order_deterministic(self) -> None:
|
||||
# Ничья эконом(0) vs бизнес(3) → меньший order-индекс (эконом).
|
||||
assert _dominant_class(["эконом", "бизнес"]) == "эконом"
|
||||
# Порядок входа не влияет — детерминированно.
|
||||
assert _dominant_class(["бизнес", "эконом"]) == "эконом"
|
||||
|
||||
|
||||
class TestMedian:
|
||||
def test_odd(self) -> None:
|
||||
assert _median([3.0, 1.0, 2.0]) == 2.0
|
||||
|
||||
def test_even(self) -> None:
|
||||
assert _median([1.0, 2.0, 3.0, 4.0]) == pytest.approx(2.5)
|
||||
|
||||
def test_single(self) -> None:
|
||||
assert _median([42.0]) == 42.0
|
||||
|
||||
def test_empty_none(self) -> None:
|
||||
assert _median([]) is None
|
||||
|
||||
|
||||
# ── class-order карта (документируется в отчёте) ───────────────────────────────
|
||||
|
||||
|
||||
def test_class_order_is_monotonic_ladder() -> None:
|
||||
"""эконом < комфорт < комфорт+ < бизнес < бизнес+ < премиум."""
|
||||
assert _CLASS_ORDER["эконом"] < _CLASS_ORDER["комфорт"] < _CLASS_ORDER["комфорт+"]
|
||||
assert _CLASS_ORDER["комфорт+"] < _CLASS_ORDER["бизнес"] < _CLASS_ORDER["бизнес+"]
|
||||
assert _CLASS_ORDER["бизнес+"] < _CLASS_ORDER["премиум"]
|
||||
Loading…
Add table
Reference in a new issue