Merge pull request 'fix(tradein/v2): канонический sources_used — стабильный счётчик «ИСТОЧНИКОВ X/7» (#2087 M1)' (#2144) from fix/tradein-v2-source-counter-canonical into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m33s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 50s

This commit is contained in:
lekss361 2026-07-02 14:31:15 +00:00
commit 227b939778
4 changed files with 130 additions and 14 deletions

View file

@ -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,

View file

@ -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 если нет данных.

View file

@ -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]

View file

@ -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
# --------------------------------------------------------------------------- #