fix(tradein/v2): канонический sources_used — стабильный счётчик «ИСТОЧНИКОВ X/7» (#2087 M1) #2144
4 changed files with 130 additions and 14 deletions
|
|
@ -235,6 +235,7 @@ def get_estimate(
|
|||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
||||
|
||||
from app.services.estimator import (
|
||||
_canonical_sources,
|
||||
_cv_from_ppm2,
|
||||
_fetch_dkp_corridor,
|
||||
_fetch_house_imv_anchor,
|
||||
|
|
@ -252,6 +253,15 @@ def get_estimate(
|
|||
# в строке. created_at берём из колонки (persisted).
|
||||
cv = _cv_from_ppm2([a.price_per_m2 for a in analogs])
|
||||
source_counts = _source_counts([a.source for a in analogs])
|
||||
# #2087 (M1): sources_used рехайдрейтим тем же helper'ом, что и POST — из ТЕХ ЖЕ
|
||||
# persisted analogs (листинговая часть) ∪ оценочных флагов, вычитанных из
|
||||
# persisted-колонки sources_used (avito_imv/yandex_valuation/cian_valuation).
|
||||
# Так один estimate_id → идентичный sources_used на POST/GET/reload, а
|
||||
# source_counts.keys() ⊆ sources_used (обе части — из одного набора analogs).
|
||||
# Чинит рассинхром и на СТАРЫХ строках (где колонка хранит радиусный набор +
|
||||
# quarter_index): листинговая часть пересобирается из analogs, служебный шум
|
||||
# отбрасывается фильтром helper'а.
|
||||
sources_used = _canonical_sources((a.source for a in analogs), (row.sources_used or []))
|
||||
|
||||
# #696: POST-only производные поля (price_trend / avito_imv / dkp_corridor /
|
||||
# last_scraped_at) на GET-rehydrate ранее были null → на shared-link / PDF /
|
||||
|
|
@ -315,7 +325,7 @@ def get_estimate(
|
|||
target_address=row.address,
|
||||
target_lat=row.lat,
|
||||
target_lon=row.lon,
|
||||
sources_used=row.sources_used or [],
|
||||
sources_used=sources_used,
|
||||
data_freshness_minutes=row.data_freshness_minutes,
|
||||
# #696 — POST-only производные поля, пересчитанные на rehydrate (см. выше).
|
||||
last_scraped_at=last_scraped_at,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import logging
|
|||
import math
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
|
@ -1839,6 +1839,40 @@ def _source_counts(sources: list[str | None]) -> dict[str, int]:
|
|||
return dict(sorted(counts.items()))
|
||||
|
||||
|
||||
# Оценочные (не-листинговые) источники канона «7» источников. Листинговые
|
||||
# (avito/cian/yandex/domklik) выводятся динамически из реальных analog-карточек;
|
||||
# эти три — внешние оценки, которые не порождают analog-строк.
|
||||
_CANONICAL_VALUATION_SOURCES: frozenset[str] = frozenset(
|
||||
{"avito_imv", "yandex_valuation", "cian_valuation"}
|
||||
)
|
||||
|
||||
|
||||
def _canonical_sources(
|
||||
analog_sources: Iterable[str | None],
|
||||
valuation_flags: Iterable[str | None],
|
||||
) -> list[str]:
|
||||
"""Единый источник правды для sources_used — #2087 (M1).
|
||||
|
||||
sources_used = {листинговые источники, РЕАЛЬНО присутствующие в persisted
|
||||
analogs} ∪ {оценочные источники (avito_imv/yandex_valuation/cian_valuation),
|
||||
которые действительно использованы}. Детерминированно отсортирован.
|
||||
|
||||
Вызывается идентично на POST (estimate_quality) и GET-rehydrate
|
||||
(api.v1.trade_in.get_estimate) из ТЕХ ЖЕ persisted analogs, поэтому один
|
||||
estimate_id → идентичный sources_used на POST/GET/reload. Гарантирует
|
||||
инвариант source_counts.keys() ⊆ sources_used: и counts, и листинговая часть
|
||||
sources_used считаются из одного и того же набора analog-источников.
|
||||
|
||||
valuation_flags фильтруется до канонических оценочных источников — можно
|
||||
безопасно передать сырой persisted-массив sources_used (листинговые сорта и
|
||||
служебные метки вроде quarter_index отбрасываются, т.к. листинговая часть
|
||||
берётся из analogs).
|
||||
"""
|
||||
listing = {s for s in analog_sources if s}
|
||||
valuation = {s for s in valuation_flags if s in _CANONICAL_VALUATION_SOURCES}
|
||||
return sorted(listing | valuation)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PricingResult:
|
||||
"""Return type of _price_from_inputs — все переменные, нужные estimate_quality после блока."""
|
||||
|
|
@ -2917,7 +2951,6 @@ async def estimate_quality(
|
|||
expected_sold_range_high = pr.expected_sold_range_high
|
||||
asking_to_sold_ratio = pr.asking_to_sold_ratio
|
||||
ratio_basis = pr.ratio_basis
|
||||
sources_used_pre = pr.sources_used_pre
|
||||
listings_clean = pr.listings_clean
|
||||
cv = pr.cv
|
||||
|
||||
|
|
@ -2952,9 +2985,21 @@ async def estimate_quality(
|
|||
analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]]
|
||||
metadata_lots = listings_clean
|
||||
deals_lots = [_deal_to_analog(d) for d in deals[:10]]
|
||||
# #2043 (BE-1): счётчики по источнику считаем по ПОЛНОЙ выборке (metadata_lots
|
||||
# — anchor-комплы или радиусные listings_clean, ДО top-N отсечки на UI).
|
||||
source_counts = _source_counts([lot.get("source") for lot in metadata_lots])
|
||||
# #2087 (M1): единый канонический sources_used + source_counts. ОБА считаем по
|
||||
# persisted analogs_lots (top-N, ровно то, что уходит в колонку analogs и
|
||||
# рехайдрейтится на GET) — не по полной metadata-выборке. Иначе POST-ответ
|
||||
# (полная выборка) расходился бы с GET-reload (persisted top-N): источник мог
|
||||
# оказаться в source_counts, но не в sources_used → счётчик «X/7» прыгал.
|
||||
# Инвариант: source_counts.keys() ⊆ sources_used (общий набор analog-источников).
|
||||
valuation_flags: set[str] = set()
|
||||
if imv_eval is not None:
|
||||
valuation_flags.add("avito_imv")
|
||||
if yandex_val is not None:
|
||||
valuation_flags.add("yandex_valuation")
|
||||
if cian_val is not None and cian_val.sale_price_rub:
|
||||
valuation_flags.add("cian_valuation")
|
||||
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
|
||||
source_counts = _source_counts([lot.source for lot in analogs_lots])
|
||||
freshness_pre = _compute_freshness_minutes(metadata_lots)
|
||||
# DaData enrichment (PR Q1) — заполняется только если service отработал.
|
||||
# При DaData = None все колонки идут в DB как NULL (graceful).
|
||||
|
|
@ -3035,7 +3080,11 @@ async def estimate_quality(
|
|||
"deals_json": json.dumps(
|
||||
[a.model_dump(mode="json") for a in deals_lots], ensure_ascii=False
|
||||
),
|
||||
"sources_json": json.dumps(sources_used_pre, ensure_ascii=False),
|
||||
# #2087 (M1): персистим КАНОНИЧЕСКИЙ sources_used (листинговые из
|
||||
# analogs ∪ оценочные флаги) — тот же массив, что в POST-ответе. GET
|
||||
# рехайдрейтит его же (валид. флаги читаются из этой колонки), history
|
||||
# и PDF получают консистентный «X/7» без quarter_index/радиусного шума.
|
||||
"sources_json": json.dumps(sources_used, ensure_ascii=False),
|
||||
"freshness": freshness_pre,
|
||||
"canonical_address": dadata.canonical_address if dadata else None,
|
||||
"house_cadnum": dadata.house_cadnum if dadata else None,
|
||||
|
|
@ -3086,13 +3135,9 @@ async def estimate_quality(
|
|||
f" cian={cian_val.sale_price_rub}" if cian_val and cian_val.sale_price_rub else "",
|
||||
)
|
||||
|
||||
sources_used = sorted({lot.source for lot in analogs_lots if lot.source})
|
||||
if imv_eval is not None:
|
||||
sources_used = sorted(set(sources_used) | {"avito_imv"})
|
||||
if yandex_val is not None:
|
||||
sources_used = sorted(set(sources_used) | {"yandex_valuation"})
|
||||
if cian_val is not None and cian_val.sale_price_rub:
|
||||
sources_used = sorted(set(sources_used) | {"cian_valuation"})
|
||||
# #2087 (M1): sources_used / source_counts уже посчитаны выше (канонически, из
|
||||
# persisted analogs_lots) — не пересчитываем, чтобы POST-ответ был байт-в-байт
|
||||
# тем, что персистится и рехайдрейтится на GET.
|
||||
freshness_min = _compute_freshness_minutes(metadata_lots)
|
||||
last_scraped_at = _compute_last_scraped_at(metadata_lots)
|
||||
# Месячный ₽/м² тренд целевого дома (web TREND chart) — best-effort, None если нет данных.
|
||||
|
|
|
|||
|
|
@ -143,6 +143,9 @@ def _stub_precision_and_pdf():
|
|||
# persisted analogs. Empty analogs in the fixture → None / {} (real behaviour).
|
||||
_cv_from_ppm2=lambda *a, **k: None,
|
||||
_source_counts=lambda *a, **k: {},
|
||||
# #2087 (M1): GET-rehydrate derives canonical sources_used via the shared
|
||||
# helper. Empty analogs + no valuation flags → [] (real behaviour).
|
||||
_canonical_sources=lambda *a, **k: [],
|
||||
)
|
||||
real_estimator = sys.modules.get("app.services.estimator")
|
||||
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
||||
|
|
|
|||
|
|
@ -81,6 +81,64 @@ def test_source_counts_keys_sorted() -> None:
|
|||
assert list(counts.keys()) == ["avito", "cian", "yandex"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _canonical_sources — #2087 (M1) single source of truth for sources_used
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_canonical_sources_listing_plus_valuation() -> None:
|
||||
# Listing sources come from analogs; valuation flags are unioned on top.
|
||||
result = estimator._canonical_sources(
|
||||
["avito", "cian", "avito"], {"avito_imv", "yandex_valuation"}
|
||||
)
|
||||
assert result == ["avito", "avito_imv", "cian", "yandex_valuation"]
|
||||
|
||||
|
||||
def test_canonical_sources_deterministic_same_inputs_same_output() -> None:
|
||||
# Same set of analogs + flags → identical, sorted sources_used (POST == GET).
|
||||
analogs = ["yandex", "avito", "domklik", "avito"]
|
||||
flags = {"cian_valuation", "avito_imv"}
|
||||
a = estimator._canonical_sources(analogs, flags)
|
||||
b = estimator._canonical_sources(list(reversed(analogs)), set(flags))
|
||||
assert a == b
|
||||
assert a == ["avito", "avito_imv", "cian_valuation", "domklik", "yandex"]
|
||||
|
||||
|
||||
def test_canonical_sources_skips_none_and_empty_analogs() -> None:
|
||||
result = estimator._canonical_sources(["avito", None, "", "cian"], set())
|
||||
assert result == ["avito", "cian"]
|
||||
|
||||
|
||||
def test_canonical_sources_filters_non_canonical_valuation_flags() -> None:
|
||||
# Passing a raw persisted column (listing sorts + quarter_index) as the
|
||||
# valuation arg must NOT leak listing/service labels — only the 3 canonical
|
||||
# valuation sources survive. Listing part comes exclusively from analogs.
|
||||
raw_column = ["avito", "domklik", "quarter_index", "avito_imv", "yandex"]
|
||||
result = estimator._canonical_sources(["cian"], raw_column)
|
||||
# From analogs: cian. From column: only avito_imv is a canonical valuation.
|
||||
assert result == ["avito_imv", "cian"]
|
||||
assert "quarter_index" not in result
|
||||
assert "domklik" not in result # listing label in the column is ignored
|
||||
|
||||
|
||||
def test_canonical_source_counts_keys_subset_of_sources_used() -> None:
|
||||
# Invariant #2087: every key of source_counts (derived from the same analog
|
||||
# sources) is present in sources_used.
|
||||
analog_sources = ["avito", "cian", "avito", "yandex", None, ""]
|
||||
sources_used = estimator._canonical_sources(analog_sources, {"avito_imv"})
|
||||
source_counts = estimator._source_counts(analog_sources)
|
||||
assert set(source_counts.keys()) <= set(sources_used)
|
||||
# And every listing source present in analogs is reflected in sources_used.
|
||||
for s in analog_sources:
|
||||
if s:
|
||||
assert s in sources_used
|
||||
|
||||
|
||||
def test_canonical_sources_empty_is_empty_list() -> None:
|
||||
assert estimator._canonical_sources([], []) == []
|
||||
assert estimator._canonical_sources([None, ""], ["quarter_index"]) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _price_from_inputs — cv surfaced on both paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue