Merge remote-tracking branch 'origin/main' into fix/2683-manifest-drift
Some checks failed
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 54s
CI / openapi-codegen-check (pull_request) Successful in 2m54s
CI / backend-tests (pull_request) Failing after 1m14s
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI Trade-In / backend-tests (pull_request) Successful in 4m44s
Some checks failed
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 54s
CI / openapi-codegen-check (pull_request) Successful in 2m54s
CI / backend-tests (pull_request) Failing after 1m14s
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI Trade-In / backend-tests (pull_request) Successful in 4m44s
# Conflicts: # tradein-mvp/backend/data/sql/_manifest_applied.txt # tradein-mvp/backend/tests/skip_allowlist.txt
This commit is contained in:
commit
2c8dc46205
106 changed files with 9978 additions and 710 deletions
|
|
@ -30,11 +30,26 @@ jobs:
|
|||
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
|
||||
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
|
||||
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
|
||||
app_version: ${{ steps.build-meta.outputs.app_version }}
|
||||
build_sha: ${{ steps.build-meta.outputs.build_sha }}
|
||||
build_date: ${{ steps.build-meta.outputs.build_date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник
|
||||
# правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки —
|
||||
# проброшены как build-args в build-backend/build-frontend ниже (см.
|
||||
# tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile).
|
||||
# Считается ОДИН раз здесь, а не в каждой job отдельно.
|
||||
- name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE)
|
||||
id: build-meta
|
||||
run: |
|
||||
echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Resolve base SHA: read last-successfully-deployed SHA from the VPS host file.
|
||||
# The file is written by the deploy job on every successful deploy.
|
||||
# Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD,
|
||||
|
|
@ -107,8 +122,20 @@ jobs:
|
|||
# scheduler_main импортирует пакет) — kit-only изменение обязано
|
||||
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
# APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend
|
||||
# ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ
|
||||
# правок кода обязан пересобрать образ, иначе GET /version и колонтитул
|
||||
# PDF продолжат отдавать старое значение при формально «успешном» деплое.
|
||||
- 'tradein-mvp/VERSION'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
# NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же
|
||||
# причина, что у backend выше.
|
||||
- 'tradein-mvp/VERSION'
|
||||
# /versions статически запекает CHANGELOG.md в билд (см.
|
||||
# frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ
|
||||
# frontend/** иначе не долетала бы до образа.
|
||||
- 'tradein-mvp/CHANGELOG.md'
|
||||
browser:
|
||||
- 'tradein-mvp/browser/**'
|
||||
infra:
|
||||
|
|
@ -216,6 +243,13 @@ jobs:
|
|||
context: ./tradein-mvp
|
||||
file: ./tradein-mvp/backend/Dockerfile
|
||||
push: true
|
||||
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
|
||||
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
|
||||
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
|
|
@ -241,6 +275,14 @@ jobs:
|
|||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
|
||||
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
|
||||
# поэтому копируем внутрь ДО build. /versions статически запекает его
|
||||
# содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage
|
||||
# комментарий). Не влияет на кэш другого шага — читается только этим.
|
||||
- name: Stage CHANGELOG.md into frontend build context
|
||||
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
|
||||
|
||||
- name: Build & push tradein-frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
|
|
@ -251,9 +293,15 @@ jobs:
|
|||
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
|
||||
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
|
||||
# уходит в notFound (404), не индексируется и не краулится.
|
||||
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
|
||||
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
|
||||
# см. frontend/Dockerfile комментарий у соответствующих ARG).
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
|
|
|
|||
|
|
@ -2189,12 +2189,21 @@ def analyze_parcel(
|
|||
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
|
||||
_weights_source = "inline"
|
||||
else:
|
||||
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
||||
_weights_source = (
|
||||
"profile"
|
||||
if profile_id is not None
|
||||
else ("user_default" if profile_user_id is not None else "system")
|
||||
)
|
||||
# Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811):
|
||||
# profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и
|
||||
# тогда веса системные или дефолтные, а не профильные.
|
||||
_resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
||||
_effective_weights = _resolved.weights
|
||||
_weights_source = _resolved.source
|
||||
|
||||
# «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит,
|
||||
# был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" —
|
||||
# иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли».
|
||||
# None когда profile_id не передавали; False когда передали, но применилось
|
||||
# другое (не найден / чужой / перебит inline-весами).
|
||||
_requested_profile_applied: bool | None = (
|
||||
None if profile_id is None else _weights_source == "profile"
|
||||
)
|
||||
|
||||
# 4) Scoring: weighted sum с distance decay
|
||||
score = 0.0
|
||||
|
|
@ -4085,9 +4094,12 @@ def analyze_parcel(
|
|||
# (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
|
||||
"program_alternatives": program_alternatives,
|
||||
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||||
# source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли
|
||||
# удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали.
|
||||
"weights_profile": {
|
||||
"source": _weights_source,
|
||||
"profile_id": profile_id,
|
||||
"requested_profile_applied": _requested_profile_applied,
|
||||
"user_id": profile_user_id,
|
||||
"weights_applied": _effective_weights,
|
||||
"inline_weights": _inline_weights,
|
||||
|
|
@ -4203,6 +4215,7 @@ def analyze_parcel(
|
|||
"profile_user_id": profile_user_id,
|
||||
"inline_weights": _inline_weights,
|
||||
"weights_source": _weights_source,
|
||||
"requested_profile_applied": _requested_profile_applied,
|
||||
"x_session_id": _session_id,
|
||||
},
|
||||
district=_district_name,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ API surface:
|
|||
- create_profile(db, payload) → WeightProfile
|
||||
- update_profile(db, user_id, profile_id, payload) → WeightProfile | None
|
||||
- delete_profile(db, user_id, profile_id) → bool
|
||||
- resolve_weights(db, user_id, profile_id) → dict[str, float]
|
||||
- resolve_weights(db, user_id, profile_id) → ResolvedWeights(weights, source)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -19,7 +19,7 @@ import json
|
|||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import text
|
||||
|
|
@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
|
||||
"""Вернуть эффективные веса для analyze_parcel.
|
||||
class ResolvedWeights(NamedTuple):
|
||||
"""Веса + КАКОЙ источник фактически применился (#2811).
|
||||
|
||||
Лестница приоритетов ниже по построению стирает разницу между «взял, что
|
||||
просили» и «не нашёл, взял что было» — а метка в ответе /analyze строится
|
||||
именно на этой разнице. Поэтому источник возвращается вместе с весами, а не
|
||||
выводится вызывающим из своих же входных параметров. NamedTuple, а не голый
|
||||
dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко,
|
||||
молча «весами» этот объект не притворится.
|
||||
"""
|
||||
|
||||
weights: dict[str, float]
|
||||
source: str # "profile" | "user_default" | "system"
|
||||
|
||||
|
||||
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights:
|
||||
"""Вернуть эффективные веса для analyze_parcel + фактический их источник.
|
||||
|
||||
Порядок приоритетов:
|
||||
1. profile_id задан → загрузить именно этот профиль
|
||||
2. user_id задан → загрузить default-профиль пользователя
|
||||
3. Иначе → вернуть системные значения _SYSTEM_POI_WEIGHTS
|
||||
1. profile_id задан → загрузить именно этот профиль → source="profile"
|
||||
2. user_id задан → загрузить default-профиль пользователя → source="user_default"
|
||||
3. Иначе → системные значения _SYSTEM_POI_WEIGHTS → source="system"
|
||||
|
||||
Запрошенный, но НЕ применённый profile_id — не тишина: warning с
|
||||
идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для
|
||||
/analyze — необязательный модификатор, а не адресуемый ресурс; 404 превратил
|
||||
бы гонку «профиль удалили между списком и анализом» в отказ вместо честно
|
||||
помеченного ответа. Клиенту хватает source + requested_profile_not_found.
|
||||
"""
|
||||
if profile_id is not None and user_id is not None:
|
||||
profile = get_profile(db, user_id, profile_id)
|
||||
|
|
@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic
|
|||
logger.debug(
|
||||
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
|
||||
)
|
||||
return dict(profile.weights)
|
||||
return ResolvedWeights(dict(profile.weights), "profile")
|
||||
|
||||
resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system")
|
||||
if user_id is not None:
|
||||
profile = get_default_profile(db, user_id)
|
||||
if profile is not None and profile.weights:
|
||||
logger.debug("resolve_weights: user=%s → default profile weights", user_id)
|
||||
return dict(profile.weights)
|
||||
resolved = ResolvedWeights(dict(profile.weights), "user_default")
|
||||
|
||||
logger.debug("resolve_weights: returning system defaults")
|
||||
return dict(_SYSTEM_POI_WEIGHTS)
|
||||
if profile_id is not None:
|
||||
# Сюда попадаем, если запрошенный профиль не применился: owner не передан
|
||||
# (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights
|
||||
# пустые. Раньше это был logger.debug, которого на проде нет, — и оценка
|
||||
# молча считалась не по тем весам (#2811, ранее #2788).
|
||||
logger.warning(
|
||||
"resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — "
|
||||
"фактический источник весов %r",
|
||||
profile_id,
|
||||
user_id,
|
||||
resolved.source,
|
||||
)
|
||||
else:
|
||||
logger.debug("resolve_weights: источник весов %s", resolved.source)
|
||||
return resolved
|
||||
|
|
|
|||
|
|
@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None:
|
|||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_analyze_missing_profile_is_not_labelled_profile() -> None:
|
||||
"""#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'.
|
||||
|
||||
Три способа промахнуться мимо профиля (все три воспроизведены живым запросом
|
||||
на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id.
|
||||
В mock-БД профилей нет — значит применились системные веса, и ответ обязан
|
||||
это признать, а не утверждать, что считал по профилю.
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS
|
||||
|
||||
for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"):
|
||||
db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
wp = resp.json()["weights_profile"]
|
||||
# sanity: веса и правда системные, промах реальный
|
||||
assert wp["weights_applied"]["tram_stop"] == pytest.approx(
|
||||
_SYSTEM_POI_WEIGHTS["tram_stop"]
|
||||
)
|
||||
assert wp["source"] != "profile", (
|
||||
f"?{qs}: применились системные веса, а метка source='profile' — "
|
||||
"ответ утверждает то, чего не было (#2811)"
|
||||
)
|
||||
assert wp["source"] == "system"
|
||||
# «что просили» не теряется: запрошенный id + явный признак промаха
|
||||
assert wp["profile_id"] == 999999
|
||||
assert wp["requested_profile_applied"] is False
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_analyze_found_profile_keeps_label_and_flag() -> None:
|
||||
"""Обратная сторона: профиль найден → source='profile', флаг промаха False."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import app.services.site_finder.weight_profiles as wp_module
|
||||
from app.core.db import get_db
|
||||
from app.services.site_finder.weight_profiles import WeightProfile
|
||||
|
||||
profile = WeightProfile(
|
||||
id=7,
|
||||
user_id="user-1",
|
||||
profile_name="test",
|
||||
weights={"tram_stop": -0.4},
|
||||
is_default=False,
|
||||
description=None,
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
db = _make_db_for_analyze()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
original = wp_module.get_profile
|
||||
wp_module.get_profile = lambda _db, uid, pid: profile
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
wp = resp.json()["weights_profile"]
|
||||
assert wp["source"] == "profile"
|
||||
assert wp["requested_profile_applied"] is True
|
||||
assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4)
|
||||
finally:
|
||||
wp_module.get_profile = original
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет:
|
|||
- resolve_weights: нет user_id и profile_id → системные дефолты
|
||||
- resolve_weights: user_id задан, default-профиль есть → его веса
|
||||
- resolve_weights: profile_id задан → его веса
|
||||
- resolve_weights: профиль не найден → системные дефолты (fallback)
|
||||
- resolve_weights: профиль не найден → системные дефолты (fallback) + source != profile
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None:
|
|||
"""Оба аргумента None → возвращаются системные веса."""
|
||||
db = MagicMock()
|
||||
result = resolve_weights(db, user_id=None, profile_id=None)
|
||||
assert result == _SYSTEM_POI_WEIGHTS
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
assert result.source == "system"
|
||||
# db не должен вызываться вообще
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
|
@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None:
|
|||
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
|
||||
db = MagicMock()
|
||||
result = resolve_weights(db, user_id=None, profile_id=None)
|
||||
result["school"] = 999.0
|
||||
result.weights["school"] = 999.0
|
||||
# Оригинал не изменён
|
||||
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
|
||||
|
||||
|
|
@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None:
|
|||
finally:
|
||||
wp_module.get_default_profile = original
|
||||
|
||||
assert result == custom_weights
|
||||
assert result.weights == custom_weights
|
||||
assert result.source == "user_default"
|
||||
|
||||
|
||||
def test_resolve_weights_uses_specific_profile() -> None:
|
||||
|
|
@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None:
|
|||
finally:
|
||||
wp_module.get_profile = original
|
||||
|
||||
assert result == custom_weights
|
||||
assert result.weights == custom_weights
|
||||
assert result.source == "profile"
|
||||
|
||||
|
||||
def test_resolve_weights_profile_not_found_fallback() -> None:
|
||||
|
|
@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None:
|
|||
wp_module.get_profile = original_get
|
||||
wp_module.get_default_profile = original_default
|
||||
|
||||
assert result == _SYSTEM_POI_WEIGHTS
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
# #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли
|
||||
assert result.source == "system"
|
||||
|
||||
|
||||
def test_resolve_weights_empty_profile_weights_fallback() -> None:
|
||||
|
|
@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None:
|
|||
finally:
|
||||
wp_module.get_default_profile = original_default
|
||||
|
||||
assert result == _SYSTEM_POI_WEIGHTS
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
assert result.source == "system"
|
||||
|
||||
|
||||
def test_resolve_weights_profile_id_without_owner_is_not_profile(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется.
|
||||
|
||||
Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 —
|
||||
source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он
|
||||
-0.4). Метка обязана быть 'system', а промах — попасть в warning.
|
||||
"""
|
||||
db = MagicMock()
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
|
||||
result = resolve_weights(db, user_id=None, profile_id=1)
|
||||
|
||||
assert result.source == "system"
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
assert "profile_id=1" in caplog.text
|
||||
db.execute.assert_not_called() # профиль даже не искали
|
||||
|
||||
|
||||
def test_resolve_weights_missing_profile_falls_to_user_default_not_profile(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль.
|
||||
|
||||
Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не
|
||||
видна. Метка должна сказать 'user_default', а не 'profile'.
|
||||
"""
|
||||
import app.services.site_finder.weight_profiles as wp_module
|
||||
|
||||
default_profile = _make_profile_mock({"school": 2.0})
|
||||
db = MagicMock()
|
||||
original_get = wp_module.get_profile
|
||||
original_default = wp_module.get_default_profile
|
||||
wp_module.get_profile = lambda _db, uid, pid: None
|
||||
wp_module.get_default_profile = lambda _db, uid: default_profile
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
|
||||
result = resolve_weights(db, user_id="user-1", profile_id=999)
|
||||
finally:
|
||||
wp_module.get_profile = original_get
|
||||
wp_module.get_default_profile = original_default
|
||||
|
||||
assert result.source == "user_default"
|
||||
assert result.weights == {"school": 2.0}
|
||||
assert "profile_id=999" in caplog.text
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At
|
|||
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
|
||||
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
|
||||
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
|
||||
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
||||
import {
|
||||
AnalyzeWeightsContext,
|
||||
adaptEgrn,
|
||||
useParcelAnalyzeQuery,
|
||||
} from "@/lib/site-finder-api";
|
||||
import type { PoiCategoryKey } from "@/lib/api/weightProfiles";
|
||||
import type {
|
||||
ParcelAnalysis,
|
||||
PendingConceptProgram,
|
||||
|
|
@ -29,7 +34,40 @@ interface Props {
|
|||
|
||||
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в
|
||||
* контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние
|
||||
* нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы
|
||||
* контекст «сверху», то есть null, и страница разъехалась бы на два разных
|
||||
* анализа — свой у шапки, свой у секций.
|
||||
*
|
||||
* null = веса не применяли → запрос как раньше, без тела.
|
||||
*/
|
||||
export function AnalysisPageContent({ cad }: Props) {
|
||||
const [appliedWeights, setAppliedWeights] = useState<Record<
|
||||
PoiCategoryKey,
|
||||
number
|
||||
> | null>(null);
|
||||
|
||||
return (
|
||||
<AnalyzeWeightsContext.Provider value={appliedWeights}>
|
||||
<AnalysisPageBody
|
||||
cad={cad}
|
||||
appliedWeights={appliedWeights}
|
||||
onWeightsApply={setAppliedWeights}
|
||||
/>
|
||||
</AnalyzeWeightsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisPageBody({
|
||||
cad,
|
||||
appliedWeights,
|
||||
onWeightsApply,
|
||||
}: Props & {
|
||||
appliedWeights: Record<PoiCategoryKey, number> | null;
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}) {
|
||||
const [horizon, setHorizon] = useState<number>(12);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) {
|
|||
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
|
||||
<GroupDivider label="Стройка и рынок" />
|
||||
|
||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
|
||||
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
|
||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1
|
||||
поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор
|
||||
пересчитывается по ползункам во ВСЕХ секциях (#2790). */}
|
||||
<Section3SettingsAndCompetitors
|
||||
cad={cad}
|
||||
data={analysis}
|
||||
weights={appliedWeights}
|
||||
onWeightsApply={onWeightsApply}
|
||||
/>
|
||||
|
||||
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
|
||||
<Section5Atmosphere cad={cad} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* #2790 п.1 — «Применить» у весов POI в §4.1 ничего не применяло.
|
||||
*
|
||||
* Состояние весов жило в `Section31Settings` и читалось только обратно в ту же
|
||||
* панель: до `/analyze` оно не доезжало никогда (слова `weights` в
|
||||
* AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал
|
||||
* «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам.
|
||||
*
|
||||
* Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и
|
||||
* настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес — карты,
|
||||
* прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй
|
||||
* POST /analyze не случается вообще → красный.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AnalysisPageContent } from "../AnalysisPageContent";
|
||||
|
||||
// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут
|
||||
// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) —
|
||||
// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста.
|
||||
vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null }));
|
||||
vi.mock("@/components/site-finder/GateVerdictBanner", () => ({
|
||||
GateVerdictBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/HorizonSelector", () => ({
|
||||
HorizonSelector: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({
|
||||
Section1ParcelInfo: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({
|
||||
Section2NetworksUtilities: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({
|
||||
Section4Estimate: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({
|
||||
Section5Atmosphere: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({
|
||||
Section6Forecast: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({
|
||||
Section7Concept: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({
|
||||
SectionAlternatives: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({
|
||||
BestLayoutsBlock: () => null,
|
||||
}));
|
||||
|
||||
const CAD = "66:41:0702017:131";
|
||||
|
||||
const ANALYSIS = {
|
||||
cad_num: CAD,
|
||||
score: 18.91,
|
||||
district: { district_name: "Чкаловский" },
|
||||
egrn: null,
|
||||
competitors: [],
|
||||
};
|
||||
|
||||
/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */
|
||||
const analyzeBodies: Array<Record<string, unknown> | undefined> = [];
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
analyzeBodies.length = 0;
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const url = typeof input === "string" ? input : String(input);
|
||||
if (url.includes("/analyze")) {
|
||||
const raw = init?.body;
|
||||
analyzeBodies.push(
|
||||
typeof raw === "string"
|
||||
? (JSON.parse(raw) as Record<string, unknown>)
|
||||
: undefined,
|
||||
);
|
||||
return jsonResponse(ANALYSIS);
|
||||
}
|
||||
if (url.includes("/api/v1/me")) {
|
||||
return jsonResponse({
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
allowed_paths: ["/**"],
|
||||
deny_paths: [],
|
||||
});
|
||||
}
|
||||
if (url.includes("/weight-profiles")) {
|
||||
return jsonResponse([]);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AnalysisPageContent cad={CAD} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Ползунок конкретной категории по подписи строки в панели весов. */
|
||||
function sliderFor(label: string): HTMLInputElement {
|
||||
const row = screen.getByText(label).closest("div");
|
||||
if (!row) throw new Error(`не нашёл строку ползунка «${label}»`);
|
||||
const input = row.querySelector('input[type="range"]');
|
||||
if (!input) throw new Error(`в строке «${label}» нет ползунка`);
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => {
|
||||
it("отправляет ползунки в тело повторного analyze", async () => {
|
||||
renderPage();
|
||||
|
||||
// Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе,
|
||||
// бэкенд считает по системным. Это же и baseline для «стало другим».
|
||||
await waitFor(() => expect(analyzeBodies.length).toBe(1));
|
||||
expect(analyzeBodies[0]).toBeUndefined();
|
||||
|
||||
fireEvent.click(await screen.findByText("POI Веса"));
|
||||
fireEvent.change(sliderFor("Парки"), { target: { value: "3" } });
|
||||
fireEvent.change(sliderFor("Трамвайные ост. (−)"), {
|
||||
target: { value: "-2" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
|
||||
// Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что
|
||||
// выставлены ползунками. До фикса второго запроса не было — красный здесь.
|
||||
await waitFor(() => expect(analyzeBodies.length).toBe(2));
|
||||
const applied = analyzeBodies[1]?.weights as Record<string, number>;
|
||||
expect(applied.park).toBe(3);
|
||||
expect(applied.tram_stop).toBe(-2);
|
||||
// Нетронутые категории уходят как есть — бэкенд мержит поверх системных,
|
||||
// но панель отправляет полный набор, чтобы ответ совпадал с ползунками.
|
||||
expect(applied.school).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
POI_LABELS,
|
||||
POI_WEIGHT_MAX,
|
||||
POI_WEIGHT_MIN,
|
||||
SYSTEM_PROFILE_USER_ID,
|
||||
useCreateProfile,
|
||||
useWeightProfiles,
|
||||
type PoiCategoryKey,
|
||||
|
|
@ -110,7 +111,18 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
|||
}
|
||||
|
||||
function handleApply() {
|
||||
onWeightsChange({ ...draft }, selectedProfileId);
|
||||
// Системный пресет не адресуем через profile_id: resolve_weights() ищет
|
||||
// профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не
|
||||
// текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и
|
||||
// отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для
|
||||
// пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса,
|
||||
// а они ровно те, что на ползунках.
|
||||
const selected = profiles.find((p) => p.id === selectedProfileId) ?? null;
|
||||
const addressableId =
|
||||
selected && selected.user_id !== SYSTEM_PROFILE_USER_ID
|
||||
? selected.id
|
||||
: null;
|
||||
onWeightsChange({ ...draft }, addressableId);
|
||||
}
|
||||
|
||||
const handleSaveProfile = useCallback(async () => {
|
||||
|
|
@ -258,6 +270,7 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
|||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.profile_name}
|
||||
{p.user_id === SYSTEM_PROFILE_USER_ID ? " · пресет" : ""}
|
||||
{p.is_default ? " ★" : ""}
|
||||
</option>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ interface Props {
|
|||
cad: string;
|
||||
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
|
||||
data: ParcelAnalysis;
|
||||
/** Уже применённые POI-веса; null = ничего не применяли (системные). */
|
||||
weights: Record<PoiCategoryKey, number> | null;
|
||||
/** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
|
|
@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) {
|
|||
function Section31Settings({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
weights,
|
||||
onWeightsApply,
|
||||
}: {
|
||||
filters: FilterState;
|
||||
onFiltersChange: (f: FilterState) => void;
|
||||
weights: Record<PoiCategoryKey, number> | null;
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}) {
|
||||
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
|
||||
() => ({ ...POI_DEFAULT_WEIGHTS }),
|
||||
);
|
||||
|
||||
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
|
||||
onFiltersChange({ ...filters, [key]: !filters[key] });
|
||||
}
|
||||
|
||||
function handleWeightsChange(
|
||||
newWeights: Record<PoiCategoryKey, number>,
|
||||
_profileId: number | null,
|
||||
) {
|
||||
setWeights(newWeights);
|
||||
}
|
||||
|
||||
const chips: Array<{
|
||||
key: keyof Omit<FilterState, "radiusKm">;
|
||||
label: string;
|
||||
|
|
@ -136,8 +133,8 @@ function Section31Settings({
|
|||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
Фильтры применяются к конкурентам локально — без повторного запроса к
|
||||
бэкенду
|
||||
Радиус и фильтры применяются к конкурентам локально. Веса POI —
|
||||
пересчёт анализа на бэкенде по кнопке «Применить»
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -259,8 +256,8 @@ function Section31Settings({
|
|||
Профиль весов POI
|
||||
</div>
|
||||
<WeightProfilePanel
|
||||
currentWeights={weights}
|
||||
onWeightsChange={handleWeightsChange}
|
||||
currentWeights={weights ?? POI_DEFAULT_WEIGHTS}
|
||||
onWeightsChange={onWeightsApply}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -769,7 +766,12 @@ function applyFilters(
|
|||
|
||||
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
|
||||
|
||||
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
||||
export function Section3SettingsAndCompetitors({
|
||||
cad,
|
||||
data,
|
||||
weights,
|
||||
onWeightsApply,
|
||||
}: Props) {
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
radiusKm: 2,
|
||||
onlyUnderConstruction: false,
|
||||
|
|
@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
|||
<StageDetails>
|
||||
{/* Sub-sections */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<Section31Settings filters={filters} onFiltersChange={setFilters} />
|
||||
<Section31Settings
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
weights={weights}
|
||||
onWeightsApply={onWeightsApply}
|
||||
/>
|
||||
|
||||
{/* Competitor table — moved before 3.2/3.3 for context */}
|
||||
{filteredCompetitors.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
* directly with a real AbortSignal and a per-URL `fetch` stub, under fake
|
||||
* timers, and assert on abort behaviour + the happy path.
|
||||
*/
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Capture the options passed to useQuery ───────────────────────────────────
|
||||
|
|
@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42";
|
|||
* polling queryFn. Reads `captured.options` via a fresh binding so TS control-
|
||||
* flow doesn't pin it (the hook mutates it opaquely through the mock).
|
||||
*
|
||||
* `useQuery` is fully mocked (it just records its options, no React state), so
|
||||
* the rules-of-hooks invariant does not apply to this call — disable locally.
|
||||
* Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые
|
||||
* веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет
|
||||
* dispatcher'а → «Cannot read properties of null». `useQuery` по-прежнему
|
||||
* замокан и просто записывает options; провайдера над хуком нет, значит
|
||||
* контекст = null, то есть ровно тот случай «весов не применяли», который этот
|
||||
* тест и гоняет.
|
||||
*/
|
||||
function getQueryFn(): CapturedQueryOptions["queryFn"] {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useParcelAnalyzeQuery(CAD, 12);
|
||||
renderHook(() => useParcelAnalyzeQuery(CAD, 12));
|
||||
const options = captured.options;
|
||||
if (options === null) throw new Error("useQuery options not captured");
|
||||
return options.queryFn;
|
||||
|
|
|
|||
|
|
@ -27,14 +27,18 @@ export interface WeightProfileCreate {
|
|||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface WeightProfileUpdate {
|
||||
profile_name?: string;
|
||||
weights?: Record<string, number>;
|
||||
is_default?: boolean;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Владелец системных пресетов (Эконом / Комфорт / Бизнес) — mirrors
|
||||
* `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py.
|
||||
* Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`:
|
||||
* `resolve_weights()` ищет профиль в области владельца, у чужого пользователя
|
||||
* его не найдёт и молча вернёт системные веса с ответом `source="profile"`
|
||||
* (#2782). Их веса уходят в analyze inline — см. WeightProfilePanel.
|
||||
*/
|
||||
export const SYSTEM_PROFILE_USER_ID = "__system__";
|
||||
|
||||
// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES.
|
||||
// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py.
|
||||
|
||||
|
|
@ -103,13 +107,20 @@ const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
|||
|
||||
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List all weight profiles for a given user_id. */
|
||||
/**
|
||||
* Профили пользователя + системные пресеты (#2790).
|
||||
*
|
||||
* `include_system=true` домешивает в конец списка три общих пресета (Эконом /
|
||||
* Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`).
|
||||
* Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в
|
||||
* проде с 16.05.2026 и не были видны никому.
|
||||
*/
|
||||
export function useWeightProfiles(userId: string) {
|
||||
return useQuery<WeightProfile[]>({
|
||||
queryKey: ["weight-profiles", userId],
|
||||
queryFn: () =>
|
||||
apiFetch<WeightProfile[]>(
|
||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`,
|
||||
),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
|
@ -132,35 +143,9 @@ export function useCreateProfile() {
|
|||
});
|
||||
}
|
||||
|
||||
/** Update an existing weight profile by id. */
|
||||
export function useUpdateProfile(userId: string, profileId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
||||
mutationFn: (payload) =>
|
||||
apiFetch<WeightProfile>(
|
||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
|
||||
export function useDeleteProfile(userId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, number>({
|
||||
mutationFn: (profileId) =>
|
||||
apiFetch<void>(
|
||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
|
||||
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
|
||||
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
|
||||
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
|
||||
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
|
||||
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext } from "react";
|
||||
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||
import { abortableSleep } from "@/lib/abortableSleep";
|
||||
import type {
|
||||
|
|
@ -503,9 +504,36 @@ export interface PoiScoreResponse {
|
|||
const ANALYZE_POLL_INTERVAL_MS = 2000;
|
||||
const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
|
||||
|
||||
/**
|
||||
* Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли → запрос
|
||||
* уходит без тела, как и раньше (бэкенд считает по системным весам).
|
||||
*
|
||||
* Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)`
|
||||
* зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) — все они делят один
|
||||
* ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос.
|
||||
* Если веса доедут только до части из них, ключи разойдутся: половина страницы
|
||||
* покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды.
|
||||
* Контекст держит всех потребителей ключа на одном значении по построению —
|
||||
* забыть прокинуть проп в новую секцию нельзя.
|
||||
*/
|
||||
export const AnalyzeWeightsContext = createContext<Record<
|
||||
string,
|
||||
number
|
||||
> | null>(null);
|
||||
|
||||
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
||||
const weights = useContext(AnalyzeWeightsContext);
|
||||
// Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем.
|
||||
// null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до
|
||||
// #2790, кэш не сбрасывается на ровном месте.
|
||||
const weightsKey = weights
|
||||
? JSON.stringify(Object.entries(weights).sort())
|
||||
: null;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["parcel-analyze", cad, horizon],
|
||||
// Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI
|
||||
// мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт.
|
||||
queryKey: ["parcel-analyze", cad, horizon, weightsKey],
|
||||
// TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts
|
||||
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
|
||||
// it through the POST/GET fetches and check it before each poll iteration so
|
||||
|
|
@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
|||
cad,
|
||||
)}/analyze?horizon=${horizon}`;
|
||||
|
||||
// Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id:
|
||||
// тело запроса == ползункам панели, и ответ рапортует source="inline" —
|
||||
// расхождению между показанными весами и посчитанным скором взяться
|
||||
// неоткуда (в отличие от profile_id, см. #2782).
|
||||
const analyzeInit: RequestInit = weights
|
||||
? { method: "POST", signal, body: JSON.stringify({ weights }) }
|
||||
: { method: "POST", signal };
|
||||
|
||||
// First request — POST /analyze. apiFetchWithStatus surfaces the 202
|
||||
// Accepted code instead of treating it as a successful payload.
|
||||
const first = await apiFetchWithStatus<
|
||||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||||
>(analyzeUrl, { method: "POST", signal });
|
||||
>(analyzeUrl, analyzeInit);
|
||||
|
||||
// 200 → geometry was cached, full analysis is ready.
|
||||
if (first.status === 200) {
|
||||
|
|
@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
|||
// rather than returning the stub (symmetry with the first request).
|
||||
const second = await apiFetchWithStatus<
|
||||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||||
>(analyzeUrl, { method: "POST", signal });
|
||||
>(analyzeUrl, analyzeInit);
|
||||
if (second.status === 200) {
|
||||
return second.body as ParcelAnalyzeResponse;
|
||||
}
|
||||
|
|
|
|||
46
tradein-mvp/CHANGELOG.md
Normal file
46
tradein-mvp/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# История версий «МЕРА»
|
||||
|
||||
Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и
|
||||
[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно
|
||||
`## <semver> — <YYYY-MM-DD>` (машинно читается страницей истории версий).
|
||||
|
||||
## 2.1.0 — 2026-08-10
|
||||
|
||||
Первая версия с явным версионированием. Номер продолжает ряд, который до этого
|
||||
показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние
|
||||
отчёты.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и
|
||||
реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень
|
||||
уверенности в оценке.
|
||||
- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав
|
||||
аналогов и сделок, формирование выкупной стоимости.
|
||||
- Аналитика по дому — история размещений объявлений и продаж в доме.
|
||||
- История прошлых оценок в личном кабинете, автодополнение адреса при поиске.
|
||||
- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история).
|
||||
- Чат поддержки на сайте, в том числе без входа в личный кабинет.
|
||||
- Публичный лендинг «МЕРА».
|
||||
- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта
|
||||
страница истории версий.
|
||||
|
||||
### Изменено
|
||||
|
||||
- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более
|
||||
раннего технического макета.
|
||||
|
||||
### Исправлено
|
||||
|
||||
- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе
|
||||
комнатности не было варианта «Студия», из-за чего для студии подбирались
|
||||
однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась.
|
||||
- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор
|
||||
автоматически расширяется (студии, срок объявлений, новостройки, радиус),
|
||||
а над результатом показывается предупреждение о сниженной точности и о том,
|
||||
какие параметры пришлось расширить.
|
||||
- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части
|
||||
адресов улица не распознавалась, и разделы оставались пустыми.
|
||||
- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов.
|
||||
- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки
|
||||
на обложке, некорректные допущения о сроке экспозиции.
|
||||
1
tradein-mvp/VERSION
Normal file
1
tradein-mvp/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
2.1.0
|
||||
|
|
@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages
|
|||
COPY --from=builder --chown=app:app /app/backend/app /app/app
|
||||
COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts
|
||||
|
||||
# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего
|
||||
# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION).
|
||||
# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста.
|
||||
COPY --chown=app:app VERSION VERSION
|
||||
|
||||
# Версия продукта + короткий git SHA + дата сборки — запечены как build-args
|
||||
# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend).
|
||||
# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам
|
||||
# (VERSION-файл выше / "dev" / момент импорта модуля).
|
||||
#
|
||||
# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых
|
||||
# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на
|
||||
# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои
|
||||
# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале
|
||||
# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get +
|
||||
# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот
|
||||
# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже).
|
||||
ARG APP_VERSION=""
|
||||
ARG BUILD_SHA=""
|
||||
ARG BUILD_DATE=""
|
||||
ENV APP_VERSION=$APP_VERSION \
|
||||
BUILD_SHA=$BUILD_SHA \
|
||||
BUILD_DATE=$BUILD_DATE
|
||||
|
||||
USER app
|
||||
|
||||
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ from app.services import proxy_rotation as proxy_rotation_svc
|
|||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
from app.services.geocoder import geocode, known_city_hint
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||
from app.services.proxy_pool import clear_source_bans
|
||||
from app.services.scheduler import has_running_run
|
||||
from app.services.scraper_adapters import (
|
||||
|
|
@ -373,6 +374,56 @@ async def geocode_missing(
|
|||
}
|
||||
|
||||
|
||||
def _cian_verify_state_error(state: dict[str, Any] | None) -> HTTPException | None:
|
||||
"""Маппинг исхода cian_session_svc.verify_session() на HTTP-ответ админки.
|
||||
|
||||
verify_session() возвращает 4 разных исхода (см. докстринг сервиса) плюс успех —
|
||||
их нельзя схлопывать в один "cookies invalid", иначе бан по IP выглядит так же,
|
||||
как протухшие куки, и человек в момент инцидента перезаливает заведомо валидные
|
||||
куки вместо починки egress/прокси (инцидент 2026-08-10).
|
||||
|
||||
Sentinel'ы сравниваются через `is`, НЕ `==` — так требует докстринг verify_session.
|
||||
|
||||
Возвращает None, если state — это успешно распаршенный state dict (в т.ч. случай
|
||||
"успех, но userId не найден" — этот случай caller должен обработать отдельно).
|
||||
"""
|
||||
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
|
||||
return HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Cian заблокировал наш IP (HTTP 403, TLS/bot-fingerprint ban). "
|
||||
"Куки, скорее всего, валидны — блокировка не про них. "
|
||||
"Нужно чинить egress: проверить SCRAPER_PROXY_URL и баны в "
|
||||
"scrape_proxy_source_bans. Перезаливать куки бесполезно. "
|
||||
"(CIAN_PROXY_URL — мёртвая переменная, снята в #2616.)"
|
||||
),
|
||||
)
|
||||
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
|
||||
return HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Cian временно недоступен (5xx или сетевой сбой при проверке кук). "
|
||||
"Повторите проверку позже. Куки не трогать — источник просто не ответил."
|
||||
),
|
||||
)
|
||||
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
|
||||
return HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Cian изменил вёрстку/схему страницы — auth-state не найден/не "
|
||||
"распарсился (scraper_kit.cian_state_parser.extract_state, MFE "
|
||||
"header-frontend). Нужен инженерный фикс парсера, перезалив кук "
|
||||
"проблему НЕ решит."
|
||||
),
|
||||
)
|
||||
if state is None:
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail="Куки протухли или сессия разлогинена на cian.ru — перезалейте куки.",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/scrape/cian/upload-cookies", status_code=200)
|
||||
async def upload_cian_cookies(
|
||||
cookies: dict[str, str],
|
||||
|
|
@ -405,15 +456,21 @@ async def upload_cian_cookies(
|
|||
)
|
||||
|
||||
state = await cian_session_svc.verify_session(cleaned)
|
||||
if state is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Cookies invalid or session not authenticated on cian.ru",
|
||||
)
|
||||
verify_error = _cian_verify_state_error(state)
|
||||
if verify_error is not None:
|
||||
raise verify_error
|
||||
assert state is not None # narrowed by _cian_verify_state_error above
|
||||
|
||||
user_id = state.get("user", {}).get("userId")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Cian подтвердил аутентификацию (state распарсился), но userId в "
|
||||
"ответе не найден — структура state неожиданная, куки тут ни при "
|
||||
"чём, смотрите server logs."
|
||||
),
|
||||
)
|
||||
|
||||
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
|
||||
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
|
||||
|
|
@ -480,15 +537,21 @@ async def cian_auto_login(
|
|||
)
|
||||
|
||||
state = await cian_session_svc.verify_session(cleaned)
|
||||
if state is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Logged in but session not authenticated (cookies rejected by cian.ru)",
|
||||
)
|
||||
verify_error = _cian_verify_state_error(state)
|
||||
if verify_error is not None:
|
||||
raise verify_error
|
||||
assert state is not None # narrowed by _cian_verify_state_error above
|
||||
|
||||
user_id = state.get("user", {}).get("userId")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Cian подтвердил аутентификацию (state распарсился), но userId в "
|
||||
"ответе не найден — структура state неожиданная, куки тут ни при "
|
||||
"чём, смотрите server logs."
|
||||
),
|
||||
)
|
||||
|
||||
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
|
||||
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
|
||||
|
|
@ -500,6 +563,12 @@ async def test_cian_auth(
|
|||
) -> dict:
|
||||
"""Проверить что текущие сохранённые Cian cookies ещё валидны.
|
||||
|
||||
reason различает 5 исходов (см. cian_session_svc.verify_session докстринг):
|
||||
"banned_403" — куки, вероятно, ОК, блокирован IP; "source_unavailable" —
|
||||
Cian недоступен, куки ни при чём; "markup_changed" — вёрстка Cian сменилась,
|
||||
нужен фикс парсера; "session_expired_or_invalid" — куки реально протухли;
|
||||
"no_session_in_db" / "encryption_key_not_configured" — конфигурация/данных нет.
|
||||
|
||||
Returns: {"authenticated": bool, "userId": <int|null>, "reason": <str|null>}
|
||||
"""
|
||||
if not settings.cookie_encryption_key:
|
||||
|
|
@ -510,10 +579,14 @@ async def test_cian_auth(
|
|||
return {"authenticated": False, "userId": None, "reason": "no_session_in_db"}
|
||||
|
||||
state = await cian_session_svc.verify_session(cookies)
|
||||
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
|
||||
return {"authenticated": False, "userId": None, "reason": "banned_403"}
|
||||
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
|
||||
return {"authenticated": False, "userId": None, "reason": "source_unavailable"}
|
||||
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
|
||||
return {"authenticated": False, "userId": None, "reason": "markup_changed"}
|
||||
if state is None:
|
||||
return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"}
|
||||
if state.get("_ban"):
|
||||
return {"authenticated": False, "userId": None, "reason": "banned_403"}
|
||||
|
||||
user_id = state.get("user", {}).get("userId")
|
||||
return {"authenticated": True, "userId": user_id, "reason": None}
|
||||
|
|
@ -1904,9 +1977,16 @@ async def scrape_cian_detail(
|
|||
Without it → debug-only (no DB write).
|
||||
"""
|
||||
_assert_allowed_url(offer_url)
|
||||
from scraper_kit.cian_exceptions import CianBlockedError
|
||||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||||
|
||||
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
|
||||
try:
|
||||
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
|
||||
except CianBlockedError as exc:
|
||||
# #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке
|
||||
# нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с
|
||||
# этого узла» — разные новости для того, кто дёргает ручку руками.
|
||||
raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc
|
||||
if enrichment is None:
|
||||
raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}")
|
||||
|
||||
|
|
@ -1961,8 +2041,9 @@ async def scrape_cian_newbuilding(
|
|||
|
||||
saved = False
|
||||
if house_id is not None:
|
||||
# save_newbuilding_enrichment — sync (def, returns None); await на sync-функции
|
||||
# раньше поднимал TypeError на любом вызове с house_id.
|
||||
# save_newbuilding_enrichment — sync (def, не корутина); await на sync-функции
|
||||
# раньше поднимал TypeError на любом вызове с house_id. Возвращаемый счёт
|
||||
# записанного (#2807) этой ручке не нужен — она отвечает фактом сохранения.
|
||||
save_newbuilding_enrichment(db, house_id, enrichment)
|
||||
saved = True
|
||||
|
||||
|
|
@ -2149,7 +2230,10 @@ class HouseIMVBackfillRequest(BaseModel):
|
|||
)
|
||||
only_status: str = Field(
|
||||
default="pending",
|
||||
description="Обрабатывать дома с этим imv_status. 'transient_error' — retry.",
|
||||
description=(
|
||||
"Обрабатывать дома с этим imv_status. По умолчанию 'pending' + автоповтор "
|
||||
"'transient_error' на половине пакета; явное значение = только этот статус."
|
||||
),
|
||||
)
|
||||
house_id: int | None = Field(
|
||||
default=None,
|
||||
|
|
@ -2186,7 +2270,12 @@ async def scrape_house_imv_backfill(
|
|||
batch_size: сколько домов обработать за запуск (default 50).
|
||||
request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV
|
||||
реагирует на частые запросы с datacenter-IP. Не снижать < 3s.
|
||||
only_status: по умолчанию 'pending'. Для retry failed — 'transient_error'.
|
||||
only_status: по умолчанию 'pending' — и тогда половина пакета сама уходит на
|
||||
повтор домов в 'transient_error' с непотраченным лимитом попыток (#2674:
|
||||
раньше повтор существовал только как этот параметр, и за 41 прогон его
|
||||
не передали ни разу — 1390 домов застряли навсегда). Явное значение
|
||||
отключает автоповтор и обрабатывает РОВНО указанный статус, включая
|
||||
дома, исчерпавшие лимит (imv_transient_attempts >= 3).
|
||||
house_id: обработать один дом (debug).
|
||||
|
||||
Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию.
|
||||
|
|
@ -2290,18 +2379,33 @@ class ScraperHealthResponse(BaseModel):
|
|||
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
||||
|
||||
|
||||
def _provider_proxy_url(source: str) -> str | None:
|
||||
"""Effective proxy URL для source (учитывает property-fallback в settings).
|
||||
def _provider_proxy_url(db: Session, source: str) -> str | None:
|
||||
"""Узел, который РЕАЛЬНО получит трафик этого источника (#2830).
|
||||
|
||||
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
|
||||
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая
|
||||
mobileproxy-подписка, #2613).
|
||||
Раньше здесь стоял `settings.scraper_proxy_url` — одна и та же статичная
|
||||
переменная для всех трёх источников. После #2825/#2831 egress выбирается из
|
||||
`scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть
|
||||
страница показывала один узел, а трафик шёл через другой — слепое пятно ровно
|
||||
того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел,
|
||||
забаненный и Avito, и Cian), только теперь на диагностической странице.
|
||||
|
||||
Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`,
|
||||
`*_detail_backfill`) — не «похожая логика», иначе страница снова начнёт
|
||||
расходиться с трафиком.
|
||||
|
||||
Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only,
|
||||
lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не
|
||||
его репутацию у Авито/Циана — присваивать узлу отказ по чужой пробе значит
|
||||
выдавать ему чужой бан.
|
||||
"""
|
||||
return {
|
||||
"avito": settings.scraper_proxy_url,
|
||||
"cian": settings.cian_proxy_url,
|
||||
"yandex": settings.yandex_proxy_url,
|
||||
}.get(source)
|
||||
try:
|
||||
return resolve_proxy_url(db, source)
|
||||
except ProxyPoolExhaustedError:
|
||||
# Пул не пуст, но для source не осталось ни одного здорового небаненного узла.
|
||||
# resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть
|
||||
# страница покажет «—», а не статичный env-узел (зелёная строка на месте
|
||||
# отказа хуже пустой).
|
||||
return None
|
||||
|
||||
|
||||
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
|
||||
|
|
@ -2417,19 +2521,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None:
|
|||
|
||||
|
||||
@router.get("/scraper/health", response_model=ScraperHealthResponse)
|
||||
async def scraper_health() -> ScraperHealthResponse:
|
||||
async def scraper_health(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> ScraperHealthResponse:
|
||||
"""Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры.
|
||||
|
||||
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
|
||||
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
|
||||
- providers: для avito/cian/yandex — proxy host/port, rotate_supported
|
||||
(#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
|
||||
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
|
||||
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
|
||||
- providers: для avito/cian/yandex — узел, который пул отдаст ЭТОМУ источнику
|
||||
сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного
|
||||
здорового узла), rotate_supported (#2616 шаг 2: всегда False — changeip
|
||||
mobileproxy-ротация снята, мёртвый аккаунт #2613; живая ASocks-ротация —
|
||||
POST /admin/proxies/{id}/rotate, #2611, не per-provider-source), best-effort
|
||||
current_ip (пробинг через этот же узел на ipify).
|
||||
|
||||
Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с.
|
||||
"""
|
||||
proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES}
|
||||
proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES}
|
||||
|
||||
browser, *ips = await asyncio.gather(
|
||||
_probe_browser_health(),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import calendar
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Annotated, Any
|
||||
|
|
@ -167,6 +169,239 @@ def _resolve_target_house_id(
|
|||
return None
|
||||
|
||||
|
||||
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────────
|
||||
# Заказчик открыл сохранённую ссылку (?id=...) и увидел «НЕДОСТАТОЧНО ДАННЫХ»:
|
||||
# запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825) и
|
||||
# лежит в БД мёртвой (median_price<=0/NULL), хотя тот же адрес/параметры
|
||||
# сейчас честно считаются. get_estimate() ниже пытается пересчитать такую
|
||||
# строку ОДИН раз (throttled) через тот же estimate_quality(), что и POST
|
||||
# /estimate, и пишет результат В ТУ ЖЕ строку (id/ссылка не меняются). Живую
|
||||
# строку (median_price>0) этот путь не трогает вообще — сохранённая клиенту
|
||||
# цена неприкосновенна.
|
||||
def _precision_to_qc_geo(precision: str | None) -> int | None:
|
||||
"""Best-effort обратное отображение к estimator._qc_geo_to_precision.
|
||||
|
||||
AggregatedEstimate наружу отдаёт только бакетированный address_precision
|
||||
(house/street/approximate), не сырой dadata.qc_geo (0..5) — тот остаётся
|
||||
приватным для estimate_quality(). При revival нам нужно записать ЧТО-ТО в
|
||||
колонку dadata_qc_geo, чтобы будущие (уже НЕ revival, обычные) GET той же
|
||||
теперь-живой строки не откатили address_precision в None. Бакеты 2..5
|
||||
(settlement/city/region/unknown) неразличимы ПОСЛЕ _qc_geo_to_precision —
|
||||
2 репрезентативно для всех: тот же helper на чтении схлопывает их обратно
|
||||
в тот же "approximate", наблюдаемое поведение не меняется.
|
||||
"""
|
||||
if precision == "house":
|
||||
return 0
|
||||
if precision == "street":
|
||||
return 1
|
||||
if precision == "approximate":
|
||||
return 2
|
||||
return None
|
||||
|
||||
|
||||
def _payload_from_dead_row(row: Any) -> TradeInEstimateInput:
|
||||
"""Восстанавливает вход оценки из мёртвой сохранённой строки для revival.
|
||||
|
||||
Только поля, реально персистящиеся в trade_in_estimates при создании
|
||||
(address/lat/lon/area_m2/rooms/floor/total_floors/year_built/house_type/
|
||||
repair_state/has_balcony) — CRM-only поля (ownership_type/has_mortgage)
|
||||
на расчёт не влияют и не нужны здесь. radius_m НИКОГДА не персистится
|
||||
(payload.radius_m живёт только в рамках одного POST-запроса, ни главный
|
||||
INSERT, ни _empty_estimate его не пишут) — None здесь даёт тот же
|
||||
default-каскад (DEFAULT_RADIUS_M/FALLBACK_RADIUS_M), что у подавляющего
|
||||
большинства сохранённых строк (явный радиус выбирает меньшинство).
|
||||
consent=None + require_consent=False у вызывающего — revival не новое
|
||||
согласие физлица, а служебный recompute уже существующей записи.
|
||||
"""
|
||||
return TradeInEstimateInput(
|
||||
address=row.address,
|
||||
area_m2=float(row.area_m2),
|
||||
rooms=row.rooms,
|
||||
floor=row.floor,
|
||||
total_floors=row.total_floors,
|
||||
year_built=row.year_built,
|
||||
house_type=row.house_type,
|
||||
repair_state=row.repair_state,
|
||||
has_balcony=row.has_balcony,
|
||||
lat=row.lat,
|
||||
lon=row.lon,
|
||||
radius_m=None,
|
||||
consent=None,
|
||||
)
|
||||
|
||||
|
||||
async def _try_revive_dead_estimate(
|
||||
db: Session, estimate_id: UUID, row: Any
|
||||
) -> AggregatedEstimate | None:
|
||||
"""Пытается пересчитать «мёртвую» (median_price<=0/NULL) строку на месте.
|
||||
|
||||
Возвращает свежий AggregatedEstimate (estimate_id ПОДМЕНЁН на исходный —
|
||||
id/ссылка не меняются) при успехе; None если: (а) throttle ещё не истёк /
|
||||
заявку уже забрал параллельный запрос — anti-storm через атомарный
|
||||
conditional `UPDATE ... RETURNING` ниже (тот же паттерн, что
|
||||
account_quota.increment, #747): WHERE перепроверяет и «мертва ли строка
|
||||
сейчас», и «давно ли последняя попытка» НЕПОСРЕДСТВЕННО в БД, а не по
|
||||
значению, прочитанному раньше в Python — TOCTOU-гонка между двумя
|
||||
параллельными GET невозможна, проигравший просто не дублирует работу;
|
||||
(б) пересчёт сам дал 0 (по-прежнему недостаточно данных); (в) пересчёт
|
||||
упал с исключением (сеть/геокод/что угодно). Во всех трёх случаях caller
|
||||
обязан отдать сохранённую (по-прежнему мёртвую) строку как раньше — НЕ 500.
|
||||
"""
|
||||
claim = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE trade_in_estimates
|
||||
SET revival_attempted_at = NOW()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND (median_price <= 0 OR median_price IS NULL)
|
||||
AND (
|
||||
revival_attempted_at IS NULL
|
||||
OR revival_attempted_at
|
||||
< NOW() - make_interval(mins => CAST(:throttle AS integer))
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"id": str(estimate_id), "throttle": settings.trade_in_revival_throttle_minutes},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
if claim is None:
|
||||
logger.info("estimate revival throttled/lost race: id=%s", estimate_id)
|
||||
return None
|
||||
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
try:
|
||||
payload = _payload_from_dead_row(row)
|
||||
result = await estimate_quality(
|
||||
payload,
|
||||
db,
|
||||
created_by=row.created_by,
|
||||
client_ip=None,
|
||||
require_consent=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("estimate revival failed: id=%s address=%r", estimate_id, row.address)
|
||||
return None
|
||||
|
||||
temp_id = result.estimate_id
|
||||
if result.median_price_rub <= 0:
|
||||
logger.info("estimate revival still insufficient data: id=%s", estimate_id)
|
||||
db.execute(
|
||||
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(temp_id)},
|
||||
)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
# estimate_quality() persists under a BRAND NEW uuid (temp_id) — it has no
|
||||
# notion of "recompute this existing row". Copy the computed OUTPUT fields
|
||||
# into the ORIGINAL row (id/link contract), then drop the throwaway one.
|
||||
# INPUT snapshot (address/area/rooms/...) is untouched — it did not change,
|
||||
# only the outputs were recomputed.
|
||||
# #incident-2026-08-11: created_at is DELIBERATELY excluded from this SET —
|
||||
# it is the client's original request date (printed in /history and in
|
||||
# AggregatedEstimate.created_at, see app/schemas/trade_in.py:317-318), NOT
|
||||
# a recompute output. It previously got clobbered with the throwaway temp
|
||||
# row's created_at (=NOW() at recompute time), which also silently
|
||||
# re-sorted the row to the top of `GET /history ORDER BY created_at DESC`.
|
||||
# revival_completed_at (migration 256) is the audit trail for "when did a
|
||||
# revival LAST successfully rewrite this row" — distinct from
|
||||
# revival_attempted_at (255), which is stamped on every claim regardless
|
||||
# of outcome (throttle loss / recompute failure included).
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE avito_imv_evaluations
|
||||
SET estimate_id = CAST(:orig AS uuid)
|
||||
WHERE estimate_id = CAST(:temp AS uuid)
|
||||
"""
|
||||
),
|
||||
{"orig": str(estimate_id), "temp": str(temp_id)},
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE trade_in_estimates SET
|
||||
median_price = :median_price,
|
||||
range_low = :range_low,
|
||||
range_high = :range_high,
|
||||
median_price_per_m2 = :median_ppm2,
|
||||
confidence = :confidence,
|
||||
confidence_explanation = :explanation,
|
||||
n_analogs = :n_analogs,
|
||||
analogs = CAST(:analogs_json AS jsonb),
|
||||
actual_deals = CAST(:deals_json AS jsonb),
|
||||
sources_used = CAST(:sources_json AS jsonb),
|
||||
data_freshness_minutes = :freshness,
|
||||
canonical_address = :canonical_address,
|
||||
house_cadnum = :house_cadnum,
|
||||
house_fias_id = :house_fias_id,
|
||||
dadata_qc_geo = :dadata_qc_geo,
|
||||
dadata_metro = CAST(:dadata_metro_json AS jsonb),
|
||||
expected_sold_price = :expected_sold_price,
|
||||
expected_sold_range_low = :expected_sold_range_low,
|
||||
expected_sold_range_high = :expected_sold_range_high,
|
||||
expected_sold_per_m2 = :expected_sold_per_m2,
|
||||
asking_to_sold_ratio = :asking_to_sold_ratio,
|
||||
ratio_basis = :ratio_basis,
|
||||
relaxations = CAST(:relaxations_json AS jsonb),
|
||||
reliability = :reliability,
|
||||
revival_completed_at = NOW()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(estimate_id),
|
||||
"median_price": result.median_price_rub,
|
||||
"range_low": result.range_low_rub,
|
||||
"range_high": result.range_high_rub,
|
||||
"median_ppm2": result.median_price_per_m2,
|
||||
"confidence": result.confidence,
|
||||
"explanation": result.confidence_explanation,
|
||||
"n_analogs": result.n_analogs,
|
||||
"analogs_json": json.dumps(
|
||||
[a.model_dump(mode="json") for a in result.analogs], ensure_ascii=False
|
||||
),
|
||||
"deals_json": json.dumps(
|
||||
[a.model_dump(mode="json") for a in result.actual_deals], ensure_ascii=False
|
||||
),
|
||||
"sources_json": json.dumps(result.sources_used, ensure_ascii=False),
|
||||
"freshness": result.data_freshness_minutes,
|
||||
"canonical_address": result.canonical_address,
|
||||
"house_cadnum": result.house_cadnum,
|
||||
"house_fias_id": result.house_fias_id,
|
||||
"dadata_qc_geo": _precision_to_qc_geo(result.address_precision),
|
||||
"dadata_metro_json": json.dumps(result.metro_nearest, ensure_ascii=False),
|
||||
"expected_sold_price": result.expected_sold_price_rub,
|
||||
"expected_sold_range_low": result.expected_sold_range_low_rub,
|
||||
"expected_sold_range_high": result.expected_sold_range_high_rub,
|
||||
"expected_sold_per_m2": result.expected_sold_per_m2,
|
||||
"asking_to_sold_ratio": result.asking_to_sold_ratio,
|
||||
"ratio_basis": result.ratio_basis,
|
||||
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
||||
"reliability": result.reliability,
|
||||
},
|
||||
)
|
||||
db.execute(
|
||||
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(temp_id)},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"estimate revived: id=%s median=%d n=%d confidence=%s reliability=%s",
|
||||
estimate_id,
|
||||
result.median_price_rub,
|
||||
result.n_analogs,
|
||||
result.confidence,
|
||||
result.reliability,
|
||||
)
|
||||
# created_at on the returned object must mirror the DB row (untouched
|
||||
# original request date, NOT the temp row's NOW()) — see UPDATE above.
|
||||
return result.model_copy(update={"estimate_id": estimate_id, "created_at": row.created_at})
|
||||
|
||||
|
||||
@router.post("/estimate", response_model=AggregatedEstimate)
|
||||
async def estimate(
|
||||
payload: TradeInEstimateInput,
|
||||
|
|
@ -281,7 +516,8 @@ def get_estimate(
|
|||
dadata_qc_geo, dadata_metro,
|
||||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis, created_by, created_at
|
||||
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
||||
relaxations, reliability
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND {ESTIMATE_READABLE_SQL}
|
||||
|
|
@ -295,6 +531,22 @@ def get_estimate(
|
|||
|
||||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
||||
|
||||
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
|
||||
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем
|
||||
# пересчитать её на месте (throttled, race-safe — см. докстринг
|
||||
# _try_revive_dead_estimate) через тот же путь, что и POST /estimate.
|
||||
# Живую строку (median_price>0) не трогаем вообще. asyncio.run() — sync↔
|
||||
# async мост (тот же паттерн, что app/scheduler_main.py): get_estimate
|
||||
# остаётся `def` (Starlette гоняет его в threadpool, как сейчас), поэтому
|
||||
# ОСТАЛЬНЫЕ синхронные db.execute() ниже по функции не переезжают на event
|
||||
# loop — только сама попытка revival временно занимает свой поток на время
|
||||
# await estimate_quality(). Любая ошибка расчёта — не 500: revived is None,
|
||||
# и функция просто продолжает как раньше, отдавая сохранённую строку.
|
||||
if row.median_price is None or row.median_price <= 0:
|
||||
revived = asyncio.run(_try_revive_dead_estimate(db, estimate_id, row))
|
||||
if revived is not None:
|
||||
return revived
|
||||
|
||||
from app.services.estimator import (
|
||||
_canonical_sources,
|
||||
_cv_from_ppm2,
|
||||
|
|
@ -304,11 +556,22 @@ def get_estimate(
|
|||
_qc_geo_to_precision,
|
||||
_resolve_target_city,
|
||||
_source_counts,
|
||||
rehydrate_search_radius_m,
|
||||
)
|
||||
|
||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||
actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
|
||||
|
||||
# #2632: search_radius_m колонкой не персистится — восстанавливаем его из
|
||||
# того, что персистится (подпись каскада «радиус расширен до N м», иначе
|
||||
# размах сохранённых аналогов). Без этого GET отдавал null, фронт падал на
|
||||
# превью-радиус 1 км и рисовал круг, за которым лежат его же пины (прод
|
||||
# 2026-08-11: 10 из 10 аналогов вне круга, самый дальний — 4381 м).
|
||||
persisted_relaxations = list(getattr(row, "relaxations", None) or [])
|
||||
search_radius_m = rehydrate_search_radius_m(
|
||||
persisted_relaxations, [a.distance_m for a in analogs]
|
||||
)
|
||||
|
||||
# #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из
|
||||
# сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
|
||||
# свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
|
||||
|
|
@ -431,6 +694,18 @@ def get_estimate(
|
|||
cv=cv,
|
||||
source_counts=source_counts,
|
||||
created_at=row.created_at,
|
||||
# PR #2823 open follow-up (fixed incident 2026-08-10, migration 255):
|
||||
# relaxations/reliability теперь персистятся — GET-rehydrate больше не
|
||||
# теряет красный баннер «точность снижена» при открытии по ссылке.
|
||||
# getattr defensive: старые in-memory test doubles / любая строка без
|
||||
# этих колонок (не должно случаться после миграции) деградируют в
|
||||
# дефолт схемы (ok / []), а не падают AttributeError.
|
||||
relaxations=persisted_relaxations,
|
||||
reliability=getattr(row, "reliability", None) or "ok",
|
||||
# #2632: фактический радиус подбора (реконструкция выше). requested_radius_m
|
||||
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
||||
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
||||
search_radius_m=search_radius_m,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -462,7 +737,8 @@ def estimate_pdf(
|
|||
dadata_qc_geo, dadata_metro,
|
||||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis, created_by
|
||||
asking_to_sold_ratio, ratio_basis, created_by,
|
||||
relaxations, reliability
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
|
|
@ -523,6 +799,10 @@ def estimate_pdf(
|
|||
house_fias_id=row.house_fias_id,
|
||||
address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
|
||||
metro_nearest=(row.dadata_metro or []),
|
||||
# migration 255 — та же сноска «точность снижена», что и на JSON GET,
|
||||
# теперь и в PDF-регенерации сохранённой оценки (см. get_estimate).
|
||||
relaxations=list(getattr(row, "relaxations", None) or []),
|
||||
reliability=getattr(row, "reliability", None) or "ok",
|
||||
)
|
||||
input_snapshot = {
|
||||
"address": row.address,
|
||||
|
|
|
|||
20
tradein-mvp/backend/app/api/v1/version.py
Normal file
20
tradein-mvp/backend/app/api/v1/version.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""GET /api/v1/trade-in/version — build metadata (product version + short SHA +
|
||||
build date), source `app/core/version.py`.
|
||||
|
||||
Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) — это не
|
||||
секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только
|
||||
process env / уже загруженные при импорте константы (без похода в БД)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
def get_version() -> dict[str, str]:
|
||||
"""{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}."""
|
||||
return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE}
|
||||
|
|
@ -629,6 +629,23 @@ class Settings(BaseSettings):
|
|||
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
|
||||
estimate_quarter_index_factor_min: float = 0.6
|
||||
estimate_quarter_index_factor_max: float = 1.8
|
||||
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
||||
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
||||
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
||||
#
|
||||
# Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр
|
||||
# известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м,
|
||||
# 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность
|
||||
# цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и
|
||||
# координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт
|
||||
# денежный — поэтому включение отдельным решением, после замера.
|
||||
#
|
||||
# Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с
|
||||
# заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И
|
||||
# сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к
|
||||
# 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить,
|
||||
# а не оставлять «на вырост».
|
||||
estimate_quarter_from_coords_enabled: bool = False
|
||||
|
||||
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
|
||||
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,
|
||||
|
|
@ -852,6 +869,19 @@ class Settings(BaseSettings):
|
|||
# «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS.
|
||||
trade_in_paid_retention_days: int = 365
|
||||
|
||||
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────
|
||||
# Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL)
|
||||
# сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F,
|
||||
# PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается
|
||||
# пересчитать такую строку через тот же estimate_quality(), что и POST
|
||||
# (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза
|
||||
# в это число минут на строку — иначе каждый refresh страницы бил бы по
|
||||
# геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс:
|
||||
# достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро,
|
||||
# чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV:
|
||||
# TRADE_IN_REVIVAL_THROTTLE_MINUTES.
|
||||
trade_in_revival_throttle_minutes: int = 10
|
||||
|
||||
# Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним
|
||||
# DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама
|
||||
# крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) —
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset(
|
|||
"/api/v1/trade-in/support/anon/messages",
|
||||
"/api/v1/trade-in/support/anon/unread",
|
||||
"/api/v1/trade-in/support/anon/read",
|
||||
# Версионирование (VERSION-файл + build-args, см. app/core/version.py):
|
||||
# не секрет, читает только process env — быстрая справка для клиента/
|
||||
# поддержки/смоук-теста, не должна требовать сессию.
|
||||
"/api/v1/trade-in/version",
|
||||
}
|
||||
)
|
||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||
|
|
|
|||
83
tradein-mvp/backend/app/core/version.py
Normal file
83
tradein-mvp/backend/app/core/version.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`.
|
||||
|
||||
`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env,
|
||||
запечённые в образ через build-args в `backend/Dockerfile`
|
||||
(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) — там же
|
||||
ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и
|
||||
`date -u +%Y-%m-%dT%H:%M:%SZ`.
|
||||
|
||||
Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env —
|
||||
тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов,
|
||||
см. `_find_version_file`), sha фолбэчит на `"dev"`, дата — на момент импорта
|
||||
модуля. Ничего здесь не должно падать при отсутствии env (потребитель —
|
||||
и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`).
|
||||
|
||||
Номер версии НЕ дублируется больше нигде в коде — читай `APP_VERSION` отсюда.
|
||||
Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION`
|
||||
в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и
|
||||
`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля /
|
||||
`@/lib/buildInfo` соответственно; не заводи третий.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_VERSION = "0.0.0"
|
||||
# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает
|
||||
# и локальный layout (backend/app/core/version.py → ../../../VERSION ==
|
||||
# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/
|
||||
# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION).
|
||||
_MAX_ANCESTORS = 6
|
||||
|
||||
|
||||
def _find_version_file() -> Path | None:
|
||||
here = Path(__file__).resolve()
|
||||
for ancestor in list(here.parents)[:_MAX_ANCESTORS]:
|
||||
candidate = ancestor / "VERSION"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _read_version_file() -> str:
|
||||
path = _find_version_file()
|
||||
if path is None:
|
||||
return _DEFAULT_VERSION
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return _DEFAULT_VERSION
|
||||
return text or _DEFAULT_VERSION
|
||||
|
||||
|
||||
def _default_build_date() -> str:
|
||||
return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# Читаются один раз при импорте модуля (совпадает с паттерном `settings =
|
||||
# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем,
|
||||
# перечитывать на каждый запрос незачем.
|
||||
APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file()
|
||||
BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev"
|
||||
BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date()
|
||||
|
||||
|
||||
def format_build_date_human(build_date: str = BUILD_DATE) -> str:
|
||||
"""ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF
|
||||
колонтитул). Никогда не бросает исключение — при неразборчивой строке
|
||||
возвращает её как есть (это футер отчёта, не API-контракт)."""
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return build_date
|
||||
return parsed.strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
def product_version_line(product_name: str = "Мера") -> str:
|
||||
"""`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта
|
||||
2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF
|
||||
колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`."""
|
||||
return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}"
|
||||
|
|
@ -34,6 +34,7 @@ from app.api.v1 import (
|
|||
support,
|
||||
team,
|
||||
trade_in,
|
||||
version,
|
||||
)
|
||||
from app.core.auth_db import get_auth_engine
|
||||
from app.core.config import settings
|
||||
|
|
@ -216,6 +217,7 @@ app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
|||
app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"])
|
||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
|
||||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
|
||||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||
|
|
|
|||
|
|
@ -275,15 +275,26 @@ class AggregatedEstimate(BaseModel):
|
|||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||
# listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может
|
||||
# ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне):
|
||||
# сервер молча расширяет 1 км → 2 км при нехватке аналогов (см.
|
||||
# confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте
|
||||
# по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально
|
||||
# использованном радиусе. None на GET-rehydrate (не персистится, старые записи)
|
||||
# и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае
|
||||
# fallback'ает на выбор пользователя.
|
||||
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
||||
# при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад
|
||||
# #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно,
|
||||
# контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему
|
||||
# выбору) — иначе карта врёт о реально использованном радиусе.
|
||||
# На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ
|
||||
# (estimator.rehydrate_search_radius_m): из persisted-подписи каскада
|
||||
# «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из
|
||||
# размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у
|
||||
# _empty_estimate (поиск не выполнялся) и у старых строк без расстояний;
|
||||
# фронт тогда fallback'ает на выбор пользователя, как раньше.
|
||||
search_radius_m: int | None = None
|
||||
# requested_radius_m — радиус, с которого поиск НАЧАЛСЯ: явный выбор
|
||||
# пользователя (TradeInEstimateInput.radius_m) либо DEFAULT_RADIUS_M, если он
|
||||
# выбрал «Авто». Отдаётся рядом с фактическим, чтобы ответ нёс ОБЕ величины —
|
||||
# что просили и что получилось — и потребителю не приходилось выводить
|
||||
# расхождение из своего локального состояния. None на GET-rehydrate:
|
||||
# radius_m не персистится, а угадывать «просили 1 км» за пользователя —
|
||||
# ровно та подмена входа результатом, которую чинит это поле.
|
||||
requested_radius_m: int | None = None
|
||||
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
||||
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
||||
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
|
||||
|
|
@ -319,6 +330,41 @@ class AggregatedEstimate(BaseModel):
|
|||
cv: float | None = None
|
||||
source_counts: dict[str, int] = Field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
# ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10,
|
||||
# #oblast-E priority RESTORED same day — see estimator.py module
|
||||
# docstring for the full 3-way headline-source rule) ──────────────────
|
||||
# Product requirement: an estimate is ALWAYS surfaced — a thin base sample
|
||||
# (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First
|
||||
# estimator.estimate_quality() progressively relaxes the analog SEARCH
|
||||
# (room-count adjacency → freshness window → novostroyki segment → radius)
|
||||
# trying to grow the sample past the threshold; if it's STILL thin,
|
||||
# _price_from_inputs() prefers a usable ДКП deals corridor over a noisy
|
||||
# thin listings median when one is available (restored #oblast-E
|
||||
# priority — the Серов repro: 3 listings must not outrank 54 deals), and
|
||||
# only falls back to the thin listings median itself when no corridor
|
||||
# exists. Real refusal happens only at genuine zero (no listings AND no
|
||||
# usable anchor/deals).
|
||||
# relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага
|
||||
# ослабления, готовые к показу пользователю как честный дисклеймер рядом с
|
||||
# confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад
|
||||
# не понадобился (обычный случай). Возможные значения (дословно, фронт
|
||||
# может на них завязываться): "снят фильтр по году постройки",
|
||||
# "учтены студии", "комнатность ±1", "объявления за 60 дней",
|
||||
# "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м",
|
||||
# "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП
|
||||
# deals corridor because the base listings sample was thin — a source
|
||||
# SWITCH, not a search widening, but surfaced the same way).
|
||||
# reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs
|
||||
# (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если
|
||||
# relaxations непусто (выборка набралась только ценой ослаблений); капается
|
||||
# на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой
|
||||
# выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего».
|
||||
# НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное
|
||||
# ограничение, каскад не переигрывается из сохранённых analogs). НЕ
|
||||
# путать с `confidence` (Literal low/medium/high — старая метрика на
|
||||
# основе уникальных адресов/IQR, см. её собственный докстринг выше).
|
||||
relaxations: list[str] = Field(default_factory=list)
|
||||
reliability: Literal["ok", "low", "very_low"] = "ok"
|
||||
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
|
||||
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
|
||||
area_m2: float | None = None
|
||||
|
|
|
|||
|
|
@ -19,19 +19,40 @@ from dataclasses import dataclass, field
|
|||
# golden-parity была доказана против legacy cian_detail-модуля до его удаления,
|
||||
# #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным
|
||||
# legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь).
|
||||
# RealScraperConfig — тот же read-only адаптер над settings, что и остальные
|
||||
# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url)
|
||||
# идентичным прежнему прямому импорту settings.
|
||||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
|
||||
from app.services.scraper_adapters import (
|
||||
RealMatcherAdapter,
|
||||
RealProxyProvider,
|
||||
RealScraperConfig,
|
||||
)
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _PoolCurlConfig(RealScraperConfig):
|
||||
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
|
||||
|
||||
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
|
||||
services.scraper.environment), а этот бэкфилл запускается ручкой
|
||||
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
|
||||
→ `settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
|
||||
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
|
||||
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
|
||||
|
||||
Флаг — рубильник раскатки pool-режима для планировщика, а не решение «этому пути
|
||||
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
|
||||
"""
|
||||
|
||||
@property
|
||||
def use_proxy_pool_curl(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class CianPriceHistoryResult:
|
||||
checked: int = 0
|
||||
|
|
@ -60,6 +81,11 @@ async def backfill_cian_price_history(
|
|||
result = CianPriceHistoryResult()
|
||||
t0 = time.time()
|
||||
delay = get_scraper_delay("cian") # default 5.0s
|
||||
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
|
||||
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
|
||||
# (mark_banned на CianBlockedError / mark_health / release).
|
||||
scraper_config = _PoolCurlConfig()
|
||||
proxy_provider = RealProxyProvider()
|
||||
|
||||
if listing_id is not None:
|
||||
rows = (
|
||||
|
|
@ -107,9 +133,27 @@ async def backfill_cian_price_history(
|
|||
url: str = row["source_url"]
|
||||
|
||||
try:
|
||||
# config= обязателен — kit fetch_detail без него не читает cian_proxy_url
|
||||
# (direct connection), а без прокси datacenter-IP блокируется Cian (#806).
|
||||
enrichment = await fetch_detail(url, config=RealScraperConfig())
|
||||
# config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси
|
||||
# datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула
|
||||
# (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про
|
||||
# `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался.
|
||||
enrichment = await fetch_detail(
|
||||
url, config=scraper_config, proxy_provider=proxy_provider
|
||||
)
|
||||
except NoProxyAvailableError as exc:
|
||||
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
|
||||
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
|
||||
# логом, который читается как «Циан нас блокирует».
|
||||
logger.error(
|
||||
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
|
||||
"на listing_id=%s (обработано %d из %d)",
|
||||
exc,
|
||||
lid,
|
||||
i,
|
||||
len(rows),
|
||||
)
|
||||
result.errors += 1
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cian_price_history: fetch failed listing_id=%s url=%s: %s",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -152,8 +153,12 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
try:
|
||||
# proxies: mobile-proxy egress (#806) — Cian блокирует datacenter-IP даже
|
||||
# при валидных DMIR_AUTH cookies. Без прокси verify всегда вернёт 403.
|
||||
# Пусто (env не задан) → прямое подключение (dev/no-op).
|
||||
_proxy_url = settings.cian_proxy_url
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.cian_proxy_url только если
|
||||
# пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
|
||||
# нездоровы для cian -- ProxyPoolExhaustedError (fail-closed, #2616), см. except
|
||||
# ниже.
|
||||
_proxy_url = resolve_proxy_url_sync("cian")
|
||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||
async with AsyncSession(
|
||||
impersonate="chrome120",
|
||||
|
|
@ -190,6 +195,17 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
logger.info("Cian cookies verified — userId=%s", user.get("userId"))
|
||||
|
||||
return result
|
||||
except ProxyPoolExhaustedError as exc:
|
||||
# Fail-closed (#2616, #2825): пул scrape_proxies не пуст, но все узлы забанены
|
||||
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
|
||||
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
|
||||
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
|
||||
logger.error(
|
||||
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
|
||||
"cookies НЕ помечены протухшими, retry на следующем такте",
|
||||
exc,
|
||||
)
|
||||
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
except Exception as exc:
|
||||
# Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
|
||||
# недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
|
||||
|
|
|
|||
|
|
@ -185,6 +185,75 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3
|
|||
# ИТОГОВОЙ выборке как headline-источнику.
|
||||
HEADLINE_LISTINGS_MIN_N = 5
|
||||
|
||||
# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live
|
||||
# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4
|
||||
# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34;
|
||||
# в радиусе 2 км rooms=0 17-29 м² — 327 активных лотов, rooms=1 — всего 10).
|
||||
# Продукт: НИКОГДА не отказывать в оценке. Если после существующего 4-шагового
|
||||
# каскада (tier0-когорта → без когорты → radius=fallback → area ±25%) выборка
|
||||
# всё ещё < HEADLINE_LISTINGS_MIN_N — estimate_quality() продолжает ослаблять
|
||||
# параметры подбора (см. #oblast-F блок там), от наименее к наиболее
|
||||
# искажающему: (a) смежность комнатности, (b) свежесть объявлений, (c) сегмент
|
||||
# (допустить новостройки), (d) радиус. Каждый применённый шаг попадает в
|
||||
# AggregatedEstimate.relaxations (честный дисклеймер для пользователя) — гейт
|
||||
# #oblast-E при этом больше НЕ обнуляет медиану (см. _price_from_inputs), а
|
||||
# только помечает результат как низконадёжный.
|
||||
RELAX_ROOMS_ADJACENT_DELTA = 1 # #oblast-F (a): rooms>=2 → BETWEEN rooms-1 AND rooms+1
|
||||
LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 дней
|
||||
RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000)
|
||||
RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум
|
||||
|
||||
# #2632: подпись радиусного ослабления — ЕДИНСТВЕННОЕ место, где число
|
||||
# фактического радиуса попадает в persisted-состояние (колонка relaxations).
|
||||
# Отсюда же его читает GET-rehydrate (radius_from_relaxations) — иначе строка и
|
||||
# парсер разъедутся, и карта опять начнёт рисовать не тот круг.
|
||||
RADIUS_RELAXATION_PREFIX = "радиус расширен до "
|
||||
|
||||
|
||||
def radius_relaxation_label(radius_m: int) -> str:
|
||||
"""RU-подпись шага (d) каскада #oblast-F для AggregatedEstimate.relaxations."""
|
||||
return f"{RADIUS_RELAXATION_PREFIX}{radius_m} м"
|
||||
|
||||
|
||||
def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
|
||||
"""Обратный разбор подписи: фактический радиус (м) или None, если его нет.
|
||||
|
||||
Нужен на GET-rehydrate: сам search_radius_m колонкой не персистится, но
|
||||
подпись каскада — да (миграция 255), и в ней лежит ровно то число, по
|
||||
которому реально отбирались аналоги.
|
||||
"""
|
||||
for label in relaxations or []:
|
||||
if label.startswith(RADIUS_RELAXATION_PREFIX):
|
||||
digits = label[len(RADIUS_RELAXATION_PREFIX) :].removesuffix(" м").strip()
|
||||
if digits.isdigit():
|
||||
return int(digits)
|
||||
return None
|
||||
|
||||
|
||||
def rehydrate_search_radius_m(
|
||||
relaxations: list[str] | None, analog_distances_m: list[int | None]
|
||||
) -> int | None:
|
||||
"""Фактический радиус подбора для СОХРАНЁННОЙ оценки (#2632).
|
||||
|
||||
Колонки под search_radius_m нет, поэтому восстанавливаем из того, что
|
||||
персистится:
|
||||
1) подпись каскада «радиус расширен до N м» — точное значение, то же,
|
||||
что вернул POST (строки, созданные с 2026-08-10, миграция 255);
|
||||
2) иначе — размах сохранённых аналогов, но не меньше DEFAULT_RADIUS_M:
|
||||
круг обязан вмещать пины, которые сам же и показывает (тот же приём
|
||||
уже применён во v2 SourcesMap — там радиус круга = max distance_m).
|
||||
None — расстояний нет вовсе (старые/anchor-строки): вызывающий честно
|
||||
падает обратно на выбор пользователя, как и до этой правки.
|
||||
"""
|
||||
labelled = radius_from_relaxations(relaxations)
|
||||
if labelled is not None:
|
||||
return labelled
|
||||
known = [d for d in analog_distances_m if d is not None]
|
||||
if not known:
|
||||
return None
|
||||
return max(DEFAULT_RADIUS_M, max(known))
|
||||
|
||||
|
||||
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
|
||||
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
|
||||
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
|
||||
|
|
@ -1288,6 +1357,80 @@ def _lookup_quarter_indexes(
|
|||
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
|
||||
|
||||
|
||||
# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов,
|
||||
# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились —
|
||||
# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр
|
||||
# от DaData по адресу, а не KNN-подсказка), 2026-08-12:
|
||||
# порог ответ выдан чужой квартал точность покрытие
|
||||
# 15 м 475 26 94.5% 19.8%
|
||||
# 25 м 748 59 92.1% 31.2%
|
||||
# 50 м 1341 271 79.8% 55.9%
|
||||
# 100 м 2090 644 69.2% 87.2%
|
||||
# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане
|
||||
# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м:
|
||||
# точность 92%, и это ровно тот компромисс, который видно в замере, а не
|
||||
# заимствованная у соседнего модуля константа.
|
||||
_TARGET_QUARTER_RADIUS_M = 25.0
|
||||
# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на
|
||||
# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
|
||||
_TARGET_QUARTER_DEG_GATE = 0.0015
|
||||
|
||||
|
||||
def _lookup_target_quarter_by_coords(
|
||||
db: Session,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
) -> str | None:
|
||||
"""Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м).
|
||||
|
||||
Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на
|
||||
проде (замер 2026-08-12) — то есть свой кадастр цели известен почти никогда.
|
||||
Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number`
|
||||
(tasks/cadastral_geo_match) — здесь он применён к координатам САМОЙ цели, а не
|
||||
к координатам аналога.
|
||||
|
||||
Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен
|
||||
только 3-й компонент кадастрового номера (квартал), а он у соседних зданий
|
||||
одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не
|
||||
полный кадастр, чтобы вызывающий код не принял его за кадастр дома.
|
||||
|
||||
None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет
|
||||
или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M.
|
||||
"""
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
try:
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
WITH p AS (
|
||||
SELECT ST_SetSRID(
|
||||
ST_MakePoint(CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)), 4326) AS g
|
||||
)
|
||||
SELECT cb.cad_num
|
||||
FROM cad_buildings_local cb, p
|
||||
WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision))
|
||||
AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision)
|
||||
ORDER BY cb.geom <-> p.g
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"deg": _TARGET_QUARTER_DEG_GATE,
|
||||
"radius": _TARGET_QUARTER_RADIUS_M,
|
||||
},
|
||||
).first()
|
||||
except Exception as exc:
|
||||
logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc)
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
return _quarter_from_cadastre(str(row[0]))
|
||||
|
||||
|
||||
def _apply_quarter_index(
|
||||
*,
|
||||
base_median_ppm2: float,
|
||||
|
|
@ -2562,12 +2705,28 @@ class PricingResult:
|
|||
# headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV
|
||||
# радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных).
|
||||
cv: float | None = None
|
||||
# #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N
|
||||
# (headline suppressed, listings_clean deliberately left intact — see gate
|
||||
# comment above). Caller uses this to also keep the thin listings out of the
|
||||
# display `analogs` cards when no anchor overrides the headline. 0 = either
|
||||
# sufficient listings were used, or genuinely zero were found.
|
||||
# #oblast-E/#oblast-F: >0 когда n листингов было найдено но ниже
|
||||
# HEADLINE_LISTINGS_MIN_N. С #oblast-F (2026-08-10) больше НЕ обнуляет
|
||||
# headline/listings_clean — median/n_analogs остаются реальными, поле лишь
|
||||
# маркирует «низкая надёжность» (confidence='low' + честный explanation,
|
||||
# см. gate comment ниже). 0 = либо выборка была достаточной, либо аналогов
|
||||
# вообще не нашлось.
|
||||
listings_headline_thin_n: int = 0
|
||||
# #oblast-E (restored priority, product correction 2026-08-10): True когда
|
||||
# headline построен из #oblast-D deals-corridor ИМЕННО потому, что базовая
|
||||
# выборка листингов была тонкой (0 < n < HEADLINE_LISTINGS_MIN_N) И доступен
|
||||
# достаточно надёжный ДКП-коридор (см. deals-headline-fallback блок ниже).
|
||||
# Caller (estimate_quality) читает это чтобы (a) добавить relaxation-подпись
|
||||
# «оценка по сделкам — мало объявлений рядом», (b) закэпить reliability на
|
||||
# 'low' (не выше). False во всех остальных случаях, включая genuinely-zero
|
||||
# listings deals-fallback (тот же блок, но без тонкой выборки позади).
|
||||
deals_headline_due_to_thin_listings: bool = False
|
||||
|
||||
|
||||
def _analog_word_dative(n: int) -> str:
|
||||
"""Дательный падеж существительного «аналог» для confidence_explanation
|
||||
тонкой (#oblast-E) выборки — «построена по N аналогу/аналогам»."""
|
||||
return "аналогу" if n == 1 else "аналогам"
|
||||
|
||||
|
||||
def _price_from_inputs(
|
||||
|
|
@ -2592,10 +2751,17 @@ def _price_from_inputs(
|
|||
ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
|
||||
quarter_index_lookup: Callable[[str], tuple[float, int] | None],
|
||||
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
|
||||
# Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется
|
||||
# ровно в одном месте, блок #764, и только ради 3-го компонента (квартала).
|
||||
# Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели.
|
||||
target_house_cadnum: str | None,
|
||||
dadata_coarse: bool,
|
||||
geo: GeocodeResult,
|
||||
dadata_qc_geo: int | None,
|
||||
# #2632: фактический радиус подбора (после каскада #oblast-F) — чтобы
|
||||
# объяснение называло тот радиус, по которому реально искали. None →
|
||||
# прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест).
|
||||
search_radius_m: int | None = None,
|
||||
) -> PricingResult:
|
||||
"""Deterministic pricing orchestration — pure, synchronous, zero I/O.
|
||||
|
||||
|
|
@ -2646,45 +2812,82 @@ def _price_from_inputs(
|
|||
n_analogs = 0
|
||||
cv = None
|
||||
|
||||
# 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above).
|
||||
# 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust
|
||||
# as a market median — suppress the AGGREGATE (median/range/n_analogs/cv)
|
||||
# exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/
|
||||
# insufficient_data chain below all take the already-honest zero-analogs
|
||||
# path automatically (no new branches there). `listings_clean` itself is
|
||||
# deliberately LEFT INTACT (not cleared) — the same-building anchor's own
|
||||
# ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell
|
||||
# "genuinely zero nearby listings" from "some nearby listings, just too few
|
||||
# to trust as THIS estimate's headline" — those are different confidence
|
||||
# signals and clearing the list here would conflate them. The caller
|
||||
# (estimate_quality) uses `listings_headline_thin_n` on the returned
|
||||
# PricingResult to also keep suppressed listings out of the display
|
||||
# `analogs` cards when no anchor overrides the headline (n_analogs
|
||||
# invariant: cards shown ⊆ what n_analogs counts).
|
||||
# 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above)
|
||||
# — priority RESTORED 2026-08-10 (product correction on top of #oblast-F):
|
||||
# 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find, but not trustworthy
|
||||
# enough to headline on their OWN — a more reliable source should win when
|
||||
# one exists. Two sub-cases:
|
||||
# (i) a usable ДКП deals corridor is available (same threshold the
|
||||
# #oblast-D deals-headline-fallback block below itself requires,
|
||||
# DEALS_HEADLINE_FALLBACK_MIN_N deals with a positive median) → the
|
||||
# listings AGGREGATE is suppressed to zero here so that block takes
|
||||
# over the headline, EXACTLY like original #oblast-E. This is the
|
||||
# Серов repro this gate exists for: n=3 listings must not outrank a
|
||||
# 54-deal corridor. `listings_clean` stays intact (never cleared) —
|
||||
# both for the anchor ghost-anchor guard (#1871) AND so
|
||||
# estimate_quality() still surfaces these listings as display
|
||||
# `analogs` cards even though they no longer drive n_analogs/median.
|
||||
# (ii) no usable corridor → #oblast-F (never-block, 2026-08-10): keep the
|
||||
# real thin median rather than refusing outright. By the time
|
||||
# control reaches this function, estimate_quality() has already run
|
||||
# the #oblast-F relaxation cascade (room-adjacency / freshness /
|
||||
# novostroyki / radius) trying to grow the sample past the
|
||||
# threshold — `listings` here is whatever that cascade could find.
|
||||
# NOTE: `gate_ceded_to_deals` (local, this function only) is DIFFERENT from
|
||||
# the `deals_headline_due_to_thin_listings` PricingResult field set later —
|
||||
# this one fires as soon as the gate DECIDES to cede (used below to skip
|
||||
# the repair-coefficient/explanation blocks safely, regardless of whether
|
||||
# anchor later overrides); the field fires only once the #oblast-D
|
||||
# deals-headline-fallback block ACTUALLY builds the headline from deals
|
||||
# (anchor may still override in between — see that block).
|
||||
listings_headline_thin_n = 0
|
||||
gate_ceded_to_deals = False
|
||||
# Outward PricingResult field — set True below, ONLY inside the actual
|
||||
# #oblast-D deals-headline-fallback block, once it fires for THIS reason.
|
||||
deals_headline_due_to_thin_listings = False
|
||||
if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N:
|
||||
listings_headline_thin_n = n_analogs
|
||||
logger.info(
|
||||
"headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing "
|
||||
"listings-derived median (falling back to anchor/deals/insufficient_data)",
|
||||
n_analogs,
|
||||
HEADLINE_LISTINGS_MIN_N,
|
||||
dkp_corridor_usable = (
|
||||
dkp_raw is not None
|
||||
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
|
||||
and dkp_raw.get("median_ppm2", 0) > 0
|
||||
)
|
||||
median_ppm2 = 0.0
|
||||
q1_ppm2 = 0.0
|
||||
q3_ppm2 = 0.0
|
||||
median_price = 0
|
||||
range_low = 0
|
||||
range_high = 0
|
||||
n_analogs = 0
|
||||
cv = None
|
||||
if dkp_corridor_usable:
|
||||
gate_ceded_to_deals = True
|
||||
logger.info(
|
||||
"headline sufficiency gate #oblast-E: n=%d < %d listings, usable ДКП "
|
||||
"corridor (n=%s) available — suppressing listings-derived median, "
|
||||
"ceding headline to deals/anchor chain",
|
||||
n_analogs,
|
||||
HEADLINE_LISTINGS_MIN_N,
|
||||
dkp_raw.get("count", 0) if dkp_raw else None,
|
||||
)
|
||||
median_ppm2 = 0.0
|
||||
q1_ppm2 = 0.0
|
||||
q3_ppm2 = 0.0
|
||||
median_price = 0
|
||||
range_low = 0
|
||||
range_high = 0
|
||||
n_analogs = 0
|
||||
cv = None
|
||||
else:
|
||||
logger.info(
|
||||
"headline sufficiency note #oblast-E: n=%d < %d listings, no usable "
|
||||
"ДКП corridor — keeping real median, flagged low-reliability "
|
||||
"(#oblast-F: never suppressed to zero without a fallback source)",
|
||||
n_analogs,
|
||||
HEADLINE_LISTINGS_MIN_N,
|
||||
)
|
||||
|
||||
# 4b. Repair coefficient — skipped when the headline was thin-suppressed
|
||||
# above (median_price is already 0; applying a coefficient would leave it
|
||||
# 0 but still emit a misleading "adjusted for repair state" note).
|
||||
# 4b. Repair coefficient — applies to any real (non-zero) median, INCLUDING
|
||||
# thin-but-kept (#oblast-F case ii) samples — a repair-state adjustment is
|
||||
# meaningful there. Skipped when the gate ceded the headline to deals
|
||||
# (gate_ceded_to_deals — median_price is already 0 above; applying a
|
||||
# coefficient would leave it 0 but still emit a misleading "adjusted for
|
||||
# repair state" note, same reasoning original #oblast-E used).
|
||||
repair_coef = _repair_coefficient(repair_state)
|
||||
repair_note = ""
|
||||
if listings_clean and not listings_headline_thin_n and repair_coef != 1.0:
|
||||
if listings_clean and not gate_ceded_to_deals and repair_coef != 1.0:
|
||||
median_price = int(median_price * repair_coef)
|
||||
range_low = int(range_low * repair_coef)
|
||||
range_high = int(range_high * repair_coef)
|
||||
|
|
@ -2724,20 +2927,22 @@ def _price_from_inputs(
|
|||
fallback_used,
|
||||
area_widened,
|
||||
listings=listings_clean,
|
||||
radius_used_m=search_radius_m,
|
||||
)
|
||||
# #oblast-E: honest override — _compute_confidence's generic "не найдено
|
||||
# аналогов" is FALSE here (we DID find listings_headline_thin_n of them,
|
||||
# just too few to trust). Stays the final explanation unless a later block
|
||||
# (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest
|
||||
# reasoning — both of those already check truthy `explanation` and either
|
||||
# replace it (anchor) or append a construction-method clause that reads
|
||||
# this same thin-count (deals-fallback), so no contradiction either way.
|
||||
if listings_headline_thin_n:
|
||||
# #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of
|
||||
# the gate above (real thin median kept, no usable deals corridor to cede
|
||||
# to). Case (i) (gate_ceded_to_deals) must NOT set this text — the
|
||||
# deals-headline-fallback block below writes its OWN "built from
|
||||
# Rosreestr deals" explanation; setting this first would leave a
|
||||
# contradictory "Оценка построена по N аналогам" sentence stapled in front
|
||||
# of it. Stays the final explanation unless a later block (anchor /
|
||||
# #oblast-D deals-fallback) overwrites it with its OWN honest reasoning.
|
||||
if listings_headline_thin_n and not gate_ceded_to_deals:
|
||||
confidence = "low"
|
||||
explanation = (
|
||||
f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., "
|
||||
f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по "
|
||||
"такой маленькой выборке слишком чувствительна к случайным лотам."
|
||||
f"Оценка построена по {listings_headline_thin_n} "
|
||||
f"{_analog_word_dative(listings_headline_thin_n)} — выборка мала, "
|
||||
"точность снижена."
|
||||
)
|
||||
|
||||
# Tier note — информируем пользователя о качестве house-match
|
||||
|
|
@ -3000,13 +3205,16 @@ def _price_from_inputs(
|
|||
and median_price > 0
|
||||
and area_m2
|
||||
):
|
||||
# target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по
|
||||
# координатам цели (_lookup_target_quarter_by_coords, вызывающий код).
|
||||
# Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» —
|
||||
# снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал
|
||||
# цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м
|
||||
# от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза
|
||||
# по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой).
|
||||
# Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя
|
||||
# заменить чужим кварталом, он и есть предмет поправки.
|
||||
target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum)
|
||||
if target_quarter is None:
|
||||
for lot in listings_clean:
|
||||
cq = _quarter_from_cadastre(lot.get("building_cadastral_number"))
|
||||
if cq is not None:
|
||||
target_quarter = cq
|
||||
break
|
||||
|
||||
if target_quarter is not None:
|
||||
qindex_result = quarter_index_lookup(target_quarter)
|
||||
|
|
@ -3316,10 +3524,16 @@ def _price_from_inputs(
|
|||
n_analogs = 0
|
||||
confidence = "low"
|
||||
cv = None
|
||||
# #oblast-E: differentiate "genuinely zero listings" (unchanged wording)
|
||||
# from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" —
|
||||
# the latter must NOT claim "рядом нет объявлений" (false, contradicts the
|
||||
# thin-sufficiency explanation already set above this block).
|
||||
# #oblast-E (priority restored 2026-08-10): differentiate "genuinely
|
||||
# zero listings" from "found some but below HEADLINE_LISTINGS_MIN_N,
|
||||
# ceded to the deals corridor" (gate above, case i) — the latter must
|
||||
# NOT claim "рядом нет объявлений" (false — some WERE found, just not
|
||||
# trusted as headline on their own). `deals_headline_due_to_thin_
|
||||
# listings` (returned on PricingResult) tells estimate_quality() this
|
||||
# was the thin-cession path specifically, so it can (a) append the
|
||||
# "оценка по сделкам — мало объявлений рядом" relaxation label, (b)
|
||||
# cap reliability at 'low' — a real deals corridor is a real signal,
|
||||
# just not a listings-comp one.
|
||||
no_listings_clause = (
|
||||
f" Из {listings_headline_thin_n} найденных объявлений недостаточно для "
|
||||
"надёжной медианы —"
|
||||
|
|
@ -3331,9 +3545,11 @@ def _price_from_inputs(
|
|||
f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.),"
|
||||
" точность ориентировочная."
|
||||
)
|
||||
if listings_headline_thin_n:
|
||||
deals_headline_due_to_thin_listings = True
|
||||
logger.info(
|
||||
"deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline"
|
||||
" (listings=0 [thin_suppressed=%d], anchor=None)",
|
||||
" (listings=0 [thin_ceded=%d], anchor=None)",
|
||||
int(median_ppm2),
|
||||
dkp_raw["count"],
|
||||
listings_headline_thin_n,
|
||||
|
|
@ -3469,6 +3685,7 @@ def _price_from_inputs(
|
|||
listings_clean=listings_clean,
|
||||
cv=cv,
|
||||
listings_headline_thin_n=listings_headline_thin_n,
|
||||
deals_headline_due_to_thin_listings=deals_headline_due_to_thin_listings,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3730,6 +3947,9 @@ async def estimate_quality(
|
|||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
)
|
||||
# #oblast-F: True only when there WAS a cohort (year_built) filter to drop —
|
||||
# surfaced later as the "снят фильтр по году постройки" relaxation label.
|
||||
cohort_dropped = cohort_range is not None and len(listings_tier0) < MIN_ANALOGS_TIER_0
|
||||
area_widened = False
|
||||
|
||||
if len(listings) < 5:
|
||||
|
|
@ -3778,6 +3998,146 @@ async def estimate_quality(
|
|||
analog_tier = analog_tier_wa
|
||||
search_radius_m = fallback_radius_m
|
||||
|
||||
# ── #oblast-F: relaxation cascade (never-block estimate, product decision
|
||||
# 2026-08-10) ──────────────────────────────────────────────────────────
|
||||
# Product requirement: NEVER refuse an estimate outright. If the 4-tier
|
||||
# cascade above still leaves the sample thinner than HEADLINE_LISTINGS_MIN_N,
|
||||
# keep loosening search criteria — least → most distorting — until either
|
||||
# the sample clears the threshold or we run out of steps. Every step that
|
||||
# ACTUALLY grew the sample is recorded in `relaxations` (RU labels, surfaced
|
||||
# via AggregatedEstimate.relaxations + appended to confidence_explanation
|
||||
# below) so a low-reliability estimate honestly explains why it stretched
|
||||
# the search. Each step carries FORWARD the relaxations already applied by
|
||||
# earlier steps (cumulative widening), not just its own single criterion.
|
||||
relaxations: list[str] = []
|
||||
if cohort_dropped:
|
||||
relaxations.append("снят фильтр по году постройки")
|
||||
|
||||
cur_rooms_min: int | None = None
|
||||
cur_rooms_max: int | None = None
|
||||
cur_fresh_days = LISTINGS_FRESH_DAYS
|
||||
cur_allow_novostroyki = False
|
||||
cur_area_tolerance = 0.25 if area_widened else AREA_TOLERANCE
|
||||
|
||||
async def _try_relax(
|
||||
*,
|
||||
rooms_min: int | None,
|
||||
rooms_max: int | None,
|
||||
fresh_days: int,
|
||||
allow_novostroyki: bool,
|
||||
radius_m: int,
|
||||
area_tolerance: float,
|
||||
) -> tuple[list[dict[str, Any]], str] | None:
|
||||
"""Один шаг каскада #oblast-F. Возвращает (listings, tier) только если
|
||||
кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана
|
||||
(ничего реально не выиграла) и вызывающий её не применяет."""
|
||||
candidate, _, tier = await asyncio.to_thread(
|
||||
_fetch_analogs,
|
||||
db,
|
||||
lat=geo.lat,
|
||||
lon=geo.lon,
|
||||
rooms=payload.rooms,
|
||||
rooms_min=rooms_min,
|
||||
rooms_max=rooms_max,
|
||||
area=payload.area_m2,
|
||||
radius_m=radius_m,
|
||||
area_tolerance=area_tolerance,
|
||||
fresh_days=fresh_days,
|
||||
allow_novostroyki=allow_novostroyki,
|
||||
full_address=geo.full_address,
|
||||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
)
|
||||
if len(candidate) > len(listings):
|
||||
return candidate, tier
|
||||
return None
|
||||
|
||||
# (a) room-count adjacency — самое дешёвое искажение: студия↔1-комн для
|
||||
# rooms<=1 (live repro: Академика Парина 46/5, rooms=1 давал n=4, rooms=0
|
||||
# тем же адресом — n=34), иначе комнатность ±RELAX_ROOMS_ADJACENT_DELTA.
|
||||
if len(listings) < HEADLINE_LISTINGS_MIN_N:
|
||||
if payload.rooms <= 1:
|
||||
try_rooms_min, try_rooms_max, rooms_label = 0, 1, "учтены студии"
|
||||
else:
|
||||
try_rooms_min = payload.rooms - RELAX_ROOMS_ADJACENT_DELTA
|
||||
try_rooms_max = payload.rooms + RELAX_ROOMS_ADJACENT_DELTA
|
||||
rooms_label = "комнатность ±1"
|
||||
rooms_result = await _try_relax(
|
||||
rooms_min=try_rooms_min,
|
||||
rooms_max=try_rooms_max,
|
||||
fresh_days=cur_fresh_days,
|
||||
allow_novostroyki=cur_allow_novostroyki,
|
||||
radius_m=search_radius_m,
|
||||
area_tolerance=cur_area_tolerance,
|
||||
)
|
||||
if rooms_result is not None:
|
||||
listings, analog_tier = rooms_result
|
||||
cur_rooms_min, cur_rooms_max = try_rooms_min, try_rooms_max
|
||||
relaxations.append(rooms_label)
|
||||
|
||||
# (b) свежесть объявлений: LISTINGS_FRESH_DAYS (14) → LISTINGS_FRESH_DAYS_RELAXED (60).
|
||||
if len(listings) < HEADLINE_LISTINGS_MIN_N:
|
||||
fresh_result = await _try_relax(
|
||||
rooms_min=cur_rooms_min,
|
||||
rooms_max=cur_rooms_max,
|
||||
fresh_days=LISTINGS_FRESH_DAYS_RELAXED,
|
||||
allow_novostroyki=cur_allow_novostroyki,
|
||||
radius_m=search_radius_m,
|
||||
area_tolerance=cur_area_tolerance,
|
||||
)
|
||||
if fresh_result is not None:
|
||||
listings, analog_tier = fresh_result
|
||||
cur_fresh_days = LISTINGS_FRESH_DAYS_RELAXED
|
||||
relaxations.append("объявления за 60 дней")
|
||||
|
||||
# (c) снять guard listing_segment — допустить новостройки в comp-пул.
|
||||
if len(listings) < HEADLINE_LISTINGS_MIN_N:
|
||||
novo_result = await _try_relax(
|
||||
rooms_min=cur_rooms_min,
|
||||
rooms_max=cur_rooms_max,
|
||||
fresh_days=cur_fresh_days,
|
||||
allow_novostroyki=True,
|
||||
radius_m=search_radius_m,
|
||||
area_tolerance=cur_area_tolerance,
|
||||
)
|
||||
if novo_result is not None:
|
||||
listings, analog_tier = novo_result
|
||||
cur_allow_novostroyki = True
|
||||
relaxations.append("учтены новостройки")
|
||||
|
||||
# (d) радиус → max(текущий, RELAX_RADIUS_STEP1_M), затем → RELAX_RADIUS_STEP2_M.
|
||||
# Пропускается, когда пользователь явно зафиксировал radius_m — тот же
|
||||
# контракт, что и у существующего radius-fallback выше (#2044: сервер не
|
||||
# авто-расширяет поиск за пределы выбранного пользователем радиуса).
|
||||
if len(listings) < HEADLINE_LISTINGS_MIN_N and payload.radius_m is None:
|
||||
for relax_radius in (max(search_radius_m, RELAX_RADIUS_STEP1_M), RELAX_RADIUS_STEP2_M):
|
||||
if relax_radius <= search_radius_m:
|
||||
continue
|
||||
radius_result = await _try_relax(
|
||||
rooms_min=cur_rooms_min,
|
||||
rooms_max=cur_rooms_max,
|
||||
fresh_days=cur_fresh_days,
|
||||
allow_novostroyki=cur_allow_novostroyki,
|
||||
radius_m=relax_radius,
|
||||
area_tolerance=cur_area_tolerance,
|
||||
)
|
||||
if radius_result is not None:
|
||||
listings, analog_tier = radius_result
|
||||
search_radius_m = relax_radius
|
||||
fallback_used = True
|
||||
if len(listings) >= HEADLINE_LISTINGS_MIN_N:
|
||||
break
|
||||
|
||||
# Area/radius relaxations derived from FINAL state (covers both the
|
||||
# pre-existing Tier B/C radius/area widening above AND step (d) here) —
|
||||
# a single check avoids double-labelling the same underlying widening.
|
||||
if area_widened:
|
||||
relaxations.append("площадь ±25%")
|
||||
if search_radius_m > base_radius_m:
|
||||
relaxations.append(radius_relaxation_label(search_radius_m))
|
||||
|
||||
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
||||
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
||||
# corridor_high был доступен для Tier C-гейта и soft-клампа headline.
|
||||
|
|
@ -3920,6 +4280,22 @@ async def estimate_quality(
|
|||
# ── Coarse-geo signals ────────────────────────────────────────────────────
|
||||
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2
|
||||
|
||||
# ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
|
||||
# Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) →
|
||||
# квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога
|
||||
# сюда больше не попадает вообще (см. блок #764 в _price_from_inputs).
|
||||
# Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на
|
||||
# точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config.
|
||||
target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None
|
||||
if (
|
||||
settings.estimate_quarter_from_coords_enabled
|
||||
and not target_quarter_cadnum
|
||||
and geo is not None
|
||||
):
|
||||
target_quarter_cadnum = await asyncio.to_thread(
|
||||
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
|
||||
)
|
||||
|
||||
# ── DB-callable wrappers injected into pure pricing ───────────────────────
|
||||
def _ratio_resolver(
|
||||
appm2: float | None,
|
||||
|
|
@ -3964,10 +4340,11 @@ async def estimate_quality(
|
|||
ratio_resolver=_ratio_resolver,
|
||||
quarter_index_lookup=_qi_lookup,
|
||||
quarter_indexes_lookup=_qis_lookup,
|
||||
target_house_cadnum=dadata.house_cadnum if dadata else None,
|
||||
target_house_cadnum=target_quarter_cadnum,
|
||||
dadata_coarse=dadata_coarse,
|
||||
geo=geo,
|
||||
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
||||
search_radius_m=search_radius_m,
|
||||
)
|
||||
|
||||
# Unpack pricing result
|
||||
|
|
@ -3990,7 +4367,73 @@ async def estimate_quality(
|
|||
ratio_basis = pr.ratio_basis
|
||||
listings_clean = pr.listings_clean
|
||||
cv = pr.cv
|
||||
listings_headline_thin_n = pr.listings_headline_thin_n
|
||||
|
||||
# #oblast-E (priority restored 2026-08-10): headline ceded to the ДКП deals
|
||||
# corridor because the base listings sample was thin — a real signal (real
|
||||
# Rosreestr deals), just not a listings-comp one. Recorded as its own
|
||||
# relaxation label (distinct from the #oblast-F cascade labels above, which
|
||||
# describe attempts to grow the LISTINGS sample — this describes switching
|
||||
# sources entirely).
|
||||
if pr.deals_headline_due_to_thin_listings:
|
||||
relaxations.append("оценка по сделкам — мало объявлений рядом")
|
||||
|
||||
# #oblast-F: reliability tier derived from the FINAL n_analogs (post anchor/
|
||||
# deals-fallback override above) — independent of `confidence` (older
|
||||
# unique-address/IQR metric, see AggregatedEstimate docstring). If the
|
||||
# #oblast-F cascade had to relax anything to get here, an otherwise-"ok"
|
||||
# sample is downgraded to "low" — the raw count looks fine, but it only
|
||||
# exists because we widened the search past the user's exact criteria.
|
||||
if n_analogs >= 8:
|
||||
reliability: Literal["ok", "low", "very_low"] = "ok"
|
||||
elif n_analogs >= 3:
|
||||
reliability = "low"
|
||||
else:
|
||||
reliability = "very_low"
|
||||
if pr.deals_headline_due_to_thin_listings:
|
||||
# #oblast-E: n_analogs is 0 here (listings-comp count, honestly zero —
|
||||
# the headline came from deals instead), which would otherwise bucket
|
||||
# to 'very_low'. Pin to 'low' instead: a 54-deal Rosreestr corridor is
|
||||
# a real, meaningful signal — "не выше low" (product spec), not
|
||||
# "почти нет сигнала" (what 'very_low' would imply here).
|
||||
reliability = "low"
|
||||
elif relaxations and reliability == "ok":
|
||||
reliability = "low"
|
||||
|
||||
# #conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11):
|
||||
# `confidence` (unique-address/IQR metric, _compute_confidence above) and
|
||||
# `reliability` (n_analogs-bucket + #oblast-F relaxation-cascade metric,
|
||||
# just computed) used to be fully independent — the cascade's room-
|
||||
# adjacency/freshness/novostroyki steps never set `fallback_used`, so
|
||||
# _compute_confidence stayed blind to them and kept scoring a wide,
|
||||
# tight-IQR (post-relaxation) sample as "high", while `reliability`
|
||||
# honestly read "low" from the SAME relaxations. Live prod repro: n=39,
|
||||
# confidence='high', reliability='low', 3 relaxations applied — client
|
||||
# saw a "высокая уверенность" badge next to a "точность снижена" banner
|
||||
# simultaneously. Fix applied at ONE point, after BOTH values are final
|
||||
# (NOT spread across the cascade steps above) — see
|
||||
# _cap_confidence_by_reliability docstring for the exact rule.
|
||||
original_confidence = confidence
|
||||
confidence = _cap_confidence_by_reliability(confidence, reliability)
|
||||
confidence_downgraded_by_reliability = confidence != original_confidence
|
||||
|
||||
if relaxations:
|
||||
relaxations_detail = "Применены послабления подбора: " + ", ".join(relaxations) + "."
|
||||
if confidence_downgraded_by_reliability:
|
||||
# #conf-cap: explanation must LEAD with the accuracy-reduced summary
|
||||
# when confidence was actually pulled down by the cap above — the
|
||||
# old ordering opened with "Найдено 39 аналогов ... разброс ±6%"
|
||||
# (reads as a confident, unqualified headline) and only tacked the
|
||||
# relaxations sentence on at the very end, which itself contradicted
|
||||
# the (now-capped) confidence badge just as much as the raw scores did.
|
||||
explanation = (
|
||||
"Оценка построена с расширенными параметрами подбора — точность "
|
||||
"снижена. " + (explanation or "") + " " + relaxations_detail
|
||||
)
|
||||
else:
|
||||
# No visible confidence change (e.g. it was already 'low'/'medium'
|
||||
# pre-cap, or reliability=='ok' downgraded nothing) — keep the
|
||||
# existing tail-append shape, unaffected by #conf-cap.
|
||||
explanation = (explanation or "") + " " + relaxations_detail
|
||||
|
||||
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
||||
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
||||
|
|
@ -4026,14 +4469,6 @@ async def estimate_quality(
|
|||
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
|
||||
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
|
||||
metadata_lots = display_pool
|
||||
elif listings_headline_thin_n:
|
||||
# #oblast-E: headline was suppressed (thin radius sample, no anchor to
|
||||
# take over) — do NOT surface those same listings as display cards
|
||||
# either, else `analogs` would show N cards while n_analogs==0 (broken
|
||||
# invariant, same dishonesty this gate exists to remove). Degrades to
|
||||
# the exact same empty-display state as "genuinely zero listings".
|
||||
analogs_lots = []
|
||||
metadata_lots = []
|
||||
else:
|
||||
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
|
||||
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
|
||||
|
|
@ -4088,6 +4523,7 @@ async def estimate_quality(
|
|||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis,
|
||||
relaxations, reliability,
|
||||
created_by,
|
||||
expires_at,
|
||||
consent, client_ip, consent_policy_version, consent_text_snapshot
|
||||
|
|
@ -4109,6 +4545,7 @@ async def estimate_quality(
|
|||
:expected_sold_price, :expected_sold_range_low,
|
||||
:expected_sold_range_high, :expected_sold_per_m2,
|
||||
:asking_to_sold_ratio, :ratio_basis,
|
||||
CAST(:relaxations_json AS jsonb), :reliability,
|
||||
:created_by,
|
||||
:expires_at,
|
||||
:consent, CAST(:client_ip AS inet), :consent_policy_version,
|
||||
|
|
@ -4162,6 +4599,12 @@ async def estimate_quality(
|
|||
"expected_sold_per_m2": expected_sold_per_m2,
|
||||
"asking_to_sold_ratio": asking_to_sold_ratio,
|
||||
"ratio_basis": ratio_basis,
|
||||
# #oblast-F / GET-rehydrate (PR #2823 open follow-up): персистим
|
||||
# relaxations/reliability вместе с median — раньше эти поля жили
|
||||
# только в POST-ответе, и красный баннер «точность снижена»
|
||||
# пропадал при открытии оценки по сохранённой ссылке (?id=).
|
||||
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
|
||||
"reliability": reliability,
|
||||
"created_by": created_by,
|
||||
"expires_at": expires_at,
|
||||
**_estimate_consent_persist_fields(require_consent, client_ip),
|
||||
|
|
@ -4341,10 +4784,11 @@ async def estimate_quality(
|
|||
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
|
||||
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
|
||||
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
||||
# #2632: фактический радиус отбора listings-аналогов (см. search_radius_m
|
||||
# def выше) — может отличаться от payload.radius_m (выбор пользователя),
|
||||
# когда сервер сам расширил поиск. None только у _empty_estimate (поиск
|
||||
# аналогов вообще не выполнялся).
|
||||
# #2632: обе величины рядом — с чего поиск начали (выбор пользователя или
|
||||
# DEFAULT_RADIUS_M) и на чём закончили. Расходятся, когда сервер сам
|
||||
# расширил поиск; подменять одно другим нельзя — это ровно тот дефект
|
||||
# метки, что чинили в #2811. None у _empty_estimate (поиск не выполнялся).
|
||||
requested_radius_m=base_radius_m,
|
||||
search_radius_m=search_radius_m,
|
||||
premium_building=premium_building,
|
||||
premium_building_median_ppm2=premium_building_median_ppm2,
|
||||
|
|
@ -4355,6 +4799,11 @@ async def estimate_quality(
|
|||
cv=cv,
|
||||
source_counts=source_counts,
|
||||
created_at=now,
|
||||
# #oblast-F (never-block relaxation cascade) — применённые ослабления
|
||||
# подбора + производная надёжность выборки (см. reliability computation
|
||||
# above, независимо от `confidence`).
|
||||
relaxations=relaxations,
|
||||
reliability=reliability,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -4760,11 +5209,27 @@ def _extract_short_addr(full_address: str | None) -> str | None:
|
|||
|
||||
# Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе.
|
||||
# Работает для FORWARD и REVERSE форматов Nominatim.
|
||||
# #pdf-honesty/#oblast-E-follow-up (live-prod fix 2026-08-10): точка после
|
||||
# сокращений (ул., пр., пер., ш., наб., пл., мкр.) сделана ОПЦИОНАЛЬНОЙ
|
||||
# (`\.?`) — DaData (основной источник адресов, дом-уровень геокодинга) отдаёт
|
||||
# формат БЕЗ точки: "ул Академика Парина", а не "ул. Академика Парина". Старый
|
||||
# regex требовал точку строго → keyword не матчился НИ НА ОДНОМ DaData-адресе
|
||||
# → street-deals/sales-vs-listings блоки молчали (WARNING "could not extract
|
||||
# street") на КАЖДОМ запросе с DaData-геокодингом, не только на репро-адресе.
|
||||
# Порядок альтернатив принципиален: `ул\.?` идёт ПЕРЕД полным словом `улица` —
|
||||
# но это безопасно за счёт backtracking Python `re` (NFA, не POSIX longest-
|
||||
# match): если `ул\.?` матчит только "ул" из "улица" и последующий `\s+`
|
||||
# после этого не находит пробел (следующий символ — "и"), движок
|
||||
# откатывается и пробует СЛЕДУЮЩУЮ альтернативу — "улица" — которая матчит
|
||||
# полностью. Проверено на "ул. X" / "ул X" / "улица X" — все три дают
|
||||
# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
|
||||
# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
|
||||
# покрывает оба варианта (с точкой и без).
|
||||
_STREET_KW_RE = re.compile(
|
||||
r"(?<![А-Яа-яёЁa-zA-Z])"
|
||||
r"(?:ул\.|улица|пр\.|пр-т|проспект|пер\.|переулок|"
|
||||
r"б-р|бульвар|ш\.|шоссе|наб\.|набережная|проезд|тракт|"
|
||||
r"пл\.|площадь|мкр\.|мкр|микрорайон)"
|
||||
r"(?:ул\.?|улица|пр\.?|пр-т|проспект|пер\.?|переулок|"
|
||||
r"б-р|бульвар|ш\.?|шоссе|наб\.?|набережная|проезд|тракт|"
|
||||
r"пл\.?|площадь|мкр\.?|микрорайон)"
|
||||
r"\s+",
|
||||
flags=re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
|
@ -4938,7 +5403,10 @@ _ANALOG_SELECT_COLS = """
|
|||
"""
|
||||
|
||||
_COMMON_WHERE = """
|
||||
AND rooms = :rooms
|
||||
-- #oblast-F (a): rooms_min/rooms_max — обычно оба = целевой rooms (exact-match
|
||||
-- byte-identical поведение). estimate_quality() расширяет диапазон (студия↔1-
|
||||
-- комн / ±1 комната) ТОЛЬКО когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N.
|
||||
AND rooms BETWEEN :rooms_min AND :rooms_max
|
||||
AND area_m2 BETWEEN :area_min AND :area_max
|
||||
AND is_active = true
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
|
|
@ -4956,8 +5424,13 @@ _COMMON_WHERE = """
|
|||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||
-- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает
|
||||
-- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента —
|
||||
-- это вторичка или неклассифицированный объект).
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
-- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki
|
||||
-- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) —
|
||||
-- дефолт False сохраняет канон-guard byte-identical.
|
||||
AND (
|
||||
CAST(:allow_novostroyki AS boolean) IS TRUE
|
||||
OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
)
|
||||
-- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled,
|
||||
-- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒
|
||||
-- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает
|
||||
|
|
@ -5012,6 +5485,14 @@ def _fetch_analogs(
|
|||
cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive
|
||||
cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive
|
||||
target_house_id: int | None = None, # #6: canonical house for same-building Tier S
|
||||
# #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты
|
||||
# byte-identical старому поведению (exact rooms match / 14 дней / без
|
||||
# новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО
|
||||
# когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring.
|
||||
rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms
|
||||
rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms
|
||||
fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED
|
||||
allow_novostroyki: bool = False, # #oblast-F (c)
|
||||
) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
"""SELECT аналогов — трёхуровневый house-match (S → H → W).
|
||||
|
||||
|
|
@ -5052,21 +5533,29 @@ def _fetch_analogs(
|
|||
"""
|
||||
area_min = area * (1 - area_tolerance)
|
||||
area_max = area * (1 + area_tolerance)
|
||||
# #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms),
|
||||
# byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a
|
||||
# widened range only past HEADLINE_LISTINGS_MIN_N thinness.
|
||||
eff_rooms_min = rooms if rooms_min is None else rooms_min
|
||||
eff_rooms_max = rooms if rooms_max is None else rooms_max
|
||||
# #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL
|
||||
# (безвредно без фильтра); статический фрагмент управляет только применением
|
||||
# `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3
|
||||
# bind-параметры не задействованы, инъекции нет.
|
||||
dup_filter = "AND rn_dup = 1"
|
||||
base_params: dict[str, Any] = {
|
||||
"rooms": rooms,
|
||||
"rooms_min": eff_rooms_min,
|
||||
"rooms_max": eff_rooms_max,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"fresh_days": fresh_days,
|
||||
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
|
||||
"cohort_year_min": cohort_year_min,
|
||||
"cohort_year_max": cohort_year_max,
|
||||
# #2012: is_apartments hard-filter — see _COMMON_WHERE comment above.
|
||||
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled,
|
||||
# #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above.
|
||||
"allow_novostroyki": allow_novostroyki,
|
||||
}
|
||||
|
||||
# ── Tier S (canonical): same building via house_id_fk ─────────────────────
|
||||
|
|
@ -5391,7 +5880,8 @@ def _fetch_analogs(
|
|||
FROM listings
|
||||
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND rooms = :rooms
|
||||
-- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же.
|
||||
AND rooms BETWEEN :rooms_min AND :rooms_max
|
||||
AND area_m2 BETWEEN :area_min AND :area_max
|
||||
AND is_active = true
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
|
|
@ -5408,7 +5898,11 @@ def _fetch_analogs(
|
|||
)
|
||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||
-- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE).
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
-- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE.
|
||||
AND (
|
||||
CAST(:allow_novostroyki AS boolean) IS TRUE
|
||||
OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
)
|
||||
-- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий
|
||||
-- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению).
|
||||
AND (
|
||||
|
|
@ -5450,16 +5944,18 @@ def _fetch_analogs(
|
|||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius": radius_m,
|
||||
"rooms": rooms,
|
||||
"rooms_min": eff_rooms_min,
|
||||
"rooms_max": eff_rooms_max,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"fresh_days": fresh_days,
|
||||
"target_year": year_built,
|
||||
"target_house_type": house_type,
|
||||
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
|
||||
"cohort_year_min": cohort_year_min, # NEW
|
||||
"cohort_year_max": cohort_year_max, # NEW
|
||||
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012
|
||||
"allow_novostroyki": allow_novostroyki, # #oblast-F (c)
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -6285,6 +6781,52 @@ def _downgrade_confidence(confidence: str) -> str:
|
|||
return {"high": "medium", "medium": "low", "low": "low"}.get(confidence, confidence)
|
||||
|
||||
|
||||
_CONFIDENCE_ORDER: dict[str, int] = {"low": 0, "medium": 1, "high": 2}
|
||||
|
||||
|
||||
def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str:
|
||||
"""#conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11).
|
||||
|
||||
`confidence` (this function's sibling `_compute_confidence` — unique-
|
||||
address/IQR metric) and `reliability` (estimate_quality — n_analogs-bucket
|
||||
+ #oblast-F relaxation-cascade metric) used to be computed fully
|
||||
independently. The #oblast-F cascade's room-adjacency/freshness/
|
||||
novostroyki steps never set `fallback_used`, so `_compute_confidence`
|
||||
stayed blind to them and could keep scoring a post-relaxation sample
|
||||
(wide, tight-IQR) as "high" while `reliability` — which DOES see the
|
||||
relaxations — honestly read "low". Live prod repro: n_analogs=39,
|
||||
confidence='high', reliability='low', relaxations=[3 items] — a client
|
||||
could see a "высокая уверенность" badge next to a "точность снижена"
|
||||
banner on the SAME estimate.
|
||||
|
||||
Rule (applied ONCE, by the caller, after both values are final — NOT
|
||||
spread across the relaxation cascade):
|
||||
reliability == 'very_low' → confidence forced to 'low'.
|
||||
reliability == 'low' → confidence capped at 'medium' (high→medium;
|
||||
medium/low pass through unchanged).
|
||||
reliability == 'ok' → confidence untouched (unaffected — this is
|
||||
the common, unrelaxed case; byte-identical
|
||||
to pre-#conf-cap behaviour).
|
||||
|
||||
Never RAISES confidence — only caps/lowers it. Unknown confidence/
|
||||
reliability values pass through unchanged (defensive, mirrors
|
||||
_downgrade_confidence above).
|
||||
"""
|
||||
if reliability == "very_low":
|
||||
return "low"
|
||||
if reliability == "low":
|
||||
current = _CONFIDENCE_ORDER.get(confidence)
|
||||
cap = _CONFIDENCE_ORDER["medium"]
|
||||
if current is not None and current > cap:
|
||||
return "medium"
|
||||
return confidence
|
||||
|
||||
|
||||
def _radius_ru(radius_m: int) -> str:
|
||||
"""«2 км» / «5 км» / «1500 м» — целые километры словом, остальное метрами."""
|
||||
return f"{radius_m // 1000} км" if radius_m % 1000 == 0 else f"{radius_m} м"
|
||||
|
||||
|
||||
def _compute_confidence(
|
||||
n_analogs: int,
|
||||
median_ppm2: float,
|
||||
|
|
@ -6293,6 +6835,7 @@ def _compute_confidence(
|
|||
fallback_radius_used: bool,
|
||||
area_widened: bool = False,
|
||||
listings: list[dict] | None = None,
|
||||
radius_used_m: int | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Confidence + explanation string.
|
||||
|
||||
|
|
@ -6326,7 +6869,12 @@ def _compute_confidence(
|
|||
iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
|
||||
notes = []
|
||||
if fallback_radius_used:
|
||||
notes.append("расширили радиус до 2 км")
|
||||
# #2632: НАЗЫВАЕМ фактический радиус. До этого здесь было прибито «2 км»
|
||||
# — константа старого 1км→2км fallback'а, а каскад #oblast-F уходит до
|
||||
# 3/5 км: текст утверждал 2 км там, где поиск реально шёл по 5 км
|
||||
# (прод, 2026-08-11: relaxations «радиус расширен до 5000 м», самый
|
||||
# дальний аналог 4381 м, объяснение — «расширили радиус до 2 км»).
|
||||
notes.append(f"расширили радиус до {_radius_ru(radius_used_m or FALLBACK_RADIUS_M)}")
|
||||
if area_widened:
|
||||
notes.append("расширили допуск по площади до ±25%")
|
||||
fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""
|
||||
|
|
@ -6510,6 +7058,7 @@ def _empty_estimate(
|
|||
confidence, confidence_explanation, n_analogs,
|
||||
analogs, actual_deals,
|
||||
sources_used,
|
||||
relaxations, reliability,
|
||||
created_by,
|
||||
expires_at,
|
||||
consent, client_ip, consent_policy_version, consent_text_snapshot
|
||||
|
|
@ -6522,6 +7071,11 @@ def _empty_estimate(
|
|||
'low', :explanation, 0,
|
||||
'[]'::jsonb, '[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
-- #oblast-F: поиск аналогов вообще не выполнялся (geocode failed /
|
||||
-- no coords) — каскад послаблений не запускался, relaxations честно
|
||||
-- пуст; reliability='very_low' зеркалит то, что возвращает Python
|
||||
-- ниже (см. AggregatedEstimate(..., reliability="very_low")).
|
||||
'[]'::jsonb, 'very_low',
|
||||
:created_by,
|
||||
:expires_at,
|
||||
:consent, CAST(:client_ip AS inet), :consent_policy_version,
|
||||
|
|
@ -6570,4 +7124,7 @@ def _empty_estimate(
|
|||
# Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна.
|
||||
address_precision=None,
|
||||
analog_tier=None, # нет данных при empty estimate
|
||||
# #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся
|
||||
# (geocode failed / no coords), а не просто "мало нашлось".
|
||||
reliability="very_low",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price
|
|||
from matplotlib.patches import Rectangle
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.version import product_version_line
|
||||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом)
|
|||
_BORDER = _LINE
|
||||
_BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)
|
||||
|
||||
# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит
|
||||
# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не
|
||||
# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь
|
||||
# HUD, а не версия PDF-модуля/API.
|
||||
_REPORT_ENGINE_VERSION = "v2.0.6"
|
||||
|
||||
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
|
||||
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
|
||||
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
|
||||
|
|
@ -505,13 +500,31 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type:
|
|||
"ДАТА", report_date.strftime("%d.%m.%Y")
|
||||
)
|
||||
|
||||
# Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение
|
||||
# владельца продукта 2026-08-10, см. app/core/version.py::product_version_line.
|
||||
# Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска
|
||||
# реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера»,
|
||||
# одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под
|
||||
# существующим masthead-рядом — не растёт по высоте ни при каком контенте
|
||||
# (клипается по ширине, не переносится), top-margin (25mm) даёт под неё
|
||||
# запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты.
|
||||
version_html = (
|
||||
f'<div style="text-align:right;font-size:6.5pt;letter-spacing:0.03em;'
|
||||
f"color:{_MUTED_2};font-family:'IBM Plex Mono','DejaVu Sans Mono',monospace;"
|
||||
f'white-space:nowrap;overflow:hidden;margin-bottom:6pt;">'
|
||||
f"{_html.escape(product_version_line())}</div>"
|
||||
)
|
||||
|
||||
return (
|
||||
f"<div>"
|
||||
f'<div style="display:flex;align-items:center;justify-content:space-between;'
|
||||
f"flex-wrap:wrap;gap:6pt;border-bottom:2pt solid {brand.primary_color};"
|
||||
f'padding-bottom:6pt;margin-bottom:9pt;">'
|
||||
f'padding-bottom:6pt;margin-bottom:3pt;">'
|
||||
f"{mark_html}"
|
||||
f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
|
||||
f"</div>"
|
||||
f"{version_html}"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -529,7 +542,11 @@ def _page_footer(
|
|||
|
||||
строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная
|
||||
линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ
|
||||
хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта.
|
||||
хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь
|
||||
НЕ дублируется — единственное место вывода версии в PDF — running-header
|
||||
(_page_header → product_version_line()); раньше рядом с wordmark висел
|
||||
decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной
|
||||
версией продукта — расходился с header на каждой странице, снесён.
|
||||
|
||||
page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер
|
||||
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
|
||||
|
|
@ -587,9 +604,6 @@ def _page_footer(
|
|||
font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
|
||||
min-width:0;overflow-wrap:anywhere;">
|
||||
{_html.escape(brand.name).upper()}</span>
|
||||
<span style="font-size:7pt;letter-spacing:0.08em;color:{_MUTED_2};
|
||||
flex-shrink:0;white-space:nowrap;">
|
||||
{_REPORT_ENGINE_VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1243,11 +1257,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int
|
|||
return min(prices), max(prices)
|
||||
|
||||
|
||||
def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str:
|
||||
"""#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest
|
||||
footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals
|
||||
corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT
|
||||
estimate.analogs is non-empty (the thin listings that triggered the cession
|
||||
are still shown below as reference cards — never cleared, see estimator.py
|
||||
#1871 ghost-anchor guard). Same tone/plain-sentence style as the web
|
||||
LowConfidenceBanner for this scenario. Empty string (no-op) otherwise —
|
||||
covers both "healthy sample" and "genuinely zero, nothing to show" cases."""
|
||||
if estimate.n_analogs != 0 or not estimate.analogs:
|
||||
return ""
|
||||
return (
|
||||
f'<p style="margin:6pt 0 0 0;font-size:{_FS_SM};color:{_MUTED};line-height:1.35;">'
|
||||
"Оценка построена по зарегистрированным сделкам Росреестра — подходящих "
|
||||
"объявлений поблизости почти нет. Объявления ниже приведены справочно, "
|
||||
"для наглядности рынка.</p>"
|
||||
)
|
||||
|
||||
|
||||
def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str:
|
||||
"""#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability`
|
||||
(estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report
|
||||
already shows this (LowConfidenceBanner); the PDF stayed silent, a
|
||||
client-visible discrepancy between the two. Empty string (no-op) when
|
||||
reliability=='ok' and relaxations is empty — the common, unrelaxed case,
|
||||
byte-identical to the report before these fields existed."""
|
||||
if estimate.reliability == "ok" and not estimate.relaxations:
|
||||
return ""
|
||||
if estimate.relaxations:
|
||||
detail = "Подбор аналогов расширен: " + ", ".join(
|
||||
_html.escape(r) for r in estimate.relaxations
|
||||
)
|
||||
else:
|
||||
# relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка,
|
||||
# которую каскад ослаблений не смог расширить, см. estimator.py
|
||||
# #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика,
|
||||
# что и в счётчике выше страницы).
|
||||
detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)"
|
||||
return f"""
|
||||
<div style="margin-top:10pt;padding:9pt 12pt;border-left:3pt solid {_WARN};
|
||||
background:{_ACCENT_2_SOFT};font-size:{_FS_SM};color:{_INK};line-height:1.35;">
|
||||
<span style="font-weight:700;color:{_WARN};">Точность оценки снижена.</span>
|
||||
{detail} — данные ниже приведены с этой оговоркой.
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# ── Page 2: Listings (market) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||
n_total = estimate.n_analogs
|
||||
# #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw
|
||||
# estimate.n_analogs is the count of listings that drove the HEADLINE math —
|
||||
# it is deliberately 0 when the headline was ceded to the ДКП deals corridor
|
||||
# (estimator.py `deals_headline_due_to_thin_listings`), even though the thin
|
||||
# listings that triggered that cession are still shown below as display cards
|
||||
# (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor
|
||||
# guard comment). Printing raw n_analogs there read as "0 шт." above a
|
||||
# non-empty examples table — a client-visible contradiction. n_analogs is
|
||||
# normally >= len(analogs) (analogs is a top-10-capped SUBSET of what
|
||||
# n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a
|
||||
# no-op in that common case (count stays the honest FULL n_analogs) and only
|
||||
# changes anything in this one pathological case, where it falls back to
|
||||
# "how many are actually shown" instead of the dishonest zero.
|
||||
n_total = max(estimate.n_analogs, len(estimate.analogs))
|
||||
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
|
||||
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
|
||||
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
|
||||
|
|
@ -1306,6 +1380,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
examples_rows = _examples_rows(top5)
|
||||
|
||||
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
|
||||
# #pdf-honesty — see helper docstrings above. Both no-op ("") in the common
|
||||
# (unrelaxed, non-deals-sourced) case — byte-identical page in that case.
|
||||
deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate)
|
||||
reliability_note = _reliability_note_html(estimate, n_total)
|
||||
|
||||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
|
@ -1320,6 +1398,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(f"{n_total} шт.")}</td></tr>
|
||||
</table>
|
||||
{deals_sourced_note}
|
||||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||||
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
||||
<div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
|
||||
|
|
@ -1344,6 +1423,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{reliability_note}
|
||||
|
||||
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
Диапазон цен в объявлениях</p>
|
||||
|
|
|
|||
|
|
@ -372,6 +372,134 @@ def _names_unrecognized_locality(address: str) -> bool:
|
|||
return bool(_LOCALITY_MARKER_RE.search(normalized))
|
||||
|
||||
|
||||
# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
|
||||
# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
|
||||
# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
|
||||
# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
|
||||
# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
|
||||
# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
|
||||
# использовал, и если названный топоним туда не дожил — топоним подменён.
|
||||
#
|
||||
# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
|
||||
# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
|
||||
# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
|
||||
# сам целевой город.
|
||||
_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
|
||||
# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
|
||||
# города — продукт РФ-only, новых значений у неё не появится (в отличие от
|
||||
# списка городов области, ради ухода от которого всё и делается).
|
||||
_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
|
||||
# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
|
||||
# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
|
||||
# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
|
||||
_STREET_LEVEL_RE = re.compile(
|
||||
r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
|
||||
r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
|
||||
r"|корпус|лит|литера|снт|сад|гск)\b"
|
||||
)
|
||||
# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
|
||||
# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
|
||||
# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
|
||||
_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
|
||||
_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
|
||||
# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
|
||||
# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
|
||||
_LOCALITY_TYPE_WORDS = frozenset(
|
||||
{
|
||||
"поселок",
|
||||
"пос",
|
||||
"село",
|
||||
"деревня",
|
||||
"дер",
|
||||
"город",
|
||||
"гор",
|
||||
"округ",
|
||||
"муниципальный",
|
||||
"городской",
|
||||
"сельское",
|
||||
"поселение",
|
||||
"тер",
|
||||
"территория",
|
||||
"станция",
|
||||
"пгт",
|
||||
"рп",
|
||||
}
|
||||
)
|
||||
_WORD_RE = re.compile(r"[а-я][а-я-]*")
|
||||
|
||||
|
||||
def _fold(value: str) -> str:
|
||||
"""lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
|
||||
return " ".join(value.lower().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _claimed_locality(address: str) -> str | None:
|
||||
"""Имя населённого пункта, названное в тексте адреса, или None.
|
||||
|
||||
Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
|
||||
(страна → регион → район → НП → улица → дом), пропускаем уровни выше/ниже
|
||||
НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент — имя НП.
|
||||
|
||||
None означает «НП не назван» — это основной трафик формы («Малышева 30»), и
|
||||
для него инвариант не применяется вовсе.
|
||||
"""
|
||||
for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
|
||||
segment = segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
|
||||
return None # улица/дом: будь НП назван, он шёл бы раньше
|
||||
if (
|
||||
_COUNTRY_RE.search(segment)
|
||||
or _REGION_LEVEL_RE.search(segment)
|
||||
or _DISTRICT_LEVEL_RE.search(segment)
|
||||
):
|
||||
continue
|
||||
name = " ".join(
|
||||
w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
|
||||
)
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _city_substituted(address: str, result: GeocodeResult) -> bool:
|
||||
"""True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
|
||||
|
||||
Три условия вместе:
|
||||
1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
|
||||
2. этого имени НЕТ в адресе, который вернул провайдер — то есть топоним не
|
||||
пережил геокодинг;
|
||||
3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
|
||||
собственному ответу провайдера — он называет Екатеринбург либо не
|
||||
называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
|
||||
тогда «внутри ЕКБ» подтверждают координаты).
|
||||
|
||||
Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
|
||||
Советская ул., 5» → ответ «Екатеринбург, Советская улица, 5» — имя не дожило,
|
||||
и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
|
||||
корректный ответ по посёлку («Кедровка, Екатеринбург, …» — Nominatim и Yandex
|
||||
пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
|
||||
не проходит. Проверяется не география посёлка, а факт «топоним потерян».
|
||||
|
||||
Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит» ⊂
|
||||
«М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок»), считается дожившим —
|
||||
пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
|
||||
приставки («пионерский, советская»), понижается до `locality` — честная
|
||||
деградация, координаты не теряются.
|
||||
"""
|
||||
claimed = _claimed_locality(address)
|
||||
if claimed is None or _EKATERINBURG_RE.search(claimed):
|
||||
return False
|
||||
answer = _fold(result.full_address or "")
|
||||
if any(word in answer for word in claimed.split()):
|
||||
return False # топоним дожил до ответа — провайдер искал там, где просили
|
||||
if not is_within_ekb_bbox(result.lat, result.lon):
|
||||
return False
|
||||
answer_locality = _claimed_locality(answer)
|
||||
return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
|
||||
|
||||
|
||||
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
|
||||
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
|
||||
|
||||
|
|
@ -1287,6 +1415,42 @@ async def suggest(
|
|||
|
||||
# ── Public API ───────────────────────────────────────────────────────────────
|
||||
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
|
||||
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
|
||||
|
||||
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
|
||||
применяется ОДНОЙ точкой на выходе — поэтому покрывает разом все источники,
|
||||
включая попадание в кэш: отравленная запись, записанная до этого фикса,
|
||||
больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
|
||||
(обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
|
||||
|
||||
Сработал инвариант → `confidence="locality"` + `city_ambiguous=True`.
|
||||
`locality` — не косметика: `estimator._geocode_is_coarse` уже трактует его
|
||||
как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
|
||||
оценки, (б) через `tasks.geocode_missing` проставляет листингу
|
||||
`geo_precision='city'`, а этот признак исключает листинг из пула аналогов
|
||||
(`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
|
||||
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
|
||||
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
|
||||
видимая, а не отказ.
|
||||
"""
|
||||
result = await _geocode_resolve(address, db, city_hint)
|
||||
if result is None or not _city_substituted(address, result):
|
||||
return result
|
||||
logger.warning(
|
||||
"geocode city substitution (#2590): %r → %r (%.5f, %.5f) provider=%s — "
|
||||
"названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
|
||||
address[:80],
|
||||
(result.full_address or "")[:80],
|
||||
result.lat,
|
||||
result.lon,
|
||||
result.provider,
|
||||
)
|
||||
return replace(result, confidence="locality", city_ambiguous=True)
|
||||
|
||||
|
||||
async def _geocode_resolve(
|
||||
address: str, db: Session, city_hint: str | None = None
|
||||
) -> GeocodeResult | None:
|
||||
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
||||
|
||||
Args:
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ WHAT this is:
|
|||
pipeline, run inside ONE transaction so a crash leaves the table untouched.
|
||||
|
||||
Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address
|
||||
COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod —
|
||||
confirmed in migration 040 — so address is the real building key). The clean source matters:
|
||||
COALESCE(short_address, full_address, address) — the address is the only building key we
|
||||
have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on
|
||||
prod», is no longer true — 2 648 of 9 179 rows carry one — and the conclusion no longer
|
||||
rests on it). The clean source matters:
|
||||
`address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр»
|
||||
→ canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66»
|
||||
(→ «вайнера66») — preferring the clean field lets such a row cluster with its twin. The canon
|
||||
|
|
@ -116,6 +118,43 @@ MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230):
|
|||
asymmetry — merge allowed without a proximity check — was invisible in data before; now
|
||||
«how many merges happened beyond N metres, on which key» is one query.
|
||||
|
||||
KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
|
||||
#2690 asked for a cluster key that does not come from the normalized address, so that two
|
||||
rows merge on two independent statements of identity rather than one restated twice. Every
|
||||
field `houses` carries was checked against the live table. None qualifies:
|
||||
|
||||
cadastral_number 2 648 filled, ALL 2 648 values DISTINCT → collapses nothing. Provenance:
|
||||
all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are
|
||||
DaData's answer to our address string, not a second observation of the
|
||||
building. (The other cadastre we hold, listings.building_cadastral_number,
|
||||
is the KNN geo-nearest hint — 20.1% of its values cover >1 ГАР building;
|
||||
#2674 refused it as an identity key and that stands.)
|
||||
house_fias_id 3 678 filled, ALL DISTINCT → the FIAS pass merges 0 rows today. Same
|
||||
DaData provenance.
|
||||
gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share
|
||||
the canon (the guid restates it), 17 do not — and 5 of those 17 are
|
||||
>250 m apart, worst 5 064 km. Still circular, still noisy.
|
||||
zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the
|
||||
loader sets it WHERE gar_house_guid = <guid>, i.e. it IS the ГАР guid for
|
||||
4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback
|
||||
— keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon,
|
||||
193 come through the fallback, and 30 of the 31 pairs >250 m apart do too.
|
||||
source+ext_house_id, cian_internal_house_id, yandex_jk_id
|
||||
distinct by construction / 39 / 0 rows → nothing to cluster.
|
||||
coordinates a real independent observation, but not an IDENTITY: neighbours share a
|
||||
yard. It is already used the only way it can be — as the guard.
|
||||
year_built+total_floors
|
||||
a FALSE witness, not a corroborator: of the 391 same-canon pairs the
|
||||
guard cannot judge, only 18 agree on both fields (357 have a NULL), while
|
||||
306 pairs the guard rejected at >250 m DO agree — it would confirm merges
|
||||
that are provably wrong.
|
||||
|
||||
Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the
|
||||
canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued — see the
|
||||
residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the
|
||||
guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart
|
||||
(median 1 084 m) — those are not duplicates at all, the canon key is wrong about them.
|
||||
|
||||
IDEMPOTENCY:
|
||||
Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the
|
||||
mapping is empty → every statement touches 0 rows → no-op. Re-running is safe.
|
||||
|
|
@ -165,6 +204,14 @@ _COMPLETENESS_EXPR = """
|
|||
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
|
||||
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
|
||||
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
|
||||
#
|
||||
# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле —
|
||||
# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина
|
||||
# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния).
|
||||
# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого
|
||||
# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить
|
||||
# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом
|
||||
# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей».
|
||||
_KEEPER_ORDER = f"""
|
||||
(h.geom IS NOT NULL) DESC,
|
||||
listing_cnt DESC NULLS LAST,
|
||||
|
|
@ -199,37 +246,17 @@ _CANON_KEY_EXPR = """
|
|||
"""
|
||||
|
||||
|
||||
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
||||
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
|
||||
def _ranked_cte(cluster_key_case: str) -> str:
|
||||
"""Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row.
|
||||
|
||||
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
|
||||
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
|
||||
row shares one fias by construction).
|
||||
|
||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
||||
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
||||
different region-66 towns share a canon; the guard stops the cross-town over-merge.
|
||||
- FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
|
||||
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
|
||||
geom-first keeper rule simultaneously repairs the broken coordinate).
|
||||
Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census
|
||||
(`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about — a census
|
||||
built from its own copy of the clustering would drift from the pass it describes and the
|
||||
drift would be invisible (it is the same class of error as #2690's cluster key: two
|
||||
expressions that look alike and are not).
|
||||
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
|
||||
"""
|
||||
geo_guard = (
|
||||
"""
|
||||
-- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
|
||||
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
|
||||
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
|
||||
-- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
|
||||
-- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
|
||||
-- geom on either side, → left as separate rows (conservative — never over-merges).
|
||||
AND keeper_geom IS NOT NULL
|
||||
AND loser_geom IS NOT NULL
|
||||
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
|
||||
if apply_geo_guard
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
|
||||
WITH clustered AS (
|
||||
SELECT
|
||||
id,
|
||||
|
|
@ -281,7 +308,41 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
|||
FROM dup_houses dh
|
||||
JOIN houses h ON h.id = dh.id
|
||||
LEFT JOIN listing_counts lc ON lc.house_id = dh.id
|
||||
)"""
|
||||
|
||||
|
||||
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
||||
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
|
||||
|
||||
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
|
||||
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
|
||||
row shares one fias by construction).
|
||||
|
||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
||||
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
||||
different region-66 towns share a canon; the guard stops the cross-town over-merge.
|
||||
- FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
|
||||
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
|
||||
geom-first keeper rule simultaneously repairs the broken coordinate).
|
||||
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
|
||||
"""
|
||||
geo_guard = (
|
||||
"""
|
||||
-- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
|
||||
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
|
||||
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
|
||||
-- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
|
||||
-- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
|
||||
-- geom on either side, → left as separate rows (conservative — never over-merges).
|
||||
AND keeper_geom IS NOT NULL
|
||||
AND loser_geom IS NOT NULL
|
||||
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
|
||||
if apply_geo_guard
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
|
||||
{_ranked_cte(cluster_key_case)}
|
||||
-- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but
|
||||
-- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon
|
||||
-- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per
|
||||
|
|
@ -314,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
|
|||
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
|
||||
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
|
||||
|
||||
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
|
||||
#
|
||||
# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY.
|
||||
# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered
|
||||
# out is counted here, bucketed by the reason it survived.
|
||||
#
|
||||
# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there
|
||||
# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a
|
||||
# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the
|
||||
# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821.
|
||||
#
|
||||
# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard
|
||||
# rejected» are opposite facts:
|
||||
# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN.
|
||||
# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are
|
||||
# NOT duplicates — the canon key is wrong about them (prod 2026-08-10:
|
||||
# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt.
|
||||
# residual_cross_fias — provably different buildings (two different ФИАС UUIDs).
|
||||
# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real
|
||||
# run; non-zero is a tripwire on the pass itself, not a census entry.
|
||||
# residual_listings is the user-visible size of the remainder (listings hanging on those rows).
|
||||
_RESIDUAL_SQL = text(
|
||||
f"""
|
||||
{_ranked_cte(_CANON_KEY_EXPR)}
|
||||
SELECT
|
||||
count(*) FILTER (WHERE rn > 1) AS residual_rows,
|
||||
COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings,
|
||||
count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias,
|
||||
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL)
|
||||
AS residual_no_geom,
|
||||
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far,
|
||||
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250)
|
||||
AS residual_mergeable
|
||||
FROM (
|
||||
SELECT rn,
|
||||
COALESCE(lc.listing_cnt, 0) AS lcnt,
|
||||
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
|
||||
THEN ST_DistanceSphere(loser_geom, keeper_geom)
|
||||
END AS dist,
|
||||
(NULLIF(loser_fias, '') IS NOT NULL
|
||||
AND NULLIF(keeper_fias, '') IS NOT NULL
|
||||
AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias
|
||||
FROM ranked
|
||||
LEFT JOIN listing_counts lc ON lc.house_id = ranked.id
|
||||
) r
|
||||
"""
|
||||
)
|
||||
|
||||
# Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op.
|
||||
_STEPS: list[tuple[str, str]] = [
|
||||
# ── Plain re-point (no UNIQUE on the FK column) ───────────────────────────
|
||||
|
|
@ -726,6 +835,15 @@ class DedupMergeResult:
|
|||
listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper
|
||||
children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children
|
||||
children_repointed: int = 0 # survivor child rows moved loser→keeper
|
||||
# Residual census (#2690): same-canon rows STILL in the table after this run, by reason.
|
||||
# Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with,
|
||||
# so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL.
|
||||
residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets)
|
||||
residual_listings: int = 0 # listings hanging on them (the user-visible size)
|
||||
residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates
|
||||
residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building
|
||||
residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings
|
||||
residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0
|
||||
dry_run: bool = False
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -736,6 +854,12 @@ class DedupMergeResult:
|
|||
"listings_repointed": self.listings_repointed,
|
||||
"children_deleted": self.children_deleted,
|
||||
"children_repointed": self.children_repointed,
|
||||
"residual_rows": self.residual_rows,
|
||||
"residual_listings": self.residual_listings,
|
||||
"residual_no_geom": self.residual_no_geom,
|
||||
"residual_far": self.residual_far,
|
||||
"residual_cross_fias": self.residual_cross_fias,
|
||||
"residual_mergeable": self.residual_mergeable,
|
||||
"dry_run": int(self.dry_run),
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
|
@ -853,6 +977,49 @@ def _run_merge_pass(
|
|||
db.execute(_BACKFILL_ALIASES_SQL)
|
||||
|
||||
|
||||
def _measure_residual(db: Session, result: DedupMergeResult) -> None:
|
||||
"""Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690).
|
||||
|
||||
Read-only; runs after both passes, so it describes the table as the run leaves it (under
|
||||
dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of
|
||||
`_run_merge_pass` because the census is about the CANON key only and must be taken once per
|
||||
call, not once per pass.
|
||||
|
||||
Never fails the merge: the merge itself is the product, the census is instrumentation, and a
|
||||
census that can abort a committed-by-now transaction would be worse than a missing number.
|
||||
"""
|
||||
try:
|
||||
rows = db.execute(_RESIDUAL_SQL).all()
|
||||
except Exception:
|
||||
logger.exception("merge_duplicate_houses: residual census failed — counters left at 0")
|
||||
return
|
||||
if not rows:
|
||||
return
|
||||
r = rows[0]
|
||||
result.residual_rows = int(r.residual_rows or 0)
|
||||
result.residual_listings = int(r.residual_listings or 0)
|
||||
result.residual_no_geom = int(r.residual_no_geom or 0)
|
||||
result.residual_far = int(r.residual_far or 0)
|
||||
result.residual_cross_fias = int(r.residual_cross_fias or 0)
|
||||
result.residual_mergeable = int(r.residual_mergeable or 0)
|
||||
logger.info(
|
||||
"merge_duplicate_houses: residual rows=%d listings=%d "
|
||||
"(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)",
|
||||
result.residual_rows,
|
||||
result.residual_listings,
|
||||
result.residual_no_geom,
|
||||
result.residual_far,
|
||||
result.residual_cross_fias,
|
||||
result.residual_mergeable,
|
||||
)
|
||||
if result.residual_mergeable:
|
||||
logger.warning(
|
||||
"merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass "
|
||||
"left work on the table (expected 0)",
|
||||
result.residual_mergeable,
|
||||
)
|
||||
|
||||
|
||||
def merge_duplicate_houses(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -908,6 +1075,10 @@ def merge_duplicate_houses(
|
|||
result=result,
|
||||
)
|
||||
|
||||
# Census of what is LEFT (read-only). Runs before the no-op early return on purpose:
|
||||
# a run that merged nothing is exactly the run whose remainder needs a number.
|
||||
_measure_residual(db, result)
|
||||
|
||||
if result.losers_deleted == 0:
|
||||
# Clean table — both passes empty. Roll back (we only opened temp tables).
|
||||
db.rollback()
|
||||
|
|
|
|||
|
|
@ -411,7 +411,8 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
|
|||
UPDATE houses
|
||||
SET imv_status = 'ok',
|
||||
last_imv_attempt_at = NOW(),
|
||||
imv_error_reason = NULL
|
||||
imv_error_reason = NULL,
|
||||
imv_transient_attempts = 0
|
||||
WHERE id = :hid
|
||||
"""),
|
||||
{"hid": house_id},
|
||||
|
|
@ -424,12 +425,19 @@ def _mark_status(
|
|||
status: str,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
# #2674: счётчик растёт ТОЛЬКО на transient_error — это «сколько раз подряд дом
|
||||
# падал по временной причине», а не «сколько раз его трогали». no_params /
|
||||
# no_address / not_found счётчик не двигают: они не занимают retry-слот.
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE houses
|
||||
SET imv_status = :s,
|
||||
last_imv_attempt_at = NOW(),
|
||||
imv_error_reason = :r
|
||||
imv_error_reason = :r,
|
||||
imv_transient_attempts = CASE
|
||||
WHEN :s = 'transient_error' THEN imv_transient_attempts + 1
|
||||
ELSE imv_transient_attempts
|
||||
END
|
||||
WHERE id = :hid
|
||||
"""),
|
||||
{"hid": house_id, "s": status, "r": reason},
|
||||
|
|
@ -443,6 +451,104 @@ _IMVStatus = Literal[
|
|||
"ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error"
|
||||
]
|
||||
|
||||
# #2674: сколько раз подряд дом может упасть в transient_error, прежде чем
|
||||
# перестанет занимать retry-слот. Число из замера: после починки сайдкара (04.08)
|
||||
# доля отказов на попытку — 2/27 и 3/25 (прогоны 3708/3467), т.е. ~10%. На 1039
|
||||
# застрявших это ~104 повторных отказа на первом проходе, ~10 на втором, ~1 на
|
||||
# третьем. Порог 3 стоит максимум ~115 слотов ВСЕГО (≈2 прогона) и гарантирует,
|
||||
# что дом со СВОЕЙ (не инфраструктурной) причиной не крутится в пакете вечно.
|
||||
# Исчерпавшие лимит не исчезают из наблюдаемости: они остаются imv_status=
|
||||
# 'transient_error' и считаются как
|
||||
# WHERE imv_status='transient_error' AND imv_transient_attempts >= 3.
|
||||
_MAX_TRANSIENT_ATTEMPTS = 3
|
||||
|
||||
# Доля пакета под повтор transient_error. Половина — потому что остальные слоты
|
||||
# после #2674 достаются ТОЛЬКО домам, по которым реально будет запрос к площадке
|
||||
# (см. _premark_unusable): раньше из 50 слотов до площадки доходили 17 (замер
|
||||
# головы очереди на 12.08), так что pending на половине пакета всё равно идёт
|
||||
# быстрее, чем на целом до правки.
|
||||
_RETRY_SLOTS_SHARE = 0.5
|
||||
|
||||
# Дом без пригодных параметров backfill всё равно пометит no_params — но только
|
||||
# заплатив слотом пакета и паузой request_delay_sec. Тот же вердикт берётся одним
|
||||
# запросом: нет ни одного объявления с rooms+area (pick_lot_params вернёт {}) ИЛИ
|
||||
# не из чего взять house_type (_map_house_type вернёт None → «unknown house_type»).
|
||||
# Причины пишем ТЕМИ ЖЕ строками, что и поштучный путь, — старые разрезы по
|
||||
# imv_error_reason продолжают работать.
|
||||
# Условие сознательно УЖЕ питоновского: normalize_house_type схлопывает в None ещё
|
||||
# и нераспознанный вокабуляр ('other', 'wireframe'), который тут остаётся текстом.
|
||||
# Промахнуться можно только в безопасную сторону — пометить меньше, чем пометил бы
|
||||
# поштучный путь.
|
||||
_PREMARK_UNUSABLE_SQL = text("""
|
||||
WITH unusable AS (
|
||||
SELECT h.id,
|
||||
CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM listings l
|
||||
WHERE l.house_id_fk = h.id
|
||||
AND l.rooms IS NOT NULL
|
||||
AND l.area_m2 IS NOT NULL)
|
||||
THEN 'no listings with rooms+area'
|
||||
ELSE 'unknown house_type'
|
||||
END AS reason
|
||||
FROM houses h
|
||||
WHERE h.imv_status = ANY(CAST(:statuses AS text[]))
|
||||
AND h.lat IS NOT NULL
|
||||
AND h.lon IS NOT NULL
|
||||
AND h.address IS NOT NULL
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM listings l
|
||||
WHERE l.house_id_fk = h.id
|
||||
AND l.rooms IS NOT NULL
|
||||
AND l.area_m2 IS NOT NULL)
|
||||
OR COALESCE(
|
||||
NULLIF(TRIM((
|
||||
SELECT mode() WITHIN GROUP (ORDER BY l.house_type)
|
||||
FROM listings l
|
||||
WHERE l.house_id_fk = h.id
|
||||
AND l.rooms IS NOT NULL
|
||||
AND l.area_m2 IS NOT NULL)), ''),
|
||||
NULLIF(TRIM(h.house_type), '')
|
||||
) IS NULL
|
||||
)
|
||||
)
|
||||
UPDATE houses
|
||||
SET imv_status = 'no_params',
|
||||
last_imv_attempt_at = NOW(),
|
||||
imv_error_reason = unusable.reason
|
||||
FROM unusable
|
||||
WHERE houses.id = unusable.id
|
||||
""")
|
||||
|
||||
# Основная очередь: один статус, как и было (only_status — публичный параметр
|
||||
# admin-API, семантику не трогаем).
|
||||
_QUEUE_SQL = text("""
|
||||
SELECT id, address, full_address, lat, lon
|
||||
FROM houses
|
||||
WHERE imv_status = :status
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
AND address IS NOT NULL
|
||||
ORDER BY last_imv_attempt_at NULLS FIRST, id
|
||||
LIMIT :batch
|
||||
""")
|
||||
|
||||
# Retry-очередь (#2674). Отдельный запрос, а не OR к основной: у pending
|
||||
# last_imv_attempt_at всегда NULL, поэтому при общем ORDER BY ... NULLS FIRST
|
||||
# transient_error не попал бы в пакет, пока не кончится pending (по замеру
|
||||
# 12.08 — 5747 домов ≈ год). Отдельная квота = отдельный проход.
|
||||
_RETRY_QUEUE_SQL = text("""
|
||||
SELECT id, address, full_address, lat, lon
|
||||
FROM houses
|
||||
WHERE imv_status = 'transient_error'
|
||||
AND imv_transient_attempts < :max_attempts
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
AND address IS NOT NULL
|
||||
ORDER BY last_imv_attempt_at NULLS FIRST, id
|
||||
LIMIT :batch
|
||||
""")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HouseIMVBackfillResult:
|
||||
|
|
@ -452,6 +558,23 @@ class HouseIMVBackfillResult:
|
|||
errors: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
status_counts: dict[str, int] = field(default_factory=dict)
|
||||
# #2674: сколько домов пакета пришло из retry-очереди transient_error и
|
||||
# сколько помечено no_params до пакета (без запроса к площадке).
|
||||
retried: int = 0
|
||||
premarked: int = 0
|
||||
|
||||
|
||||
def _premark_unusable(db: Session, statuses: list[str]) -> int:
|
||||
"""Пометить no_params дома, по которым запрос к площадке невозможен. → сколько.
|
||||
|
||||
Не новое поведение, а тот же вердикт _process_one_house одним запросом: на
|
||||
12.08 в очереди 1925 таких домов из 5143 (113 без объявлений с rooms+area,
|
||||
1812 без house_type) — каждый занимал слот пакета и паузу, чтобы получить
|
||||
ответ, который виден в SQL.
|
||||
"""
|
||||
res = db.execute(_PREMARK_UNUSABLE_SQL, {"statuses": statuses})
|
||||
db.commit()
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
|
||||
def _beat(heartbeat: Callable[[], None] | None) -> None:
|
||||
|
|
@ -480,7 +603,11 @@ async def backfill_house_imv(
|
|||
batch_size: max houses to process (ignored when house_id given).
|
||||
request_delay_sec: sleep between Avito API calls (default 5s — anti-bot).
|
||||
only_status: process houses with this imv_status (default 'pending').
|
||||
Use 'transient_error' to retry failures.
|
||||
Use 'transient_error' to retry failures. При значении по умолчанию
|
||||
часть пакета (_RETRY_SLOTS_SHARE) автоматически уходит на повтор
|
||||
transient_error с непотраченным лимитом попыток (#2674) — явно
|
||||
переданный only_status этот проход отключает, оператор получает
|
||||
ровно то, что попросил, включая исчерпавшие лимит дома.
|
||||
house_id: process a single specific house (debug).
|
||||
heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES
|
||||
домов — caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies
|
||||
|
|
@ -509,23 +636,45 @@ async def backfill_house_imv(
|
|||
.all()
|
||||
)
|
||||
else:
|
||||
rows = (
|
||||
# Повторный проход только на расписании (only_status по умолчанию): явный
|
||||
# only_status от оператора — это ручной запрос ровно одного статуса.
|
||||
retry_lane = only_status == "pending"
|
||||
|
||||
statuses = [only_status] + (["transient_error"] if retry_lane else [])
|
||||
result.premarked = _premark_unusable(db, statuses)
|
||||
if result.premarked:
|
||||
logger.info(
|
||||
"house_imv_backfill: %d домов помечены no_params до пакета (нет rooms+area "
|
||||
"или house_type) — слоты пакета не потрачены",
|
||||
result.premarked,
|
||||
)
|
||||
|
||||
retry_rows: list = []
|
||||
if retry_lane:
|
||||
retry_rows = (
|
||||
db.execute(
|
||||
_RETRY_QUEUE_SQL,
|
||||
{
|
||||
"max_attempts": _MAX_TRANSIENT_ATTEMPTS,
|
||||
"batch": int(batch_size * _RETRY_SLOTS_SHARE),
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
result.retried = len(retry_rows)
|
||||
|
||||
# Недобор retry-очереди (она кончится раньше pending: 1039 против 3218 на
|
||||
# 12.08) возвращается pending — пакет не простаивает.
|
||||
fresh_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT id, address, full_address, lat, lon
|
||||
FROM houses
|
||||
WHERE imv_status = :status
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
AND address IS NOT NULL
|
||||
ORDER BY last_imv_attempt_at NULLS FIRST, id
|
||||
LIMIT :batch
|
||||
"""),
|
||||
{"status": only_status, "batch": batch_size},
|
||||
_QUEUE_SQL,
|
||||
{"status": only_status, "batch": max(batch_size - result.retried, 0)},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
rows = list(fresh_rows) + list(retry_rows)
|
||||
|
||||
result.checked = len(rows)
|
||||
if not rows:
|
||||
|
|
@ -534,9 +683,11 @@ async def backfill_house_imv(
|
|||
return result
|
||||
|
||||
logger.info(
|
||||
"house_imv_backfill: %d houses (status=%r delay=%.1fs)",
|
||||
"house_imv_backfill: %d houses (status=%r retry=%d premarked=%d delay=%.1fs)",
|
||||
result.checked,
|
||||
only_status,
|
||||
result.retried,
|
||||
result.premarked,
|
||||
request_delay_sec,
|
||||
)
|
||||
|
||||
|
|
@ -610,11 +761,14 @@ async def backfill_house_imv(
|
|||
|
||||
result.duration_sec = time.time() - t0
|
||||
logger.info(
|
||||
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs %s",
|
||||
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d "
|
||||
"retried=%d premarked=%d %.1fs %s",
|
||||
result.checked,
|
||||
result.saved,
|
||||
result.skipped,
|
||||
result.errors,
|
||||
result.retried,
|
||||
result.premarked,
|
||||
result.duration_sec,
|
||||
result.status_counts,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ def match_or_create_house(
|
|||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
source_url: str | None = None,
|
||||
city: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
"""Match existing house or create new canonical record.
|
||||
|
||||
|
|
@ -88,6 +89,18 @@ def match_or_create_house(
|
|||
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
|
||||
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
|
||||
|
||||
Args:
|
||||
city: город-цель развёртки, собравшей эту карточку (`save_listings(city=…)`,
|
||||
он же `listings.city`) — НЕЗАВИСИМОЕ от строки адреса наблюдение города
|
||||
(#2777). Нужен ровно там, где адресный токен города бессилен: областной
|
||||
формат Avito SERP «ул. Кирова,4» города не называет, а бескоординатный
|
||||
ключ Tier-2a вырождается в один нормализованный адрес и становится
|
||||
глобально уникальным. Опционален: вызывающие без sweep-контекста
|
||||
(estimate-путь, ad-hoc скрипты) передают None → поведение прежнее.
|
||||
Про независимость: в #2690 доказано, что усиление ключа полем, выведенным
|
||||
из ТОЙ ЖЕ строки адреса (gar_house_guid), защиту отменяет, а не усиливает —
|
||||
здесь признак приходит другим каналом (какой город запрашивала развёртка).
|
||||
|
||||
Returns:
|
||||
(house_id, confidence ∈ [0.0, 1.0], method ∈ {
|
||||
'cadastr_exact', 'source_exact', 'fingerprint',
|
||||
|
|
@ -212,18 +225,40 @@ def match_or_create_house(
|
|||
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
|
||||
# scope, only relevant once the oblast sweep is enabled.
|
||||
#
|
||||
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
|
||||
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
|
||||
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
|
||||
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
|
||||
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
|
||||
# residual needs sweep-context and is out of this fix's scope.
|
||||
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
|
||||
# EKB happy-path is byte-identical: the guard fires ONLY when the card's city is known to
|
||||
# be non-ЕКБ AND no coords disambiguate. ЕКБ cards and cards with no city signal at all
|
||||
# (resolved city None) run Tier-2a/2b exactly as before.
|
||||
#
|
||||
# #2777: the residual the comment above used to describe as out of scope — a BARE oblast
|
||||
# card ('ул. Кирова,4', today's Avito SERP format) — is closed here by the `city` kwarg.
|
||||
# The sweep already knows which city it was crawling and stamps it on the listing row
|
||||
# (save_listings → listings.city); that observation just never reached this guard, so
|
||||
# 26 of 26 measured cross-city stitches went through Tier 2a on a coord-less key. Prod
|
||||
# 2026-08-10: 7303 of 21603 aliases are coord-less keys, 6047 of them carry no city token
|
||||
# at all — i.e. a globally unique 'street + number' that ANY city's card can hit.
|
||||
# The address token still wins when present (it describes THIS card; the sweep city
|
||||
# describes the batch).
|
||||
_resolved_city = None
|
||||
if lat is None and lon is None:
|
||||
_resolved_city = resolve_city_token(norm_addr) or (normalize_address(city) or None)
|
||||
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
|
||||
# Известный потолок правки, названный числом (прод 2026-08-10, 35 домов со
|
||||
# «сшитыми» городами по метке listings.city):
|
||||
# • 30 из 35 — приходящая карточка областная, алиас принадлежит дому другого
|
||||
# города → страж срабатывает;
|
||||
# • 5 из 35 — приходящая карточка ЕКБ, а алиас завёл областной дом. Тут страж
|
||||
# молчит: города владельца алиаса мы не знаем (в house_address_aliases его
|
||||
# нет). Апгрейд — city-ключ у алиаса, но это миграция + перекладка 7303
|
||||
# бескоординатных ключей, и до неё нужен журнал слияний (#2690 п.1).
|
||||
# • посёлки внутри ЕКБ-развёртки (Кедровка, Б. Седельниково, Решёты — 12-17 км
|
||||
# разброса) этим признаком НЕ ловятся вовсе: у них тот же город-цель
|
||||
# «Екатеринбург». Гранулярность независимого наблюдения — город, не населённый
|
||||
# пункт; это ограничение данных, а не недоделка стража.
|
||||
if _skip_oblast_alias:
|
||||
logger.info(
|
||||
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
|
||||
"house tier2a/2b skip: coord-less non-ЕКБ city %r (sweep_city=%r) na=%r src=%s",
|
||||
_resolved_city,
|
||||
city,
|
||||
norm_addr,
|
||||
ext_source,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -447,6 +447,12 @@ async def _job_house_imv_backfill(
|
|||
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
|
||||
"total_seen": result.checked,
|
||||
"new_count": result.saved,
|
||||
# #2674: из скольких слотов пакета взяты дома на ПОВТОР (transient_error)
|
||||
# и сколько домов ушло в no_params до пакета одним запросом. Без этих
|
||||
# двух счётчиков в scrape_runs.counters проверить, что застрявшие
|
||||
# действительно возвращаются в очередь, можно только по houses.
|
||||
"retried": result.retried,
|
||||
"premarked": result.premarked,
|
||||
}
|
||||
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
|
||||
# «сделали то, что собирались», а не «не поймали известное исключение».
|
||||
|
|
|
|||
301
tradein-mvp/backend/app/services/proxy_egress.py
Normal file
301
tradein-mvp/backend/app/services/proxy_egress.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"""Резолвер egress-прокси по источнику для ad-hoc сессий вне scrape_run (#2825).
|
||||
|
||||
ПРОБЛЕМА (доказана на проде 2026-08-10): `settings.scraper_proxy_url` (и его алиасы
|
||||
`cian_proxy_url`/`yandex_proxy_url`, все три — прямая проекция ENV `SCRAPER_PROXY_URL`,
|
||||
см. `app.core.config`) был ЕДИНСТВЕННЫМ egress для всех curl_cffi/httpx-сессий, которые
|
||||
строятся напрямую в `app/services/*` и `app/tasks/*` МИМО `app.services.proxy_pool` /
|
||||
`scraper_kit`-оркестрации. При этом `scrape_proxy_source_bans` (миграция 210, #2600 п.2)
|
||||
аккуратно вела учёт банов по паре «узел × источник» — но эти прямые сессии её никогда
|
||||
не читали и месяц ходили через узел, забаненный и Avito, и Cian.
|
||||
|
||||
ЧТО ЭТОТ МОДУЛЬ НЕ ДЕЛАЕТ: не берёт lease. `app.services.proxy_pool.acquire()` уже
|
||||
реализует pick-с-учётом-банов, но с полной lease-семантикой (leased_by/release/
|
||||
reap_stale_leases) — она рассчитана на долгоживущие `scrape_run`/`BrowserFetcher`-сессии
|
||||
(см. `RealProxyProvider` в `app.services.scraper_adapters`). Вызывающие здесь — короткие
|
||||
одноразовые fetch'и (проверка cookies, одна detail-страница) без run_id и без
|
||||
гарантированного `release` на каждом пути выхода; занимать под них lease значило бы
|
||||
дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже —
|
||||
ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок
|
||||
и без мутаций.
|
||||
|
||||
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
|
||||
(тот же карантинный порог, что у acquire), нет активной строки в
|
||||
scrape_proxy_source_bans для ЭТОГО source. Среди кандидатов — меньший consecutive_fails,
|
||||
при равенстве — более свежий last_ok_at (NULLS LAST). Не изобретаем ротацию/балансировку:
|
||||
это резолвер «дай рабочий прокси прямо сейчас», не lease-менеджер.
|
||||
|
||||
FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review этой правки): пул и статичный
|
||||
`SCRAPER_PROXY_URL` — РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет":
|
||||
|
||||
1. Пул ПУСТ (в `scrape_proxies` вообще нет строк — dev/staging без БД-пула, легитимный
|
||||
сценарий). Тогда fallback на `settings.scraper_proxy_url` ЛЕГИТИМЕН — пула для этого
|
||||
окружения попросту не существует, идти больше некуда. `logger.warning`.
|
||||
2. Пул НЕ пуст, но НИ ОДИН узел не прошёл фильтр для source (все забанены ИМЕННО для
|
||||
этого источника / нездоровы / выключены). Здесь fallback на `SCRAPER_PROXY_URL`
|
||||
ЗАПРЕЩЁН: инцидент 2026-08-10 — это ровно случай (2), узел статичной переменной был
|
||||
тем же самым забаненным узлом, что и в пуле, «резервный» путь тихо возвращал систему
|
||||
к первопричине. `resolve_proxy_url` в этом случае бросает `ProxyPoolExhaustedError` —
|
||||
вызывающий обязан явно отказаться от запроса (`logger.error`), а не соскользнуть на
|
||||
env в обход учёта банов.
|
||||
|
||||
НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials — url
|
||||
несёт логин/пароль, в лог никогда не идёт целиком) и id узла; при legit-fallback —
|
||||
warning с текстом «пуст» (сценарий 1); при exhaustion — error с разбивкой
|
||||
banned_for_source/unhealthy_or_disabled (сценарий 2) — тексты НАМЕРЕННО разные, чтобы
|
||||
их нельзя было спутать в логах/алертах.
|
||||
|
||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings as _settings
|
||||
from app.core.db import SessionLocal as _SessionLocal
|
||||
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["ProxyPoolExhaustedError", "resolve_proxy_url", "resolve_proxy_url_sync"]
|
||||
|
||||
|
||||
class ProxyPoolExhaustedError(RuntimeError):
|
||||
"""Пул `scrape_proxies` НЕ пуст, но ни один узел не прошёл фильтр для `source`
|
||||
(все забанены именно для этого источника / нездоровы / выключены).
|
||||
|
||||
Fail-closed (#2616): вызывающий обязан явно отказаться от запроса (пропустить run
|
||||
с понятным логом), а НЕ уйти в обход пула через статичный
|
||||
`settings.scraper_proxy_url` — тот самый узел мог быть источником текущего
|
||||
инцидента (см. module docstring, сценарий 2).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: str,
|
||||
*,
|
||||
pool_total: int,
|
||||
banned_for_source: int,
|
||||
unhealthy_or_disabled: int,
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.pool_total = pool_total
|
||||
self.banned_for_source = banned_for_source
|
||||
self.unhealthy_or_disabled = unhealthy_or_disabled
|
||||
super().__init__(
|
||||
f"proxy pool exhausted for source={source!r}: pool_total={pool_total} "
|
||||
f"banned_for_source={banned_for_source} unhealthy_or_disabled={unhealthy_or_disabled}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Candidate:
|
||||
id: int
|
||||
url: str
|
||||
label: str | None
|
||||
|
||||
|
||||
def _safe_label(proxy_id: int, label: str | None, url: str) -> str:
|
||||
"""host:port для логов — НИКОГДА не credentials из url (userinfo)."""
|
||||
if label:
|
||||
return label
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or "?"
|
||||
return f"{host}:{parts.port}" if parts.port else host
|
||||
except ValueError:
|
||||
return f"proxy#{proxy_id}"
|
||||
|
||||
|
||||
def _pick_candidate(db: Session, source: str) -> _Candidate | None:
|
||||
"""READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, url, label
|
||||
FROM scrape_proxies
|
||||
WHERE enabled
|
||||
AND consecutive_fails < CAST(:max_fails AS integer)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxy_source_bans b
|
||||
WHERE b.proxy_id = scrape_proxies.id
|
||||
AND b.source = CAST(:source AS text)
|
||||
AND b.banned_until > now()
|
||||
)
|
||||
ORDER BY consecutive_fails ASC, last_ok_at DESC NULLS LAST, id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"max_fails": MAX_CONSECUTIVE_FAILS, "source": source},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
# Чистое чтение без блокировок — ничего не коммитим/не откатываем намеренно,
|
||||
# оставляем управление транзакцией вызывающему коду (тот же db может быть в
|
||||
# середине более широкой операции).
|
||||
if row is None:
|
||||
return None
|
||||
return _Candidate(id=int(row["id"]), url=str(row["url"]), label=row["label"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ExhaustionDiag:
|
||||
"""Разбивка причин "кандидата нет" — ТОЛЬКО когда пул реально не пуст (сценарий 2
|
||||
в докстринге модуля). Используется исключительно для diagnostic-лога/исключения."""
|
||||
|
||||
pool_total: int
|
||||
banned_for_source: int
|
||||
unhealthy_or_disabled: int
|
||||
|
||||
|
||||
def _diagnose_no_candidate(db: Session, source: str) -> _ExhaustionDiag:
|
||||
"""Отдельный запрос, вызывается ТОЛЬКО когда основной SELECT кандидата вернул
|
||||
пусто — не платим за агрегаты в happy-path (кандидат найден с первого запроса)."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS pool_total,
|
||||
count(*) FILTER (
|
||||
WHERE NOT enabled
|
||||
OR consecutive_fails >= CAST(:max_fails AS integer)
|
||||
) AS unhealthy_or_disabled,
|
||||
count(*) FILTER (
|
||||
WHERE enabled
|
||||
AND consecutive_fails < CAST(:max_fails AS integer)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxy_source_bans b
|
||||
WHERE b.proxy_id = scrape_proxies.id
|
||||
AND b.source = CAST(:source AS text)
|
||||
AND b.banned_until > now()
|
||||
)
|
||||
) AS banned_for_source
|
||||
FROM scrape_proxies
|
||||
"""
|
||||
),
|
||||
{"max_fails": MAX_CONSECUTIVE_FAILS, "source": source},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
if row is None: # pragma: no cover — count(*) всегда возвращает строку
|
||||
return _ExhaustionDiag(pool_total=0, banned_for_source=0, unhealthy_or_disabled=0)
|
||||
return _ExhaustionDiag(
|
||||
pool_total=int(row["pool_total"]),
|
||||
banned_for_source=int(row["banned_for_source"]),
|
||||
unhealthy_or_disabled=int(row["unhealthy_or_disabled"]),
|
||||
)
|
||||
|
||||
|
||||
def resolve_proxy_url(db: Session, source: str) -> str | None:
|
||||
"""Egress-URL для source (avito/cian/yandex/domclick) — пул с учётом банов пары
|
||||
«узел × источник». См. докстринг модуля за разбором двух РАЗНЫХ исходов
|
||||
"кандидата нет":
|
||||
|
||||
- пул пуст (0 строк в `scrape_proxies`) → fallback на
|
||||
`settings.scraper_proxy_url`, `logger.warning`, легитимный dev/staging-сценарий;
|
||||
- пул не пуст, все отсеяны (баны/health/disabled) → `ProxyPoolExhaustedError`
|
||||
(`logger.error`), fail-closed — БЕЗ прохода через статичный env.
|
||||
|
||||
БД пула недоступна (connection error и т.п., напр. dev-окружение без поднятой БД)
|
||||
— трактуем КАК пустой пул (не можем подтвердить exhaustion — небезопасно поднимать
|
||||
error/исключение по неполным данным), `logger.warning` + explicit (не silent
|
||||
failure). Отличается от сценария exhaustion: там мы ТОЧНО знаем, что узлы есть и
|
||||
все отсеяны; здесь мы вообще ничего не знаем о пуле.
|
||||
"""
|
||||
try:
|
||||
candidate = _pick_candidate(db, source)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies недоступен (ошибка БД), "
|
||||
"лечим как пустой пул (fallback на статичный SCRAPER_PROXY_URL)",
|
||||
source,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
# Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG:
|
||||
# "current transaction is aborted") — если db переживёт этот вызов
|
||||
# (долгоживущая caller-сессия, напр. avito_detail_backfill/
|
||||
# yandex_detail_backfill), последующие запросы на ней иначе все падали
|
||||
# бы с той же ошибкой, маскируя реальную причину.
|
||||
db.rollback()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- rollback после сбоя пула тоже не удался",
|
||||
source,
|
||||
exc_info=True,
|
||||
)
|
||||
return _settings.scraper_proxy_url
|
||||
|
||||
if candidate is not None:
|
||||
logger.info(
|
||||
"proxy_egress: source=%s -> pool proxy id=%d (%s)",
|
||||
source,
|
||||
candidate.id,
|
||||
_safe_label(candidate.id, candidate.label, candidate.url),
|
||||
)
|
||||
return candidate.url
|
||||
|
||||
diag = _diagnose_no_candidate(db, source)
|
||||
|
||||
if diag.pool_total == 0:
|
||||
# Сценарий 1: пул для этого окружения попросту не сконфигурирован
|
||||
# (dev/staging без БД-пула) — легитимный fallback.
|
||||
fallback = _settings.scraper_proxy_url
|
||||
if fallback:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies ПУСТ (0 записей), "
|
||||
"окружение без БД-пула -- идём через статичный SCRAPER_PROXY_URL "
|
||||
"(fallback)",
|
||||
source,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies пуст и SCRAPER_PROXY_URL "
|
||||
"не задан, идём прямым подключением без прокси",
|
||||
source,
|
||||
)
|
||||
return fallback
|
||||
|
||||
# Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного
|
||||
# здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env.
|
||||
logger.error(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН "
|
||||
"не прошёл фильтр для этого источника (banned_for_source=%d, "
|
||||
"unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через "
|
||||
"статичный SCRAPER_PROXY_URL (тот самый узел мог быть источником инцидента)",
|
||||
source,
|
||||
diag.pool_total,
|
||||
diag.banned_for_source,
|
||||
diag.unhealthy_or_disabled,
|
||||
)
|
||||
raise ProxyPoolExhaustedError(
|
||||
source,
|
||||
pool_total=diag.pool_total,
|
||||
banned_for_source=diag.banned_for_source,
|
||||
unhealthy_or_disabled=diag.unhealthy_or_disabled,
|
||||
)
|
||||
|
||||
|
||||
def resolve_proxy_url_sync(source: str) -> str | None:
|
||||
"""Как `resolve_proxy_url`, но сама открывает короткую `SessionLocal()` — для
|
||||
вызывающих без готового `db` в сигнатуре (напр. `cian_session.verify_session`).
|
||||
|
||||
`ProxyPoolExhaustedError` из `resolve_proxy_url` пробрасывается как есть (fail-closed) —
|
||||
вызывающий обязан явно её поймать и решить, как деградировать (см. call site'ы).
|
||||
"""
|
||||
db = _SessionLocal()
|
||||
try:
|
||||
return resolve_proxy_url(db, source)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -179,6 +179,109 @@ def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> Non
|
|||
)
|
||||
|
||||
|
||||
def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
|
||||
"""Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625).
|
||||
|
||||
Возвращает текст причины (для error) либо None, если прогон таким не является.
|
||||
|
||||
Третий исход, у которого не было терминального статуса. Развёртка различает:
|
||||
1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу →
|
||||
`mark_banned` в самих sweep'ах (#2642, cian/yandex);
|
||||
2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений →
|
||||
`done` с нулём, это здоровый результат (в Серове реально 10 объявлений);
|
||||
3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как
|
||||
что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает
|
||||
НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки),
|
||||
и статуса ему никто не выдал — прогон уходил в `done`.
|
||||
|
||||
Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров:
|
||||
`errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что
|
||||
отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ
|
||||
доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус
|
||||
'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается
|
||||
по умолчанию).
|
||||
|
||||
Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала —
|
||||
`errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор).
|
||||
|
||||
Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов
|
||||
(yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с,
|
||||
таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5;
|
||||
yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса).
|
||||
НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной
|
||||
пустоты (errors_count=0) — они остаются 'done'.
|
||||
"""
|
||||
anchors = _pick_int(counters, "anchors_total")
|
||||
errors = _pick_int(counters, "errors_count")
|
||||
if not anchors or anchors <= 0 or errors is None or errors < anchors:
|
||||
return None
|
||||
if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает
|
||||
return None
|
||||
return (
|
||||
f"sweep-honest-status: отказом кончились все {anchors} якорей прогона "
|
||||
f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ "
|
||||
f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или "
|
||||
f"блокировкой площадки — статус 'failed' без диагноза (#2625)"
|
||||
)
|
||||
|
||||
|
||||
# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило.
|
||||
# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд
|
||||
# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу
|
||||
# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90
|
||||
# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не
|
||||
# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд.
|
||||
_PHASE_MIN_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
|
||||
"""Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None.
|
||||
|
||||
Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит
|
||||
случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на
|
||||
сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ.
|
||||
|
||||
Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50,
|
||||
detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала
|
||||
HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового
|
||||
прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ
|
||||
подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`).
|
||||
|
||||
Признак — собственная бухгалтерия фазы: `<phase>_failed == <phase>_attempted` при
|
||||
`attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ
|
||||
`X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это
|
||||
ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя
|
||||
настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv.
|
||||
|
||||
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому
|
||||
'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
|
||||
|
||||
Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по
|
||||
боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed'
|
||||
42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не
|
||||
знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 —
|
||||
это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов
|
||||
отказали обе фазы (detail и houses) сразу, и они посчитались дважды.
|
||||
"""
|
||||
for key in sorted(counters):
|
||||
if not key.endswith("_attempted"):
|
||||
continue
|
||||
phase = key[: -len("_attempted")]
|
||||
attempted = _pick_int(counters, key)
|
||||
failed = _pick_int(counters, f"{phase}_failed")
|
||||
if attempted is None or failed is None:
|
||||
continue
|
||||
if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted:
|
||||
return (
|
||||
f"phase-honest-status: фаза '{phase}' отказала полностью — "
|
||||
f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы "
|
||||
f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. "
|
||||
f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус "
|
||||
f"'failed' без диагноза (#2700)"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
||||
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
|
||||
|
||||
|
|
@ -445,7 +548,27 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
|||
|
||||
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
|
||||
в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
|
||||
|
||||
#2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился
|
||||
каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а
|
||||
не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
|
||||
страж, который надо не забыть позвать, — это тот же дефект оборванной проводки,
|
||||
из-за которого задача и появилась.
|
||||
|
||||
#2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка
|
||||
целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано
|
||||
ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет».
|
||||
"""
|
||||
did_nothing = _sweep_run_did_nothing(counters)
|
||||
if did_nothing is not None:
|
||||
logger.error("%s run_id=%d", did_nothing, run_id)
|
||||
mark_failed(db, run_id, did_nothing, counters)
|
||||
return
|
||||
phase_dead = _phase_totally_failed(counters)
|
||||
if phase_dead is not None:
|
||||
logger.error("%s run_id=%d", phase_dead, run_id)
|
||||
mark_failed(db, run_id, phase_dead, counters)
|
||||
return
|
||||
total_seen, new_count = _column_counts(counters)
|
||||
row = db.execute(
|
||||
text(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ class RealMatcherAdapter:
|
|||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
source_url: str | None = None,
|
||||
city: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
# house_id is None when the matcher refuses a numberless address without a
|
||||
# cadastral number (method 'no_house_number', P1). Callers must tolerate None.
|
||||
|
|
@ -80,6 +81,7 @@ class RealMatcherAdapter:
|
|||
year_built=year_built,
|
||||
building_cadastral_number=building_cadastral_number,
|
||||
source_url=source_url,
|
||||
city=city,
|
||||
)
|
||||
|
||||
def upsert_listing_source(
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ async def backfill_yandex_addresses(
|
|||
Returns:
|
||||
YandexAddressBackfillResult with checked/saved/skipped/errors counters.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||
|
||||
result = YandexAddressBackfillResult()
|
||||
t0 = time.time()
|
||||
|
|
@ -130,7 +130,23 @@ async def backfill_yandex_addresses(
|
|||
request_delay_sec,
|
||||
)
|
||||
|
||||
_proxy_url = settings.scraper_proxy_url
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если пул
|
||||
# пуст (легитимный dev/staging-сценарий).
|
||||
try:
|
||||
_proxy_url = resolve_proxy_url(db, "yandex")
|
||||
except ProxyPoolExhaustedError as exc:
|
||||
# Fail-closed (#2616, #2825): пул не пуст, но все узлы забанены для yandex/
|
||||
# нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module
|
||||
# docstring). Явный пропуск run'а вместо слепого прохода через egress, который
|
||||
# мог быть источником текущего инцидента.
|
||||
logger.error(
|
||||
"yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run "
|
||||
"пропущен, ни один листинг не обработан",
|
||||
exc,
|
||||
)
|
||||
result.duration_sec = time.time() - t0
|
||||
return result
|
||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||
|
||||
async with AsyncSession(
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ from sqlalchemy.orm import Session
|
|||
from app.core.config import settings
|
||||
from app.core.shutdown import shutdown_requested
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.proxy_egress import resolve_proxy_url
|
||||
from app.services.scraper_adapters import RealScraperConfig
|
||||
|
||||
# #2397 Part D1 (#2330 закрыт): _AVITO_WARM_SEARCH_URL/build_warmed_session больше
|
||||
|
|
@ -268,12 +269,19 @@ async def run_avito_detail_backfill(
|
|||
elif not use_curl:
|
||||
# curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False):
|
||||
# строим shared сессию через auv, как делает run_avito_city_sweep (kit).
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только
|
||||
# если пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все
|
||||
# забанены/нездоровы для avito -- resolve_proxy_url бросает
|
||||
# ProxyPoolExhaustedError (fail-closed, #2616): НАРОЧНО не ловим здесь --
|
||||
# штатный except Exception ниже (mark_failed + logger.exception + raise)
|
||||
# уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен.
|
||||
own_session = True
|
||||
session = AsyncSession(
|
||||
impersonate="chrome120",
|
||||
timeout=25,
|
||||
headers=DOCUMENT_HEADERS,
|
||||
proxies=http_proxies(settings.scraper_proxy_url),
|
||||
proxies=http_proxies(resolve_proxy_url(db, "avito")),
|
||||
)
|
||||
scraper._cffi = session
|
||||
|
||||
|
|
|
|||
|
|
@ -109,10 +109,17 @@ class NewbuildingEnrichBackfillResult:
|
|||
failed_fetch: int = 0 # fetch returned None / raised
|
||||
failed_save: int = 0 # save raised after a good fetch
|
||||
|
||||
# Row-level deltas (how much actually landed).
|
||||
price_dynamics_rows: int = 0
|
||||
reliability_rows: int = 0
|
||||
review_rows: int = 0
|
||||
# Сколько РЕАЛЬНО записано, по словам самих писателей (#2807). Раньше здесь стоял
|
||||
# прирост COUNT(*) по таблице до/после сохранения — то есть «выросла ли таблица», а
|
||||
# не «сколько записали»: при ON CONFLICT DO UPDATE обновление даёт ноль, а у
|
||||
# reliability ноль давал ещё и _dedup_reliability, схлопывающий дубль сразу после
|
||||
# вставки. Ключи переименованы намеренно: у price_dynamics_rows/reliability_rows/
|
||||
# review_rows в истории прогонов старый смысл, и молча поменять его под тем же
|
||||
# именем — ровно тот дефект, ради которого правка и делается.
|
||||
price_dynamics_inserted: int = 0 # новых точек динамики цен
|
||||
price_dynamics_updated: int = 0 # существующих точек переписано свежей ценой
|
||||
reliability_inserted: int = 0 # строк house_reliability_checks вставлено
|
||||
review_upserted: int = 0 # отзывов записано (вставка+обновление, ключ ext_review_id)
|
||||
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -407,6 +414,9 @@ async def backfill_newbuilding_enrichment(
|
|||
# уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠
|
||||
# «включить пул»: реально пул задействуется, только если включён
|
||||
# config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding).
|
||||
# #2830: тот же провайдер уходит и в resolve-ногу (curl_cffi, флаг
|
||||
# use_proxy_pool_curl) — #2767 починил только fetch, а резолв ЖК-url остался на
|
||||
# статичном cian_proxy_url, то есть на второй ноге той же цепочки.
|
||||
proxy_provider = RealProxyProvider()
|
||||
|
||||
result = NewbuildingEnrichBackfillResult()
|
||||
|
|
@ -484,7 +494,9 @@ async def backfill_newbuilding_enrichment(
|
|||
continue
|
||||
|
||||
try:
|
||||
resolved = await resolve_cian_zhk_url_via_search(nb_id, config=scraper_config)
|
||||
resolved = await resolve_cian_zhk_url_via_search(
|
||||
nb_id, config=scraper_config, proxy_provider=proxy_provider
|
||||
)
|
||||
except Exception as exc: # defensive — resolver already catches internally
|
||||
logger.warning(
|
||||
"zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc
|
||||
|
|
@ -557,15 +569,15 @@ async def backfill_newbuilding_enrichment(
|
|||
continue
|
||||
|
||||
# ── Save under a SAVEPOINT so one bad house can't poison the batch ──
|
||||
# begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally,
|
||||
# so we snapshot the row counts BEFORE and recompute the delta AFTER its commit
|
||||
# rather than relying on the nested transaction staying open.
|
||||
pd_before, rc_before, rv_before = _house_enrichment_counts(db, house_id)
|
||||
# begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally.
|
||||
# COUNT(*) до сохранения нужен ТОЛЬКО для had_reliability (дедуп ниже): сколько
|
||||
# записано, теперь сообщают сами писатели, а не разница COUNT'ов (#2807).
|
||||
_, rc_before, _ = _house_enrichment_counts(db, house_id)
|
||||
try:
|
||||
had_reliability = rc_before > 0
|
||||
|
||||
# 1) price_dynamics + reliability + houses UPDATE (existing, commits inside).
|
||||
save_newbuilding_enrichment(db, house_id, enrichment)
|
||||
saved = save_newbuilding_enrichment(db, house_id, enrichment)
|
||||
|
||||
# 2) reviews — added here (save_newbuilding_enrichment skips them).
|
||||
# SAVEPOINT around the review write so a malformed review can't lose the
|
||||
|
|
@ -601,16 +613,18 @@ async def backfill_newbuilding_enrichment(
|
|||
sp.rollback()
|
||||
logger.warning("reliability dedup failed house_id=%s: %s", house_id, dexc)
|
||||
|
||||
pd_after, rc_after, rv_after = _house_enrichment_counts(db, house_id)
|
||||
result.price_dynamics_rows += max(0, pd_after - pd_before)
|
||||
result.reliability_rows += max(0, rc_after - rc_before)
|
||||
result.review_rows += max(0, rv_after - rv_before)
|
||||
result.price_dynamics_inserted += saved.price_inserted
|
||||
result.price_dynamics_updated += saved.price_updated
|
||||
result.reliability_inserted += saved.reliability_inserted
|
||||
result.review_upserted += review_written
|
||||
result.succeeded += 1
|
||||
logger.info(
|
||||
"enriched house_id=%s: +pd=%d +reliability=%d +reviews=%d (parsed reviews=%d)",
|
||||
"enriched house_id=%s: динамика цен +%d новых / %d обновлено, "
|
||||
"reliability +%d, отзывов записано %d (распознано %d)",
|
||||
house_id,
|
||||
max(0, pd_after - pd_before),
|
||||
max(0, rc_after - rc_before),
|
||||
saved.price_inserted,
|
||||
saved.price_updated,
|
||||
saved.reliability_inserted,
|
||||
review_written,
|
||||
len(enrichment.reviews),
|
||||
)
|
||||
|
|
@ -629,8 +643,8 @@ async def backfill_newbuilding_enrichment(
|
|||
result.duration_sec = time.time() - t0
|
||||
logger.info(
|
||||
"newbuilding-enrich backfill done: processed=%d ok=%d skip=%d resolved=%d "
|
||||
"resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d "
|
||||
"| %.1fs",
|
||||
"resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / "
|
||||
"%d обновлено, reliability +%d, отзывов %d | %.1fs",
|
||||
result.processed,
|
||||
result.succeeded,
|
||||
result.skipped_already_enriched,
|
||||
|
|
@ -638,9 +652,10 @@ async def backfill_newbuilding_enrichment(
|
|||
result.failed_resolve,
|
||||
result.failed_fetch,
|
||||
result.failed_save,
|
||||
result.price_dynamics_rows,
|
||||
result.reliability_rows,
|
||||
result.review_rows,
|
||||
result.price_dynamics_inserted,
|
||||
result.price_dynamics_updated,
|
||||
result.reliability_inserted,
|
||||
result.review_upserted,
|
||||
result.duration_sec,
|
||||
)
|
||||
return result
|
||||
|
|
@ -787,8 +802,8 @@ async def run_newbuilding_enrich(
|
|||
)
|
||||
logger.info(
|
||||
"scheduler: newbuilding_enrich run_id=%d finished — processed=%d ok=%d skip=%d "
|
||||
"resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d "
|
||||
"| pending=%d %.1fs",
|
||||
"resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / "
|
||||
"%d обновлено, reliability +%d, отзывов %d | pending=%d %.1fs",
|
||||
run_id,
|
||||
result.processed,
|
||||
result.succeeded,
|
||||
|
|
@ -796,9 +811,10 @@ async def run_newbuilding_enrich(
|
|||
result.failed_resolve,
|
||||
result.failed_fetch,
|
||||
result.failed_save,
|
||||
result.price_dynamics_rows,
|
||||
result.reliability_rows,
|
||||
result.review_rows,
|
||||
result.price_dynamics_inserted,
|
||||
result.price_dynamics_updated,
|
||||
result.reliability_inserted,
|
||||
result.review_upserted,
|
||||
result.cian_houses_pending,
|
||||
result.duration_sec,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
|
|||
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
|
||||
такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они
|
||||
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
|
||||
целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может
|
||||
разобрать, а размер отброшенного видно в counters.unenrichable_pending.
|
||||
целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать.
|
||||
|
||||
Но «не по тому URL» ≠ «нечего обогащать» (разобрано 2026-08-12, см. комментарий
|
||||
у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по
|
||||
собранному из него каноническому URL страница отдаётся и парсится. Поэтому в
|
||||
очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены:
|
||||
url_from_offer_id — сколько ждёт починки адреса, unenrichable_pending — сколько
|
||||
не адресуемо вообще (ни offer-URL, ни числового source_id).
|
||||
|
||||
Why curl_cffi and not YandexDetailScraper.fetch_detail:
|
||||
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
|
||||
|
|
@ -45,12 +51,14 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.proxy_egress import resolve_proxy_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"CANONICAL_URL_SQL",
|
||||
"OFFER_ID_PATTERN",
|
||||
"OFFER_URL_PATTERN",
|
||||
"YandexDetailBackfillResult",
|
||||
"run_yandex_detail_backfill",
|
||||
|
|
@ -63,8 +71,7 @@ __all__ = [
|
|||
#
|
||||
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
|
||||
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
|
||||
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из
|
||||
# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer/<id>/.
|
||||
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса.
|
||||
#
|
||||
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
|
||||
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
|
||||
|
|
@ -72,6 +79,39 @@ __all__ = [
|
|||
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
|
||||
OFFER_URL_PATTERN = "/offer/[0-9]+"
|
||||
|
||||
# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ────────────────
|
||||
# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на
|
||||
# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде:
|
||||
#
|
||||
# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная;
|
||||
# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк,
|
||||
# вставленных после самой свежей его строки, id 2583989), и выйти из него
|
||||
# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое
|
||||
# множество и обязано быть константой — вопрос был не «почему не растёт», а
|
||||
# «правда ли они непригодны».
|
||||
#
|
||||
# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId
|
||||
# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из
|
||||
# него собирается — это инвариант #2235 (`_canonical_source_url` в
|
||||
# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164
|
||||
# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси,
|
||||
# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая
|
||||
# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php.
|
||||
#
|
||||
# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни
|
||||
# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235
|
||||
# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ
|
||||
# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной
|
||||
# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес,
|
||||
# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки.
|
||||
#
|
||||
# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой
|
||||
# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией:
|
||||
# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят
|
||||
# на сайты застройщиков вместо Яндекса.
|
||||
OFFER_ID_PATTERN = "^[0-9]+$"
|
||||
CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
|
||||
|
||||
|
||||
@dataclass
|
||||
class YandexDetailBackfillResult:
|
||||
|
|
@ -80,6 +120,11 @@ class YandexDetailBackfillResult:
|
|||
attempted: int = 0
|
||||
enriched: int = 0
|
||||
failed: int = 0
|
||||
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
|
||||
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
|
||||
# прогону; замер на месте = очередь снова читается не тем признаком.
|
||||
url_from_offer_id: int = 0
|
||||
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
|
||||
unenrichable_pending: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -88,6 +133,7 @@ class YandexDetailBackfillResult:
|
|||
"attempted": self.attempted,
|
||||
"enriched": self.enriched,
|
||||
"failed": self.failed,
|
||||
"url_from_offer_id": self.url_from_offer_id,
|
||||
"unenrichable_pending": self.unenrichable_pending,
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
|
@ -132,51 +178,86 @@ async def run_yandex_detail_backfill(
|
|||
|
||||
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
|
||||
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
|
||||
# Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
|
||||
# (см. комментарий у константы): в очередь не берём то, что заведомо
|
||||
# непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
|
||||
# В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо
|
||||
# сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес
|
||||
# собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире
|
||||
# этого условия — гарантированный parse→None пачкой и обрыв по брейкеру.
|
||||
snapshot = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, source_url
|
||||
f"""
|
||||
SELECT id,
|
||||
CASE
|
||||
WHEN source_url ~ CAST(:offer_url_pattern AS text)
|
||||
THEN source_url
|
||||
ELSE {CANONICAL_URL_SQL}
|
||||
END AS source_url
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND source_url IS NOT NULL
|
||||
AND source_url ~ CAST(:offer_url_pattern AS text)
|
||||
AND (
|
||||
(
|
||||
source_url IS NOT NULL
|
||||
AND source_url ~ CAST(:offer_url_pattern AS text)
|
||||
)
|
||||
OR source_id ~ CAST(:offer_id_pattern AS text)
|
||||
)
|
||||
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
|
||||
LIMIT CAST(:batch_size AS int)
|
||||
"""
|
||||
# f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля,
|
||||
# не пользовательский ввод. Всё изменяемое — bind-параметры.
|
||||
),
|
||||
{"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
|
||||
{
|
||||
"batch_size": batch_size,
|
||||
"offer_url_pattern": OFFER_URL_PATTERN,
|
||||
"offer_id_pattern": OFFER_ID_PATTERN,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
|
||||
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674).
|
||||
counters.unenrichable_pending = int(
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND source_url IS NOT NULL
|
||||
AND source_url !~ CAST(:offer_url_pattern AS text)
|
||||
"""
|
||||
),
|
||||
{"offer_url_pattern": OFFER_URL_PATTERN},
|
||||
).scalar_one()
|
||||
)
|
||||
if counters.unenrichable_pending:
|
||||
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно
|
||||
# разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как
|
||||
# «тут делать нечего» и держало 3535 квартир вне обогащения неделю.
|
||||
pending = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
count(*) FILTER (
|
||||
WHERE source_id ~ CAST(:offer_id_pattern AS text)
|
||||
) AS url_from_offer_id,
|
||||
count(*) FILTER (
|
||||
WHERE source_id IS NULL
|
||||
OR source_id !~ CAST(:offer_id_pattern AS text)
|
||||
) AS unenrichable_pending
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND (
|
||||
source_url IS NULL
|
||||
OR source_url !~ CAST(:offer_url_pattern AS text)
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN},
|
||||
).one()
|
||||
counters.url_from_offer_id = int(pending.url_from_offer_id)
|
||||
counters.unenrichable_pending = int(pending.unenrichable_pending)
|
||||
if counters.url_from_offer_id:
|
||||
logger.info(
|
||||
"yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: "
|
||||
"source_url ведёт не на карточку Яндекса (%s), парсер их отвергает "
|
||||
"до сети",
|
||||
"yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый "
|
||||
"source_url не ведёт на карточку Яндекса; адресуем их по offerId из "
|
||||
"source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)",
|
||||
run_id,
|
||||
counters.url_from_offer_id,
|
||||
)
|
||||
if counters.unenrichable_pending:
|
||||
logger.warning(
|
||||
"yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: нет ни "
|
||||
"offer-URL (%s), ни числового source_id — адресовать их нечем",
|
||||
run_id,
|
||||
counters.unenrichable_pending,
|
||||
OFFER_URL_PATTERN,
|
||||
|
|
@ -203,8 +284,15 @@ async def run_yandex_detail_backfill(
|
|||
max_consecutive_blocks,
|
||||
)
|
||||
|
||||
# Build proxies dict once — mirrors yandex_address_backfill.py
|
||||
_proxy = settings.scraper_proxy_url
|
||||
# Build proxies dict once — mirrors yandex_address_backfill.py.
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если
|
||||
# пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
|
||||
# нездоровы для yandex -- resolve_proxy_url бросает ProxyPoolExhaustedError
|
||||
# (fail-closed, #2616): НАРОЧНО не ловим здесь -- штатный except Exception ниже
|
||||
# (mark_failed + logger.exception + raise) уже даёт явную деградацию run'а с
|
||||
# понятным логом, отдельный catch не нужен.
|
||||
_proxy = resolve_proxy_url(db, "yandex")
|
||||
_proxies = {"http": _proxy, "https": _proxy} if _proxy else None
|
||||
|
||||
consecutive_none = 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
-- 254_listings_backfill_avito_rating_glued_address.sql
|
||||
-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814).
|
||||
--
|
||||
-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же <p>
|
||||
-- в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4
|
||||
-- отзыва». Парсер починен в #2815 (merged, прод-verified 2026-08-10 09:57 UTC), но
|
||||
-- УЖЕ ЗАПИСАННЫЕ строки сами не вылечатся: апсерт пишет
|
||||
-- `address = COALESCE(listings.address, EXCLUDED.address)` (base.py:614) — при
|
||||
-- конфликте адрес осознанно НЕ перезаписывается (#2777: свежий сырой адрес от
|
||||
-- площадки откатил бы чистку миграций 062/108/124). Эта миграция — единственный
|
||||
-- путь, которым старые строки могут стать чистыми.
|
||||
--
|
||||
-- ЗАМЕР НА ПРОДЕ 2026-08-10, после деплоя #2815 (не «по релиз-метке», а по данным):
|
||||
--
|
||||
-- класс адреса (source='avito', is_active) строк с координатами
|
||||
-- ------------------------------------------ ------ --------------
|
||||
-- чистый 7892 6111 (77.4%)
|
||||
-- загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%)
|
||||
-- NULL 360 0 (0.0%)
|
||||
--
|
||||
-- 1123 не изменились после деплоя парсера — ни одной из этих строк свип не касался
|
||||
-- с 09:57 (max(last_seen_at) = 2026-08-09 16:53), и не коснётся с толком: COALESCE.
|
||||
-- Все 1123 — source='avito', все is_active. Других источников с таким хвостом нет.
|
||||
-- Цена простоя: строка без geom молча выпадает из радиусного отбора аналогов
|
||||
-- (Tier W, ST_DWithin — NULL не проходит предикат и нигде не считается).
|
||||
--
|
||||
-- ПРАВИЛО РЕЗКИ — ДОСЛОВНО ПАРСЕРНОЕ, не изобретённое здесь.
|
||||
-- providers/avito/serp.py: _NOT_ADDRESS_TAIL_RE = re.compile(
|
||||
-- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I)
|
||||
-- _clean_address: split(maxsplit=1)[0] → _deglue_house_marker → strip(" ,.\n\t")
|
||||
-- → `return cleaned or None`.
|
||||
-- Ниже — тот же альтернатив-набор, флаг 'i' = flags=re.I, `.*$` + regexp_replace =
|
||||
-- взять текст ДО первого совпадения (обе реализации leftmost), тот же набор символов
|
||||
-- в trim, NULLIF(...,'') = `or None`.
|
||||
-- Ключевая тонкость (#1773): резать по «·» можно ТОЛЬКО когда за ней идёт ЦИФРА.
|
||||
-- За буквой идёт район — «улица Бебеля, 138 · р-н Железнодорожный», и этот хвост
|
||||
-- сохраняется намеренно. На проде таких строк 296, и они обязаны остаться целыми
|
||||
-- (проверено в dry-run: 296 до = 296 после).
|
||||
-- _deglue_house_marker в SQL НЕ повторяется — замерено, что он здесь no-op: после
|
||||
-- резки хвоста ни одна из 1123 строк не содержит слипшегося «29р-н» (0 совпадений
|
||||
-- паттерном _DEGLUE_RE). Повторять в SQL лукахеды ради нуля строк незачем.
|
||||
--
|
||||
-- ПАРИТЕТ ПРОВЕРЕН ТЕМ ЖЕ КОДОМ, А НЕ ПО ГЛАЗАМ. Все 1123 сырых адреса выгружены с
|
||||
-- прода и прогнаны через ЖИВОЙ парсер в боевом контейнере:
|
||||
-- docker exec tradein-scraper python /tmp/m2814-parity.py
|
||||
-- → rows=1123 mismatches=0
|
||||
-- т.е. SQL-выражение ниже даёт побайтово то же, что `_clean_address` в проде.
|
||||
--
|
||||
-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-10):
|
||||
-- UPDATE 1123 · осталось загрязнённых 0 · районных «·» сохранено 296/296
|
||||
-- ул. Ткачей,17·5,0 · 4 отзыва → ул. Ткачей,17
|
||||
-- ул. Свердлова,32Б·4,2 · 5 отзывов → ул. Свердлова,32Б
|
||||
-- ул. Щорса,103·4,3 · 15 отзывов → ул. Щорса,103
|
||||
-- Уральская ул.,5·4,8 · 15 отзывов → Уральская ул.,5
|
||||
-- Селькоровская ул.,60·5,0 · 3 отзыва → Селькоровская ул.,60
|
||||
-- ул. Азина,22/2·4,6 · 17 отзывов → ул. Азина,22/2
|
||||
-- ул. 8 Марта,204Г/2·4,3 · 3 отзыва → ул. 8 Марта,204Г/2
|
||||
-- жилой район Сортировочный, мкр-н Старая Сортировка, Кунарская ул.,14к2·4,3 · 6 отзывов
|
||||
-- → жилой район Сортировочный, мкр-н Старая
|
||||
-- Сортировка, Кунарская ул.,14к2
|
||||
-- мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов
|
||||
-- → мкр-н Широкая Речка, ул. Анатолия Муранова,18
|
||||
-- ·3,1 · 11 отзывов → NULL (id 10377315, ровно 1 строка: адрес
|
||||
-- состоял ИЗ рейтинга целиком. Парсер на такой строке возвращает None — здесь то
|
||||
-- же самое через NULLIF. Оставлять «·3,1 · 11 отзывов» в колонке хуже пустоты:
|
||||
-- NULL апсерт теперь ДОзаполняет (#2777), мусор — нет.)
|
||||
--
|
||||
-- ОБРАТИМОСТЬ — без новой таблицы и без новой колонки: прежнее значение УЖЕ хранится.
|
||||
-- `listings.raw_payload->>'address'` пишется скрейпером на INSERT и НЕ входит в
|
||||
-- `ON CONFLICT DO UPDATE SET` (проверено по base.py: raw_payload отсутствует в SET) —
|
||||
-- т.е. переживает любой свип. Замерено на проде: у 1123 из 1123 строк
|
||||
-- raw_payload->>'address' = address ПОБАЙТОВО, NULL-ов нет ни одного.
|
||||
-- Откат (idempotent, безопасен к повторному запуску):
|
||||
--
|
||||
-- UPDATE listings
|
||||
-- SET address = raw_payload->>'address'
|
||||
-- WHERE source = 'avito'
|
||||
-- AND raw_payload->>'address' ~ '·\s*\d'
|
||||
-- AND address IS NOT DISTINCT FROM NULLIF(trim(both E' ,.\n\t' FROM
|
||||
-- regexp_replace(raw_payload->>'address',
|
||||
-- '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')), '');
|
||||
--
|
||||
-- Предикат самоидентифицирующий, список id хранить не нужно, и это ПРОВЕРЕНО, а не
|
||||
-- предположено. В dry-run (BEGIN…ROLLBACK) после UPDATE он дал по всей таблице ровно
|
||||
-- 1123 совпадения, все 1123 — наши; restored = before побайтово у 1123 из 1123.
|
||||
-- Ложных срабатываний нет и на строках-соседях: есть 10 строк, где raw_payload грязный,
|
||||
-- а address уже чистый (их адрес позже перезаписал avito_detail полным «Свердловская
|
||||
-- обл., Первоуральск, …») — второе условие их не берёт (замерено: 0), и это ПРАВИЛЬНО:
|
||||
-- возвращать рейтинг поверх нормализованного адреса не надо. Со временем предикат сам
|
||||
-- перестаёт брать строки, у которых address улучшил detail-путь, — откат не деградирует
|
||||
-- в порчу.
|
||||
-- `geocode_tried_at` откатывать нечего: это метка backoff'а, не данные.
|
||||
--
|
||||
-- ПОЧЕМУ geocode_tried_at = NULL. Очередь geocode_missing_listings отбирает по
|
||||
-- `geocode_tried_at IS NULL OR < NOW() - 7 days`, и метка привязана к ТЕКСТУ
|
||||
-- (address, city). У 711 из 1123 строк она стоит (у 370 — свежее 7 суток) — но стоит
|
||||
-- она на СТАРОМ, заведомо негеокодируемом тексте. После смены текста она смысла не
|
||||
-- имеет и лишь держала бы вычищенный адрес вне очереди до 7 суток. Сброс — это не
|
||||
-- «попробовать ещё раз то же самое», а «текст другой». Побочный расход честно измерен:
|
||||
-- 19 пар из 854 имеют соседа, которому геокодер отказал за последние 7 суток, т.е. до
|
||||
-- 19 лишних запросов к Nominatim — цена ниже, чем неделя ожидания у 370 строк.
|
||||
--
|
||||
-- ЧТО БУДЕТ ДАЛЬШЕ (и чего НЕ будет). Чистый адрес координат сам не даёт. После миграции
|
||||
-- 1122 строки (854 уникальные пары address+city; 1123-я — та самая NULL) попадают в
|
||||
-- выборку geocode_missing_listings: `lat IS NULL AND is_active AND address IS NOT NULL
|
||||
-- AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR < 7 days)`. Очередь
|
||||
-- станет 1938 строк / 1370 пар против 1569 / 1241 сейчас (+369 строк: 753 из 1123 уже
|
||||
-- стояли в ней СО СВОИМ ГРЯЗНЫМ адресом и жгли бюджет Nominatim впустую — этот расход
|
||||
-- миграция тоже снимает). Расписание: enabled, окно 0-23 UTC, batch_size=200,
|
||||
-- budget_sec=1800, ближайший next_run_at = 2026-08-10 17:45 UTC.
|
||||
-- Гарантированный низ (замер по живому geocode_cache тем же ключом, что строит
|
||||
-- `_cache_key`): 138 из 854 пар уже лежат в кэше с координатами и не истекли → 245
|
||||
-- строк получат geom мгновенно, без единого внешнего запроса. Остальное — как повезёт
|
||||
-- тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов давали 17-53%
|
||||
-- успеха на адрес, гадать точнее не буду.
|
||||
--
|
||||
-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
|
||||
-- * не трогает COALESCE в апсерте — поведение осознанное (#2777);
|
||||
-- * не трогает 360 строк с address IS NULL — их #2777 ДОзаполняет сам на ближайшем
|
||||
-- свипе (замерено: пустых строк '' среди них 0, все именно NULL);
|
||||
-- * не переносит координаты с соседних строк того же адреса. Такая возможность есть
|
||||
-- (789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city),
|
||||
-- но у 88 из 548 донорских пар соседи расходятся между собой больше чем на 50 м, у
|
||||
-- 32 — больше 250 м, худший разброс 15 км. Выбирать победителя между ними — это
|
||||
-- новая политика, а не бэкфилл; отдельным решением, не тихо здесь.
|
||||
--
|
||||
-- Dependencies: 002_core_tables.sql (listings), 089_listings_geo_precision.sql
|
||||
-- (geocode_tried_at). Триггер listings_set_geom_trg тут не участвует: он BEFORE
|
||||
-- INSERT OR UPDATE OF lat, lon — эта миграция координат не пишет.
|
||||
-- Идемпотентность: по построению. Второй прогон видит 0 строк с '·<цифра>' и не делает
|
||||
-- ничего (WHERE самоисчерпывающийся). Новые вставки чисты с #2815.
|
||||
-- lock_timeout: блокирующего DDL здесь нет, но UPDATE по «горячей» listings берёт
|
||||
-- ROW EXCLUSIVE, и ждать его выдачи за чужой ACCESS EXCLUSIVE сессией — ровно та
|
||||
-- очередь перед приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт
|
||||
-- громко (ON_ERROR_STOP=on), чем встанет тихо.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
UPDATE listings
|
||||
SET address = NULLIF(
|
||||
trim(both E' ,.\n\t' FROM
|
||||
regexp_replace(
|
||||
address,
|
||||
'\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$',
|
||||
'',
|
||||
'i'
|
||||
)),
|
||||
''),
|
||||
geocode_tried_at = NULL
|
||||
WHERE source = 'avito'
|
||||
AND address ~ '·\s*\d';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
-- 255_trade_in_estimates_revival_relaxations.sql
|
||||
-- Номер сверен по `ls data/sql | sort` (max applied = 254) непосредственно
|
||||
-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap
|
||||
-- (108_*/084_* уже дублировались в прошлом).
|
||||
--
|
||||
-- ── Incident 2026-08-10: «мёртвые» сохранённые оценки ───────────────────────
|
||||
-- Заказчик открыл сохранённую ссылку /trade-in/v2?id=... и увидел «НЕДОСТАТОЧНО
|
||||
-- ДАННЫХ»: запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR
|
||||
-- #2823/#2825) и лежит в БД с median_price=0/NULL, хотя тот же адрес и
|
||||
-- параметры сейчас честно считаются (4 031 157 ₽ / 39 аналогов). 117 из 1071
|
||||
-- строк trade_in_estimates находятся в этом состоянии (29 за последние 30
|
||||
-- дней). GET /api/v1/trade-in/estimate/{id} (app/api/v1/trade_in.py::
|
||||
-- _try_revive_dead_estimate) теперь пересчитывает такую строку на месте и
|
||||
-- пишет результат В ТУ ЖЕ строку (id/ссылка не меняются) — этому нужны две
|
||||
-- новые колонки:
|
||||
--
|
||||
-- revival_attempted_at — throttle: не пересчитывать чаще одного раза в N
|
||||
-- минут (settings.trade_in_revival_throttle_minutes, default 10) на одну
|
||||
-- строку. Заявка на пересчёт — атомарный conditional
|
||||
-- `UPDATE ... WHERE revival_attempted_at IS NULL OR ... < NOW() - N min
|
||||
-- RETURNING id` (тот же паттерн, что account_quota.increment, #747) —
|
||||
-- защищает и от шторма повторных попыток на мёртвый адрес, и от гонки
|
||||
-- двух параллельных GET (второй просто теряет заявку и отдаёт то, что
|
||||
-- есть, без 500).
|
||||
--
|
||||
-- ── relaxations / reliability (открытый хвост PR #2823, найден post-deploy
|
||||
-- 2026-08-10 — см. fixes/Fix_Mera_Studio_Not_Estimated_Never_Block_Aug10) ──
|
||||
-- Обе колонки УЖЕ вычисляются в estimator.estimate_quality() и уходят в POST-
|
||||
-- ответ (AggregatedEstimate.relaxations/reliability), но раньше НЕ
|
||||
-- персистились — на GET-rehydrate (открытие сохранённой ссылки) красный
|
||||
-- баннер «точность снижена» пропадал, хотя цена по-прежнему построена на
|
||||
-- расширенной/тонкой выборке. Теперь пишутся при каждом (re)compute (основной
|
||||
-- INSERT в estimate_quality() + этот revival-путь) и читаются на GET.
|
||||
--
|
||||
-- ── IDEMPOTENCY ───────────────────────────────────────────────────────────
|
||||
-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: все существующие
|
||||
-- строки получают DEFAULT (reliability='ok', relaxations='[]', revival_
|
||||
-- attempted_at=NULL) — честно отражает то, что для них каскад послаблений
|
||||
-- никогда не считался (записи ДО #oblast-F) и revival ещё не запускался.
|
||||
--
|
||||
-- Dependencies: 001_trade_in_estimates.sql. Apply after: 254_*.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS relaxations jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS reliability text NOT NULL DEFAULT 'ok'
|
||||
CHECK (reliability IN ('ok', 'low', 'very_low'));
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS revival_attempted_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.relaxations IS
|
||||
'RU-подписи применённых послаблений подбора (estimator.py #oblast-F cascade) '
|
||||
'— персистится, чтобы GET-rehydrate (?id=) мог восстановить дисклеймер '
|
||||
'«точность снижена». [] = базовой выборки хватило / запись создана до '
|
||||
'#oblast-F (без бэкфилла).';
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.reliability IS
|
||||
'Надёжность итоговой выборки (ok|low|very_low), производная от n_analogs + '
|
||||
'relaxations (estimator.py::estimate_quality) — персистится для GET-rehydrate '
|
||||
'красного баннера. Default ok = запись создана до #oblast-F (без бэкфилла).';
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.revival_attempted_at IS
|
||||
'Момент последней попытки пересчитать «мёртвую» (median_price<=0/NULL) '
|
||||
'строку на GET /estimate/{id} (incident 2026-08-10, app/api/v1/trade_in.py::'
|
||||
'_try_revive_dead_estimate). Throttle: не пересчитывать чаще одного раза в '
|
||||
'settings.trade_in_revival_throttle_minutes на одну строку — атомарный '
|
||||
'conditional UPDATE...RETURNING (см. модульный докстринг). NULL = либо '
|
||||
'строка живая (median_price>0) и revival никогда не запускался, либо '
|
||||
'запись создана до этой фичи.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
-- 256_trade_in_estimates_revival_completed_at.sql
|
||||
-- Номер сверен по `ls data/sql | sort` (max applied = 255) непосредственно
|
||||
-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap
|
||||
-- (108_*/084_* уже дублировались в прошлом).
|
||||
--
|
||||
-- ── fix/tradein-created (2026-08-11): created_at перезаписывался revival'ом ──
|
||||
-- `_try_revive_dead_estimate` (app/api/v1/trade_in.py, migration 255) писал
|
||||
-- пересчитанные поля в ИСХОДНУЮ строку trade_in_estimates, и вместе с ними —
|
||||
-- `created_at`, скопированный из временной строки (estimate_quality() ставит
|
||||
-- туда NOW() на момент пересчёта). Факт с прода: оценка
|
||||
-- ff421062-cc38-4c4c-ad2e-0cfac52d14ff создана 2026-08-10 12:54:47, после
|
||||
-- revival'а на GET её created_at стал 2026-08-11 04:30:03 — «дата обращения»
|
||||
-- клиента (печатается в /history и в схеме AggregatedEstimate.created_at,
|
||||
-- см. app/schemas/trade_in.py:317-318 «для метки «отчёт от DD.MM» в UI»)
|
||||
-- подменилась датой служебного пересчёта. Заодно ломался ORDER BY created_at
|
||||
-- DESC в GET /history — оживлённая старая заявка выпрыгивала в начало списка.
|
||||
--
|
||||
-- Фикс (app/api/v1/trade_in.py): created_at исключён из UPDATE SET revival'а,
|
||||
-- исходная дата больше не трогается. Момент, когда revival РЕАЛЬНО пересчитал
|
||||
-- строку (не просто "попытался" — revival_attempted_at из 255 ставится на
|
||||
-- claim'е ДО вызова estimate_quality(), в том числе при throttle-проигрыше и
|
||||
-- при неудачном пересчёте), нужен для аудита отдельно — новая колонка.
|
||||
--
|
||||
-- ── IDEMPOTENCY ───────────────────────────────────────────────────────────
|
||||
-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: NULL = либо
|
||||
-- строка живая и revival никогда успешно не пересчитывал, либо запись
|
||||
-- создана до этой колонки.
|
||||
--
|
||||
-- Dependencies: 255_trade_in_estimates_revival_relaxations.sql. Apply after: 255_*.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS revival_completed_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.revival_completed_at IS
|
||||
'Момент УСПЕШНОГО пересчёта «мёртвой» (median_price<=0/NULL) строки '
|
||||
'revival''ом (app/api/v1/trade_in.py::_try_revive_dead_estimate) — '
|
||||
'выставляется, когда пересчёт реально записал новые значения в строку. '
|
||||
'Отличается от revival_attempted_at (255): тот ставится на claim''е ДО '
|
||||
'вызова estimate_quality() и фиксирует ЛЮБУЮ попытку (включая throttled-'
|
||||
'проигрыш гонки и неудачный пересчёт), этот — только успех. created_at '
|
||||
'строки при этом НЕ меняется (исходная дата обращения клиента, печатается '
|
||||
'в /history) — см. fix/tradein-created 2026-08-11.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
-- 257_listings_backfill_yandex_source_url.sql
|
||||
-- Разовое лечение source_url у yandex-строк, чей адрес ведёт на сайт застройщика (#2838).
|
||||
--
|
||||
-- WHY. `source_url` пишется ТОЛЬКО при вставке: его нет ни в `ON CONFLICT DO UPDATE`,
|
||||
-- ни в reconcile-UPDATE (`scraper_kit/base.py`). Поэтому починка продюсера (#2235,
|
||||
-- `_canonical_source_url` в providers/yandex/serp.py) вылечила только НОВЫЕ строки,
|
||||
-- а миграция 164 — только те легаси, чей URL ДЕЛИЛИ несколько строк (её CTE `shared`
|
||||
-- искал дубли URL, а не непарсимость адреса). Строки с УНИКАЛЬНОЙ ссылкой на карточку
|
||||
-- застройщика не попали ни туда, ни туда и носят адрес, замороженный в момент вставки.
|
||||
-- Цена простоя: `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/`
|
||||
-- и без него возвращает None ещё ДО обращения к HTML — такие строки не обогащаются
|
||||
-- никогда, а `yandex_address_backfill` вдобавок ходит по ним на чужие сайты.
|
||||
-- PR #2838 научил ОЧЕРЕДЬ адресовать их по source_id; колонку чинит эта миграция.
|
||||
--
|
||||
-- ЗАМЕР НА ПРОДЕ 2026-08-12 (SELECT-only, не «по описанию из issue»):
|
||||
--
|
||||
-- source='yandex' AND source_url !~ '/offer/[0-9]+' строк
|
||||
-- ------------------------------------------------------- -----
|
||||
-- всего 3535
|
||||
-- из них source_id ~ '^[0-9]+$' (адрес восстановим) 3535
|
||||
-- из них source_id NULL/нечисловой (нечем адресовать) 0
|
||||
-- из них source_url IS NULL 0
|
||||
-- из них is_active 3522
|
||||
--
|
||||
-- хосты: macroserver.ru 912, macro.sbercrm.com 440, akademicheskiy.org 356,
|
||||
-- na100.pro 331, strana.com 318, ten-stroy.ru 189, ecologica.ru 167,
|
||||
-- xn--b1agbiqxpe4gxa.xn--p1ai 145, sinara-development.ru 114,
|
||||
-- ekaterinburg.razum.life 111, www.lsr.ru 81, samolet.ru 68, хвост.
|
||||
--
|
||||
-- Множество ЗАМКНУТО (важно: значит список ниже не устареет между PR и деплоем):
|
||||
-- самая свежая его строка — id 2583989, после неё вставлено 6892 yandex-строк,
|
||||
-- и НИ ОДНА в множество не попала — продюсер после #2235 таких адресов не пишет.
|
||||
-- Множество может только уменьшаться (удаление строк), не расти.
|
||||
--
|
||||
-- ФОРМА АДРЕСА — ДОСЛОВНО ПРОДЮСЕРНАЯ, не изобретённая здесь.
|
||||
-- scraper_kit/providers/yandex/serp.py::_canonical_source_url:
|
||||
-- return f"https://realty.yandex.ru/offer/{offer_id}/" # ветка «url не ведёт на realty.yandex»
|
||||
-- тот же литерал живёт в app/tasks/yandex_detail_backfill.py::CANONICAL_URL_SQL
|
||||
-- "'https://realty.yandex.ru/offer/' || source_id || '/'"
|
||||
-- и та же формула стоит в миграции 164. Ниже — она же, посимвольно;
|
||||
-- tests/test_migration_257_yandex_source_url_backfill.py держит это сцепление
|
||||
-- (сравнивает выражение из ЭТОГО файла с CANONICAL_URL_SQL, который, в свою
|
||||
-- очередь, уже сверен с продюсером в test_yandex_detail_backfill.py).
|
||||
-- Условия отбора — те же строковые константы OFFER_URL_PATTERN ('/offer/[0-9]+')
|
||||
-- и OFFER_ID_PATTERN ('^[0-9]+$'), которыми очередь #2838 отбирает эти же строки.
|
||||
--
|
||||
-- КОЛЛИЗИЙ НЕТ — ПРОВЕРЕНО, А НЕ ЗАЯВЛЕНО:
|
||||
-- * новый URL, уже занятый ДРУГОЙ строкой listings (любой источник): 0;
|
||||
-- * два кандидата с одинаковым новым URL внутри самого множества: 0
|
||||
-- (source_id уникален по constraint 133_listings_uq_source_source_id.sql);
|
||||
-- * после UPDATE в dry-run дублей source_url среди ВСЕХ yandex-строк: 0.
|
||||
--
|
||||
-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-12, тем же телом, что ниже):
|
||||
-- UPDATE 3535 · осталось непарсимых 0 · дублей source_url у yandex 0
|
||||
-- счётчики очереди #2838 после: url_from_offer_id 3535 → 0, unenrichable_pending 0
|
||||
-- yandex_address_backfill (кандидаты 5545): с непарсимым URL 1777 → 0
|
||||
--
|
||||
-- было → стало (10 строк, взяты по id DESC):
|
||||
-- 2583989 https://ekaterinburg.razum.life/flats/7228451 → .../offer/7087563582288224501/
|
||||
-- 2583986 https://sinara-development.ru/#/macrocatalog/… → .../offer/6990986462977811151/
|
||||
-- 2583940 https://www.an-nks.ru/catalog/38/4241/ → .../offer/7567121745684380093/
|
||||
-- 2583938 https://ten-stroy.ru/parametric/osnovinskiye-… → .../offer/5227777077487552091/
|
||||
-- 2583931 https://ekaterinburg.razum.life/flats/7225097 → .../offer/7087563582288131414/
|
||||
-- 2583929 https://samolet.ru/ekaterinburg/project/payer/… → .../offer/1827858605736006765/
|
||||
-- 2583923 https://samolet.ru/ekaterinburg/project/auruum/… → .../offer/2812449412758148821/
|
||||
-- 2583917 http://na100.pro/go.php?link=uRy09YqcU9pqegrRc… → .../offer/895871493295352384/
|
||||
-- 2583891 https://macroserver.ru/id/8783797/ → .../offer/6378643964567459685/
|
||||
-- 2583878 https://strana.com/ekb/uralskij-sad/flats/14986370→ .../offer/6591508026346911121/
|
||||
-- (префикс «стало» везде один: https://realty.yandex.ru/offer/<source_id>/)
|
||||
-- Живая проба прод-трактом 2026-08-12 (тот же прокси, curl_cffi chrome120, тот же
|
||||
-- parse) по таким восстановленным адресам: 6 из 6 — HTTP 200 и parse OK.
|
||||
--
|
||||
-- ОБРАТИМОСТЬ — ТАБЛИЦА, А НЕ ПРЕДИКАТ, И ВОТ ПОЧЕМУ (проверено, а не предположено).
|
||||
-- Ход «прежнее значение уже где-то лежит» (как в 254, где им был
|
||||
-- raw_payload->>'address') здесь НЕ работает:
|
||||
-- * listings.raw_payload ключа 'url' НЕ содержит: 0 из 3535. Ключи там
|
||||
-- ceiling_height, kitchen_area_m2, offer_id, page_param, raw_building_type,
|
||||
-- site_name — адреса нет ни под одним именем;
|
||||
-- * listings.house_url / newbuilding_url у всех 3535 = NULL;
|
||||
-- * listing_sources.source_url (тоже insert-only: в его ON CONFLICT DO UPDATE
|
||||
-- source_url отсутствует) хранит прежний адрес у 3529 из 3535 — но восстановить
|
||||
-- ПО НЕМУ нельзя точно: самоидентифицирующий предикат «ls.source_url не
|
||||
-- realty.yandex» берёт 4832 строки, из которых наши только 3529; сузив его
|
||||
-- уникальностью URL, всё равно получаем 3529 наших + 9 чужих (это строки,
|
||||
-- чей listings.source_url канонизировала ещё 164 — вернуть им URL застройщика
|
||||
-- значило бы отменить чужую починку). Плюс 6 наших строк не покрыты вовсе
|
||||
-- (у 3 нет строки в listing_sources, у 3 там уже канонический адрес).
|
||||
-- Поэтому прежние значения сохраняются ЯВНО и поимённо — таблица ниже. Откат:
|
||||
--
|
||||
-- UPDATE listings l
|
||||
-- SET source_url = b.old_source_url
|
||||
-- FROM yandex_source_url_backfill_257 b
|
||||
-- WHERE l.id = b.listing_id
|
||||
-- AND l.source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/';
|
||||
--
|
||||
-- (второе условие — чтобы откат не затирал адрес, который к тому моменту записал
|
||||
-- кто-то другой; повторный прогон отката безвреден). Таблица маленькая
|
||||
-- (3535 строк) и одноразовая: когда откат больше не нужен, её можно просто
|
||||
-- удалить — на приложение она не влияет, читателей у неё нет.
|
||||
--
|
||||
-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
|
||||
-- * не трогает `ON CONFLICT DO UPDATE` / reconcile в scraper_kit/base.py —
|
||||
-- дописывание source_url в апсерт это отдельное решение (прецедент #2818: там
|
||||
-- COALESCE в апсерте так же намеренно не трогали);
|
||||
-- * не трогает listing_sources.source_url — читателей у колонки нет (grep по
|
||||
-- app/: единственное обращение — тот самый INSERT), а в ней остаётся живая
|
||||
-- история того, что отдал gate-API;
|
||||
-- * не трогает строки с source_url IS NULL — их 0, а не «на всякий случай»
|
||||
-- (`!~` на NULL даёт NULL, такие строки предикат и так не берёт);
|
||||
-- * не гасит и не удаляет ни одной строки: меняется ровно одна колонка.
|
||||
--
|
||||
-- Dependencies: 002_core_tables.sql (listings), 133_listings_uq_source_source_id.sql
|
||||
-- (уникальность source_id, на ней держится «коллизий 0»), 164 (та же формула).
|
||||
-- Идемпотентность: по построению. Второй прогон видит 0 строк с непарсимым URL →
|
||||
-- UPDATE и INSERT берут пустое множество; CREATE TABLE IF NOT EXISTS + ON CONFLICT
|
||||
-- DO NOTHING делают повтор безопасным и при частичном откате.
|
||||
-- lock_timeout: блокирующего DDL здесь нет (гейт check-migration-lock-timeout.py
|
||||
-- про CREATE TABLE молчит), но UPDATE по «горячей» listings берёт ROW EXCLUSIVE, и
|
||||
-- ждать его выдачи за чужой ACCESS EXCLUSIVE-сессией — ровно та очередь перед
|
||||
-- приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт громко.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yandex_source_url_backfill_257 (
|
||||
listing_id bigint PRIMARY KEY,
|
||||
old_source_url text NOT NULL,
|
||||
changed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE yandex_source_url_backfill_257 IS
|
||||
'Прежние (застройщицкие) listings.source_url, переписанные миграцией 257 (#2838). '
|
||||
'Только для отката; читателей в приложении нет, удаляется без последствий.';
|
||||
|
||||
INSERT INTO yandex_source_url_backfill_257 (listing_id, old_source_url)
|
||||
SELECT id, source_url
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND source_url !~ '/offer/[0-9]+'
|
||||
AND source_id ~ '^[0-9]+$'
|
||||
ON CONFLICT (listing_id) DO NOTHING;
|
||||
|
||||
UPDATE listings
|
||||
SET source_url = 'https://realty.yandex.ru/offer/' || source_id || '/'
|
||||
WHERE source = 'yandex'
|
||||
AND source_url !~ '/offer/[0-9]+'
|
||||
AND source_id ~ '^[0-9]+$';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
-- 258_houses_imv_transient_attempts.sql
|
||||
-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674).
|
||||
--
|
||||
-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь
|
||||
-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию
|
||||
-- 'pending'), и за всю историю (41 прогон, 26.06–11.08) ни один не был запущен
|
||||
-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из
|
||||
-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All
|
||||
-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой
|
||||
-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе
|
||||
-- 108 из 108 POST /fetch-json = 200).
|
||||
--
|
||||
-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически
|
||||
-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из
|
||||
-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт
|
||||
-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд.
|
||||
--
|
||||
-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error',
|
||||
-- прежние разрезы по imv_status/imv_error_reason работают как работали, а
|
||||
-- «застряли окончательно» — это
|
||||
-- SELECT count(*) FROM houses
|
||||
-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3;
|
||||
--
|
||||
-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по
|
||||
-- imv_status IN ('pending','transient_error') с сортировкой по
|
||||
-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на
|
||||
-- выборке в тысячи строк.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE houses
|
||||
ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN houses.imv_transient_attempts IS
|
||||
'Сколько раз подряд домовая IMV-оценка падала в transient_error. '
|
||||
'Растёт только на transient_error, обнуляется успехом. '
|
||||
'>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
},
|
||||
"low": {
|
||||
"coverage_pct": 82.09,
|
||||
"mape_pct": 13.2,
|
||||
"mape_pct": 12.67,
|
||||
"n": 276,
|
||||
"n_covered": 220
|
||||
},
|
||||
|
|
@ -26,22 +26,22 @@
|
|||
],
|
||||
"expected_sold": {
|
||||
"overall": {
|
||||
"mape_pct": 13.18,
|
||||
"median_bias_pct": -3.71,
|
||||
"mape_pct": 12.63,
|
||||
"median_bias_pct": -3.74,
|
||||
"n": 269,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -16.11,
|
||||
"p75_pct": 8.92
|
||||
"p25_pct": -15.17,
|
||||
"p75_pct": 8.67
|
||||
},
|
||||
"per_rooms": {
|
||||
"0": {
|
||||
"label": "студия",
|
||||
"mape_pct": 19.38,
|
||||
"median_bias_pct": 18.1,
|
||||
"mape_pct": 16.96,
|
||||
"median_bias_pct": 16.96,
|
||||
"n": 35,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": 1.5,
|
||||
"p75_pct": 34.32
|
||||
"p75_pct": 38.82
|
||||
},
|
||||
"1": {
|
||||
"label": "1к",
|
||||
|
|
@ -50,50 +50,50 @@
|
|||
"n": 93,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -14.43,
|
||||
"p75_pct": 6.98
|
||||
"p75_pct": 8.15
|
||||
},
|
||||
"2": {
|
||||
"label": "2к",
|
||||
"mape_pct": 18.26,
|
||||
"median_bias_pct": -12.2,
|
||||
"mape_pct": 17.39,
|
||||
"median_bias_pct": -11.71,
|
||||
"n": 74,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -24.35,
|
||||
"p25_pct": -23.07,
|
||||
"p75_pct": -0.36
|
||||
},
|
||||
"3": {
|
||||
"label": "3к",
|
||||
"mape_pct": 9.34,
|
||||
"median_bias_pct": -3.08,
|
||||
"mape_pct": 7.79,
|
||||
"median_bias_pct": -4.05,
|
||||
"n": 43,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -10.26,
|
||||
"p75_pct": 4.68
|
||||
"p75_pct": 3.82
|
||||
},
|
||||
"4": {
|
||||
"label": "4+",
|
||||
"mape_pct": 16.3,
|
||||
"median_bias_pct": 3.34,
|
||||
"mape_pct": 16.38,
|
||||
"median_bias_pct": -1.32,
|
||||
"n": 24,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -14.91,
|
||||
"p75_pct": 15.11
|
||||
"p75_pct": 15.41
|
||||
}
|
||||
},
|
||||
"per_segment": {
|
||||
"бизнес": {
|
||||
"mape_pct": 14.65,
|
||||
"mape_pct": 13.65,
|
||||
"median_bias_pct": -10.54,
|
||||
"n": 46,
|
||||
"p25_pct": -22.93,
|
||||
"p25_pct": -27.15,
|
||||
"p75_pct": -1.31
|
||||
},
|
||||
"комфорт": {
|
||||
"mape_pct": 11.8,
|
||||
"median_bias_pct": -4.61,
|
||||
"mape_pct": 10.14,
|
||||
"median_bias_pct": -5.01,
|
||||
"n": 101,
|
||||
"p25_pct": -17.07,
|
||||
"p75_pct": 6.04
|
||||
"p25_pct": -15.49,
|
||||
"p75_pct": 4.37
|
||||
},
|
||||
"премиум": {
|
||||
"mape_pct": 68.92,
|
||||
|
|
@ -103,11 +103,11 @@
|
|||
"p75_pct": -68.92
|
||||
},
|
||||
"эконом": {
|
||||
"mape_pct": 13.71,
|
||||
"median_bias_pct": 2.54,
|
||||
"mape_pct": 14.2,
|
||||
"median_bias_pct": 3.33,
|
||||
"n": 115,
|
||||
"p25_pct": -9.8,
|
||||
"p75_pct": 25.5
|
||||
"p25_pct": -8.69,
|
||||
"p75_pct": 27.3
|
||||
},
|
||||
"элит": {
|
||||
"mape_pct": 33.2,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
"""Адрес Авито не должен утаскивать за собой рейтинг дома и число отзывов.
|
||||
|
||||
Прод 2026-08-10: 1 123 активных объявления Авито с адресом вида
|
||||
«ул. Ткачей,17·5,0 · 4 отзыва», и у 1 123 из 1 123 нет координат — доля 100%.
|
||||
У объявлений с чистым адресом координаты есть у 4 766. Хвост появился 27.07.2026,
|
||||
когда Авито начал рендерить рейтинг внутри того же <p> в item-location.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from scraper_kit.providers.avito.serp import _clean_address
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
# Живые строки с прода — рейтинг + счётчик отзывов.
|
||||
("Авиационная ул.,10·4,9 · 11 отзывов", "Авиационная ул.,10"),
|
||||
("ул. 8 Марта,204Г/2·4,3 · 3 отзыва", "ул. 8 Марта,204Г/2"),
|
||||
("ул. Академика Шварца,18к2·1 отзыв", "ул. Академика Шварца,18к2"),
|
||||
# Только счётчик, без рейтинга.
|
||||
("ул. Фурманова,59·2 отзыва", "ул. Фурманова,59"),
|
||||
# Пробелы вокруг разделителя.
|
||||
("ул. Ткачей, 17 · 5,0 · 4 отзыва", "ул. Ткачей, 17"),
|
||||
],
|
||||
)
|
||||
def test_rating_tail_stripped(raw: str, expected: str) -> None:
|
||||
assert _clean_address(raw) == expected
|
||||
|
||||
|
||||
def test_district_marker_after_dot_survives() -> None:
|
||||
"""Режем «·» только перед цифрой — район за точкой остаётся на месте.
|
||||
|
||||
Иначе фикс рейтинга съел бы разделитель района, который _deglue_house_marker
|
||||
намеренно восстанавливает (#1773).
|
||||
"""
|
||||
assert (
|
||||
_clean_address("улица Вильгельма де Геннина, 59 · р-н Академический")
|
||||
== "улица Вильгельма де Геннина, 59 · р-н Академический"
|
||||
)
|
||||
|
||||
|
||||
def test_clean_address_unchanged() -> None:
|
||||
assert _clean_address("с. Новоалексеевское, ул. 8 Марта,35") == (
|
||||
"с. Новоалексеевское, ул. 8 Марта,35"
|
||||
)
|
||||
|
||||
|
||||
def test_existing_noise_still_stripped() -> None:
|
||||
"""Регрессия: старые хвосты (CSS/метро/«от N мин.») режутся как раньше."""
|
||||
assert _clean_address("ул. Токарей, 56к1Площадь 1905 года.css-39hgr0{fill:red}") == (
|
||||
"ул. Токарей, 56к1"
|
||||
)
|
||||
assert _clean_address("ул. Малышева, 1.css-xxx{color:blue}") == "ул. Малышева, 1"
|
||||
# #1773: слипшийся маркер района по-прежнему расклеивается.
|
||||
assert _clean_address("ул. Евгения Савкова, 29р-н Академический") == (
|
||||
"ул. Евгения Савкова, 29, р-н Академический"
|
||||
)
|
||||
332
tradein-mvp/backend/tests/services/test_proxy_egress.py
Normal file
332
tradein-mvp/backend/tests/services/test_proxy_egress.py
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616).
|
||||
|
||||
Покрытие БЕЗ live-сети/БД: FakeSession эмулирует ДВА запроса над scrape_proxies +
|
||||
scrape_proxy_source_bans — основной SELECT кандидата (`_pick_candidate`) и, только
|
||||
когда он вернул пусто, diagnostic-агрегат (`_diagnose_no_candidate`) для различения
|
||||
"пул пуст" от "пул не пуст, все отсеяны".
|
||||
|
||||
- выбирается небанненный прокси;
|
||||
- забаненный ДЛЯ ИСТОЧНИКА не выбирается;
|
||||
- забаненный для ДРУГОГО источника — выбирается (суть #2600 п.2: Авито банит IP,
|
||||
Яндекс через тот же IP ходит чисто);
|
||||
- при нескольких кандидатах — меньший consecutive_fails выигрывает;
|
||||
- при равном consecutive_fails — более свежий last_ok_at выигрывает;
|
||||
- пул ПУСТ (0 строк вообще) → легитимный fallback на settings.scraper_proxy_url,
|
||||
logger.WARNING с текстом «пуст»;
|
||||
- пул пуст И SCRAPER_PROXY_URL не задан → None (прямое подключение), WARNING;
|
||||
- пул НЕ пуст, но все кандидаты забанены/нездоровы/выключены → ProxyPoolExhaustedError
|
||||
(fail-closed, #2616), logger.ERROR с разбивкой — env НЕ используется, даже если
|
||||
задан;
|
||||
- тексты "пуст" и "все отсеяны" в логах РАЗНЫЕ (не перепутать при чтении логов/алертов).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||
|
||||
# ── stateful fake session (эмулирует ОБА read-only запроса proxy_egress) ──────────
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows: list[dict[str, Any]]):
|
||||
self._rows = rows
|
||||
|
||||
def mappings(self) -> _FakeResult:
|
||||
return self
|
||||
|
||||
def fetchone(self) -> dict[str, Any] | None:
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, rows: list[dict[str, Any]], bans: list[dict[str, Any]] | None = None):
|
||||
self.rows = rows
|
||||
self.bans = bans or []
|
||||
|
||||
def _has_active_ban(self, pid: int, source: str) -> bool:
|
||||
return any(
|
||||
b["proxy_id"] == pid and b["source"] == source and b["banned_until"] > datetime.now(UTC)
|
||||
for b in self.bans
|
||||
)
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
sql = str(stmt)
|
||||
p = params or {}
|
||||
assert "FROM scrape_proxies" in sql
|
||||
assert "scrape_proxy_source_bans" in sql
|
||||
max_fails = p["max_fails"]
|
||||
source = p["source"]
|
||||
|
||||
if "pool_total" in sql: # _diagnose_no_candidate aggregate
|
||||
unhealthy = sum(
|
||||
1 for r in self.rows if not r["enabled"] or r["consecutive_fails"] >= max_fails
|
||||
)
|
||||
banned = sum(
|
||||
1
|
||||
for r in self.rows
|
||||
if r["enabled"]
|
||||
and r["consecutive_fails"] < max_fails
|
||||
and self._has_active_ban(r["id"], source)
|
||||
)
|
||||
return _FakeResult(
|
||||
[
|
||||
{
|
||||
"pool_total": len(self.rows),
|
||||
"unhealthy_or_disabled": unhealthy,
|
||||
"banned_for_source": banned,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# _pick_candidate primary SELECT
|
||||
cands = [
|
||||
r
|
||||
for r in self.rows
|
||||
if r["enabled"]
|
||||
and r["consecutive_fails"] < max_fails
|
||||
and not self._has_active_ban(r["id"], source)
|
||||
]
|
||||
cands.sort(
|
||||
key=lambda r: (
|
||||
r["consecutive_fails"],
|
||||
-(r["last_ok_at"] or datetime.min.replace(tzinfo=UTC)).timestamp(),
|
||||
r["id"],
|
||||
)
|
||||
)
|
||||
return _FakeResult([dict(r) for r in cands[:1]])
|
||||
|
||||
|
||||
def _proxy(
|
||||
id_: int,
|
||||
*,
|
||||
enabled: bool = True,
|
||||
consecutive_fails: int = 0,
|
||||
last_ok_at: datetime | None = None,
|
||||
label: str | None = None,
|
||||
url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": id_,
|
||||
"url": url or f"http://user:pass@proxy{id_}.local:8080",
|
||||
"label": label,
|
||||
"enabled": enabled,
|
||||
"consecutive_fails": consecutive_fails,
|
||||
"last_ok_at": last_ok_at,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_fallback_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Изолируем тесты от реального прод-значения ENV (если случайно унаследовано).
|
||||
monkeypatch.setattr(settings, "scraper_proxy_url_env", None)
|
||||
|
||||
|
||||
def test_picks_healthy_unbanned_proxy() -> None:
|
||||
db = FakeSession([_proxy(1, url="http://u:p@good.local:8080")])
|
||||
result = resolve_proxy_url(db, "avito")
|
||||
assert result == "http://u:p@good.local:8080"
|
||||
|
||||
|
||||
def test_banned_for_source_raises_pool_exhausted() -> None:
|
||||
"""Пул НЕ пуст (1 узел), но он забанен для ИМЕННО этого источника — fail-closed,
|
||||
НЕ fallback на env (#2616)."""
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[_proxy(1, url="http://u:p@banned.local:8080")],
|
||||
bans=[{"proxy_id": 1, "source": "avito", "banned_until": now + timedelta(hours=6)}],
|
||||
)
|
||||
with pytest.raises(ProxyPoolExhaustedError) as exc_info:
|
||||
resolve_proxy_url(db, "avito")
|
||||
assert exc_info.value.source == "avito"
|
||||
assert exc_info.value.pool_total == 1
|
||||
assert exc_info.value.banned_for_source == 1
|
||||
assert exc_info.value.unhealthy_or_disabled == 0
|
||||
|
||||
|
||||
def test_banned_for_other_source_still_picked() -> None:
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[_proxy(1, url="http://u:p@shared.local:8080")],
|
||||
bans=[{"proxy_id": 1, "source": "cian", "banned_until": now + timedelta(hours=6)}],
|
||||
)
|
||||
# Забанен только для cian — для avito остаётся первосортным кандидатом.
|
||||
result = resolve_proxy_url(db, "avito")
|
||||
assert result == "http://u:p@shared.local:8080"
|
||||
|
||||
|
||||
def test_expired_ban_does_not_block() -> None:
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[_proxy(1, url="http://u:p@revived.local:8080")],
|
||||
bans=[{"proxy_id": 1, "source": "avito", "banned_until": now - timedelta(hours=1)}],
|
||||
)
|
||||
result = resolve_proxy_url(db, "avito")
|
||||
assert result == "http://u:p@revived.local:8080"
|
||||
|
||||
|
||||
def test_tiebreak_lower_consecutive_fails_wins() -> None:
|
||||
db = FakeSession(
|
||||
[
|
||||
_proxy(1, consecutive_fails=2, url="http://u:p@flaky.local:8080"),
|
||||
_proxy(2, consecutive_fails=0, url="http://u:p@solid.local:8080"),
|
||||
]
|
||||
)
|
||||
result = resolve_proxy_url(db, "yandex")
|
||||
assert result == "http://u:p@solid.local:8080"
|
||||
|
||||
|
||||
def test_tiebreak_fresher_last_ok_at_wins_on_equal_fails() -> None:
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[
|
||||
_proxy(
|
||||
1,
|
||||
consecutive_fails=0,
|
||||
last_ok_at=now - timedelta(hours=2),
|
||||
url="http://u:p@stale.local:8080",
|
||||
),
|
||||
_proxy(
|
||||
2,
|
||||
consecutive_fails=0,
|
||||
last_ok_at=now - timedelta(minutes=5),
|
||||
url="http://u:p@fresh.local:8080",
|
||||
),
|
||||
]
|
||||
)
|
||||
result = resolve_proxy_url(db, "yandex")
|
||||
assert result == "http://u:p@fresh.local:8080"
|
||||
|
||||
|
||||
def test_empty_pool_falls_back_to_env_with_warning(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Сценарий 1 (легитимный): 0 строк в scrape_proxies вообще — dev/staging без
|
||||
БД-пула. fallback на env разрешён."""
|
||||
monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://static-fallback.local:9999")
|
||||
db = FakeSession([])
|
||||
with caplog.at_level("WARNING"):
|
||||
result = resolve_proxy_url(db, "cian")
|
||||
assert result == "http://static-fallback.local:9999"
|
||||
warnings = [rec for rec in caplog.records if rec.levelname == "WARNING"]
|
||||
assert any("пуст" in rec.message.lower() for rec in warnings)
|
||||
assert not any(rec.levelname == "ERROR" for rec in caplog.records)
|
||||
|
||||
|
||||
def test_empty_pool_and_no_env_returns_none_with_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
db = FakeSession([])
|
||||
with caplog.at_level("WARNING"):
|
||||
result = resolve_proxy_url(db, "domclick")
|
||||
assert result is None
|
||||
assert any("прямым подключением" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_all_candidates_banned_raises_pool_exhausted_not_env_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Сценарий 2 (инцидент 2026-08-10): пул НЕ пуст (2 узла), оба забанены для
|
||||
source — fail-closed. env ЗАДАН, но НЕ используется — это и есть сама суть фикса."""
|
||||
monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://static-fallback.local:9999")
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[_proxy(1), _proxy(2)],
|
||||
bans=[
|
||||
{"proxy_id": 1, "source": "cian", "banned_until": now + timedelta(hours=6)},
|
||||
{"proxy_id": 2, "source": "cian", "banned_until": now + timedelta(hours=6)},
|
||||
],
|
||||
)
|
||||
with caplog.at_level("WARNING"):
|
||||
with pytest.raises(ProxyPoolExhaustedError) as exc_info:
|
||||
resolve_proxy_url(db, "cian")
|
||||
assert exc_info.value.pool_total == 2
|
||||
assert exc_info.value.banned_for_source == 2
|
||||
assert exc_info.value.unhealthy_or_disabled == 0
|
||||
errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
|
||||
assert any("fail-closed" in rec.message.lower() for rec in errors)
|
||||
# НЕ должно быть "обход пула" / "static-fallback" в логах — env не тронут.
|
||||
assert not any("static-fallback" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_exhausted_and_empty_pool_log_texts_are_distinct(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Регрессия на замечание ревью: "пуст" и "все отсеяны" — РАЗНЫЕ формулировки И
|
||||
разные уровни (WARNING vs ERROR), иначе их нельзя различить в логах/алертах."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
resolve_proxy_url(FakeSession([]), "avito")
|
||||
empty_pool_messages = {rec.levelname: rec.message for rec in caplog.records}
|
||||
caplog.clear()
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
with pytest.raises(ProxyPoolExhaustedError):
|
||||
resolve_proxy_url(
|
||||
FakeSession(
|
||||
[_proxy(1)],
|
||||
bans=[
|
||||
{"proxy_id": 1, "source": "avito", "banned_until": now + timedelta(hours=6)}
|
||||
],
|
||||
),
|
||||
"avito",
|
||||
)
|
||||
exhausted_messages = {rec.levelname: rec.message for rec in caplog.records}
|
||||
|
||||
assert "ERROR" not in empty_pool_messages
|
||||
assert "ERROR" in exhausted_messages
|
||||
assert empty_pool_messages.get("WARNING") != exhausted_messages.get("ERROR")
|
||||
|
||||
|
||||
def test_disabled_proxy_raises_pool_exhausted() -> None:
|
||||
db = FakeSession([_proxy(1, enabled=False)])
|
||||
with pytest.raises(ProxyPoolExhaustedError) as exc_info:
|
||||
resolve_proxy_url(db, "avito")
|
||||
assert exc_info.value.pool_total == 1
|
||||
assert exc_info.value.unhealthy_or_disabled == 1
|
||||
assert exc_info.value.banned_for_source == 0
|
||||
|
||||
|
||||
def test_unhealthy_proxy_raises_pool_exhausted() -> None:
|
||||
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
|
||||
|
||||
db = FakeSession([_proxy(1, consecutive_fails=MAX_CONSECUTIVE_FAILS)])
|
||||
with pytest.raises(ProxyPoolExhaustedError) as exc_info:
|
||||
resolve_proxy_url(db, "avito")
|
||||
assert exc_info.value.unhealthy_or_disabled == 1
|
||||
|
||||
|
||||
class _RaisingSession:
|
||||
"""db, у которой execute() всегда роняет (DB недоступна) — резолвер не может
|
||||
подтвердить exhaustion, лечит это КАК пустой пул (см. resolve_proxy_url docstring)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rollback_called = False
|
||||
|
||||
def execute(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise RuntimeError("connection refused")
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rollback_called = True
|
||||
|
||||
|
||||
def test_db_error_treated_as_empty_pool_falls_back_with_warning(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://static-fallback.local:9999")
|
||||
db = _RaisingSession()
|
||||
with caplog.at_level("WARNING"):
|
||||
result = resolve_proxy_url(db, "avito") # type: ignore[arg-type]
|
||||
assert result == "http://static-fallback.local:9999"
|
||||
assert db.rollback_called
|
||||
assert not any(rec.levelname == "ERROR" for rec in caplog.records)
|
||||
|
|
@ -70,3 +70,12 @@ tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_
|
|||
# из job-контейнера всё равно не проходит (run 6977, connection refused).
|
||||
tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted
|
||||
tests/test_migration_numbering.py::test_new_migration_takes_a_free_number
|
||||
# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
|
||||
# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
|
||||
# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут
|
||||
# по-настоящему (Postgres + схема из data/sql) — там прогон и был красным на
|
||||
# origin/main и зелёным на ветке. Статическая половина файла
|
||||
# (test_retry_queue_is_not_bound_to_only_status) БД не требует и идёт в обоих лэйнах.
|
||||
tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses
|
||||
tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
|
||||
tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient
|
||||
|
|
|
|||
|
|
@ -81,6 +81,12 @@ _SETTINGS = "app.tasks.avito_detail_backfill.settings"
|
|||
_SHUTDOWN = "app.tasks.avito_detail_backfill.shutdown_requested"
|
||||
_BUILD_WARM = "app.tasks.avito_detail_backfill.build_warmed_session"
|
||||
_RESEARCH = "app.tasks.avito_detail_backfill.research_in_session"
|
||||
# #2825: settings.scraper_proxy_url в "elif not use_curl" (legacy curl_cffi) branch
|
||||
# заменён на resolve_proxy_url(db, "avito") (пул scrape_proxies с учётом банов,
|
||||
# fallback на settings.scraper_proxy_url внутри app.services.proxy_egress) -- эти
|
||||
# тесты про block/ban/rotate-логику, не про подбор прокси (см.
|
||||
# tests/services/test_proxy_egress.py), поэтому мокаем сам резолвер.
|
||||
_RESOLVE_PROXY_URL = "app.tasks.avito_detail_backfill.resolve_proxy_url"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
|
|
@ -225,6 +231,7 @@ async def test_backfill_reports_ban_kind_of_the_blocks_it_saw(
|
|||
patch(_SESSION, return_value=AsyncMock()),
|
||||
patch(_SCRAPER, mock_scraper),
|
||||
patch(_RUNS, runs),
|
||||
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
|
||||
patch(_FETCH, AsyncMock(side_effect=exc_factory())),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
):
|
||||
|
|
@ -263,6 +270,7 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None:
|
|||
patch(_SESSION, return_value=AsyncMock()),
|
||||
patch(_SCRAPER, mock_scraper),
|
||||
patch(_RUNS, runs),
|
||||
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
|
||||
patch(_FETCH, mock_fetch),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
):
|
||||
|
|
@ -387,6 +395,7 @@ async def test_backfill_rotate_ip_called_on_each_block() -> None:
|
|||
patch(_SESSION, return_value=AsyncMock()),
|
||||
patch(_SCRAPER, mock_scraper),
|
||||
patch(_RUNS, runs),
|
||||
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
|
||||
patch(_FETCH, mock_fetch),
|
||||
patch(_SAVE, return_value=True),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
|
|
@ -664,6 +673,7 @@ async def test_backfill_listing_gone_marks_inactive_no_breaker() -> None:
|
|||
patch(_SESSION, return_value=AsyncMock()),
|
||||
patch(_SCRAPER, mock_scraper),
|
||||
patch(_RUNS, runs),
|
||||
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
|
||||
patch(_FETCH, mock_fetch),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ _wp_mock = MagicMock()
|
|||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment # noqa: E402
|
||||
from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
|
||||
NewbuildingEnrichment,
|
||||
NewbuildingSaveCounts,
|
||||
)
|
||||
|
||||
from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
|
||||
NewbuildingEnrichBackfillResult,
|
||||
|
|
@ -168,24 +171,37 @@ def _enrichment_with_everything(seed: int = 0) -> NewbuildingEnrichment:
|
|||
|
||||
|
||||
def _fake_save_newbuilding_enrichment(db, house_id, enrichment):
|
||||
"""Stand-in for the real saver: lands price_dynamics + reliability into FakeDB."""
|
||||
"""Stand-in for the real saver: lands price_dynamics + reliability into FakeDB.
|
||||
|
||||
Возвращает NewbuildingSaveCounts, как настоящий (#2807): вставку от обновления
|
||||
различает сам писатель — снаружи по таблице их не отличить (UPSERT по dim_key).
|
||||
"""
|
||||
inserted = updated = 0
|
||||
for p in enrichment.realty_valuation_chart:
|
||||
if p.get("price_per_sqm") is None:
|
||||
continue
|
||||
db.price_dynamics.add(
|
||||
(
|
||||
house_id,
|
||||
p["month_date"],
|
||||
"cian_realty_valuation",
|
||||
p.get("room_count", "all"),
|
||||
p.get("prices_type", "price"),
|
||||
p.get("period", "halfYear"),
|
||||
)
|
||||
key = (
|
||||
house_id,
|
||||
p["month_date"],
|
||||
"cian_realty_valuation",
|
||||
p.get("room_count", "all"),
|
||||
p.get("prices_type", "price"),
|
||||
p.get("period", "halfYear"),
|
||||
)
|
||||
if key in db.price_dynamics:
|
||||
updated += 1
|
||||
else:
|
||||
inserted += 1
|
||||
db.price_dynamics.add(key)
|
||||
reliability = 0
|
||||
for c in enrichment.reliability_checks:
|
||||
if c.get("check_name") or c.get("check_status"):
|
||||
db.reliability.append((house_id, "cian_nashdom"))
|
||||
reliability += 1
|
||||
db.commit()
|
||||
return NewbuildingSaveCounts(
|
||||
price_inserted=inserted, price_updated=updated, reliability_inserted=reliability
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -279,9 +295,10 @@ async def test_backfill_populates_all_three_tables() -> None:
|
|||
assert len(db.price_dynamics) == 2 # 1 chart point × 2 houses
|
||||
assert len(db.reliability) == 2
|
||||
assert len(db.reviews) == 4 # 2 reviews × 2 houses
|
||||
assert result.price_dynamics_rows == 2
|
||||
assert result.reliability_rows == 2
|
||||
assert result.review_rows == 4
|
||||
assert result.price_dynamics_inserted == 2
|
||||
assert result.price_dynamics_updated == 0
|
||||
assert result.reliability_inserted == 2
|
||||
assert result.review_upserted == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -544,7 +561,7 @@ async def test_run_wrapper_marks_done_and_passes_params(monkeypatch: pytest.Monk
|
|||
async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None):
|
||||
# on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем.
|
||||
seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec)
|
||||
return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_rows=2)
|
||||
return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_inserted=2)
|
||||
|
||||
monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake_backfill)
|
||||
monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import json
|
|||
import os
|
||||
import re
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
|
@ -25,6 +26,9 @@ sys.modules.setdefault("weasyprint", _wp_mock)
|
|||
import pytest # noqa: E402
|
||||
|
||||
from app.tasks.yandex_detail_backfill import ( # noqa: E402
|
||||
CANONICAL_URL_SQL,
|
||||
OFFER_ID_PATTERN,
|
||||
OFFER_URL_PATTERN,
|
||||
YandexDetailBackfillResult,
|
||||
run_yandex_detail_backfill,
|
||||
)
|
||||
|
|
@ -38,7 +42,11 @@ _PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
|
|||
_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
|
||||
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
|
||||
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
|
||||
_SETTINGS = "app.tasks.yandex_detail_backfill.settings"
|
||||
# #2825: settings.scraper_proxy_url заменён на resolve_proxy_url(db, "yandex")
|
||||
# (пул scrape_proxies с учётом банов, fallback на settings.scraper_proxy_url внутри
|
||||
# app.services.proxy_egress) — эти тесты про loop/parse-логику, не про подбор прокси
|
||||
# (см. tests/services/test_proxy_egress.py), поэтому мокаем сам резолвер.
|
||||
_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
|
@ -51,13 +59,21 @@ def _make_snapshot(n: int) -> list[dict]:
|
|||
]
|
||||
|
||||
|
||||
def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
|
||||
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а
|
||||
.scalar_one() — размер отброшенной (непарсимой) части очереди."""
|
||||
def _mock_db(
|
||||
snapshot: list[dict],
|
||||
unenrichable: int = 0,
|
||||
url_from_offer_id: int = 0,
|
||||
) -> MagicMock:
|
||||
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а .one() —
|
||||
остаток очереди вне снапшота, РАЗБИТЫЙ по причине (адрес восстановим из
|
||||
source_id / адресовать нечем)."""
|
||||
db = MagicMock()
|
||||
sel = MagicMock()
|
||||
sel.mappings.return_value.all.return_value = snapshot
|
||||
sel.scalar_one.return_value = unenrichable
|
||||
sel.one.return_value = SimpleNamespace(
|
||||
url_from_offer_id=url_from_offer_id,
|
||||
unenrichable_pending=unenrichable,
|
||||
)
|
||||
db.execute.return_value = sel
|
||||
return db
|
||||
|
||||
|
|
@ -83,10 +99,9 @@ def _make_session_ctx(get_side_effect) -> MagicMock:
|
|||
return session_cls, session
|
||||
|
||||
|
||||
def _mock_settings(proxy: str | None = "http://proxy:3128") -> MagicMock:
|
||||
s = MagicMock()
|
||||
s.scraper_proxy_url = proxy
|
||||
return s
|
||||
def _mock_resolve_proxy_url(proxy: str | None = "http://proxy:3128") -> MagicMock:
|
||||
"""Мок resolve_proxy_url(db, source) -> proxy, независимо от db/source."""
|
||||
return MagicMock(return_value=proxy)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -104,7 +119,7 @@ async def test_backfill_empty_snapshot_marks_done() -> None:
|
|||
with (
|
||||
patch(_ASYNC_SESSION, session_cls),
|
||||
patch(_RUNS, runs),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db, run_id=1, params={"batch_size": 10, "budget_sec": 60}
|
||||
|
|
@ -135,7 +150,7 @@ async def test_backfill_processes_snapshot_to_completion() -> None:
|
|||
patch(_RUNS, runs),
|
||||
patch(_SAVE, return_value=True),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db, run_id=2, params={"batch_size": 10, "budget_sec": 3600}
|
||||
|
|
@ -168,7 +183,7 @@ async def test_backfill_parse_none_abort_after_max_consecutive() -> None:
|
|||
patch(_PARSE, return_value=None),
|
||||
patch(_RUNS, runs),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db,
|
||||
|
|
@ -201,7 +216,7 @@ async def test_backfill_parse_none_resets_on_success() -> None:
|
|||
patch(_RUNS, runs),
|
||||
patch(_SAVE, return_value=True),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db,
|
||||
|
|
@ -230,7 +245,7 @@ async def test_backfill_non200_counts_as_fail_and_aborts() -> None:
|
|||
patch(_PARSE, return_value=MagicMock()),
|
||||
patch(_RUNS, runs),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db,
|
||||
|
|
@ -259,7 +274,7 @@ async def test_backfill_budget_guard_stops_loop() -> None:
|
|||
patch(_ASYNC_SESSION, session_cls),
|
||||
patch(_RUNS, runs),
|
||||
patch("app.tasks.yandex_detail_backfill.time.monotonic", side_effect=mono_values),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
await run_yandex_detail_backfill(db, run_id=6, params={"batch_size": 5, "budget_sec": 1})
|
||||
|
||||
|
|
@ -276,7 +291,7 @@ async def test_backfill_top_level_exception_marks_failed() -> None:
|
|||
|
||||
with (
|
||||
patch(_RUNS, runs),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="DB connection lost"):
|
||||
await run_yandex_detail_backfill(
|
||||
|
|
@ -305,7 +320,7 @@ async def test_backfill_fetch_exception_continues() -> None:
|
|||
patch(_RUNS, runs),
|
||||
patch(_SAVE, return_value=True),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db, run_id=8, params={"batch_size": 10, "budget_sec": 3600}
|
||||
|
|
@ -336,7 +351,7 @@ async def test_backfill_no_proxy_when_settings_none() -> None:
|
|||
patch(_RUNS, runs),
|
||||
patch(_SAVE, return_value=True),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
patch(_SETTINGS, _mock_settings(proxy=None)),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url(proxy=None)),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db, run_id=9, params={"batch_size": 10, "budget_sec": 3600}
|
||||
|
|
@ -488,7 +503,7 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
|
|||
with (
|
||||
patch(_ASYNC_SESSION, session_cls),
|
||||
patch(_RUNS, runs),
|
||||
patch(_SETTINGS, _mock_settings()),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db, run_id=42, params={"batch_size": 10, "budget_sec": 60}
|
||||
|
|
@ -507,3 +522,72 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
|
|||
|
||||
assert result.unenrichable_pending == 3535
|
||||
assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# «Непригодно» — ярлык, а не диагноз (2026-08-12)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_canonical_sql(offer_id: str) -> str:
|
||||
"""Считает CANONICAL_URL_SQL как строку: '||' — конкатенация, source_id — значение."""
|
||||
parts = [p.strip() for p in CANONICAL_URL_SQL.split("||")]
|
||||
return "".join(offer_id if p == "source_id" else p.strip("'") for p in parts)
|
||||
|
||||
|
||||
def test_recovered_url_equals_producer_canonical_form() -> None:
|
||||
"""Адрес, вычисленный из source_id, — тот же, что пишет продюсер, и парсер его примет.
|
||||
|
||||
Шесть прогонов подряд unenrichable_pending равнялся ровно 3535 не потому, что
|
||||
счётчик застыл (SELECT живой), а потому что множество замкнуто: продюсер после
|
||||
#2235 таких строк больше не создаёт, а выйти оттуда нельзя — source_url пишется
|
||||
только при вставке. Ярлык «непригодны» был неверен: у всех есть offerId, и по
|
||||
собранному из него URL страница парсится (прод-проба 2026-08-12, 6/6).
|
||||
|
||||
Сторож держит ровно это: формула восстановления в SQL не должна разъехаться с
|
||||
`_canonical_source_url` продюсера, а результат — пройти гейт парсера.
|
||||
"""
|
||||
from scraper_kit.providers.yandex.serp import _canonical_source_url
|
||||
|
||||
offer_id = "7416316697684470413" # реальный source_id прод-строки с macroserver.ru
|
||||
recovered = _render_canonical_sql(offer_id)
|
||||
|
||||
assert re.match(OFFER_ID_PATTERN, offer_id)
|
||||
assert re.search(OFFER_URL_PATTERN, recovered), recovered
|
||||
for stored_url, is_offer_url in _PROD_QUEUE_HEAD:
|
||||
if is_offer_url:
|
||||
continue # у этих сохранённый адрес уже канонический, чинить нечего
|
||||
assert _canonical_source_url(stored_url, offer_id) == recovered, stored_url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_counter_split_by_reason() -> None:
|
||||
"""Остаток очереди делится по ПРИЧИНЕ, и восстановимое не зовётся непригодным.
|
||||
|
||||
Одно число на две разные судьбы («адрес чиним» и «адресовать нечем») читается
|
||||
как «тут делать нечего» — так 3535 квартир простояли неделю вне обогащения.
|
||||
"""
|
||||
db = _mock_db([], unenrichable=0, url_from_offer_id=3535)
|
||||
runs = MagicMock()
|
||||
session_cls, _session = _make_session_ctx([])
|
||||
|
||||
with (
|
||||
patch(_ASYNC_SESSION, session_cls),
|
||||
patch(_RUNS, runs),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db, run_id=43, params={"batch_size": 10, "budget_sec": 60}
|
||||
)
|
||||
|
||||
assert result.url_from_offer_id == 3535
|
||||
assert result.unenrichable_pending == 0
|
||||
counters = runs.mark_done.call_args.args[2]
|
||||
assert counters["url_from_offer_id"] == 3535
|
||||
assert counters["unenrichable_pending"] == 0
|
||||
|
||||
# Снапшот-SELECT берёт такие строки в работу по вычисленному адресу, а не
|
||||
# выбрасывает: без этой ветки они не попадут в очередь никогда.
|
||||
snapshot_sql = str(db.execute.call_args_list[0].args[0])
|
||||
assert "OR source_id ~ CAST(:offer_id_pattern AS text)" in snapshot_sql
|
||||
assert CANONICAL_URL_SQL in snapshot_sql
|
||||
|
|
|
|||
188
tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
Normal file
188
tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""#2625: прогон, у которого отказом кончился каждый якорь, перестаёт быть 'done'.
|
||||
|
||||
Задача заводилась про капчу Циана и пустые выдачи Яндекса. Основную её часть закрыл
|
||||
#2642 (детект провала извлечения структуры → 'banned'). Замер на проде 2026-08-10
|
||||
подтверждает эффект симптоматически: развёртки cian/yandex, статус 'done' с нулём —
|
||||
42 из 89 до деплоя #2642 (04.08 19:00 UTC) и 1 из 30 после.
|
||||
|
||||
Этот единственный оставшийся — прогон 3557 (`yandex_city_sweep_pervouralsk`,
|
||||
09.08 17:11): 155 миллисекунд, `{"anchors_total": 1, "anchors_done": 1,
|
||||
"errors_count": 1, "lots_fetched": 0}`, статус 'done'. И он не новый: за 90 суток под
|
||||
тот же признак попадают 28 прогонов, включая `yandex_city_sweep_nizhniy_tagil` —
|
||||
16 суток подряд (15-30.07), каждый ровно 240 секунд (таймаут якоря), ноль лотов,
|
||||
'done' каждый раз.
|
||||
|
||||
Почему детект #2642 их не видит — и правильно не видит. Он считает попытки РАЗБОРА
|
||||
(`_track_gate_result`), а transport_error туда НАМЕРЕННО не попадает (#2600 п.1:
|
||||
«наш прокси сдох» не должен выглядеть баном площадки). Якорь, упавший по таймауту
|
||||
или исключению до первого разобранного ответа, даёт `attempts == 0`, условие
|
||||
`attempts > 0 and failures == attempts` молчит — и прогон уходит в 'done'.
|
||||
|
||||
То есть третий исход существовал, но терминального статуса у него не было:
|
||||
* площадка отбила → 'banned' (#2642, есть);
|
||||
* площадка честно пуста → 'done' (есть, и это здоровый ответ);
|
||||
* мы не дошли → ??? → 'done' ← дефект.
|
||||
|
||||
Признак намеренно НЕ опирается на список антибот-маркеров: маркеры объясняют уже
|
||||
случившийся отказ и молчат про неизвестный (урок 09.08, #2798). Здесь считается
|
||||
собственная бухгалтерия прогона — `errors_count >= anchors_total` при измеренном
|
||||
нуле. Что она доказывает: каждый якорь кончился отказом и собрано ноль. Чего НЕ
|
||||
доказывает: кто виноват. Поэтому 'failed' без ban_kind, а не 'banned'/'platform'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.orchestration import runs as kit_runs
|
||||
|
||||
from app.services import scrape_runs as app_runs
|
||||
|
||||
_MODULES = {"kit": kit_runs, "app": app_runs}
|
||||
|
||||
# Реальные counters с прода (2026-08-10), не выдуманные.
|
||||
PROD_3557_PERVOURALSK = {
|
||||
"anchors_done": 1,
|
||||
"errors_count": 1,
|
||||
"lots_fetched": 0,
|
||||
"lots_updated": 0,
|
||||
"anchors_total": 1,
|
||||
"lots_inserted": 0,
|
||||
"address_failed": 0,
|
||||
"combos_skipped": 0,
|
||||
"address_enriched": 0,
|
||||
"address_attempted": 0,
|
||||
"price_history_rows": 0,
|
||||
}
|
||||
# Тот же source, прогон 3320 от 06.08 — отказ якоря БЫЛ (errors_count=1), но 117 лотов
|
||||
# собрано. Частичный сбор — сбор, статус обязан остаться 'done'.
|
||||
PROD_3320_PARTIAL = {**PROD_3557_PERVOURALSK, "lots_fetched": 117, "lots_inserted": 63}
|
||||
# Прогон 2930 (cian_city_sweep_serov, 02.08): ноль лотов БЕЗ единого отказа —
|
||||
# это либо честная пустота, либо недетект капчи; и то и другое — не наша тема,
|
||||
# капча — предмет #2642, а честная пустота обязана оставаться 'done'.
|
||||
PROD_2930_HONEST_EMPTY = {**PROD_3557_PERVOURALSK, "errors_count": 0}
|
||||
|
||||
|
||||
def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]:
|
||||
"""Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов.
|
||||
|
||||
Читаем СТАТУС В SQL, а не имя вызванной функции: тест должен краснеть на
|
||||
поведении финализатора, а не на отсутствии нового имени в старом коде.
|
||||
"""
|
||||
statuses: list[str] = []
|
||||
|
||||
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
|
||||
sql = str(stmt)
|
||||
for status in ("done", "failed", "banned"):
|
||||
if f"status = '{status}'" in sql:
|
||||
statuses.append(status)
|
||||
return MagicMock()
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = _execute
|
||||
# sentry заглушаем: алерт-хуки best-effort и к предмету теста отношения не имеют.
|
||||
with patch.object(mod, "sentry_sdk", MagicMock()):
|
||||
mod.mark_done(db, 3557, dict(counters))
|
||||
return statuses
|
||||
|
||||
|
||||
# ── 1. Три исхода разведены ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_all_anchors_failed_zero_lots_is_not_done(name: str) -> None:
|
||||
"""Прод-прогон 3557: все якоря отказали, собрано 0 → 'failed', НЕ 'done'.
|
||||
|
||||
Красный на старом коде: mark_done писал status='done'.
|
||||
"""
|
||||
assert _capture_status(_MODULES[name], PROD_3557_PERVOURALSK) == ["failed"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_partial_harvest_stays_done(name: str) -> None:
|
||||
"""Прод-прогон 3320: отказ якоря был, но 117 лотов собрано → остаётся 'done'."""
|
||||
assert _capture_status(_MODULES[name], PROD_3320_PARTIAL) == ["done"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_honest_empty_stays_done(name: str) -> None:
|
||||
"""Ноль лотов без единого отказа — честная пустота, 'done' (ложной тревоги нет)."""
|
||||
assert _capture_status(_MODULES[name], PROD_2930_HONEST_EMPTY) == ["done"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_failed_run_carries_no_ban_diagnosis(name: str) -> None:
|
||||
"""Причина не установлена → ban_kind не пишется вовсе (#2764).
|
||||
|
||||
'banned' с диагнозом означал бы «нас забанила площадка» — а мы знаем только,
|
||||
что якоря отказали. Ротацию IP (#2611) на догадке дёргать нельзя.
|
||||
"""
|
||||
mod = _MODULES[name]
|
||||
sqls: list[str] = []
|
||||
|
||||
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
|
||||
sqls.append(str(stmt))
|
||||
return MagicMock()
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = _execute
|
||||
with patch.object(mod, "sentry_sdk", MagicMock()):
|
||||
mod.mark_done(db, 3557, dict(PROD_3557_PERVOURALSK))
|
||||
|
||||
assert not any("ban_kind" in s for s in sqls), "статус не должен нести диагноз"
|
||||
assert any("status = 'failed'" in s for s in sqls)
|
||||
|
||||
|
||||
# ── 2. Классификатор: границы, на которых легко получить ложную тревогу ──────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
@pytest.mark.parametrize(
|
||||
("counters", "flagged", "why"),
|
||||
[
|
||||
({"anchors_total": 1, "errors_count": 1, "lots_fetched": 0}, True, "1 из 1 отказал"),
|
||||
({"anchors_total": 5, "errors_count": 5, "lots_fetched": 0}, True, "5 из 5 (avito ЕКБ)"),
|
||||
({"anchors_total": 5, "errors_count": 1, "lots_fetched": 0}, False, "1 из 5 — не все"),
|
||||
({"anchors_total": 5, "errors_count": 5, "lots_fetched": 12}, False, "собрано 12"),
|
||||
({"anchors_total": 1, "errors_count": 0, "lots_fetched": 0}, False, "честная пустота"),
|
||||
# full-load'ы пишут unique_fetched, а не lots_fetched — тот же смысл.
|
||||
({"anchors_total": 2, "errors_count": 2, "unique_fetched": 0}, True, "full-load ноль"),
|
||||
({"anchors_total": 2, "errors_count": 2, "unique_fetched": 340}, False, "full-load сбор"),
|
||||
# Результат НЕ измерен — судить нечем, ноль не выдумывается (#2703).
|
||||
({"anchors_total": 1, "errors_count": 1}, False, "результата в counters нет"),
|
||||
# Якорей нет вовсе: это не развёртка, чужой словарь счётчиков не трогаем.
|
||||
({"errors_count": 9, "lots_fetched": 0}, False, "не развёртка"),
|
||||
({"attempted": 5, "enriched": 0, "failed": 5}, False, "detail-backfill, чужой словарь"),
|
||||
({}, False, "пустые counters"),
|
||||
({"anchors_total": 0, "errors_count": 0, "lots_fetched": 0}, False, "нуль якорей"),
|
||||
],
|
||||
)
|
||||
def test_classifier_boundaries(
|
||||
name: str, counters: dict[str, Any], flagged: bool, why: str
|
||||
) -> None:
|
||||
reason = _MODULES[name]._sweep_run_did_nothing(counters)
|
||||
assert (reason is not None) is flagged, why
|
||||
if flagged:
|
||||
assert "#2625" in (reason or "")
|
||||
|
||||
|
||||
# ── 3. Ровно те 16 суток, что прод прожил молча ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_sixteen_silent_tagil_runs_would_have_been_failed(name: str) -> None:
|
||||
"""15-30.07: 16 прогонов `yandex_city_sweep_nizhniy_tagil` по 240 с, 0 лотов.
|
||||
|
||||
Каждый отчитался 'done'. Ни один алерт их не поднял: `_alert_if_consecutive_failures`
|
||||
считает только failed/banned. С этой правкой все 16 — 'failed', то есть первый же
|
||||
из них попадает в лестницу вех failed-стрика (#2670).
|
||||
"""
|
||||
tagil = {"anchors_total": 1, "anchors_done": 1, "errors_count": 1, "lots_fetched": 0}
|
||||
statuses = [_capture_status(_MODULES[name], tagil) for _ in range(16)]
|
||||
assert statuses == [["failed"]] * 16
|
||||
199
tradein-mvp/backend/tests/test_2670_stale_source_digest.py
Normal file
199
tradein-mvp/backend/tests/test_2670_stale_source_digest.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""#2670 (остаток): лестница напоминаний не отвечает на вопрос «что сломано сейчас».
|
||||
|
||||
#2720 вылечил «алерт ровно один раз за серию»: теперь вехи 3, 6, 12, 24, 48… Но лестница
|
||||
шагает по ПОДРЯД ИДУЩИМ завершённым failed/banned прогонам, а на проде 2026-08-10 три
|
||||
самых залежавшихся источника из шести просроченных ей недоступны — и лишь один из трёх
|
||||
из-за редких вех:
|
||||
|
||||
┌────────────────────────────┬────────┬───────┬──────────────────────────────────────┐
|
||||
│ источник │ стрик │ сут. │ когда напомнит лестница │
|
||||
├────────────────────────────┼────────┼───────┼──────────────────────────────────────┤
|
||||
│ avito_full_load_exhaustive │ 0 │ 49.5 │ никогда: 5 банов обнулил 'cancelled' │
|
||||
│ cian_history_backfill │ 0 │ 42.1 │ никогда: прогонов нет с 30.06 │
|
||||
│ avito_full_load │ 31 │ 37.7 │ веха 48 → +17 прогонов × 7 сут = 119 │
|
||||
│ avito_detail_backfill │ 5 │ 5.2 │ веха 6 → завтра │
|
||||
│ domclick_city_sweep │ 5 │ 5.1 │ веха 6 → завтра │
|
||||
│ domclick_detail_backfill │ 4 │ 5.0 │ веха 6 → послезавтра │
|
||||
└────────────────────────────┴────────┴───────┴──────────────────────────────────────┘
|
||||
|
||||
Уплотнение вех (3,4,5,6…) чинит ТОЛЬКО третью строку: у первых двух стрик равен нулю,
|
||||
уплотнять нечего — «замолчал» там означает «перестал производить прогоны», а не «серия
|
||||
длиннее последней вехи». Поэтому остаток задачи закрывает сводка, считающая КАЛЕНДАРНЫЙ
|
||||
возраст последнего успеха, а не длину серии.
|
||||
|
||||
Фальсификация: на коде до этой правки `emit_stale_digest`/`stale_sources` не существует
|
||||
(ImportError на сборе тестов) — сводки нет ни в каком виде. Тест
|
||||
`test_ladder_is_silent_for_the_worst_two` — КОНТРОЛЬ: он зелёный и до, и после правки и
|
||||
показывает ровно то, чего сводка не заменяет, а добавляет: лестница на этих двух молчит.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.orchestration import runs as kit_runs
|
||||
from scraper_kit.orchestration import scheduler as sched
|
||||
|
||||
NOW = datetime(2026, 8, 10, 8, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _row(source: str, interval_days: Any, age_days: float, never_ok: bool = False) -> Any:
|
||||
"""Строка `_STALE_SOURCES_SQL`: last_ok уже схлопнут в `since` через COALESCE."""
|
||||
return SimpleNamespace(
|
||||
source=source,
|
||||
interval_days=interval_days,
|
||||
since=NOW - timedelta(days=age_days),
|
||||
never_ok=never_ok,
|
||||
)
|
||||
|
||||
|
||||
# Снимок прода 2026-08-10 08:00 UTC: все 52 включённых расписания не влезают, взяты все
|
||||
# просроченные + четыре контрольных, каждое из которых мимо порога по своей причине.
|
||||
PROD_ROWS = [
|
||||
_row("cian_history_backfill", None, 42.1), # такт по умолчанию (daily)
|
||||
_row("avito_full_load_exhaustive", 7, 49.5),
|
||||
_row("avito_full_load", 7, 37.7),
|
||||
_row("avito_detail_backfill", None, 5.2),
|
||||
_row("domclick_city_sweep", None, 5.1),
|
||||
_row("domclick_detail_backfill", None, 5.0),
|
||||
# ── контроль: НЕ просрочены ──
|
||||
_row("rosreestr_quarter_poll", 28, 24.0), # 24 сут при такте 28 — норма
|
||||
_row("sber_index_pull", 7, 4.1),
|
||||
_row("avito_city_sweep", None, 1.1),
|
||||
_row("proxy_healthcheck", None, 0.02),
|
||||
]
|
||||
|
||||
# Порядок — по числу ПРОПУЩЕННЫХ ТАКТОВ (age/interval), а не по календарю: 42 суток
|
||||
# у суточного backfill'а = 42 пропущенных такта, 49.5 у недельного = 7.
|
||||
PROD_STALE = [
|
||||
"cian_history_backfill", # 42.1 / 1
|
||||
"avito_full_load_exhaustive", # 49.5 / 7 = 7.07
|
||||
"avito_full_load", # 37.7 / 7 = 5.39
|
||||
"avito_detail_backfill", # 5.2 / 1
|
||||
"domclick_city_sweep", # 5.1 / 1
|
||||
"domclick_detail_backfill", # 5.0 / 1
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_digest_clock() -> Any:
|
||||
"""Выпуск сводки помнится в памяти модуля — сбрасываем между тестами."""
|
||||
sched._last_stale_digest_at = None
|
||||
yield
|
||||
sched._last_stale_digest_at = None
|
||||
|
||||
|
||||
def _db(rows: list[Any]) -> MagicMock:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.fetchall.return_value = rows
|
||||
return db
|
||||
|
||||
|
||||
# ── 1. Чистая логика порога ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_stale_sources_names_exactly_the_prod_six() -> None:
|
||||
"""Шесть просроченных из десяти, порядок — по числу пропущенных ТАКТОВ, не суток."""
|
||||
stale = sched.stale_sources(PROD_ROWS, NOW)
|
||||
assert [s.source for s in stale] == PROD_STALE
|
||||
|
||||
|
||||
def test_quarterly_source_is_not_stale_at_24_days() -> None:
|
||||
"""Порог считается в тактах: 24 сут для 28-суточного poll'а — не просрочка."""
|
||||
assert sched.stale_sources([_row("rosreestr_quarter_poll", 28, 24.0)], NOW) == []
|
||||
# …а 85 суток (>3×28) — уже просрочка.
|
||||
assert [s.source for s in sched.stale_sources([_row("q", 28, 85.0)], NOW)] == ["q"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [None, "null", "", "abc", 0, -5])
|
||||
def test_broken_interval_falls_back_to_daily(raw: Any) -> None:
|
||||
"""`interval_days: null` и мусор → такт 1 сут, как у compute_next_run_at."""
|
||||
assert sched._schedule_interval_days(raw) == 1
|
||||
|
||||
|
||||
def test_never_successful_source_is_reported_with_a_flag() -> None:
|
||||
"""Расписание без единого 'done' считается от created_at и помечается явно."""
|
||||
(only,) = sched.stale_sources([_row("brand_new", 1, 9.0, never_ok=True)], NOW)
|
||||
assert only.never_ok is True
|
||||
|
||||
|
||||
# ── 2. Выпуск сводки ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_digest_emits_one_event_listing_all_stale_sources() -> None:
|
||||
sentry = MagicMock()
|
||||
with patch.object(sched, "sentry_sdk", sentry):
|
||||
stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW)
|
||||
assert [s.source for s in stale] == PROD_STALE
|
||||
sentry.capture_message.assert_called_once()
|
||||
msg = sentry.capture_message.call_args[0][0]
|
||||
assert msg.startswith("6 scraper sources are stale")
|
||||
for name in PROD_STALE:
|
||||
assert name in msg
|
||||
assert "rosreestr_quarter_poll" not in msg
|
||||
|
||||
|
||||
def test_digest_covers_the_two_sources_the_ladder_cannot_reach() -> None:
|
||||
"""Главное свойство: стрик 0 не мешает сводке — она меряет календарь, а не серию."""
|
||||
sentry = MagicMock()
|
||||
with patch.object(sched, "sentry_sdk", sentry):
|
||||
stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW)
|
||||
zero_streak = {"avito_full_load_exhaustive", "cian_history_backfill"}
|
||||
assert zero_streak <= {s.source for s in stale}
|
||||
|
||||
|
||||
def test_digest_is_quiet_when_everything_is_fresh() -> None:
|
||||
sentry = MagicMock()
|
||||
fresh = [_row("avito_city_sweep", None, 1.1), _row("sber_index_pull", 7, 4.1)]
|
||||
with patch.object(sched, "sentry_sdk", sentry):
|
||||
assert sched.emit_stale_digest(_db(fresh), now=NOW) == []
|
||||
sentry.capture_message.assert_not_called()
|
||||
|
||||
|
||||
def test_digest_is_daily_not_per_tick() -> None:
|
||||
"""Планировщик тикает раз в минуту; сводка обязана выходить раз в сутки."""
|
||||
sentry = MagicMock()
|
||||
db = _db(PROD_ROWS)
|
||||
with patch.object(sched, "sentry_sdk", sentry):
|
||||
sched.emit_stale_digest(db, now=NOW)
|
||||
sched.emit_stale_digest(db, now=NOW + timedelta(minutes=1))
|
||||
sched.emit_stale_digest(db, now=NOW + timedelta(hours=23))
|
||||
assert sentry.capture_message.call_count == 1
|
||||
sched.emit_stale_digest(db, now=NOW + timedelta(hours=24, minutes=1))
|
||||
assert sentry.capture_message.call_count == 2
|
||||
|
||||
|
||||
def test_digest_failure_never_breaks_the_tick() -> None:
|
||||
"""Сводка — best-effort: упавший запрос не имеет права уронить тик планировщика."""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("db down")
|
||||
with patch.object(sched, "sentry_sdk", MagicMock()):
|
||||
assert sched.emit_stale_digest(db, now=NOW) == []
|
||||
|
||||
|
||||
# ── 3. Контроль: что именно сводка ДОБАВЛЯЕТ к лестнице ──────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "streak"),
|
||||
[("avito_full_load_exhaustive", 0), ("cian_history_backfill", 0), ("avito_full_load", 31)],
|
||||
)
|
||||
def test_ladder_is_silent_for_the_worst_two(name: str, streak: int) -> None:
|
||||
"""КОНТРОЛЬ (зелёный и до правки): у трёх худших источников лестница молчит.
|
||||
|
||||
Стрик 0 — прогонов нет / серию обнулил 'cancelled'; стрик 31 — между вехами 24 и 48.
|
||||
"""
|
||||
rows = [SimpleNamespace(status="banned") for _ in range(streak)]
|
||||
rows += [SimpleNamespace(status="done") for _ in range(3)]
|
||||
sentry = MagicMock()
|
||||
with patch.object(kit_runs, "sentry_sdk", sentry):
|
||||
kit_runs._alert_if_consecutive_failures(_db(rows), name)
|
||||
sentry.capture_message.assert_not_called()
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"""#2687: опустевший пул прокси mid-run стирал чекпоинт полного обхода.
|
||||
|
||||
Найдено при разборе шести `ban_kind='infra'` у `avito_full_load` (29.07-03.08). Сами
|
||||
шесть объяснены и починены раньше (#2637 подключил браузерный путь Авито к пулу,
|
||||
#2634/#2640 добили запасной путь), но рядом с ними на main живёт соседний дефект того
|
||||
же класса, что и #2686: наш собственный отказ финализируется веткой, которая теряет
|
||||
чекпоинт.
|
||||
|
||||
Механизм. `NoProxyAvailableError` (proxy_errors.py) — наследник `RuntimeError`, и его
|
||||
собственный докстринг говорит прямо: «это НАША инфраструктура (нет живого прокси), не
|
||||
внешний блок». В `run_*_full_load` он попадал в общую ветку `except RuntimeError` →
|
||||
`mark_failed`, а `mark_failed`, в отличие от `mark_banned`, НЕ пишет `done_buckets`
|
||||
(pipeline.py, ветка блока пишет его явно). Плюс `ban_kind_of_exception` возвращал для
|
||||
него `unknown` — диагноз «наша инфраструктура» терялся дважды: и как метка, и как
|
||||
сохранённый прогресс.
|
||||
|
||||
Достижимость на проде (замер 2026-08-12, read-only):
|
||||
- под `avito` доступны три узла (id 9/10/11; id 1 забанен парой до 13.08);
|
||||
- `BrowserFetcher` меняет lease после `_LEASE_ROTATE_AFTER_FAILS=3` подряд неудачных
|
||||
`/fetch`, а `proxy_pool.acquire` не выдаёт узел с
|
||||
`consecutive_fails >= MAX_CONSECUTIVE_FAILS=3` → девять неудачных POST'ов
|
||||
опустошают пул под источник;
|
||||
- лестница ОДНОГО полного обхода при отказывающем сайдкаре делает до 24 POST'ов
|
||||
(4 бакета `_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED` × 3 попытки
|
||||
`_AVITO_SIDECAR_TRANSIENT_RETRIES`+1 × 2 внутренних httpx-ретрая `fetch`),
|
||||
то есть заведомо проходит через это состояние.
|
||||
Цена потери чекпоинта — прогон 3547 (09.08): 35 бакетов, 5496 объявлений за 2ч58м.
|
||||
|
||||
Фальсификация (красный прогон на коде до правки):
|
||||
- `ban_kind_of_exception` → `'unknown'` вместо `'infra'`;
|
||||
- `run_*_full_load` → `mark_failed` вместо `mark_banned`, исключение улетает наружу
|
||||
(тест падает на непойманном `NoProxyAvailableError`), `done_buckets` нигде нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.orchestration import runs as kit_runs
|
||||
from scraper_kit.orchestration.pipeline import (
|
||||
ban_kind_of_exception,
|
||||
run_avito_full_load,
|
||||
run_cian_full_load,
|
||||
run_yandex_full_load,
|
||||
)
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
|
||||
PFX = "scraper_kit.orchestration.pipeline"
|
||||
|
||||
_FULL_LOAD = {
|
||||
"avito": (run_avito_full_load, f"{PFX}.AvitoScraper"),
|
||||
"cian": (run_cian_full_load, f"{PFX}.CianScraper"),
|
||||
"yandex": (run_yandex_full_load, f"{PFX}.YandexRealtyScraper"),
|
||||
}
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""Двойник scrape_runs: интересны имя финализатора, ban_kind и counters."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, dict[str, Any]]] = []
|
||||
|
||||
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||
self.calls.append(("mark_done", "", dict(counters)))
|
||||
|
||||
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||||
self.calls.append(("mark_failed", "", dict(counters)))
|
||||
|
||||
def mark_banned(
|
||||
self,
|
||||
db: Any,
|
||||
run_id: int,
|
||||
error: str,
|
||||
counters: dict[str, Any],
|
||||
*,
|
||||
ban_kind: str = kit_runs.BAN_KIND_UNKNOWN,
|
||||
) -> None:
|
||||
self.calls.append(("mark_banned", ban_kind, dict(counters)))
|
||||
|
||||
|
||||
def _config() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
scraper_fetch_mode="curl_cffi",
|
||||
browser_http_endpoint="http://browser.test/fetch",
|
||||
scraper_proxy_url=None,
|
||||
avito_proxy_max_rotations=0,
|
||||
avito_serp_ok_not_banned=True,
|
||||
avito_proxy_rotate_settle_s=0.0,
|
||||
proxy_rotate_attempts=1,
|
||||
proxy_rotate_attempt_timeout_s=1.0,
|
||||
cian_proxy_max_rotations=0,
|
||||
yandex_proxy_max_rotations=0,
|
||||
cian_full_load_per_fetch_timeout_s=0.0,
|
||||
scraper_skip_seen_today=False,
|
||||
)
|
||||
|
||||
|
||||
def _scraper_that_saves_one_bucket_then_runs_out_of_proxies() -> MagicMock:
|
||||
"""Двойник скрапера: один бакет доехал до on_bucket, затем пул опустел."""
|
||||
|
||||
async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None:
|
||||
on_bucket("2к:0-5m", [MagicMock(source_id="a1")])
|
||||
raise NoProxyAvailableError("avito")
|
||||
|
||||
m = MagicMock()
|
||||
m.__aenter__ = AsyncMock(return_value=m)
|
||||
m.__aexit__ = AsyncMock(return_value=None)
|
||||
m.fetch_all_secondary = _fetch
|
||||
m.state_extraction_attempts = 0
|
||||
m.state_extraction_failures = 0
|
||||
m.gate_fetch_attempts = 0
|
||||
m.gate_fetch_failures = 0
|
||||
m._browser = None
|
||||
return m
|
||||
|
||||
|
||||
def test_ban_kind_of_no_proxy_is_infra() -> None:
|
||||
"""Пустой пул — наша инфраструктура по определению самого исключения.
|
||||
|
||||
Раньше классификатор отдавал 'unknown' (дефолт #2764): для НЕизвестной причины
|
||||
это верно, но здесь причина известна и названа в типе.
|
||||
"""
|
||||
assert ban_kind_of_exception(NoProxyAvailableError("avito")) == kit_runs.BAN_KIND_INFRA
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["avito", "cian", "yandex"])
|
||||
async def test_full_load_pool_exhaustion_keeps_checkpoint(source: str) -> None:
|
||||
"""Пул опустел mid-run → 'banned'/'infra' и done_buckets целы, а не mark_failed.
|
||||
|
||||
Требование #2686 («чекпоинт сохраняется и при нашем сбое, и при блокировке
|
||||
площадкой») до этой правки держалось только для отказа сайдкара; отказ пула шёл
|
||||
мимо него и обнулял прогресс следующего прогона.
|
||||
"""
|
||||
fn, scraper_target = _FULL_LOAD[source]
|
||||
recorder = _Recorder()
|
||||
extra: dict[str, Any] = {}
|
||||
if source == "yandex":
|
||||
enrichment = MagicMock()
|
||||
enrichment.record_yandex_price_history = MagicMock(return_value=0)
|
||||
extra["enrichment"] = enrichment
|
||||
|
||||
scraper = _scraper_that_saves_one_bucket_then_runs_out_of_proxies()
|
||||
with (
|
||||
patch(scraper_target, return_value=scraper),
|
||||
patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))),
|
||||
patch(f"{PFX}.runs", recorder),
|
||||
):
|
||||
await fn(MagicMock(), run_id=1, config=_config(), matcher=MagicMock(), **extra)
|
||||
|
||||
assert [c[0] for c in recorder.calls] == ["mark_banned"]
|
||||
_method, ban_kind, counters = recorder.calls[0]
|
||||
assert ban_kind == kit_runs.BAN_KIND_INFRA
|
||||
assert counters["done_buckets"] == ["2к:0-5m"]
|
||||
208
tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py
Normal file
208
tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""#2700: 403 Циана перестаёт умирать внутри провайдера — узел снимается, прогон честен.
|
||||
|
||||
Живая различающая проба на проде 2026-08-10 (один и тот же detail-URL, один и тот же
|
||||
код, менялся ТОЛЬКО прокси-узел):
|
||||
|
||||
узел 1 (asocks-residential-1, 46.8.110.92) → HTTP 403, 21 564 б, `cian_waf_block`
|
||||
узел 9 (asocks-mobile-1, 5.227.16.0) → HTTP 200, 617 352 б, state ok
|
||||
узел 10 (asocks-mobile-2, 95.104.183.29) → HTTP 200, 617 355 б, state ok
|
||||
узел 11 (asocks-mobile-3, 95.55.49.98) → HTTP 200, 617 407 б, state ok
|
||||
|
||||
То есть отбита была ПАРА «узел × Циан», а не площадка (и не наши заголовки: те же 200
|
||||
пришли без единой куки — detail-страница авторизации не требует). Пятнадцать суток
|
||||
подряд это выглядело как «Циан нас забанил» ровно потому, что 403 гасился в
|
||||
`return None`: пул получал `mark_health(ok=True)` на отбитый узел и продолжал выдавать
|
||||
его Циану, а прогон писал `detail_failed=50` при `errors_count=0` и статусе `done`.
|
||||
|
||||
Тесты красные на старом коде:
|
||||
* `fetch_detail` возвращал None и НЕ звал `mark_banned` → узел оставался в выдаче;
|
||||
* `mark_done` писал `status='done'` прогону, у которого отказали все 50 попыток.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.cian_exceptions import CianBlockedError
|
||||
from scraper_kit.contracts import ProxyLease
|
||||
from scraper_kit.orchestration import runs as kit_runs
|
||||
from scraper_kit.providers.cian import detail as cian_detail
|
||||
from scraper_kit.proxy_errors import ProxyBanError
|
||||
|
||||
from app.services import scrape_runs as app_runs
|
||||
|
||||
_MODULES = {"kit": kit_runs, "app": app_runs}
|
||||
_LEASE = ProxyLease(id=1, url="http://user:pass@node-1:10423", kind="http", rotate_url=None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeConfig:
|
||||
use_proxy_pool_curl: bool = True
|
||||
cian_proxy_url: str | None = None
|
||||
environment: str = "production"
|
||||
|
||||
|
||||
class _SpyProvider:
|
||||
"""ProxyProvider-заглушка (тот же контракт, что в test_proxy_pool_curl_paths)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.mark_health_calls: list[tuple[int, bool]] = []
|
||||
self.mark_banned_calls: list[tuple[int, str]] = []
|
||||
self.release_calls: list[int] = []
|
||||
|
||||
def acquire(self, provider: str) -> ProxyLease:
|
||||
return _LEASE
|
||||
|
||||
def release(self, lease: ProxyLease) -> None:
|
||||
self.release_calls.append(lease.id)
|
||||
|
||||
def mark_health(
|
||||
self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None
|
||||
) -> None:
|
||||
self.mark_health_calls.append((lease.id, ok))
|
||||
|
||||
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
|
||||
self.mark_banned_calls.append((lease.id, source))
|
||||
|
||||
|
||||
def _session_returning(status_code: int, text: str = "") -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text))
|
||||
session.close = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
async def _fetch(status_code: int, spy: _SpyProvider) -> Any:
|
||||
with patch.object(
|
||||
cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
|
||||
):
|
||||
return await cian_detail.fetch_detail(
|
||||
"https://ekb.cian.ru/sale/flat/332775238/",
|
||||
config=_FakeConfig(),
|
||||
proxy_provider=spy,
|
||||
)
|
||||
|
||||
|
||||
# ── 1. 403 доходит до пула ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_403_bans_the_node_for_cian_only() -> None:
|
||||
"""Красный на старом коде: было `return None`, бана узла не происходило.
|
||||
|
||||
Проверяется ПОВЕДЕНИЕ пула (`mark_banned` на паре «узел × cian»), а не наличие
|
||||
нового имени в коде.
|
||||
"""
|
||||
spy = _SpyProvider()
|
||||
with pytest.raises(CianBlockedError):
|
||||
await _fetch(403, spy)
|
||||
assert spy.mark_banned_calls == [(1, "cian")]
|
||||
assert spy.mark_health_calls == [(1, False)]
|
||||
assert spy.release_calls == [1] # lease не течёт даже на бане
|
||||
|
||||
|
||||
def test_blocked_error_is_recognised_by_generic_proxy_layer() -> None:
|
||||
"""Generic curl-слой узнаёт бан по `ProxyBanError`, не зная про Циан."""
|
||||
assert issubclass(CianBlockedError, ProxyBanError)
|
||||
|
||||
|
||||
# ── 2. Не-бан остаётся не-баном ───────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_404_does_not_ban_the_node() -> None:
|
||||
"""Снятое объявление — не бан: наказывать за него здоровый узел нельзя."""
|
||||
spy = _SpyProvider()
|
||||
assert await _fetch(404, spy) is None
|
||||
assert spy.mark_banned_calls == []
|
||||
assert spy.mark_health_calls == [(1, True)]
|
||||
|
||||
|
||||
# ── 3. Прогон с полностью отказавшей фазой перестаёт быть 'done' ──────────────
|
||||
|
||||
|
||||
def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]:
|
||||
"""Статусы всех UPDATE'ов, которые сделал mark_done на фейковой сессии.
|
||||
|
||||
Читаем СТАТУС В SQL (как в test_2625_run_that_did_nothing), а не имя вызванной
|
||||
функции: тест обязан краснеть на поведении финализатора.
|
||||
"""
|
||||
statuses: list[str] = []
|
||||
|
||||
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
|
||||
sql = str(stmt)
|
||||
for status in ("done", "failed", "banned"):
|
||||
if f"status = '{status}'" in sql:
|
||||
statuses.append(status)
|
||||
return MagicMock()
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = _execute
|
||||
with patch.object(mod, "sentry_sdk", MagicMock()):
|
||||
mod.mark_done(db, 3258, dict(counters))
|
||||
return statuses
|
||||
|
||||
|
||||
# Реальные counters с прода, не выдуманные.
|
||||
# Прогон 3258 (cian_city_sweep, 06.08): 50 из 50 detail'ов отказали, статус 'done'.
|
||||
PROD_3258_ALL_DETAIL_FAILED = {
|
||||
"anchors_done": 5,
|
||||
"anchors_total": 5,
|
||||
"errors_count": 0,
|
||||
"lots_fetched": 1680,
|
||||
"lots_inserted": 59,
|
||||
"lots_updated": 626,
|
||||
"detail_attempted": 50,
|
||||
"detail_failed": 50,
|
||||
"detail_enriched": 0,
|
||||
"houses_attempted": 40,
|
||||
"houses_failed": 40,
|
||||
"houses_enriched": 0,
|
||||
}
|
||||
# Прогон 3597 (cian_city_sweep, 10.08, уже после того как 403 ушёл): 10 из 11 удачны.
|
||||
PROD_3597_MOSTLY_OK = {
|
||||
**PROD_3258_ALL_DETAIL_FAILED,
|
||||
"errors_count": 1,
|
||||
"detail_attempted": 11,
|
||||
"detail_failed": 1,
|
||||
"detail_enriched": 10,
|
||||
"houses_attempted": 0,
|
||||
"houses_failed": 0,
|
||||
}
|
||||
# Одиночная попытка, и та неудачна — шум, а не диагноз: прогон остаётся 'done'.
|
||||
SINGLE_ATTEMPT_FAILED = {
|
||||
**PROD_3597_MOSTLY_OK,
|
||||
"detail_attempted": 1,
|
||||
"detail_failed": 1,
|
||||
"detail_enriched": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_phase_failed_100_percent_is_not_done(name: str) -> None:
|
||||
"""Прод-прогон 3258: detail 50/50 отказ → 'failed'. Красный на старом коде."""
|
||||
assert _capture_status(_MODULES[name], PROD_3258_ALL_DETAIL_FAILED) == ["failed"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_partial_phase_failure_stays_done(name: str) -> None:
|
||||
"""Прод-прогон 3597: 1 отказ из 11 → остаётся 'done' (частичный отказ — не отказ)."""
|
||||
assert _capture_status(_MODULES[name], PROD_3597_MOSTLY_OK) == ["done"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_single_failed_attempt_stays_done(name: str) -> None:
|
||||
"""Порог _PHASE_MIN_ATTEMPTS: одна неудачная попытка прогон не роняет."""
|
||||
assert _capture_status(_MODULES[name], SINGLE_ATTEMPT_FAILED) == ["done"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_zero_attempts_stays_done(name: str) -> None:
|
||||
"""Фаза не запускалась (0 попыток) — 0 == 0 не должно читаться как отказ."""
|
||||
counters = {**PROD_3597_MOSTLY_OK, "detail_attempted": 0, "detail_failed": 0}
|
||||
assert _capture_status(_MODULES[name], counters) == ["done"]
|
||||
|
|
@ -185,7 +185,7 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None:
|
|||
_stub_backfill(
|
||||
monkeypatch,
|
||||
NewbuildingEnrichBackfillResult(
|
||||
processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_rows=7
|
||||
processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_inserted=7
|
||||
),
|
||||
)
|
||||
calls = _stub_finalisers(monkeypatch)
|
||||
|
|
@ -196,5 +196,5 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None:
|
|||
assert counters["attempted"] == 10
|
||||
assert counters["enriched"] == 3
|
||||
assert counters["failed"] == 7
|
||||
assert counters["price_dynamics_rows"] == 7 # исходные счётчики на месте
|
||||
assert counters["price_dynamics_inserted"] == 7 # исходные счётчики на месте
|
||||
assert counters["succeeded"] == 3
|
||||
|
|
|
|||
193
tradein-mvp/backend/tests/test_2807_write_counters_honesty.py
Normal file
193
tradein-mvp/backend/tests/test_2807_write_counters_honesty.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""#2807: счётчик мерил прирост таблицы, а читался как «сколько записали».
|
||||
|
||||
`newbuilding_enrich_backfill` считал свою работу разницей `COUNT(*)` до и после
|
||||
сохранения. Вставка в houses_price_dynamics идёт `ON CONFLICT … DO UPDATE`, поэтому
|
||||
обновление существующей точки давало ноль. Прод 10.08: прогон 3578 отчитался
|
||||
`price_dynamics_rows: 0`, обновив за своё окно **64 строки по 10 домам** — те самые,
|
||||
что вставил прогон 3563 накануне (у него в тех же counters стояло 64). Ноль читался
|
||||
как «динамика цен снова не пишется».
|
||||
|
||||
Соседние счётчики врали в том же месте по своим причинам: `reliability_rows` обнулял
|
||||
`_dedup_reliability`, схлопывающий строку сразу после вставки, а `review_rows`
|
||||
игнорировал число, которое `_save_cian_reviews` УЖЕ возвращал, в пользу разницы COUNT'ов.
|
||||
|
||||
Фальсификация (см. прогон в PR): на коде до правки `test_second_pass_reports_updates`
|
||||
даёт `price_dynamics_rows == 0` при 64 переписанных точках — ровно прод-симптом.
|
||||
|
||||
Отдельно проверяется, что правка НЕ ослабила сторожа нулевого результата: он смотрит на
|
||||
`attempted`/`enriched`/`gone`/`blocked` (#2695), а не на счётчики записи, и прогон,
|
||||
который ничего не обогатил, обязан остаться 'failed' при любых числах в `*_written`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
sys.modules.setdefault("weasyprint", MagicMock())
|
||||
|
||||
from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
|
||||
NewbuildingSaveCounts,
|
||||
save_newbuilding_enrichment,
|
||||
)
|
||||
|
||||
from app.services import scrape_runs as runs_mod # noqa: E402
|
||||
from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
|
||||
NewbuildingEnrichBackfillResult,
|
||||
)
|
||||
|
||||
# Прод-масштаб прогона 3578: 10 домов × 64/10 точек. Держим ровно 64, чтобы число в
|
||||
# тесте совпадало с числом в задаче.
|
||||
PROD_POINTS = 64
|
||||
|
||||
|
||||
class _UpsertDB:
|
||||
"""Сессия, у которой houses_price_dynamics уже населена (второй проход).
|
||||
|
||||
`RETURNING (xmax = 0)` возвращает False на конфликте — это и есть «обновили».
|
||||
"""
|
||||
|
||||
def __init__(self, *, already_present: bool) -> None:
|
||||
self.already_present = already_present
|
||||
self.price_writes = 0
|
||||
self.reliability_writes = 0
|
||||
self.committed = False
|
||||
|
||||
def execute(self, statement, params=None):
|
||||
sql = str(statement)
|
||||
res = MagicMock()
|
||||
if "INSERT INTO houses_price_dynamics" in sql:
|
||||
self.price_writes += 1
|
||||
assert "RETURNING (xmax = 0)" in sql, "писатель обязан различать вставку и update"
|
||||
res.fetchone.return_value = (not self.already_present,)
|
||||
return res
|
||||
if "INSERT INTO house_reliability_checks" in sql:
|
||||
self.reliability_writes += 1
|
||||
res.fetchone.return_value = None
|
||||
return res
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
|
||||
def _enrichment(points: int):
|
||||
from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment
|
||||
|
||||
return NewbuildingEnrichment(
|
||||
cian_internal_house_id=1,
|
||||
cian_zhk_url="https://zhk-x.cian.ru/",
|
||||
name="ЖК Тест",
|
||||
realty_valuation_chart=[
|
||||
{
|
||||
"month_date": f"2026-{(i % 12) + 1:02d}-01",
|
||||
"room_count": "all",
|
||||
"prices_type": "price",
|
||||
"period": "halfYear",
|
||||
"price_per_sqm": 150000.0 + i,
|
||||
}
|
||||
for i in range(points)
|
||||
],
|
||||
reliability_checks=[{"check_name": "Надёжный застройщик", "check_status": "reliable"}],
|
||||
reviews=[],
|
||||
)
|
||||
|
||||
|
||||
# ── 1. Писатель различает вставку и обновление ───────────────────────────────
|
||||
|
||||
|
||||
def test_first_pass_reports_inserts() -> None:
|
||||
db = _UpsertDB(already_present=False)
|
||||
counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS))
|
||||
assert counts.price_inserted == PROD_POINTS
|
||||
assert counts.price_updated == 0
|
||||
assert counts.reliability_inserted == 1
|
||||
assert db.price_writes == PROD_POINTS
|
||||
|
||||
|
||||
def test_second_pass_reports_updates() -> None:
|
||||
"""Прод-симптом: те же 64 точки, ничего нового — но записаны все 64.
|
||||
|
||||
До правки этот прогон отчитывался нулём по всем трём счётчикам.
|
||||
"""
|
||||
db = _UpsertDB(already_present=True)
|
||||
counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS))
|
||||
assert counts.price_inserted == 0
|
||||
assert counts.price_updated == PROD_POINTS
|
||||
assert counts.price_written == PROD_POINTS
|
||||
assert db.price_writes == PROD_POINTS
|
||||
|
||||
|
||||
def test_nothing_to_write_stays_zero() -> None:
|
||||
"""Встречная проверка: пустой график — ноль и во «вставлено», и в «обновлено»."""
|
||||
db = _UpsertDB(already_present=True)
|
||||
counts = save_newbuilding_enrichment(db, 42, _enrichment(0))
|
||||
assert (counts.price_inserted, counts.price_updated, counts.price_written) == (0, 0, 0)
|
||||
|
||||
|
||||
def test_points_without_price_are_not_counted_as_written() -> None:
|
||||
"""Точка без price_per_sqm пропускается писателем — и не попадает в счёт."""
|
||||
enrichment = _enrichment(2)
|
||||
enrichment.realty_valuation_chart[0]["price_per_sqm"] = None
|
||||
db = _UpsertDB(already_present=False)
|
||||
counts = save_newbuilding_enrichment(db, 42, enrichment)
|
||||
assert counts.price_written == 1
|
||||
assert db.price_writes == 1
|
||||
|
||||
|
||||
# ── 2. Сторож нулевого результата не ослаблен ────────────────────────────────
|
||||
|
||||
|
||||
def _finalize(counters: dict[str, int]) -> str:
|
||||
"""Прогнать counters через боевой финализатор и вернуть выбранный статус."""
|
||||
chosen: dict[str, str] = {}
|
||||
with (
|
||||
patch.object(runs_mod, "mark_done", lambda *a, **k: chosen.setdefault("s", "done")),
|
||||
patch.object(runs_mod, "mark_failed", lambda *a, **k: chosen.setdefault("s", "failed")),
|
||||
patch.object(runs_mod, "mark_banned", lambda *a, **k: chosen.setdefault("s", "banned")),
|
||||
):
|
||||
runs_mod.mark_backfill_finished(MagicMock(), 1, counters, source="newbuilding_enrich")
|
||||
return chosen["s"]
|
||||
|
||||
|
||||
def test_watchdog_still_fails_a_run_that_enriched_nothing() -> None:
|
||||
"""Прогон без обогащений остаётся 'failed', сколько бы записей ни насчитали.
|
||||
|
||||
Числа записи в решение сторожа не входят вовсе — он судит по attempted/enriched.
|
||||
Если бы входили, честный счётчик «обновлено» превратил бы холостой прогон в успех.
|
||||
"""
|
||||
result = NewbuildingEnrichBackfillResult(
|
||||
processed=25,
|
||||
succeeded=0,
|
||||
failed_fetch=25,
|
||||
price_dynamics_updated=PROD_POINTS, # «что-то писали» — но никого не обогатили
|
||||
)
|
||||
assert _finalize(result.to_backfill_counters()) == "failed"
|
||||
|
||||
|
||||
def test_watchdog_verdict_ignores_the_new_keys() -> None:
|
||||
"""Явно: добавление/убирание новых ключей не двигает вердикт ни в одну сторону."""
|
||||
base = {"attempted": 25, "enriched": 3, "failed": 22}
|
||||
assert _finalize(dict(base)) == "done"
|
||||
assert _finalize({**base, "price_dynamics_inserted": 0, "price_dynamics_updated": 0}) == "done"
|
||||
zero = {"attempted": 25, "enriched": 0, "failed": 25}
|
||||
assert _finalize(dict(zero)) == "failed"
|
||||
assert _finalize({**zero, "price_dynamics_updated": 999}) == "failed"
|
||||
|
||||
|
||||
def test_counters_carry_both_numbers_into_the_run() -> None:
|
||||
"""В scrape_runs.counters уезжают ОБА числа — ноль одного больше не читается как ноль."""
|
||||
counters = NewbuildingEnrichBackfillResult(
|
||||
processed=25, succeeded=25, price_dynamics_updated=PROD_POINTS
|
||||
).to_backfill_counters()
|
||||
assert counters["price_dynamics_inserted"] == 0
|
||||
assert counters["price_dynamics_updated"] == PROD_POINTS
|
||||
# Старые имена не должны остаться: у них в истории прогонов другой смысл.
|
||||
assert "price_dynamics_rows" not in counters
|
||||
assert "reliability_rows" not in counters
|
||||
assert "review_rows" not in counters
|
||||
|
||||
|
||||
def test_save_counts_written_is_the_sum() -> None:
|
||||
assert NewbuildingSaveCounts(price_inserted=3, price_updated=4).price_written == 7
|
||||
340
tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
Normal file
340
tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"""#2830: хвосты инцидента 2026-08-10 — пути, которые ещё ходили мимо пула прокси.
|
||||
|
||||
Корень (issue #2830, разбор владельца): `SCRAPER_PROXY_URL` — один статичный узел,
|
||||
общий для всех источников и НЕ знающий про `scrape_proxy_source_bans`. Три места
|
||||
продолжали брать egress именно оттуда:
|
||||
|
||||
1. `cian_price_history` (ручка POST /admin/scrape/cian-price-history);
|
||||
2. `_provider_proxy_url` / GET /admin/scraper/health — ops-панель показывала статичный
|
||||
узел, пока трафик уже выбирался пулом (#2825/#2831);
|
||||
3. `resolve_cian_zhk_url_via_search` — вторая нога обогащения ЖК: #2767 перевёл на пул
|
||||
только `fetch_newbuilding`, резолв ЖК-url остался на `config.cian_proxy_url`.
|
||||
|
||||
Тесты проверяют ПОВЕДЕНИЕ пула (какой узел взят и какой вердикт вернулся), а не наличие
|
||||
kwarg'а в вызове. Красные на старом коде:
|
||||
|
||||
* (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не
|
||||
сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»:
|
||||
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`,
|
||||
где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`).
|
||||
* (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел
|
||||
параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан»
|
||||
выражения не имел вообще.
|
||||
* (3) `resolve_cian_zhk_url_via_search` на 403 возвращал None: узел получал
|
||||
`mark_health(ok=True)` и оставался в выдаче Циану (механика #2700/#2821).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.cian_exceptions import CianBlockedError
|
||||
from scraper_kit.contracts import ProxyLease
|
||||
from scraper_kit.providers.cian import detail as cian_detail
|
||||
from scraper_kit.providers.cian import newbuilding as cian_newbuilding
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
|
||||
# Узел 9 (asocks-mobile-1) — тот, что 2026-08-10 отдавал Циану 200, пока статичный
|
||||
# SCRAPER_PROXY_URL смотрел на забаненный узел 1 (asocks-residential-1).
|
||||
_LEASE = ProxyLease(id=9, url="http://user:pass@pool-node-9:10313", kind="http", rotate_url=None)
|
||||
|
||||
|
||||
class _SpyProvider:
|
||||
"""ProxyProvider-заглушка: запоминает вердикты, которые путь вернул пулу."""
|
||||
|
||||
def __init__(self, lease: ProxyLease | None = _LEASE) -> None:
|
||||
self._lease = lease
|
||||
self.acquire_calls: list[str] = []
|
||||
self.mark_health_calls: list[tuple[int, bool]] = []
|
||||
self.mark_banned_calls: list[tuple[int, str]] = []
|
||||
self.release_calls: list[int] = []
|
||||
|
||||
def acquire(self, provider: str) -> ProxyLease | None:
|
||||
self.acquire_calls.append(provider)
|
||||
return self._lease
|
||||
|
||||
def release(self, lease: ProxyLease) -> None:
|
||||
self.release_calls.append(lease.id)
|
||||
|
||||
def mark_health(
|
||||
self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None
|
||||
) -> None:
|
||||
self.mark_health_calls.append((lease.id, ok))
|
||||
|
||||
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
|
||||
self.mark_banned_calls.append((lease.id, source))
|
||||
|
||||
|
||||
class _SpyPool:
|
||||
"""Заглушка МОДУЛЯ `app.services.proxy_pool` — подставляется ПОД `RealProxyProvider`.
|
||||
|
||||
Так тест краснеет на поведении (пул не получил вердикт), а не на отсутствии нового
|
||||
имени в модуле: старый код проходит ровно тот же путь, просто ничего пулу не говорит.
|
||||
"""
|
||||
|
||||
def __init__(self, lease: Any = _LEASE) -> None:
|
||||
self._lease = lease
|
||||
self.acquire_calls: list[str] = []
|
||||
self.mark_health_calls: list[tuple[int, bool]] = []
|
||||
self.mark_banned_calls: list[tuple[int, str]] = []
|
||||
self.release_calls: list[int] = []
|
||||
|
||||
def acquire(self, db: Any, provider: str, **kw: Any) -> Any:
|
||||
self.acquire_calls.append(provider)
|
||||
return self._lease
|
||||
|
||||
def release(self, db: Any, lease_id: int) -> None:
|
||||
self.release_calls.append(lease_id)
|
||||
|
||||
def mark_health(self, db: Any, lease_id: int, ok: bool, **kw: Any) -> None:
|
||||
self.mark_health_calls.append((lease_id, ok))
|
||||
|
||||
def mark_banned(self, db: Any, lease_id: int, *, source: str) -> None:
|
||||
self.mark_banned_calls.append((lease_id, source))
|
||||
|
||||
|
||||
def _session_returning(status_code: int, text: str = "") -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text))
|
||||
session.close = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
# ── 1. cian_price_history: узел из пула + вердикт обратно ────────────────────
|
||||
|
||||
|
||||
def _price_history_db(n_listings: int) -> MagicMock:
|
||||
"""Фейковая сессия: SELECT листингов, дальше COUNT'ы (до fetch дело не дойдёт)."""
|
||||
rows = [
|
||||
{"id": 100 + i, "source_url": f"https://ekb.cian.ru/sale/flat/{100 + i}/"}
|
||||
for i in range(n_listings)
|
||||
]
|
||||
listings_result = MagicMock()
|
||||
listings_result.mappings.return_value.all.return_value = rows
|
||||
db = MagicMock()
|
||||
db.execute.return_value = listings_result
|
||||
return db
|
||||
|
||||
|
||||
async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any:
|
||||
from app.services import scraper_adapters
|
||||
from app.services.cian_price_history import backfill_cian_price_history
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
|
||||
),
|
||||
patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
|
||||
patch.object(scraper_adapters, "_proxy_pool", pool),
|
||||
patch.object(scraper_adapters, "_SessionLocal", MagicMock()),
|
||||
):
|
||||
return await backfill_cian_price_history(_price_history_db(n), batch_size=n)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_history_takes_pool_node_despite_flag_off() -> None:
|
||||
"""Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend).
|
||||
|
||||
Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного
|
||||
pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
assert (
|
||||
settings.use_proxy_pool_curl is False
|
||||
), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL"
|
||||
pool = _SpyPool()
|
||||
await _run_price_history(pool, status_code=200)
|
||||
assert pool.acquire_calls == ["cian"]
|
||||
assert pool.release_calls == [9] # lease не течёт
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_history_403_bans_the_node_for_cian() -> None:
|
||||
"""403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = []."""
|
||||
pool = _SpyPool()
|
||||
result = await _run_price_history(pool, status_code=403)
|
||||
assert pool.mark_banned_calls == [(9, "cian")]
|
||||
assert pool.mark_health_calls == [(9, False)]
|
||||
assert result.errors == 1 # прогон честен: отказ посчитан
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_history_404_does_not_ban_the_node() -> None:
|
||||
"""Снятое объявление — не бан площадки: чужой вердикт узлу не присваиваем (#2805)."""
|
||||
pool = _SpyPool()
|
||||
await _run_price_history(pool, status_code=404)
|
||||
assert pool.mark_banned_calls == []
|
||||
assert pool.mark_health_calls == [(9, True)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_history_aborts_batch_when_pool_empty() -> None:
|
||||
"""Пул пуст в проде → отказ ДО HTTP и разрыв батча, а не 3 попытки подряд."""
|
||||
from app.core.config import settings
|
||||
|
||||
pool = _SpyPool(lease=None)
|
||||
# Прод-признак читает `curl_proxy_url` через config.environment (#2616 шаг 1):
|
||||
# только в проде пустой пул = отказ, в dev это легитимный env/direct-путь.
|
||||
with patch.object(settings, "environment", "production"):
|
||||
result = await _run_price_history(pool, status_code=200, n=3)
|
||||
assert pool.acquire_calls == ["cian"], "после отказа пула батч продолжаться не должен"
|
||||
assert result.errors == 1
|
||||
assert result.checked == 3 # выбрали 3, но оборвались на первом
|
||||
|
||||
|
||||
def test_no_proxy_available_is_not_a_platform_ban() -> None:
|
||||
"""Наш отказ инфраструктуры не должен читаться как бан площадки (#2616)."""
|
||||
from scraper_kit.proxy_errors import ProxyBanError
|
||||
|
||||
assert not issubclass(NoProxyAvailableError, ProxyBanError)
|
||||
|
||||
|
||||
# ── 2. /admin/scraper/health: показывает узел, который реально получит трафик ──
|
||||
|
||||
|
||||
def _health_db(*, candidate: dict[str, Any] | None, diag: dict[str, int]) -> MagicMock:
|
||||
"""Сессия для `proxy_egress`: SELECT кандидата, при пустом — SELECT-разбивка причин."""
|
||||
|
||||
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
|
||||
row = diag if "count(*)" in str(stmt).lower() else candidate
|
||||
res = MagicMock()
|
||||
res.mappings.return_value.fetchone.return_value = row
|
||||
return res
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = _execute
|
||||
return db
|
||||
|
||||
|
||||
def _get_health(db: MagicMock) -> dict[str, Any]:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import admin as admin_module
|
||||
from app.core.db import get_db
|
||||
|
||||
def _fake_db() -> Any:
|
||||
yield db
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(admin_module.router, prefix="/api/v1/admin")
|
||||
app.dependency_overrides[get_db] = _fake_db
|
||||
|
||||
async def _no_browser() -> Any:
|
||||
return admin_module.BrowserHealth(reachable=False, browsers={})
|
||||
|
||||
async def _no_ip(proxy_url: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
with (
|
||||
patch.object(admin_module, "_probe_browser_health", _no_browser),
|
||||
patch.object(admin_module, "_probe_current_ip", _no_ip),
|
||||
# Статичный env-узел ОТЛИЧАЕТСЯ от пулового — иначе тест не различит источники.
|
||||
patch.object(
|
||||
admin_module.settings.__class__,
|
||||
"scraper_proxy_url",
|
||||
property(lambda _self: "http://u:p@static-env-node:10423"),
|
||||
),
|
||||
):
|
||||
r = TestClient(app).get("/api/v1/admin/scraper/health")
|
||||
assert r.status_code == 200
|
||||
return {p["source"]: p for p in r.json()["providers"]}
|
||||
|
||||
|
||||
def test_health_shows_pool_node_not_static_env() -> None:
|
||||
"""Панель показывает узел, который получит трафик, а не SCRAPER_PROXY_URL.
|
||||
|
||||
Красный на старом коде поведенчески: возвращался `static-env-node` — ровно то
|
||||
расхождение «панель показывает один узел, трафик идёт через другой», из-за
|
||||
которого инцидент 2026-08-10 месяц оставался невидимым.
|
||||
"""
|
||||
db = _health_db(
|
||||
candidate={"id": 9, "url": _LEASE.url, "label": "asocks-mobile-1"},
|
||||
diag={"pool_total": 4, "banned_for_source": 0, "unhealthy_or_disabled": 0},
|
||||
)
|
||||
by_source = _get_health(db)
|
||||
assert {p["proxy_host"] for p in by_source.values()} == {"pool-node-9"}
|
||||
assert by_source["cian"]["proxy_port"] == 10313
|
||||
|
||||
|
||||
def test_health_shows_nothing_when_pool_exhausted_for_source() -> None:
|
||||
"""Все узлы забанены для источника → «—», а не статичный env-узел.
|
||||
|
||||
Зелёная строка на месте отказа хуже пустой: она читается как «прокси есть».
|
||||
"""
|
||||
db = _health_db(
|
||||
candidate=None,
|
||||
diag={"pool_total": 4, "banned_for_source": 4, "unhealthy_or_disabled": 0},
|
||||
)
|
||||
by_source = _get_health(db)
|
||||
assert by_source["cian"]["proxy_host"] is None
|
||||
assert by_source["cian"]["proxy_port"] is None
|
||||
|
||||
|
||||
# ── 3. resolve ЖК-url: вторая нога обогащения (#2767 чинил только первую) ─────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeConfig:
|
||||
use_proxy_pool_curl: bool = True
|
||||
cian_proxy_url: str | None = "http://static-env-node:10423"
|
||||
environment: str = "production"
|
||||
|
||||
|
||||
async def _resolve(status_code: int, spy: _SpyProvider, html: str = "") -> Any:
|
||||
with patch.object(
|
||||
cian_newbuilding,
|
||||
"build_curl_cffi_session",
|
||||
return_value=_session_returning(status_code, html),
|
||||
):
|
||||
return await cian_newbuilding.resolve_cian_zhk_url_via_search(
|
||||
48853, config=_FakeConfig(), proxy_provider=spy
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zhk_resolve_uses_pool_node() -> None:
|
||||
"""Резолв ЖК-url берёт узел из пула, а не статичный cian_proxy_url."""
|
||||
spy = _SpyProvider()
|
||||
html = '<h1 data-name="Title"><a href="https://zhk-parkovyy-kvartal-ekb-i.cian.ru">ЖК</a></h1>'
|
||||
assert await _resolve(200, spy, html) == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
||||
assert spy.acquire_calls == ["cian"]
|
||||
assert spy.mark_health_calls == [(9, True)]
|
||||
assert spy.release_calls == [9]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zhk_resolve_403_reaches_the_pool() -> None:
|
||||
"""Красный на старом коде: 403 гасился в `return None`, узел оставался в выдаче."""
|
||||
spy = _SpyProvider()
|
||||
with pytest.raises(CianBlockedError):
|
||||
await _resolve(403, spy)
|
||||
assert spy.mark_banned_calls == [(9, "cian")]
|
||||
assert spy.mark_health_calls == [(9, False)]
|
||||
assert spy.release_calls == [9]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zhk_resolve_404_is_a_soft_miss_not_a_ban() -> None:
|
||||
"""404 cat.php — не бан: здоровый узел за отсутствующий ЖК не наказываем."""
|
||||
spy = _SpyProvider()
|
||||
assert await _resolve(404, spy) is None
|
||||
assert spy.mark_banned_calls == []
|
||||
assert spy.mark_health_calls == [(9, True)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zhk_resolve_empty_serp_is_a_soft_miss_not_a_ban() -> None:
|
||||
"""200 без слага (дрейф вёрстки / пустая выдача) — тоже не повод банить узел."""
|
||||
spy = _SpyProvider()
|
||||
assert await _resolve(200, spy, "<html><body>ничего</body></html>") is None
|
||||
assert spy.mark_banned_calls == []
|
||||
assert spy.mark_health_calls == [(9, True)]
|
||||
290
tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py
Normal file
290
tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"""Offline tests для Cian cookie-session admin-эндпоинтов (#инцидент 2026-08-10).
|
||||
|
||||
cian_session_svc.verify_session() возвращает 5 разных исходов (state dict / None /
|
||||
VERIFY_BAN_SENTINEL / VERIFY_SOURCE_UNAVAILABLE_SENTINEL / VERIFY_MARKUP_CHANGED_SENTINEL)
|
||||
и раньше admin.py проверял только `if state is None`, из-за чего реальный бан по IP
|
||||
(403) и недоступность источника (5xx) выглядели как "куки протухли" — человек в
|
||||
момент инцидента перезаливал заведомо валидные куки вместо починки egress/прокси.
|
||||
|
||||
Покрытие 3 эндпоинтов (db/verify_session/BrowserFetcher мокаются, NO live network/DB),
|
||||
зеркалит паттерн test_domclick_admin_apis.py (dependency_overrides[get_db] + TestClient):
|
||||
- POST /api/v1/admin/scrape/cian/upload-cookies
|
||||
- POST /api/v1/admin/scrape/cian/auto-login
|
||||
- GET /api/v1/admin/scrape/cian/test-auth
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.services.cian_session import (
|
||||
VERIFY_BAN_SENTINEL,
|
||||
VERIFY_MARKUP_CHANGED_SENTINEL,
|
||||
VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
from app.api.v1 import admin as admin_module
|
||||
from app.core.db import get_db
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(admin_module.router, prefix="/api/v1/admin")
|
||||
|
||||
def fake_db():
|
||||
yield MagicMock()
|
||||
|
||||
app.dependency_overrides[get_db] = fake_db
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
_UPLOAD_URL = "/api/v1/admin/scrape/cian/upload-cookies"
|
||||
_AUTOLOGIN_URL = "/api/v1/admin/scrape/cian/auto-login"
|
||||
_TEST_AUTH_URL = "/api/v1/admin/scrape/cian/test-auth"
|
||||
|
||||
_RAW_COOKIES = {"DMIR_AUTH": "abc", "_CIAN_GK": "def"}
|
||||
_AUTHENTICATED_STATE = {"user": {"isAuthenticated": True, "userId": 102963817}}
|
||||
_AUTHENTICATED_STATE_NO_USERID = {"user": {"isAuthenticated": True}}
|
||||
|
||||
|
||||
# ── POST /scrape/cian/upload-cookies — пять исходов verify_session ────────────
|
||||
|
||||
|
||||
def test_upload_cookies_ban_returns_503(client: TestClient) -> None:
|
||||
"""403 TLS/bot ban → 503, текст говорит чинить прокси, НЕ перезаливать куки."""
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
|
||||
),
|
||||
):
|
||||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||||
assert resp.status_code == 503
|
||||
detail = resp.json()["detail"]
|
||||
assert "заблокировал" in detail
|
||||
assert "прокси" in detail or "egress" in detail
|
||||
assert "бесполезно" in detail
|
||||
# Текст обязан называть ЖИВУЮ переменную: инцидент 2026-08-10 — правка мёртвой
|
||||
# CIAN_PROXY_URL не давала эффекта, реальный egress задаётся SCRAPER_PROXY_URL.
|
||||
assert "SCRAPER_PROXY_URL" in detail
|
||||
|
||||
|
||||
def test_upload_cookies_source_unavailable_returns_503(client: TestClient) -> None:
|
||||
"""5xx/сеть → 503, текст говорит повторить позже, куки не трогать."""
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL),
|
||||
),
|
||||
):
|
||||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||||
assert resp.status_code == 503
|
||||
detail = resp.json()["detail"]
|
||||
assert "недоступен" in detail
|
||||
assert "позже" in detail
|
||||
|
||||
|
||||
def test_upload_cookies_markup_changed_returns_500(client: TestClient) -> None:
|
||||
"""200 но auth-state не распарсился → 500, текст указывает на фикс парсера."""
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL),
|
||||
),
|
||||
):
|
||||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||||
assert resp.status_code == 500
|
||||
detail = resp.json()["detail"]
|
||||
assert "вёрстк" in detail
|
||||
assert "перезалив" in detail
|
||||
|
||||
|
||||
def test_upload_cookies_expired_returns_401(client: TestClient) -> None:
|
||||
"""None → 401, текст просит перезалить куки (единственный случай re-upload)."""
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||||
assert resp.status_code == 401
|
||||
assert "перезалейте" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_upload_cookies_success_missing_userid_returns_400(client: TestClient) -> None:
|
||||
"""Успешный state, но без userId → 400 с уточнением что куки тут ни при чём."""
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=_AUTHENTICATED_STATE_NO_USERID),
|
||||
),
|
||||
):
|
||||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||||
assert resp.status_code == 400
|
||||
detail = resp.json()["detail"]
|
||||
assert "userId" in detail
|
||||
assert "куки тут ни при" in detail
|
||||
|
||||
|
||||
def test_upload_cookies_success_saves_and_returns_200(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=_AUTHENTICATED_STATE),
|
||||
),
|
||||
patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save,
|
||||
):
|
||||
resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["ok"] is True
|
||||
assert body["userId"] == 102963817
|
||||
mock_save.assert_called_once()
|
||||
_, kwargs = mock_save.call_args
|
||||
assert kwargs["account_user_id"] == 102963817
|
||||
|
||||
|
||||
# ── POST /scrape/cian/auto-login — тот же маппинг после успешного browser-login ──
|
||||
|
||||
|
||||
def _mock_browser_fetcher(raw_cookies: dict[str, str]) -> MagicMock:
|
||||
fetcher = AsyncMock()
|
||||
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
|
||||
fetcher.__aexit__ = AsyncMock(return_value=None)
|
||||
fetcher.login = AsyncMock(return_value=raw_cookies)
|
||||
return fetcher
|
||||
|
||||
|
||||
def test_auto_login_ban_returns_503(client: TestClient) -> None:
|
||||
"""Browser login прошёл, но verify_session ловит 403 ban → 503, не "куки протухли"."""
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"),
|
||||
patch("app.api.v1.admin.settings.cian_login_password", "secret"),
|
||||
patch(
|
||||
"app.api.v1.admin.BrowserFetcher",
|
||||
return_value=_mock_browser_fetcher(_RAW_COOKIES),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
|
||||
),
|
||||
):
|
||||
resp = client.post(_AUTOLOGIN_URL)
|
||||
assert resp.status_code == 503
|
||||
assert "заблокировал" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_auto_login_success_saves_and_returns_200(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"),
|
||||
patch("app.api.v1.admin.settings.cian_login_password", "secret"),
|
||||
patch(
|
||||
"app.api.v1.admin.BrowserFetcher",
|
||||
return_value=_mock_browser_fetcher(_RAW_COOKIES),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=_AUTHENTICATED_STATE),
|
||||
),
|
||||
patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save,
|
||||
):
|
||||
resp = client.post(_AUTOLOGIN_URL)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["userId"] == 102963817
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
# ── GET /scrape/cian/test-auth — reason различает все пять исходов ────────────
|
||||
|
||||
|
||||
def test_test_auth_ban_reason(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
|
||||
),
|
||||
):
|
||||
resp = client.get(_TEST_AUTH_URL)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["authenticated"] is False
|
||||
assert body["reason"] == "banned_403"
|
||||
|
||||
|
||||
def test_test_auth_source_unavailable_reason(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL),
|
||||
),
|
||||
):
|
||||
resp = client.get(_TEST_AUTH_URL)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["reason"] == "source_unavailable"
|
||||
|
||||
|
||||
def test_test_auth_markup_changed_reason(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL),
|
||||
),
|
||||
):
|
||||
resp = client.get(_TEST_AUTH_URL)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["reason"] == "markup_changed"
|
||||
|
||||
|
||||
def test_test_auth_expired_reason(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
resp = client.get(_TEST_AUTH_URL)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["reason"] == "session_expired_or_invalid"
|
||||
|
||||
|
||||
def test_test_auth_success(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
|
||||
patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
|
||||
patch(
|
||||
"app.api.v1.admin.cian_session_svc.verify_session",
|
||||
new=AsyncMock(return_value=_AUTHENTICATED_STATE),
|
||||
),
|
||||
):
|
||||
resp = client.get(_TEST_AUTH_URL)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["authenticated"] is True
|
||||
assert body["userId"] == 102963817
|
||||
assert body["reason"] is None
|
||||
|
|
@ -500,7 +500,11 @@ async def test_backfill_house_imv_ok_path():
|
|||
patch("app.services.house_imv_backfill.save_imv_result") as mock_save,
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = houses
|
||||
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
|
||||
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
|
||||
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
|
||||
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
|
||||
mock_mappings.all.side_effect = [[], houses]
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
|
||||
|
|
@ -539,7 +543,11 @@ async def test_backfill_house_imv_no_params():
|
|||
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = houses
|
||||
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
|
||||
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
|
||||
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
|
||||
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
|
||||
mock_mappings.all.side_effect = [[], houses]
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
|
||||
|
|
@ -593,7 +601,11 @@ async def test_backfill_house_imv_not_found():
|
|||
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = houses
|
||||
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
|
||||
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
|
||||
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
|
||||
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
|
||||
mock_mappings.all.side_effect = [[], houses]
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
|
||||
|
|
|
|||
|
|
@ -57,8 +57,9 @@ def _mock_db_bti(
|
|||
address: str | None = "Екатеринбург, улица Малышева, 51",
|
||||
lat: float = 56.83,
|
||||
lon: float = 60.6,
|
||||
city: str | None = "Екатеринбург",
|
||||
) -> MagicMock:
|
||||
"""Mock db: SELECT address/lat/lon FROM listings → mappings().first() dict."""
|
||||
"""Mock db: SELECT address/city/lat/lon FROM listings → mappings().first() dict."""
|
||||
db = MagicMock()
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -72,9 +73,10 @@ def _mock_db_bti(
|
|||
mock_result = MagicMock()
|
||||
mock_result.fetchone.return_value = None
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
if "SELECT address, lat, lon FROM listings" in sql_str:
|
||||
if "SELECT address, city, lat, lon FROM listings" in sql_str:
|
||||
mock_result.mappings.return_value.first.return_value = {
|
||||
"address": address,
|
||||
"city": city,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
}
|
||||
|
|
@ -110,6 +112,9 @@ def test_bti_data_present_updates_house_columns_via_coalesce():
|
|||
assert kwargs["ext_source"] == "cian_bti"
|
||||
assert kwargs["ext_id"] == "123"
|
||||
assert kwargs["address"] == "Екатеринбург, улица Малышева, 51"
|
||||
# #2777: город-цель развёртки (listings.city) — независимое от адреса наблюдение;
|
||||
# без него бескоординатная BTI-карточка матчится в одноимённый дом другого города.
|
||||
assert kwargs["city"] == "Екатеринбург"
|
||||
|
||||
call = _bti_update_call(db)
|
||||
assert call is not None, "UPDATE houses с BTI-полями не найден"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,21 @@ def mock_db() -> MagicMock:
|
|||
return db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_resolve_proxy_url_sync(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""#2825: verify_session больше не читает settings.cian_proxy_url напрямую, а
|
||||
зовёт resolve_proxy_url_sync("cian") (пул scrape_proxies + fallback внутри
|
||||
app.services.proxy_egress, отдельно покрыт tests/services/test_proxy_egress.py).
|
||||
Без мока это реальный SessionLocal() -> живая (падающая в test-окружении) БД,
|
||||
из-за чего verify_session уходил в generic except ДО session.get и все
|
||||
verify_session-тесты ниже ловили не то, что проверяют. Мокаем на уровне модуля,
|
||||
чтобы не трогать каждый тест по отдельности."""
|
||||
monkeypatch.setattr(
|
||||
"app.services.cian_session.resolve_proxy_url_sync",
|
||||
lambda source: "http://test-proxy.local:8080",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CIAN_REQUIRED_COOKIES
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -424,6 +439,40 @@ async def test_verify_session_network_error_returns_source_unavailable_sentinel(
|
|||
assert result is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_session_pool_exhausted_returns_source_unavailable_with_error_log(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""#2825 fail-closed (#2616): пул scrape_proxies исчерпан для cian (все узлы
|
||||
забанены/нездоровы) — session.get НЕ вызывается (никуда не ходим без egress),
|
||||
возвращается VERIFY_SOURCE_UNAVAILABLE_SENTINEL, но с ERROR-логом (не warning,
|
||||
отдельным от обычного network-error пути) — явная деградация, а не проглатывание."""
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError
|
||||
|
||||
def _raise(source: str) -> str | None:
|
||||
raise ProxyPoolExhaustedError(
|
||||
source, pool_total=2, banned_for_source=2, unhealthy_or_disabled=0
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.services.cian_session.resolve_proxy_url_sync", _raise)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.get = AsyncMock(return_value=_make_cffi_resp(200))
|
||||
|
||||
with (
|
||||
patch("app.services.cian_session.AsyncSession", return_value=mock_session),
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
result = await verify_session({"DMIR_AUTH": "x"})
|
||||
|
||||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
mock_session.get.assert_not_called()
|
||||
errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
|
||||
assert any("пул прокси исчерпан" in rec.message.lower() for rec in errors)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_session_uses_chrome120_impersonate() -> None:
|
||||
"""curl_cffi AsyncSession must be constructed with impersonate='chrome120'."""
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
|
|||
retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every
|
||||
row that existed before that migration; explicit param lets retention-gate
|
||||
tests (see test_estimate_retention_gate.py) construct a paid row.
|
||||
|
||||
relaxations/reliability (migration 255) default to the schema defaults
|
||||
('[]' / 'ok') -- matches every pre-migration row (no backfill). Revival
|
||||
scenarios (a "dead" median_price<=0/NULL row) are covered separately in
|
||||
test_estimate_revival.py with their own dedicated row builders, since this
|
||||
fixture's downstream tests here all assume a "live" estimate.
|
||||
"""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
|
|
@ -99,6 +105,8 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
|
|||
ratio_basis="per_rooms",
|
||||
created_by=created_by,
|
||||
created_at=datetime.now(tz=UTC),
|
||||
relaxations=[],
|
||||
reliability="ok",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -155,6 +163,10 @@ def _stub_precision_and_pdf():
|
|||
# #2087 (M1): GET-rehydrate derives canonical sources_used via the shared
|
||||
# helper. Empty analogs + no valuation flags → [] (real behaviour).
|
||||
_canonical_sources=lambda *a, **k: [],
|
||||
# #2632: GET-rehydrate реконструирует фактический радиус подбора. У этих
|
||||
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
|
||||
# поведение (см. tests/test_estimator_search_radius_2632.py).
|
||||
rehydrate_search_radius_m=lambda *a, **k: None,
|
||||
)
|
||||
real_estimator = sys.modules.get("app.services.estimator")
|
||||
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
||||
|
|
|
|||
602
tradein-mvp/backend/tests/test_estimate_revival.py
Normal file
602
tradein-mvp/backend/tests/test_estimate_revival.py
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
"""Tests for GET /estimate/{id} revival of "dead" (median_price<=0/NULL) rows.
|
||||
|
||||
Incident 2026-08-10: a customer opened a saved estimate link and saw
|
||||
«НЕДОСТАТОЧНО ДАННЫХ» — the row was created BEFORE the estimator fix
|
||||
(#oblast-E/#oblast-F, PR #2823/#2825) and is permanently stuck at
|
||||
median_price=0, even though the same address/params now compute a real
|
||||
price. app.api.v1.trade_in::_try_revive_dead_estimate recomputes such a row
|
||||
in place (same id/link) via the same estimate_quality() path as POST
|
||||
/estimate. Also covers migration 255 (relaxations/reliability persistence).
|
||||
|
||||
Реальная БД не нужна: DB + get_role + estimate_quality мокируются, mirroring
|
||||
test_estimate_idor.py's approach (self-contained, no cross-file fixture
|
||||
imports — this repo has no precedent for importing fixtures across
|
||||
tests/test_*.py modules, only from tests/support/).
|
||||
|
||||
DB mock dispatches by SQL substring (not call-position): get_estimate's
|
||||
existing rehydrate path calls the real (non-stubbed) _resolve_target_house_id
|
||||
helper, which itself fires 1-2 incidental `SELECT id FROM houses` queries
|
||||
whenever the revival attempt does NOT short-circuit with an early return —
|
||||
hand-counting positional side_effect entries around that would be brittle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import.
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222"
|
||||
_TEMP_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_get_role():
|
||||
"""Restore app.core.auth.get_role after each test (mirror test_estimate_idor)."""
|
||||
from app.core import auth as auth_mod
|
||||
|
||||
original = auth_mod.get_role
|
||||
yield
|
||||
auth_mod.get_role = original
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def trade_in_app() -> FastAPI:
|
||||
"""Minimal FastAPI app mounting only the trade-in router."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
return application
|
||||
|
||||
|
||||
def _make_dead_row(
|
||||
*,
|
||||
median_price: int | None = 0,
|
||||
house_type: str | None = None,
|
||||
repair_state: str | None = None,
|
||||
relaxations: list[str] | None = None,
|
||||
reliability: str = "ok",
|
||||
) -> SimpleNamespace:
|
||||
"""A trade_in_estimates row in the "dead" state (incident 2026-08-10).
|
||||
|
||||
house_type/repair_state default to None (valid TradeInEstimateInput input —
|
||||
most revival tests need a row that round-trips through pydantic cleanly);
|
||||
a dedicated test passes legacy Russian literal values to exercise the
|
||||
"invalid persisted value" graceful-degrade path.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
id=_ESTIMATE_ID,
|
||||
median_price=median_price,
|
||||
range_low=0,
|
||||
range_high=0,
|
||||
median_price_per_m2=0,
|
||||
confidence="low",
|
||||
confidence_explanation="Рядом найдено недостаточно объявлений (4 шт.)",
|
||||
n_analogs=0,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
sources_used=[],
|
||||
data_freshness_minutes=None,
|
||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
|
||||
retain_until=None,
|
||||
address="г Екатеринбург, ул Академика Парина, д 46, к 5",
|
||||
lat=56.8519,
|
||||
lon=60.6122,
|
||||
area_m2=23.1,
|
||||
rooms=1,
|
||||
floor=5,
|
||||
total_floors=16,
|
||||
year_built=2018,
|
||||
house_type=house_type,
|
||||
repair_state=repair_state,
|
||||
has_balcony=None,
|
||||
canonical_address=None,
|
||||
house_cadnum=None,
|
||||
house_fias_id=None,
|
||||
dadata_qc_geo=None,
|
||||
dadata_metro=[],
|
||||
expected_sold_price=None,
|
||||
expected_sold_range_low=None,
|
||||
expected_sold_range_high=None,
|
||||
expected_sold_per_m2=None,
|
||||
asking_to_sold_ratio=None,
|
||||
ratio_basis=None,
|
||||
created_by="kopylov",
|
||||
created_at=datetime(2026, 5, 29, tzinfo=UTC),
|
||||
relaxations=relaxations or [],
|
||||
reliability=reliability,
|
||||
)
|
||||
|
||||
|
||||
def _make_live_row(
|
||||
*, relaxations: list[str] | None = None, reliability: str = "ok"
|
||||
) -> SimpleNamespace:
|
||||
"""A "live" row (median_price>0) — revival must never touch it."""
|
||||
return SimpleNamespace(
|
||||
id=_ESTIMATE_ID,
|
||||
median_price=4_031_157,
|
||||
range_low=3_700_000,
|
||||
range_high=4_300_000,
|
||||
median_price_per_m2=174_000,
|
||||
confidence="medium",
|
||||
confidence_explanation="Найдено 39 аналогов",
|
||||
n_analogs=39,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
sources_used=["avito", "rosreestr"],
|
||||
data_freshness_minutes=15,
|
||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
|
||||
retain_until=None,
|
||||
address="г Екатеринбург, ул Академика Парина, д 46, к 5",
|
||||
lat=56.8519,
|
||||
lon=60.6122,
|
||||
area_m2=23.1,
|
||||
rooms=1,
|
||||
floor=5,
|
||||
total_floors=16,
|
||||
year_built=2018,
|
||||
house_type=None,
|
||||
repair_state=None,
|
||||
has_balcony=None,
|
||||
canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5",
|
||||
house_cadnum=None,
|
||||
house_fias_id=None,
|
||||
dadata_qc_geo=0,
|
||||
dadata_metro=[],
|
||||
expected_sold_price=None,
|
||||
expected_sold_range_low=None,
|
||||
expected_sold_range_high=None,
|
||||
expected_sold_per_m2=None,
|
||||
asking_to_sold_ratio=None,
|
||||
ratio_basis=None,
|
||||
created_by="kopylov",
|
||||
created_at=datetime.now(tz=UTC),
|
||||
relaxations=relaxations or [],
|
||||
reliability=reliability,
|
||||
)
|
||||
|
||||
|
||||
def _fake_revived_result(**overrides):
|
||||
"""A canned AggregatedEstimate mimicking a successful estimate_quality() call."""
|
||||
from app.schemas.trade_in import AggregatedEstimate
|
||||
|
||||
defaults = dict(
|
||||
estimate_id=_TEMP_ID,
|
||||
median_price_rub=4_031_157,
|
||||
range_low_rub=3_700_000,
|
||||
range_high_rub=4_300_000,
|
||||
median_price_per_m2=174_000,
|
||||
confidence="medium",
|
||||
confidence_explanation="Найдено 39 аналогов",
|
||||
n_analogs=39,
|
||||
period_months=12,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
|
||||
target_address="г Екатеринбург, ул Академика Парина, д 46, к 5",
|
||||
target_lat=56.8519,
|
||||
target_lon=60.6122,
|
||||
sources_used=["avito", "rosreestr"],
|
||||
data_freshness_minutes=15,
|
||||
canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5",
|
||||
relaxations=["снят фильтр по году постройки", "учтены студии"],
|
||||
reliability="low",
|
||||
created_at=datetime.now(tz=UTC),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return AggregatedEstimate(**defaults)
|
||||
|
||||
|
||||
def _dispatch_db(row: object, claim_result: object = None) -> MagicMock:
|
||||
"""DB session mock dispatching fetchone() results by SQL substring.
|
||||
|
||||
- initial GET SELECT ("SELECT id, median_price ...") -> row
|
||||
- revival claim UPDATE ("SET revival_attempted_at") -> claim_result
|
||||
- everything else (houses lookup, persist UPDATE, DELETE,
|
||||
avito_imv UPDATE — none of which .fetchone() in real code
|
||||
except the two above, but MagicMock tolerates the unused
|
||||
call either way) -> None
|
||||
|
||||
call_args_list still records every call in order regardless of dispatch,
|
||||
so tests can assert on it directly (grep by substring) without needing to
|
||||
hand-count incidental queries fired by _resolve_target_house_id.
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(clause, params=None, *_a, **_k):
|
||||
sql = getattr(clause, "text", str(clause))
|
||||
result = MagicMock()
|
||||
if "SET revival_attempted_at" in sql:
|
||||
result.fetchone.return_value = claim_result
|
||||
elif "SELECT id, median_price" in sql:
|
||||
result.fetchone.return_value = row
|
||||
else:
|
||||
result.fetchone.return_value = None
|
||||
return result
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
return db
|
||||
|
||||
|
||||
def _calls_containing(db: MagicMock, needle: str) -> list:
|
||||
return [c for c in db.execute.call_args_list if needle in getattr(c.args[0], "text", "")]
|
||||
|
||||
|
||||
def _client_with(app: FastAPI, db_mock: MagicMock, role: str = "pilot") -> TestClient:
|
||||
from app.core.db import get_db
|
||||
|
||||
def _override_db():
|
||||
yield db_mock
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
auth_mod = sys.modules["app.core.auth"]
|
||||
auth_mod.get_role = lambda _u: role # type: ignore[assignment]
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _estimator_stub():
|
||||
"""Replaces app.services.estimator with a SimpleNamespace stub.
|
||||
|
||||
Mirrors test_estimate_idor.py::_stub_precision_and_pdf, plus an
|
||||
`estimate_quality` async attribute (revival's own lazy import target).
|
||||
Individual tests overwrite `estimate_quality` per-scenario.
|
||||
"""
|
||||
real_estimator = sys.modules.get("app.services.estimator")
|
||||
|
||||
async def _default_estimate_quality(*_a, **_k): # pragma: no cover — overridden per test
|
||||
raise AssertionError("estimate_quality stub not configured for this test")
|
||||
|
||||
stub = SimpleNamespace(
|
||||
_qc_geo_to_precision=lambda _qc: None,
|
||||
_fetch_price_trend=lambda *a, **k: None,
|
||||
_fetch_dkp_corridor=lambda *a, **k: None,
|
||||
_fetch_house_imv_anchor=lambda *a, **k: None,
|
||||
_resolve_target_city=lambda *a, **k: None,
|
||||
_cv_from_ppm2=lambda *a, **k: None,
|
||||
_source_counts=lambda *a, **k: {},
|
||||
_canonical_sources=lambda *a, **k: [],
|
||||
# #2632: GET-rehydrate реконструирует фактический радиус подбора. У строк
|
||||
# этой фикстуры analogs пусты → None и есть настоящее поведение
|
||||
# (см. tests/test_estimator_search_radius_2632.py).
|
||||
rehydrate_search_radius_m=lambda *a, **k: None,
|
||||
estimate_quality=_default_estimate_quality,
|
||||
)
|
||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||
yield stub
|
||||
if real_estimator is not None:
|
||||
sys.modules["app.services.estimator"] = real_estimator
|
||||
else:
|
||||
sys.modules.pop("app.services.estimator", None)
|
||||
|
||||
|
||||
# ── Revival success ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dead_row_revives_and_updates_db(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
"""Dead row (median_price=0) → recomputed, written to the SAME id, returned fresh.
|
||||
|
||||
Success path returns early (before the pre-existing rehydrate block), so
|
||||
the call count is exactly the 5 documented in _try_revive_dead_estimate's
|
||||
docstring: claim, avito_imv relink, persist UPDATE, DELETE temp — plus the
|
||||
initial SELECT.
|
||||
"""
|
||||
|
||||
async def _fake_estimate_quality(payload, db, **kwargs):
|
||||
assert payload.address.startswith("г Екатеринбург")
|
||||
assert payload.rooms == 1
|
||||
assert payload.radius_m is None # never persisted — default cascade
|
||||
return _fake_revived_result()
|
||||
|
||||
_estimator_stub.estimate_quality = _fake_estimate_quality
|
||||
|
||||
row = _make_dead_row()
|
||||
db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
|
||||
client = _client_with(trade_in_app, db)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# id/link contract: response carries the ORIGINAL id, not the temp uuid
|
||||
# estimate_quality() minted internally.
|
||||
assert body["estimate_id"] == _ESTIMATE_ID
|
||||
assert body["median_price_rub"] == 4_031_157
|
||||
assert body["n_analogs"] == 39
|
||||
assert body["insufficient_data"] is False
|
||||
assert body["reliability"] == "low"
|
||||
assert "снят фильтр по году постройки" in body["relaxations"]
|
||||
|
||||
assert len(db.execute.call_args_list) == 5
|
||||
persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET")
|
||||
assert len(persist_calls) == 1
|
||||
persist_params = persist_calls[0].args[1]
|
||||
assert persist_params["id"] == _ESTIMATE_ID
|
||||
assert persist_params["median_price"] == 4_031_157
|
||||
assert persist_params["reliability"] == "low"
|
||||
delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates")
|
||||
assert len(delete_calls) == 1
|
||||
assert delete_calls[0].args[1]["id"] == _TEMP_ID
|
||||
|
||||
|
||||
def test_dead_row_revival_preserves_created_at(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
"""#incident-2026-08-11: created_at is the client's ORIGINAL request date
|
||||
(printed on /history, see app/schemas/trade_in.py:317-318) — revival must
|
||||
not clobber it with the throwaway temp row's NOW(). Regression for the
|
||||
prod incident (estimate ff421062-...: created_at jumped from the original
|
||||
2026-08-10 12:54:47 to the revival moment 2026-08-11 04:30:03).
|
||||
|
||||
Also asserts: (a) the persist UPDATE never sets created_at at all — the
|
||||
fix removes the column from SET, it doesn't just overwrite it with the
|
||||
right value; (b) migration 256's revival_completed_at IS stamped, as the
|
||||
separate "when did revival last succeed" audit trail; (c) the JSON
|
||||
response mirrors the original created_at, not the temp result's.
|
||||
"""
|
||||
_original_created_at = datetime(2026, 5, 29, tzinfo=UTC)
|
||||
|
||||
async def _fake_estimate_quality(payload, db, **kwargs):
|
||||
# The temp row estimate_quality() mints internally always carries
|
||||
# NOW() as its created_at — deliberately far from the original, so a
|
||||
# regression (copying result.created_at through) is unmissable.
|
||||
return _fake_revived_result(created_at=datetime(2026, 8, 11, 4, 30, 3, tzinfo=UTC))
|
||||
|
||||
_estimator_stub.estimate_quality = _fake_estimate_quality
|
||||
|
||||
row = _make_dead_row()
|
||||
row.created_at = _original_created_at
|
||||
db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
|
||||
client = _client_with(trade_in_app, db)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["created_at"] == "2026-05-29T00:00:00Z"
|
||||
|
||||
persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET")
|
||||
assert len(persist_calls) == 1
|
||||
persist_sql = persist_calls[0].args[0].text
|
||||
persist_params = persist_calls[0].args[1]
|
||||
assert "created_at" not in persist_sql
|
||||
assert "created_at" not in persist_params
|
||||
assert "revival_completed_at = NOW()" in persist_sql
|
||||
# Input-snapshot / TTL columns (what the client originally asked for and
|
||||
# for how long the row is retained) are likewise not recompute outputs —
|
||||
# untouched by the revival persist UPDATE.
|
||||
# NB: "address" is checked via persist_params only (not persist_sql) —
|
||||
# canonical_address (a legitimate recompute output) ends in "address =",
|
||||
# which would false-positive a substring check against the raw SQL text.
|
||||
for protected in ("expires_at", "retain_until", "created_by"):
|
||||
assert f"{protected} =" not in persist_sql
|
||||
assert protected not in persist_params
|
||||
assert "address" not in persist_params
|
||||
|
||||
|
||||
# ── Live row is never touched ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_live_row_never_triggers_revival(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
"""median_price>0 → revival branch skipped entirely: estimate_quality is
|
||||
never called and no claim UPDATE fires — the saved price is untouched."""
|
||||
|
||||
async def _must_not_be_called(*_a, **_k):
|
||||
raise AssertionError("estimate_quality must not be called for a live row")
|
||||
|
||||
_estimator_stub.estimate_quality = _must_not_be_called
|
||||
|
||||
row = _make_live_row()
|
||||
db = _dispatch_db(row)
|
||||
client = _client_with(trade_in_app, db)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["median_price_rub"] == 4_031_157
|
||||
assert _calls_containing(db, "SET revival_attempted_at") == []
|
||||
|
||||
|
||||
# ── Throttle / anti-storm ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dead_row_throttled_does_not_recompute(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
"""Claim UPDATE returns no row (recent attempt / lost race) → no recompute,
|
||||
honest still-dead response, no 500, exactly one claim attempt (no retry
|
||||
loop within the same request)."""
|
||||
|
||||
async def _must_not_be_called(*_a, **_k):
|
||||
raise AssertionError("estimate_quality must not be called when throttled")
|
||||
|
||||
_estimator_stub.estimate_quality = _must_not_be_called
|
||||
|
||||
row = _make_dead_row()
|
||||
db = _dispatch_db(row, claim_result=None) # throttled: WHERE matched nothing
|
||||
client = _client_with(trade_in_app, db)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["insufficient_data"] is True
|
||||
assert body["median_price_rub"] == 0
|
||||
assert len(_calls_containing(db, "SET revival_attempted_at")) == 1
|
||||
|
||||
|
||||
# ── Recompute error degrades gracefully (no 500) ─────────────────────────
|
||||
|
||||
|
||||
def test_revival_recompute_exception_falls_back_without_500(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
async def _raises(*_a, **_k):
|
||||
raise RuntimeError("geocode timeout")
|
||||
|
||||
_estimator_stub.estimate_quality = _raises
|
||||
|
||||
row = _make_dead_row()
|
||||
db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
|
||||
client = _client_with(trade_in_app, db)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["insufficient_data"] is True
|
||||
# exception happened AFTER the claim — throttle was still recorded once.
|
||||
assert len(_calls_containing(db, "SET revival_attempted_at")) == 1
|
||||
# ...but nothing was written back to the row (no persist UPDATE fired).
|
||||
assert _calls_containing(db, "UPDATE trade_in_estimates SET") == []
|
||||
|
||||
|
||||
def test_revival_still_zero_falls_back_without_500(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
"""Recompute runs but still finds nothing (median_price_rub=0) — honest
|
||||
insufficient_data, temp throwaway row cleaned up, no crash."""
|
||||
|
||||
async def _still_empty(*_a, **_k):
|
||||
return _fake_revived_result(
|
||||
median_price_rub=0,
|
||||
range_low_rub=0,
|
||||
range_high_rub=0,
|
||||
median_price_per_m2=0,
|
||||
n_analogs=0,
|
||||
confidence="low",
|
||||
)
|
||||
|
||||
_estimator_stub.estimate_quality = _still_empty
|
||||
|
||||
row = _make_dead_row()
|
||||
db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
|
||||
client = _client_with(trade_in_app, db)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["insufficient_data"] is True
|
||||
delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates")
|
||||
assert len(delete_calls) == 1
|
||||
assert delete_calls[0].args[1]["id"] == _TEMP_ID
|
||||
# the still-dead ORIGINAL row was never overwritten with (fresh) zeros.
|
||||
assert _calls_containing(db, "UPDATE trade_in_estimates SET") == []
|
||||
|
||||
|
||||
def test_revival_invalid_persisted_house_type_falls_back_gracefully(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
"""Legacy row with a house_type outside the current Literal set — payload
|
||||
reconstruction itself raises (pydantic ValidationError), caught the same
|
||||
way as any other recompute failure. No 500."""
|
||||
|
||||
async def _must_not_be_called(*_a, **_k):
|
||||
raise AssertionError("estimate_quality must not be reached — payload build fails first")
|
||||
|
||||
_estimator_stub.estimate_quality = _must_not_be_called
|
||||
|
||||
row = _make_dead_row(house_type="монолит", repair_state="хороший")
|
||||
db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
|
||||
client = _client_with(trade_in_app, db)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["insufficient_data"] is True
|
||||
|
||||
|
||||
# ── relaxations/reliability round-trip (migration 255) ───────────────────
|
||||
|
||||
|
||||
def test_relaxations_reliability_roundtrip_on_get(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
"""A live row with persisted relaxations/reliability surfaces them
|
||||
byte-for-byte on GET — the red "точность снижена" banner survives
|
||||
reopening a saved link (previously always reset to ok/[])."""
|
||||
row = _make_live_row(
|
||||
relaxations=["радиус расширен до 2000 м", "площадь ±25%"], reliability="low"
|
||||
)
|
||||
db = _dispatch_db(row)
|
||||
client = _client_with(trade_in_app, db)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["reliability"] == "low"
|
||||
assert body["relaxations"] == ["радиус расширен до 2000 м", "площадь ±25%"]
|
||||
|
||||
|
||||
def test_get_estimate_defaults_relaxations_reliability_when_row_lacks_columns(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
"""Defensive getattr fallback: a row/mock without relaxations/reliability
|
||||
attrs (e.g. a stale test double) degrades to schema defaults, not a crash."""
|
||||
row = _make_live_row()
|
||||
del row.relaxations
|
||||
del row.reliability
|
||||
db = _dispatch_db(row)
|
||||
client = _client_with(trade_in_app, db)
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["reliability"] == "ok"
|
||||
assert body["relaxations"] == []
|
||||
|
||||
|
||||
# ── estimator.py persists relaxations/reliability on (re)compute ─────────
|
||||
|
||||
|
||||
def test_estimate_quality_insert_persists_relaxations_reliability() -> None:
|
||||
"""Source guard: the main POST-path INSERT must write relaxations/
|
||||
reliability, not just return them in the response — regression guard
|
||||
against the exact gap this migration closes (PR #2823 open follow-up)."""
|
||||
from app.services import estimator
|
||||
|
||||
src = inspect.getsource(estimator.estimate_quality)
|
||||
assert "relaxations_json" in src
|
||||
assert '"reliability": reliability' in src
|
||||
|
||||
|
||||
def test_empty_estimate_persists_relaxations_reliability() -> None:
|
||||
"""_empty_estimate's INSERT must also set reliability='very_low' (mirrors
|
||||
the Python object it returns) rather than silently defaulting to 'ok'."""
|
||||
from app.services import estimator
|
||||
|
||||
src = inspect.getsource(estimator._empty_estimate)
|
||||
assert "relaxations, reliability" in src
|
||||
assert "'very_low'" in src
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
"""#2827 — confidence↔reliability consistency (live-prod smoke, 2026-08-11).
|
||||
|
||||
`confidence` (`_compute_confidence` — unique-address/IQR metric) and
|
||||
`reliability` (`estimate_quality` — n_analogs-bucket + #oblast-F relaxation-
|
||||
cascade metric, see #2823) were computed fully independently. The cascade's
|
||||
room-adjacency/freshness/novostroyki steps never set `fallback_used`, so
|
||||
`_compute_confidence` stayed blind to them and could keep scoring a wide,
|
||||
tight-IQR (post-relaxation) sample as "high", while `reliability` — which DOES
|
||||
see the relaxations — honestly read "low".
|
||||
|
||||
Live prod repro (customer address, rooms=1, 23.1 m², radius=2000):
|
||||
n_analogs: 39
|
||||
confidence: high
|
||||
reliability: low
|
||||
relaxations: ['снят фильтр по году постройки', 'учтены студии', 'площадь ±25%']
|
||||
|
||||
A client could see a "высокая уверенность" badge next to a "точность снижена"
|
||||
banner on the SAME estimate — exactly the class of contradiction cleaned up
|
||||
from the PDF/counters earlier in this cycle (#2824-adjacent work), now leaking
|
||||
through the two headline confidence signals themselves.
|
||||
|
||||
Fix: `_cap_confidence_by_reliability` (estimator.py), applied ONCE in
|
||||
`estimate_quality` right after both `confidence` and `reliability` are final
|
||||
— NOT spread across the #oblast-F cascade steps. Rule:
|
||||
reliability == 'very_low' → confidence forced to 'low'
|
||||
reliability == 'low' → confidence capped at 'medium'
|
||||
reliability == 'ok' → confidence untouched (common case, unaffected)
|
||||
|
||||
Two layers:
|
||||
1. `_cap_confidence_by_reliability` direct unit tests — the 3 rules in
|
||||
isolation, no DB/estimate_quality overhead.
|
||||
2. `estimate_quality` integration tests — the prod repro shape (confidence
|
||||
downgraded + confidence_explanation restructured to LEAD with the
|
||||
accuracy-reduced summary) and the byte-identical-when-unrelaxed control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.estimator import _cap_confidence_by_reliability
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Layer 1 — `_cap_confidence_by_reliability` direct unit tests
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_very_low_reliability_forces_confidence_low() -> None:
|
||||
"""reliability == 'very_low' → confidence forced to 'low', regardless of
|
||||
what _compute_confidence originally scored."""
|
||||
for original in ("high", "medium", "low"):
|
||||
assert (
|
||||
_cap_confidence_by_reliability(original, "very_low") == "low"
|
||||
), f"original={original!r} must be forced to 'low' under very_low reliability"
|
||||
|
||||
|
||||
def test_low_reliability_caps_confidence_at_medium() -> None:
|
||||
"""reliability == 'low' → confidence capped at 'medium': 'high' is pulled
|
||||
down to 'medium'; 'medium'/'low' pass through unchanged (already <= cap)."""
|
||||
assert _cap_confidence_by_reliability("high", "low") == "medium"
|
||||
assert _cap_confidence_by_reliability("medium", "low") == "medium"
|
||||
assert _cap_confidence_by_reliability("low", "low") == "low"
|
||||
|
||||
|
||||
def test_ok_reliability_leaves_confidence_untouched() -> None:
|
||||
"""reliability == 'ok' — the common, unrelaxed case — must NOT change
|
||||
confidence at all (byte-identical to pre-#2827 behaviour)."""
|
||||
for original in ("high", "medium", "low"):
|
||||
assert _cap_confidence_by_reliability(original, "ok") == original
|
||||
|
||||
|
||||
def test_cap_never_raises_confidence() -> None:
|
||||
"""Sanity: the cap only lowers/holds — never raises 'low' to something
|
||||
higher under any reliability value."""
|
||||
for reliability in ("ok", "low", "very_low"):
|
||||
assert _cap_confidence_by_reliability("low", reliability) == "low"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _geo() -> GeocodeResult:
|
||||
return GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Академика Парина, 46/5",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 23.1) -> dict[str, Any]:
|
||||
return {
|
||||
"source": "avito",
|
||||
"source_url": f"https://avito.ru/offer/{address}",
|
||||
"address": address,
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"rooms": 1,
|
||||
"area_m2": area_m2,
|
||||
"floor": 5,
|
||||
"total_floors": 9,
|
||||
"price_rub": price_per_m2 * area_m2,
|
||||
"price_per_m2": price_per_m2,
|
||||
"listing_date": datetime(2026, 5, 1),
|
||||
"days_on_market": 10,
|
||||
"photo_urls": [],
|
||||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||||
"distance_m": 150.0,
|
||||
"relevance_score": 0.1,
|
||||
}
|
||||
|
||||
|
||||
def _tight_price_pool(n: int, base_ppm2: float = 195_000.0) -> list[dict[str, Any]]:
|
||||
"""n listings, distinct addresses, tightly clustered price/m² (~±2%) —
|
||||
engineered to clear _compute_confidence's 'high' bar (unique_addr_count>=7
|
||||
AND iqr_pct<0.15) once assembled, exactly like the live prod repro (n=39,
|
||||
confidence='high' pre-fix)."""
|
||||
return [
|
||||
_make_listing(
|
||||
price_per_m2=base_ppm2 + (i - n / 2) * 200,
|
||||
address=f"ул. Академика Парина, {i + 1}",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _payload_with_year() -> Any:
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Академика Парина, 46/5",
|
||||
area_m2=23.1,
|
||||
rooms=1,
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
year_built=2010,
|
||||
city_hint="Екатеринбург",
|
||||
radius_m=2000,
|
||||
)
|
||||
|
||||
|
||||
def _run_estimate(
|
||||
*,
|
||||
payload: Any,
|
||||
fetch_analogs_side_effect: Any,
|
||||
dkp_raw: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
geo = _geo()
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
side_effect=fetch_analogs_side_effect,
|
||||
),
|
||||
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
return anyio.run(_run)
|
||||
|
||||
|
||||
def test_e2e_prod_repro_high_confidence_low_reliability_gets_capped() -> None:
|
||||
"""Live prod repro shape: cohort (year_built) filter gets dropped by the
|
||||
Tier-0 cascade (empty cohort-call → fallback), landing on a 39-listing,
|
||||
tight-price, 39-unique-address pool — a healthy sample that
|
||||
`_compute_confidence` would score 'high' on its own. Because a relaxation
|
||||
WAS applied to get there, `reliability` reads 'low' — the cap must pull
|
||||
`confidence` down to 'medium' (not leave it at the contradictory 'high'),
|
||||
and `confidence_explanation` must LEAD with the accuracy-reduced summary."""
|
||||
pool = _tight_price_pool(39)
|
||||
|
||||
def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
# Tier 0 (with cohort filter) → empty, forces the "drop cohort" fallback.
|
||||
if kwargs.get("cohort_year_min") is not None:
|
||||
return [], False, "W"
|
||||
# Every subsequent (no-cohort) call → the full healthy pool.
|
||||
return list(pool), False, "W"
|
||||
|
||||
est = _run_estimate(payload=_payload_with_year(), fetch_analogs_side_effect=_fetch_analogs_stub)
|
||||
|
||||
assert est.n_analogs == 39
|
||||
assert est.reliability == "low"
|
||||
assert "снят фильтр по году постройки" in est.relaxations
|
||||
assert est.confidence == "medium", (
|
||||
f"confidence={est.confidence!r} must be capped to 'medium' under reliability='low' "
|
||||
"(was 'high' pre-#2827 — the exact prod contradiction)"
|
||||
)
|
||||
explanation = est.confidence_explanation
|
||||
assert explanation is not None
|
||||
assert explanation.startswith(
|
||||
"Оценка построена с расширенными параметрами подбора — точность снижена."
|
||||
), f"explanation must LEAD with the accuracy-reduced summary, got: {explanation!r}"
|
||||
assert "Найдено 39 аналогов" in explanation, "original detail must be preserved"
|
||||
assert "Применены послабления подбора: снят фильтр по году постройки." in explanation
|
||||
|
||||
|
||||
def test_e2e_unrelaxed_estimate_is_byte_identical_control() -> None:
|
||||
"""#3 (task spec): no relaxations, reliability=='ok' → confidence and
|
||||
confidence_explanation must be UNCHANGED by #2827 — this is the common
|
||||
path most estimates take, and it must not regress."""
|
||||
pool = _tight_price_pool(39)
|
||||
|
||||
def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
# No cohort filter this time (payload has no year_built) — Tier 0 is
|
||||
# skipped outright (cohort_range is None), so no relaxation fires.
|
||||
return list(pool), False, "W"
|
||||
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
payload = TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Академика Парина, 46/5",
|
||||
area_m2=23.1,
|
||||
rooms=1,
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
city_hint="Екатеринбург",
|
||||
)
|
||||
est = _run_estimate(payload=payload, fetch_analogs_side_effect=_fetch_analogs_stub)
|
||||
|
||||
assert est.n_analogs == 39
|
||||
assert est.relaxations == []
|
||||
assert est.reliability == "ok"
|
||||
assert est.confidence == "high", "unrelaxed healthy sample must keep its real confidence"
|
||||
assert est.confidence_explanation is not None
|
||||
assert not est.confidence_explanation.startswith("Оценка построена с расширенными параметрами")
|
||||
assert "Применены послабления подбора" not in est.confidence_explanation
|
||||
assert est.confidence_explanation.startswith("Найдено 39 аналогов"), (
|
||||
"unrelaxed explanation must keep its original (pre-#2827) leading sentence, got: "
|
||||
f"{est.confidence_explanation!r}"
|
||||
)
|
||||
|
|
@ -1,22 +1,55 @@
|
|||
"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02).
|
||||
"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority
|
||||
RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product
|
||||
decision, 2026-08-10).
|
||||
|
||||
Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings →
|
||||
headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring
|
||||
street in the same town swung ±66% on 1-2 different random listings. Каменск-
|
||||
Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match
|
||||
either, with no honest refusal surfaced. Первоуральск (0 listings) already fell
|
||||
back to the (pre-existing) ДКП deals-headline fallback correctly — this gate
|
||||
routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME,
|
||||
already-tested path instead of trusting a 1-4-lot median as the headline.
|
||||
History:
|
||||
1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1)
|
||||
listings sample to a literal zero, forcing the anchor/#oblast-D-deals-
|
||||
fallback/insufficient_data chain to take over — motivated by a live
|
||||
Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of
|
||||
54 126 ₽/м²).
|
||||
2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE —
|
||||
a thin sample always kept its own median, even when a much more reliable
|
||||
deals corridor was available. That accidentally REOPENED the exact Серов
|
||||
bug #oblast-E existed to close.
|
||||
3. #oblast-E priority RESTORED (2026-08-10, same day, product correction):
|
||||
"никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3
|
||||
объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in
|
||||
`_price_from_inputs`'s gate:
|
||||
- n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected).
|
||||
- 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor
|
||||
exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) →
|
||||
listings aggregate suppressed to zero, headline ceded to the
|
||||
#oblast-D deals-headline-fallback chain (original #oblast-E
|
||||
behaviour, restored). `PricingResult.deals_headline_due_to_thin_
|
||||
listings=True` — estimate_quality() adds relaxation label "оценка по
|
||||
сделкам — мало объявлений рядом" and caps reliability at 'low'.
|
||||
Listings display cards are NOT hidden (unlike original #oblast-E) —
|
||||
`listings_clean` stays intact and estimate_quality() still surfaces
|
||||
them as context even though they no longer drive n_analogs/median.
|
||||
- 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor →
|
||||
#oblast-F: keep the real thin median (never refuse outright).
|
||||
Real refusal ("недостаточно данных") now happens ONLY at genuine n=0
|
||||
(no listings AND no usable anchor/deals) — the never-block requirement
|
||||
with an honest, priority-ordered source selection.
|
||||
|
||||
`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F
|
||||
relaxation cascade (room-adjacency / freshness / novostroyki / radius, see
|
||||
estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in
|
||||
Layer 1) ever runs the 3-way gate above — `listings` here is whatever that
|
||||
cascade could find.
|
||||
|
||||
Two layers:
|
||||
1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) —
|
||||
boundary behaviour of the gate itself.
|
||||
boundary behaviour of the gate itself: the 3-way rule, low-reliability
|
||||
wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_
|
||||
thin_listings bookkeeping.
|
||||
2. `estimate_quality` integration tests — proves the money-path invariants
|
||||
that matter to a caller: literal 0 never leaks as a "confident" price,
|
||||
display `analogs` cards never outnumber what `n_analogs` claims, and the
|
||||
explanation text describes what actually happened (not a stock "аналогов
|
||||
не найдено" when some WERE found, just too few).
|
||||
that matter to a caller: thin+usable-deals routes to the deals corridor
|
||||
(Серов repro), thin+no-deals keeps its own median, display `analogs`
|
||||
cards are shown either way, and the #oblast-F room-adjacency relaxation
|
||||
(studio↔1-комн) actually grows a thin sample and is reported via
|
||||
`AggregatedEstimate.relaxations` / `reliability`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -103,15 +136,14 @@ def test_threshold_is_five_not_lower() -> None:
|
|||
assert HEADLINE_LISTINGS_MIN_N == 5
|
||||
|
||||
|
||||
def test_four_listings_below_threshold_suppressed_no_fallback() -> None:
|
||||
"""n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state,
|
||||
NOT the naive median of 4 listings."""
|
||||
def test_four_listings_below_threshold_kept_not_suppressed() -> None:
|
||||
"""#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision
|
||||
2026-08-10 — never zero out a thin-but-real sample), just flagged low."""
|
||||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0]))
|
||||
assert pr.median_ppm2 == 0.0
|
||||
assert pr.median_price == 0
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.range_low == 0
|
||||
assert pr.range_high == 0
|
||||
assert pr.median_ppm2 == 215_000.0
|
||||
assert pr.n_analogs == 4
|
||||
assert pr.median_price == round(215_000.0 * 45.0)
|
||||
assert pr.confidence == "low"
|
||||
|
||||
|
||||
def test_five_listings_at_threshold_not_suppressed() -> None:
|
||||
|
|
@ -122,18 +154,21 @@ def test_five_listings_at_threshold_not_suppressed() -> None:
|
|||
assert pr.median_price == round(210_000.0 * 45.0)
|
||||
|
||||
|
||||
def test_one_listing_below_threshold_suppressed() -> None:
|
||||
"""n=1 — the sharpest form of the Серов bug (a single random lot deciding
|
||||
the whole headline) — must be suppressed exactly like n=4."""
|
||||
def test_one_listing_below_threshold_kept_not_suppressed() -> None:
|
||||
"""#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot)
|
||||
median rather than being zeroed; confidence stays 'low'."""
|
||||
pr = _call(listings=_lots([200_000.0]))
|
||||
assert pr.median_ppm2 == 0.0
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.median_ppm2 == 200_000.0
|
||||
assert pr.n_analogs == 1
|
||||
assert pr.confidence == "low"
|
||||
|
||||
|
||||
def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
|
||||
"""n=3 listings (thin) + a usable ДКП corridor → headline comes from the
|
||||
deal corridor median, NOT the 3-listing median (live Серов repro: 3
|
||||
listings gave 42 391 vs the honest ДКП-based ~54 126)."""
|
||||
"""#oblast-E priority RESTORED (2026-08-10 product correction): a thin
|
||||
(n=3) listings sample must NOT outrank a usable ДКП deals corridor — this
|
||||
is the exact live Серов repro #oblast-E exists for (3 noisy listings gave
|
||||
42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline
|
||||
comes from the deal corridor median, NOT the 3-listing median."""
|
||||
dkp_raw = {
|
||||
"count": 54,
|
||||
"low_ppm2": 44_000,
|
||||
|
|
@ -151,12 +186,23 @@ def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
|
|||
)
|
||||
assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline"
|
||||
assert pr.confidence == "low"
|
||||
assert pr.deals_headline_due_to_thin_listings is True
|
||||
assert pr.listings_clean, "listings_clean must stay intact — display cards still show them"
|
||||
# #4: explanation must not falsely claim "рядом нет объявлений" (some WERE
|
||||
# found, just ceded priority to the more reliable deals corridor) and must
|
||||
# NOT also carry the separate "Оценка построена по N аналогам" thin-kept
|
||||
# wording (that phrasing is reserved for the no-usable-corridor branch).
|
||||
assert pr.explanation is not None
|
||||
assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
|
||||
assert "сделкам росреестра" in pr.explanation.lower()
|
||||
assert "оценка построена по 3" not in pr.explanation.lower()
|
||||
|
||||
|
||||
def test_thin_sample_with_insufficient_deals_stays_zero() -> None:
|
||||
def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None:
|
||||
"""n=3 listings (thin) + a ДКП corridor that is ITSELF too thin
|
||||
(< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero,
|
||||
not a fabricated number from either side."""
|
||||
(< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so
|
||||
#oblast-F's never-block rule applies: the real listings median is kept
|
||||
rather than refusing (neither source alone would justify a hard zero)."""
|
||||
dkp_raw = {
|
||||
"count": 1,
|
||||
"low_ppm2": 40_000,
|
||||
|
|
@ -165,49 +211,51 @@ def test_thin_sample_with_insufficient_deals_stays_zero() -> None:
|
|||
"period_months": 12,
|
||||
}
|
||||
pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw)
|
||||
assert pr.median_ppm2 == 0.0
|
||||
assert pr.median_price == 0
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.median_ppm2 == 42_391.0
|
||||
assert pr.n_analogs == 3
|
||||
assert pr.deals_headline_due_to_thin_listings is False
|
||||
|
||||
|
||||
def test_thin_sample_explanation_is_honest_about_count() -> None:
|
||||
"""The explanation for a thin-but-nonzero sample must say HOW MANY listings
|
||||
were found (not the generic 'ничего не найдено' text used for a genuine
|
||||
zero-listing case) — #4 in the task: explanation must match reality."""
|
||||
def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None:
|
||||
"""#4 (task spec): the explanation for a thin-but-real sample must read as
|
||||
"small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для
|
||||
оценки по рынку" copy, and NOT the generic zero-analogs text."""
|
||||
pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2
|
||||
assert pr.explanation is not None
|
||||
assert "2" in pr.explanation
|
||||
assert "недостаточно" in pr.explanation.lower()
|
||||
# Must NOT reuse the "nothing found at all" copy — 2 listings WERE found.
|
||||
assert "выборка мала" in pr.explanation.lower()
|
||||
assert "точность снижена" in pr.explanation.lower()
|
||||
assert "минимум для оценки по рынку" not in pr.explanation.lower()
|
||||
assert "не найдено аналогов" not in pr.explanation.lower()
|
||||
|
||||
|
||||
def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None:
|
||||
"""#4: once the ДКП fallback fires for a thin (not zero) sample, the
|
||||
explanation must not falsely claim 'рядом нет объявлений' — some WERE
|
||||
found, just not enough to trust."""
|
||||
def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None:
|
||||
"""Control: the #oblast-D deals-headline-fallback path is UNCHANGED for
|
||||
GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin
|
||||
case, not the true-zero case, which still needs a fallback source."""
|
||||
dkp_raw = {
|
||||
"count": 20,
|
||||
"low_ppm2": 40_000,
|
||||
"median_ppm2": 60_000,
|
||||
"high_ppm2": 80_000,
|
||||
"count": 54,
|
||||
"low_ppm2": 44_000,
|
||||
"median_ppm2": 65_957,
|
||||
"high_ppm2": 89_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw)
|
||||
pr = _call(listings=[], dkp_raw=dkp_raw)
|
||||
assert pr.median_ppm2 == 65_957.0
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.confidence == "low"
|
||||
assert pr.explanation is not None
|
||||
assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
|
||||
assert "рядом нет актуальных объявлений" in pr.explanation.lower()
|
||||
assert "сделкам росреестра" in pr.explanation.lower()
|
||||
|
||||
|
||||
def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None:
|
||||
"""Regression guard: the gate must suppress the AGGREGATE (median/n_analogs)
|
||||
without clearing `listings_clean` itself — the same-building anchor's own
|
||||
ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell
|
||||
"genuinely zero nearby listings" from "some nearby, just too few to trust
|
||||
as headline", and conflating the two was caught regressing
|
||||
test_estimator_split_corridor_1871.py during this change."""
|
||||
def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None:
|
||||
"""listings_clean stays intact (unchanged invariant — same-building anchor's
|
||||
ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is
|
||||
the REAL count (not zeroed) while listings_headline_thin_n still marks the
|
||||
sample as thin for the low-reliability note upstream."""
|
||||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]))
|
||||
assert pr.n_analogs == 0
|
||||
assert pr.n_analogs == 3
|
||||
assert len(pr.listings_clean) == 3
|
||||
assert pr.listings_headline_thin_n == 3
|
||||
|
||||
|
|
@ -219,6 +267,42 @@ def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None:
|
|||
assert pr.listings_headline_thin_n == 0
|
||||
|
||||
|
||||
def test_repair_coefficient_now_applies_to_thin_sample() -> None:
|
||||
"""#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a
|
||||
thin sample because the headline was already zeroed (applying it would be a
|
||||
no-op). Now that the real median is kept, the coefficient must apply."""
|
||||
pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin
|
||||
pr = _price_from_inputs(
|
||||
listings=_lots([200_000.0, 210_000.0]),
|
||||
area_m2=45.0,
|
||||
rooms=2,
|
||||
repair_state="excellent",
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
target_year=None,
|
||||
analog_tier="W",
|
||||
fallback_used=False,
|
||||
area_widened=False,
|
||||
anchor_comps=[],
|
||||
anchor_tier_fetched=None,
|
||||
dkp_raw=None,
|
||||
imv_anchor=None,
|
||||
imv_eval=None,
|
||||
yandex_val_present=False,
|
||||
cian_val_present=False,
|
||||
ratio_resolver=lambda _appm2: (None, None),
|
||||
quarter_index_lookup=lambda q: None,
|
||||
quarter_indexes_lookup=lambda qs: {},
|
||||
target_house_cadnum=None,
|
||||
dadata_coarse=False,
|
||||
geo=_geo(),
|
||||
dadata_qc_geo=None,
|
||||
)
|
||||
assert (
|
||||
pr.median_price != pr_no_repair.median_price
|
||||
), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -261,24 +345,31 @@ def _serov_payload() -> Any:
|
|||
|
||||
def _run_estimate(
|
||||
*,
|
||||
analogs: list[dict[str, Any]],
|
||||
analogs: list[dict[str, Any]] | None = None,
|
||||
dkp_raw: dict[str, Any] | None,
|
||||
fetch_analogs_side_effect: Any = None,
|
||||
payload: Any = None,
|
||||
geo: GeocodeResult | None = None,
|
||||
) -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
payload = _serov_payload()
|
||||
payload = payload or _serov_payload()
|
||||
geo = geo or _geo()
|
||||
|
||||
fetch_analogs_kwargs: dict[str, Any] = (
|
||||
{"side_effect": fetch_analogs_side_effect}
|
||||
if fetch_analogs_side_effect is not None
|
||||
else {"return_value": (list(analogs or []), False, "W")}
|
||||
)
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
return_value=(list(analogs), False, "W"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_analogs", **fetch_analogs_kwargs),
|
||||
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
|
|
@ -301,40 +392,47 @@ def _run_estimate(
|
|||
return anyio.run(_run)
|
||||
|
||||
|
||||
def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None:
|
||||
"""Каменск-Уральский-style repro: thin listings, no usable ДКП corridor —
|
||||
median_price_rub must be 0 AND insufficient_data must be True TOGETHER
|
||||
(the AggregatedEstimate.insufficient_data computed_field invariant that
|
||||
stops a literal 0 ₽ reaching the user as a confident number)."""
|
||||
def test_e2e_thin_sample_no_relaxation_help_keeps_real_median() -> None:
|
||||
"""#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always
|
||||
returns the SAME 2 listings regardless of relaxation params (none of them
|
||||
help) — median_price_rub must be the REAL non-zero 2-listing median,
|
||||
insufficient_data False, n_analogs=2, confidence='low', reliability
|
||||
'very_low' (n<3), relaxations empty (nothing actually helped)."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
|
||||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
|
||||
]
|
||||
est = _run_estimate(analogs=analogs, dkp_raw=None)
|
||||
assert est.median_price_rub == 0
|
||||
assert est.insufficient_data is True
|
||||
assert est.n_analogs == 0
|
||||
assert est.median_price_rub == round(205_000.0 * 45.0)
|
||||
assert est.insufficient_data is False
|
||||
assert est.n_analogs == 2
|
||||
assert est.confidence == "low"
|
||||
assert est.relaxations == []
|
||||
assert est.reliability == "very_low"
|
||||
|
||||
|
||||
def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None:
|
||||
"""The 2 thin listings must NOT be surfaced as `analogs` display cards while
|
||||
n_analogs reports 0 — that would be the same dishonesty (confident-looking
|
||||
UI) this whole gate exists to remove."""
|
||||
def test_e2e_thin_sample_display_cards_match_n_analogs() -> None:
|
||||
"""#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin
|
||||
sample — they must match n_analogs exactly (both = 2), never hidden."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
|
||||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
|
||||
]
|
||||
est = _run_estimate(analogs=analogs, dkp_raw=None)
|
||||
assert est.n_analogs == 0
|
||||
assert est.analogs == []
|
||||
assert est.n_analogs == 2
|
||||
assert len(est.analogs) == 2
|
||||
|
||||
|
||||
def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
|
||||
"""Live Серов repro (n=3 scraped listings, wide ДКП corridor available):
|
||||
headline must come from the deal corridor, not the noisy 3-listing median,
|
||||
and the estimate must be honestly non-'insufficient' (a real number, low
|
||||
confidence, deals-sourced)."""
|
||||
"""Live Серов repro (n=3 scraped listings, wide ДКП corridor available) —
|
||||
#oblast-E priority RESTORED: headline must come from the deal corridor,
|
||||
not the noisy 3-listing median. Also proves the #4 task-spec requirements
|
||||
layered on top of the restored priority: the estimate is honestly non-
|
||||
'insufficient' (a real number, low confidence), reliability is capped at
|
||||
'low' (not 'very_low' — a 54-deal corridor is real signal), the
|
||||
relaxation label names the source switch, AND the 3 thin listings are
|
||||
still shown as display cards (not discarded) even though they no longer
|
||||
drive n_analogs/median."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"),
|
||||
_make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"),
|
||||
|
|
@ -354,12 +452,15 @@ def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
|
|||
assert est.confidence == "low"
|
||||
assert est.confidence_explanation is not None
|
||||
assert "сделкам росреестра" in est.confidence_explanation.lower()
|
||||
assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'"
|
||||
assert "оценка по сделкам — мало объявлений рядом" in est.relaxations
|
||||
assert len(est.analogs) == 3, "thin listings must still surface as display cards"
|
||||
|
||||
|
||||
def test_e2e_sufficient_five_analogs_unaffected_control() -> None:
|
||||
"""Control (mirrors the Екатеринбург prod check in the PR): a sample that
|
||||
clears the threshold is priced exactly as before — headline is the real
|
||||
listings median, all 5 analogs counted."""
|
||||
listings median, all 5 analogs counted, no relaxations needed."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"),
|
||||
_make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"),
|
||||
|
|
@ -371,3 +472,78 @@ def test_e2e_sufficient_five_analogs_unaffected_control() -> None:
|
|||
assert est.median_price_per_m2 == 210_000
|
||||
assert est.n_analogs == 5
|
||||
assert est.insufficient_data is False
|
||||
assert est.relaxations == []
|
||||
assert est.reliability == "low" # n=5 falls in the 3..7 bucket
|
||||
|
||||
|
||||
def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None:
|
||||
"""#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin
|
||||
sample (studio-adjacent building, live prod repro Академика Парина 46/5) →
|
||||
cascade retries with rooms IN (0,1) and finds a trustworthy sample there.
|
||||
Asserts: studios pulled in, `relaxations` names it, real non-zero median,
|
||||
reliability downgraded to 'low' (thin base sample)."""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
exact_rooms1 = [
|
||||
_make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0),
|
||||
_make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0),
|
||||
]
|
||||
studio_pool = [
|
||||
*exact_rooms1,
|
||||
_make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0),
|
||||
_make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0),
|
||||
_make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0),
|
||||
]
|
||||
|
||||
def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1:
|
||||
return list(studio_pool), False, "W"
|
||||
return list(exact_rooms1), False, "W"
|
||||
|
||||
geo = GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5",
|
||||
provider="nominatim",
|
||||
)
|
||||
payload = TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Парина, 46/5",
|
||||
area_m2=23.1,
|
||||
rooms=1,
|
||||
)
|
||||
|
||||
est = _run_estimate(
|
||||
dkp_raw=None,
|
||||
fetch_analogs_side_effect=_fetch_analogs_stub,
|
||||
payload=payload,
|
||||
geo=geo,
|
||||
)
|
||||
|
||||
assert "учтены студии" in est.relaxations
|
||||
assert est.median_price_rub > 0
|
||||
assert est.reliability == "low"
|
||||
assert est.n_analogs == 5
|
||||
|
||||
|
||||
def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None:
|
||||
"""#oblast-F step (d) contract: when the user explicitly picked radius_m
|
||||
(#2044), the cascade must NOT auto-expand past it — mirrors the existing
|
||||
radius-fallback contract above (no auto-expansion beyond user's choice)."""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
thin = [
|
||||
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
|
||||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
|
||||
]
|
||||
payload = TradeInEstimateInput(
|
||||
address="Серов, ул. Ленина, 5",
|
||||
area_m2=45.0,
|
||||
rooms=2,
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
city_hint="Серов",
|
||||
radius_m=1500,
|
||||
)
|
||||
est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload)
|
||||
assert not any("радиус расширен" in r for r in est.relaxations)
|
||||
assert est.search_radius_m == 1500
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
"""Квартал ЦЕЛИ для индекса #764 берётся у цели, а не у аналога.
|
||||
|
||||
Дефект (прод, замер 2026-08-12): `dadata.house_cadnum` пуст в 15 из 15 применений
|
||||
квартального индекса, и `target_quarter` молча брался из кадастра ПЕРВОГО аналога с
|
||||
кадастром. Аналог-донор стоял в 201-4087 м от цели; индексы кварталов на такой
|
||||
дистанции расходятся в 1.15-1.23 раза по медиане и в 1.6-1.8 по p90 — то есть
|
||||
«индекс квартала цели» на витрине был индексом квартала соседа, и он двигал цену
|
||||
(применённые на проде множители: 0.60 ×3, 0.73 ×2, 0.76 ×2, 0.79 ×2, 0.80, 1.03 ×3,
|
||||
1.08, 1.09).
|
||||
|
||||
Паттерн: os.environ.setdefault перед импортом (как test_estimator_quarter_index.py).
|
||||
Фикстуры лотов/payload переиспользуются оттуда же; harness здесь свой, потому что
|
||||
тестам нужны РАЗНЫЕ индексы у разных кварталов (общий harness отдаёт один индекс
|
||||
всем кварталам → factor всегда 1.0, подмена квартала невидима) и контроль над
|
||||
координатным резолвом квартала цели.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import ExitStack
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import anyio
|
||||
|
||||
from tests.test_estimator_quarter_index import (
|
||||
_AREA,
|
||||
_BASE_PPM2,
|
||||
_make_fake_dadata,
|
||||
_make_fake_geo_qi,
|
||||
_make_listing_qi,
|
||||
_make_payload_qi,
|
||||
)
|
||||
|
||||
_TARGET_Q = "66:41:0204016"
|
||||
_NEIGH_CHEAP_Q = "66:41:0601022"
|
||||
_NEIGH_RICH_Q = "66:41:0601033"
|
||||
_INDEXES = {_TARGET_Q: 1.2, _NEIGH_CHEAP_Q: 0.8, _NEIGH_RICH_Q: 1.6}
|
||||
|
||||
_UNPATCHED = object() # sentinel: координатный резолвер не подменяем
|
||||
|
||||
|
||||
def _run_estimate(
|
||||
*,
|
||||
analogs: list[dict[str, Any]],
|
||||
dadata_cadnum: str | None,
|
||||
coords_quarter: Any = _UNPATCHED,
|
||||
coords_enabled: bool = True,
|
||||
):
|
||||
"""estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES.
|
||||
|
||||
coords_quarter:
|
||||
* `_UNPATCHED` (default) — координатный резолвер НЕ подменяется, db-мок
|
||||
отдаёт «зданий рядом нет» (`.first() → None`), т.е. свой квартал цели
|
||||
неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) —
|
||||
старый код подставит квартал аналога, на чём тест и падает.
|
||||
* str / None — явная подмена резолвера.
|
||||
|
||||
coords_enabled — значение `estimate_quarter_from_coords_enabled` на время
|
||||
прогона (в проде флаг ВЫКЛЮЧЕН, см. config); тесты координатной половины
|
||||
включают его явно.
|
||||
"""
|
||||
from app.services.estimator import estimate_quality, settings
|
||||
|
||||
db = MagicMock()
|
||||
# «Ни одного здания в радиусе порога»: единственный db.execute(...).first() на
|
||||
# пути оценки (всё остальное I/O застаблено) — координатный резолв квартала.
|
||||
db.execute.return_value.first.return_value = None
|
||||
|
||||
dadata_obj = _make_fake_dadata(dadata_cadnum) if dadata_cadnum is not None else None
|
||||
|
||||
def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals):
|
||||
idx = _INDEXES.get(quarter_cad_number)
|
||||
return (idx, 30) if idx is not None else None
|
||||
|
||||
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
|
||||
return {q: _INDEXES[q] for q in quarter_cad_numbers if q in _INDEXES}
|
||||
|
||||
async def _run():
|
||||
stubs = [
|
||||
patch(
|
||||
"app.services.estimator.geocode",
|
||||
new=AsyncMock(return_value=_make_fake_geo_qi()),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.dadata_clean_address",
|
||||
new=AsyncMock(return_value=dadata_obj),
|
||||
),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
return_value=(list(analogs), False, "W"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
|
||||
patch("app.services.estimator._lookup_quarter_index", side_effect=_fake_lookup_index),
|
||||
patch(
|
||||
"app.services.estimator._lookup_quarter_indexes",
|
||||
side_effect=_fake_lookup_indexes,
|
||||
),
|
||||
]
|
||||
if coords_quarter is not _UNPATCHED:
|
||||
stubs.append(
|
||||
patch(
|
||||
"app.services.estimator._lookup_target_quarter_by_coords",
|
||||
return_value=coords_quarter,
|
||||
)
|
||||
)
|
||||
stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled))
|
||||
with ExitStack() as stack:
|
||||
for stub in stubs:
|
||||
stack.enter_context(stub)
|
||||
return await estimate_quality(_make_payload_qi(), db)
|
||||
|
||||
return anyio.run(_run)
|
||||
|
||||
|
||||
def _analogs_two_quarters() -> list[dict[str, Any]]:
|
||||
"""5 аналогов в дешёвом квартале + 5 в дорогом, одинаковая цена ₽/м².
|
||||
|
||||
Первым в listings_clean идёт дешёвый — именно его кадастр старый код брал за
|
||||
кадастр ЦЕЛИ. avg_analog_index = (0.8 + 1.6) / 2 = 1.2 при равных весах.
|
||||
Разные этажи обязательны: физический ключ _dedup_cross_source иначе схлопнет
|
||||
все лоты в один.
|
||||
"""
|
||||
cheap = [
|
||||
_make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i)
|
||||
for i in range(5)
|
||||
]
|
||||
rich = [
|
||||
_make_listing_qi(building_cadastral_number=f"{_NEIGH_RICH_Q}:{200 + i}", floor=8 + i)
|
||||
for i in range(5)
|
||||
]
|
||||
return cheap + rich
|
||||
|
||||
|
||||
def test_neighbour_cadastre_no_longer_sets_target_quarter() -> None:
|
||||
"""Свой квартал цели неизвестен → поправки нет (а не «квартал первого аналога»).
|
||||
|
||||
КРАСНЫЙ на старом коде: там target_quarter = 66:41:0601022 (дешёвый сосед,
|
||||
index 0.8), avg = 1.2 → factor 0.667 → медиана падает на треть, и пользователю
|
||||
пишется «Учтена локация квартала (индекс цен квартала ×0.67)».
|
||||
"""
|
||||
est = _run_estimate(analogs=_analogs_two_quarters(), dadata_cadnum=None)
|
||||
|
||||
assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
|
||||
assert "индекс цен квартала" not in (est.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_target_quarter_from_own_coords_drives_correction() -> None:
|
||||
"""Квартал цели снят по ЕЁ координатам → поправка считается от него.
|
||||
|
||||
index цели 1.2 / avg аналогов 1.2 → factor 1.00: цена не двигается, потому что
|
||||
уровень аналогов и уровень цели совпали. Старый код взял бы 0.8 у соседа и
|
||||
×0.67 без всяких данных о цели.
|
||||
"""
|
||||
est = _run_estimate(
|
||||
analogs=_analogs_two_quarters(),
|
||||
dadata_cadnum=None,
|
||||
coords_quarter=_TARGET_Q,
|
||||
)
|
||||
|
||||
assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
|
||||
assert "индекс цен квартала ×1.00" in (est.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_own_cadastre_wins_over_coords() -> None:
|
||||
"""Свой кадастр DaData приоритетнее координатного резолва — резолв не зовётся."""
|
||||
analogs_cheap = [
|
||||
_make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i)
|
||||
for i in range(5)
|
||||
]
|
||||
with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy:
|
||||
est = _run_estimate(analogs=analogs_cheap, dadata_cadnum=f"{_TARGET_Q}:350")
|
||||
|
||||
assert spy.call_count == 0
|
||||
# target 1.2 / avg 0.8 = 1.5
|
||||
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5)
|
||||
|
||||
|
||||
def test_coords_resolver_dormant_by_default() -> None:
|
||||
"""Флаг выключен (прод-дефолт) → координатный резолв не зовётся вообще.
|
||||
|
||||
Поставляемое поведение = снятие фолбэка на кадастр аналога, и только оно:
|
||||
точность самого резолва измерена (92% на 25 м), а эффект поправки на ЦЕНУ —
|
||||
нет, поэтому включение — отдельное решение (см. config, критерий и дата).
|
||||
"""
|
||||
with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy:
|
||||
est = _run_estimate(
|
||||
analogs=_analogs_two_quarters(),
|
||||
dadata_cadnum=None,
|
||||
coords_quarter=_UNPATCHED,
|
||||
coords_enabled=False,
|
||||
)
|
||||
|
||||
assert spy.call_count == 0
|
||||
assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
|
||||
assert "индекс цен квартала" not in (est.confidence_explanation or "")
|
||||
250
tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
Normal file
250
tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""#2632 — ответ несёт ФАКТИЧЕСКИЙ радиус подбора, а не выбор пользователя.
|
||||
|
||||
Прод, 2026-08-11 (оценка 4c20518f, ул. Советская 51): сохранённая строка несёт
|
||||
`relaxations = ["радиус расширен до 5000 м"]`, все 10 сохранённых аналогов лежат
|
||||
дальше километра (самый дальний — 4381 м), а `GET /estimate/{id}` отдавал
|
||||
`search_radius_m: null` → фронт падал на превью-радиус 1 км и рисовал круг, за
|
||||
которым остаются его же пины. Там же вторая ложь: `confidence_explanation`
|
||||
утверждал «расширили радиус до 2 км» — константа старого 1км→2км fallback'а,
|
||||
хотя каскад #oblast-F ушёл до 5 км.
|
||||
|
||||
Тесты падают на коде до этой правки: helper'ов не существует (ImportError),
|
||||
а объяснение прибито к «2 км».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.services.estimator import ( # noqa: E402
|
||||
DEFAULT_RADIUS_M,
|
||||
_compute_confidence,
|
||||
radius_from_relaxations,
|
||||
radius_relaxation_label,
|
||||
rehydrate_search_radius_m,
|
||||
)
|
||||
|
||||
_ESTIMATE_ID = "4c20518f-7d5d-4f9a-9dde-8f65d5ea2202"
|
||||
|
||||
|
||||
# ── подпись каскада ↔ разбор: единственный носитель числа в persisted-состоянии ──
|
||||
|
||||
|
||||
def test_radius_label_roundtrips() -> None:
|
||||
assert radius_from_relaxations([radius_relaxation_label(5000)]) == 5000
|
||||
assert radius_from_relaxations(["учтены студии", radius_relaxation_label(2000)]) == 2000
|
||||
# прод-строка дословно
|
||||
assert radius_from_relaxations(["радиус расширен до 5000 м"]) == 5000
|
||||
|
||||
|
||||
def test_radius_label_absent_or_malformed_gives_none() -> None:
|
||||
assert radius_from_relaxations(None) is None
|
||||
assert radius_from_relaxations([]) is None
|
||||
assert radius_from_relaxations(["учтены новостройки", "площадь ±25%"]) is None
|
||||
assert radius_from_relaxations(["радиус расширен до пяти км"]) is None
|
||||
|
||||
|
||||
# ── реконструкция радиуса для сохранённой оценки ──────────────────────────────
|
||||
|
||||
|
||||
def test_rehydrate_prefers_persisted_label_over_analog_extent() -> None:
|
||||
"""Подпись — точное значение (то же, что вернул POST); размах — лишь оценка снизу."""
|
||||
assert rehydrate_search_radius_m(["радиус расширен до 5000 м"], [4381, 1200, None]) == 5000
|
||||
|
||||
|
||||
def test_rehydrate_falls_back_to_analog_extent() -> None:
|
||||
"""Строки до миграции 255: подписи нет, но круг обязан вмещать свои же пины."""
|
||||
assert rehydrate_search_radius_m([], [800, 2428, None]) == 2428
|
||||
|
||||
|
||||
def test_rehydrate_never_below_default_radius() -> None:
|
||||
"""Все аналоги рядом → поиск всё равно шёл по базовому радиусу, не по 300 м."""
|
||||
assert rehydrate_search_radius_m([], [120, 300]) == DEFAULT_RADIUS_M
|
||||
|
||||
|
||||
def test_rehydrate_without_distances_stays_none() -> None:
|
||||
"""Нечего утверждать — фронт честно падает на выбор пользователя (как раньше)."""
|
||||
assert rehydrate_search_radius_m([], []) is None
|
||||
assert rehydrate_search_radius_m(None, [None, None]) is None
|
||||
|
||||
|
||||
# ── объяснение называет тот радиус, по которому реально искали ────────────────
|
||||
|
||||
|
||||
def _confidence(radius_used_m: int | None) -> str:
|
||||
_, explanation = _compute_confidence(
|
||||
12,
|
||||
100_000.0,
|
||||
95_000.0,
|
||||
105_000.0,
|
||||
True, # fallback_radius_used
|
||||
False,
|
||||
listings=[{"address": f"дом {i}"} for i in range(12)],
|
||||
radius_used_m=radius_used_m,
|
||||
)
|
||||
return explanation
|
||||
|
||||
|
||||
def test_explanation_names_actual_radius_not_hardcoded_2km() -> None:
|
||||
text = _confidence(5000)
|
||||
assert "5 км" in text
|
||||
assert "2 км" not in text
|
||||
|
||||
|
||||
def test_explanation_keeps_2km_wording_when_that_is_the_truth() -> None:
|
||||
assert "2 км" in _confidence(2000)
|
||||
# не-круглый радиус не притворяется километрами
|
||||
assert "1500 м" in _confidence(1500)
|
||||
|
||||
|
||||
# ── GET /estimate/{id}: сохранённая оценка отдаёт фактический радиус ──────────
|
||||
|
||||
|
||||
def _prod_shaped_row() -> SimpleNamespace:
|
||||
"""Форма прод-строки 4c20518f (расширение до 5 км, аналоги дальше километра)."""
|
||||
analog = {
|
||||
"address": "Екатеринбург, ул. Советская, 51",
|
||||
"area_m2": 44.0,
|
||||
"rooms": 2,
|
||||
"floor": 3,
|
||||
"total_floors": 9,
|
||||
"price_rub": 6_000_000,
|
||||
"price_per_m2": 136_000,
|
||||
"listing_date": None,
|
||||
"days_on_market": None,
|
||||
"source": "cian",
|
||||
"distance_m": 4381,
|
||||
}
|
||||
return SimpleNamespace(
|
||||
id=_ESTIMATE_ID,
|
||||
median_price=6_000_000,
|
||||
range_low=5_500_000,
|
||||
range_high=6_500_000,
|
||||
median_price_per_m2=136_000,
|
||||
confidence="low",
|
||||
confidence_explanation="Найдено 42 аналогов",
|
||||
n_analogs=42,
|
||||
analogs=[analog, {**analog, "distance_m": 1200}],
|
||||
actual_deals=[],
|
||||
sources_used=["cian"],
|
||||
data_freshness_minutes=15,
|
||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
|
||||
retain_until=None,
|
||||
address="Свердловская область, г. Екатеринбург, ул. Советская, д. 51",
|
||||
lat=56.8519,
|
||||
lon=60.6122,
|
||||
area_m2=44.0,
|
||||
rooms=2,
|
||||
floor=3,
|
||||
total_floors=9,
|
||||
year_built=1975,
|
||||
house_type=None,
|
||||
repair_state=None,
|
||||
has_balcony=None,
|
||||
canonical_address=None,
|
||||
house_cadnum=None,
|
||||
house_fias_id=None,
|
||||
dadata_qc_geo=None,
|
||||
dadata_metro=[],
|
||||
expected_sold_price=None,
|
||||
expected_sold_range_low=None,
|
||||
expected_sold_range_high=None,
|
||||
expected_sold_per_m2=None,
|
||||
asking_to_sold_ratio=None,
|
||||
ratio_basis=None,
|
||||
created_by="praktika",
|
||||
created_at=datetime.now(tz=UTC),
|
||||
relaxations=["радиус расширен до 5000 м"],
|
||||
reliability="low",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _light_estimator_stub():
|
||||
"""Подменяет тяжёлые POST-only helper'ы estimator'а, оставляя РЕАЛЬНУЮ реконструкцию.
|
||||
|
||||
Тот же приём, что в test_estimate_idor / test_estimate_revival (не тащить
|
||||
геокодер/скрейперы в API-тест), но `rehydrate_search_radius_m` берётся
|
||||
настоящий — его поведение и проверяем.
|
||||
"""
|
||||
real_estimator = sys.modules.get("app.services.estimator")
|
||||
stub = SimpleNamespace(
|
||||
_qc_geo_to_precision=lambda _qc: None,
|
||||
_fetch_price_trend=lambda *a, **k: None,
|
||||
_fetch_dkp_corridor=lambda *a, **k: None,
|
||||
_fetch_house_imv_anchor=lambda *a, **k: None,
|
||||
_resolve_target_city=lambda *a, **k: None,
|
||||
_cv_from_ppm2=lambda *a, **k: None,
|
||||
_source_counts=lambda *a, **k: {},
|
||||
_canonical_sources=lambda *a, **k: ["cian"],
|
||||
rehydrate_search_radius_m=rehydrate_search_radius_m,
|
||||
)
|
||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||
yield stub
|
||||
if real_estimator is not None:
|
||||
sys.modules["app.services.estimator"] = real_estimator
|
||||
else:
|
||||
sys.modules.pop("app.services.estimator", None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_get_role():
|
||||
from app.core import auth as auth_mod
|
||||
|
||||
original = auth_mod.get_role
|
||||
yield
|
||||
auth_mod.get_role = original
|
||||
|
||||
|
||||
def test_get_estimate_returns_actual_search_radius(_light_estimator_stub: SimpleNamespace) -> None:
|
||||
"""Прод-репро: круг на реоткрытой оценке рисовался по 1 км при поиске в 5 км."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core import auth as auth_mod
|
||||
from app.core.db import get_db
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
row = _prod_shaped_row()
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(clause, params=None, *_a, **_k):
|
||||
sql = getattr(clause, "text", str(clause))
|
||||
result = MagicMock()
|
||||
result.fetchone.return_value = row if "SELECT id, median_price" in sql else None
|
||||
return result
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
|
||||
def _override_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
|
||||
|
||||
resp = TestClient(app).get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "praktika"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["search_radius_m"] == 5000
|
||||
# круг обязан вмещать пины, которые сам же показывает
|
||||
assert body["search_radius_m"] >= max(a["distance_m"] for a in body["analogs"])
|
||||
# вход не выдаём за результат: radius_m не персистится — «просили» неизвестно
|
||||
assert body["requested_radius_m"] is None
|
||||
277
tradein-mvp/backend/tests/test_geocoder_city_substitution.py
Normal file
277
tradein-mvp/backend/tests/test_geocoder_city_substitution.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ».
|
||||
|
||||
Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры.
|
||||
Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не
|
||||
находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой
|
||||
«точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк,
|
||||
2026-08-12): **1251 запись** подпадает под инвариант — 1245 `provider=yandex`,
|
||||
6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов.
|
||||
|
||||
Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город)
|
||||
и «Малышева» (улица) структурно неразличимы — оба «слово до запятой». После
|
||||
ответа различимы: провайдер сам пишет, какой НП он использовал.
|
||||
|
||||
Адреса и full_address в тестах — живые строки из прод-кэша (не выдуманные).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.geocoder import (
|
||||
GeocodeResult,
|
||||
GeocodeSuggestion,
|
||||
_city_substituted,
|
||||
_claimed_locality,
|
||||
geocode,
|
||||
)
|
||||
|
||||
# ── _claimed_locality: какой НП назван в тексте ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,expected",
|
||||
[
|
||||
# НП назван явно
|
||||
("реж, ленина", "реж"),
|
||||
("арамиль, красноармейская ул, 118дк1", "арамиль"),
|
||||
("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось
|
||||
# Уровни выше НП пропускаются, НП находится дальше
|
||||
("свердловская обл., г.о. рефтинский", "рефтинский"),
|
||||
("россия, свердловская область, реж, ленина 5", "реж"),
|
||||
# НП НЕ назван — основной трафик формы оценки, инвариант не применяется
|
||||
("малышева 30", None),
|
||||
("ул. титова/монтерская/смоленская, стр. 2", None),
|
||||
("улица декабристов, 16-18литж · р-н октябрьский", None),
|
||||
("мкр-н широкая речка, ул. анатолия муранова,18", None),
|
||||
# Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним
|
||||
("малышева, мопра", "малышева"),
|
||||
],
|
||||
)
|
||||
def test_claimed_locality(address: str, expected: str | None) -> None:
|
||||
assert _claimed_locality(address) == expected
|
||||
|
||||
|
||||
# ── _city_substituted: подмена vs честный ответ ──────────────────────────────
|
||||
|
||||
|
||||
def _res(full_address: str, lat: float, lon: float) -> GeocodeResult:
|
||||
return GeocodeResult(
|
||||
lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,full_address,lat,lon",
|
||||
[
|
||||
# Живые записи прод-кэша из тела #2590 (все provider=yandex)
|
||||
(
|
||||
"реж, ленина",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Ленина",
|
||||
56.83953,
|
||||
60.61244,
|
||||
),
|
||||
(
|
||||
"реж, краснофлотцев",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Краснофлотцев",
|
||||
56.88996,
|
||||
60.63184,
|
||||
),
|
||||
(
|
||||
"арамиль, заводская",
|
||||
"Россия, Свердловская область, Екатеринбург, Заводская улица",
|
||||
56.83043,
|
||||
60.55170,
|
||||
),
|
||||
(
|
||||
"свердловская обл., г.о. рефтинский",
|
||||
"Россия, Свердловская область, Екатеринбург",
|
||||
56.83744,
|
||||
60.59764,
|
||||
),
|
||||
# Города вне списка 37 и без структурной приставки — дыра, оставленная #2589
|
||||
(
|
||||
"талица, кузнецова",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Кузнецова",
|
||||
56.84226,
|
||||
60.64557,
|
||||
),
|
||||
(
|
||||
"верхотурье, герцена",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Герцена",
|
||||
56.744397,
|
||||
60.677299,
|
||||
),
|
||||
# Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от
|
||||
# улицы Советской, куда провайдер поставил точку (живая запись прода).
|
||||
(
|
||||
"пос. кедровка, советская ул.,5",
|
||||
"Россия, Свердловская область, Екатеринбург, Советская улица, 5",
|
||||
56.8506,
|
||||
60.6282,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None:
|
||||
assert _city_substituted(address, _res(full_address, lat, lon)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,full_address,lat,lon",
|
||||
[
|
||||
# Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address.
|
||||
# Тот же вход, что и в подмене выше: разделяет их не география, а
|
||||
# сохранность топонима (граница из #2777 — посёлки не ловятся
|
||||
# «городским» признаком, и здесь ловить их и не нужно).
|
||||
(
|
||||
"пос. кедровка, советская ул.,5",
|
||||
"5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
|
||||
56.9902,
|
||||
60.7166,
|
||||
),
|
||||
(
|
||||
"шувакиш, ленина 4",
|
||||
"4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург",
|
||||
56.9295,
|
||||
60.4938,
|
||||
),
|
||||
# НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза
|
||||
(
|
||||
"малышева 30",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Малышева, 30",
|
||||
56.8389,
|
||||
60.6057,
|
||||
),
|
||||
# Явный Екатеринбург в адресе — не подмена по определению
|
||||
("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686),
|
||||
("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943),
|
||||
# Честный ответ по другому городу области: провайдер назвал НП, координаты
|
||||
# в его собственном bbox — под инвариант не подпадает
|
||||
("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813),
|
||||
# Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит
|
||||
# внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено
|
||||
(
|
||||
"режевской, ленина",
|
||||
"Россия, Свердловская область, Берёзовский, улица Ленина",
|
||||
56.9088,
|
||||
60.8107,
|
||||
),
|
||||
# Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не
|
||||
# является подменой города
|
||||
(
|
||||
"малышева, мопра",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Малышева",
|
||||
56.8389,
|
||||
60.6057,
|
||||
),
|
||||
# Тот же НП, но за пределами ЕКБ — инвариант молчит
|
||||
("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897),
|
||||
],
|
||||
)
|
||||
def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None:
|
||||
assert _city_substituted(address, _res(full_address, lat, lon)) is False
|
||||
|
||||
|
||||
# ── geocode(): сквозной эффект ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _tiers(*, cached=None, nominatim=None, geoportal=None):
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached))
|
||||
stack.enter_context(patch("app.services.geocoder._cache_put"))
|
||||
stack.enter_context(
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=geoportal)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None)
|
||||
)
|
||||
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
|
||||
stack.enter_context(
|
||||
patch("app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim))
|
||||
)
|
||||
stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()))
|
||||
yield
|
||||
|
||||
|
||||
async def test_geocode_downgrades_substituted_result() -> None:
|
||||
"""«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact.
|
||||
|
||||
Координаты остаются (честная деградация, не отказ), но confidence падает до
|
||||
`locality` — а это уже рабочий контракт: `estimator._geocode_is_coarse`
|
||||
включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу
|
||||
`geo_precision='city'`, что исключает его из пула аналогов.
|
||||
"""
|
||||
db = MagicMock()
|
||||
substituted = GeocodeResult(
|
||||
lat=56.83953,
|
||||
lon=60.61244,
|
||||
full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5",
|
||||
provider="nominatim",
|
||||
confidence="exact",
|
||||
)
|
||||
with _tiers(nominatim=substituted):
|
||||
result = await geocode("Реж, Ленина 5", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.confidence == "locality"
|
||||
assert result.city_ambiguous is True
|
||||
assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены
|
||||
|
||||
|
||||
async def test_geocode_poisoned_cache_hit_downgraded() -> None:
|
||||
"""Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже
|
||||
НЕ как точная — без единого DELETE в `geocode_cache`."""
|
||||
db = MagicMock()
|
||||
poisoned = GeocodeResult(
|
||||
lat=56.83744,
|
||||
lon=60.59764,
|
||||
full_address="Россия, Свердловская область, Екатеринбург",
|
||||
provider="cache",
|
||||
confidence="exact",
|
||||
)
|
||||
with _tiers(cached=poisoned):
|
||||
result = await geocode("Арамиль, Красноармейская ул, 118дк1", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.confidence == "locality"
|
||||
assert result.city_ambiguous is True
|
||||
|
||||
|
||||
async def test_geocode_ekb_address_unaffected() -> None:
|
||||
"""Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города)
|
||||
остаётся `exact` — инвариант не трогает основной трафик."""
|
||||
db = MagicMock()
|
||||
hit = GeocodeSuggestion(
|
||||
label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house"
|
||||
)
|
||||
with _tiers(geoportal=hit):
|
||||
result = await geocode("Екатеринбург, Малышева 30", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.confidence == "exact"
|
||||
assert result.city_ambiguous is False
|
||||
|
||||
|
||||
async def test_geocode_settlement_inside_ekb_kept_exact() -> None:
|
||||
"""Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не
|
||||
отбраковывается — граница, о которой предупреждает #2777."""
|
||||
db = MagicMock()
|
||||
honest = GeocodeResult(
|
||||
lat=56.9902,
|
||||
lon=60.7166,
|
||||
full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
|
||||
provider="nominatim",
|
||||
confidence="exact",
|
||||
)
|
||||
with _tiers(nominatim=honest):
|
||||
result = await geocode("пос. Кедровка, Советская ул., 5", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.confidence == "exact"
|
||||
|
|
@ -18,6 +18,7 @@ import inspect
|
|||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
|
@ -486,9 +487,13 @@ class _FakeDB:
|
|||
mapping_rows: list[_Row],
|
||||
step_rowcount: int = 1,
|
||||
fk_children: dict[str, str] | None = None,
|
||||
residual: dict[str, int] | None = None,
|
||||
residual_raises: bool = False,
|
||||
):
|
||||
self._mapping_rows = mapping_rows
|
||||
self._step_rowcount = step_rowcount
|
||||
self._residual = residual
|
||||
self._residual_raises = residual_raises
|
||||
self._mapping_served = False
|
||||
# The catalog the FK-child guard reads; defaults to the real live set.
|
||||
self._fk_children = _FK_CHILDREN if fk_children is None else fk_children
|
||||
|
|
@ -503,6 +508,12 @@ class _FakeDB:
|
|||
return _FakeResult()
|
||||
if "FROM pg_constraint" in sql:
|
||||
return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()])
|
||||
if "AS residual_rows" in sql: # residual census (#2690) — read-only, after both passes
|
||||
if self._residual_raises:
|
||||
raise RuntimeError("census exploded")
|
||||
if self._residual is None:
|
||||
return _FakeResult(rows=[])
|
||||
return _FakeResult(rows=[SimpleNamespace(**self._residual)])
|
||||
if "SELECT loser_id, keeper_id, norm_address" in sql:
|
||||
# The service now runs TWO passes (fias, then canon). Model «fias pass found the
|
||||
# duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards.
|
||||
|
|
@ -550,6 +561,85 @@ def test_dry_run_computes_counts_but_rolls_back() -> None:
|
|||
assert db.rollbacks == 1
|
||||
|
||||
|
||||
# ── Residual census (#2690 п.2/п.4): остаток фиксируется числом, а не оценкой ──
|
||||
|
||||
|
||||
def test_residual_census_reuses_the_canon_mapping_prelude() -> None:
|
||||
"""Перепись остатка обязана считать РОВНО те строки, о которых рассуждает канон-проход.
|
||||
|
||||
Общий `_ranked_cte` — не косметика: собственная копия кластеризации разъехалась бы с
|
||||
проходом, который она описывает, и разъезд был бы невидим (это тот же класс ошибки, что и
|
||||
ключ в #2690 — два выражения, похожие друг на друга и не равные). RED до фикса: переписи
|
||||
нет вовсе.
|
||||
"""
|
||||
residual = _flat(str(hdm._RESIDUAL_SQL.text))
|
||||
prelude = _flat(hdm._ranked_cte(hdm._CANON_KEY_EXPR))
|
||||
assert prelude in residual
|
||||
assert prelude in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR))
|
||||
|
||||
|
||||
def test_residual_census_keeps_silent_guard_apart_from_rejecting_guard() -> None:
|
||||
"""«Координат нет» и «дальше 250 м» — противоположные факты, в одну сумму их нельзя.
|
||||
|
||||
Первое означает, что страж не смог высказаться (остаток неизвестен), второе — что он
|
||||
высказался по существу и дома РАЗНЫЕ (это вообще не дубли). Сумма из этих двух читается как
|
||||
«долг», которого нет. Плюс: перепись НЕ применяет гео-фильтр — иначе она считала бы только
|
||||
то, что и так слилось.
|
||||
"""
|
||||
residual = _flat(str(hdm._RESIDUAL_SQL.text))
|
||||
for bucket in (
|
||||
"AS residual_rows",
|
||||
"AS residual_listings",
|
||||
"AS residual_no_geom",
|
||||
"AS residual_far",
|
||||
"AS residual_cross_fias",
|
||||
"AS residual_mergeable",
|
||||
):
|
||||
assert bucket in residual, bucket
|
||||
# Гео-страж 250 м здесь — РАЗДЕЛИТЕЛЬ корзин, а не фильтр строк.
|
||||
assert "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in residual
|
||||
assert set(hdm.DedupMergeResult().to_counters()) >= {
|
||||
"residual_rows",
|
||||
"residual_listings",
|
||||
"residual_no_geom",
|
||||
"residual_far",
|
||||
"residual_cross_fias",
|
||||
"residual_mergeable",
|
||||
}
|
||||
|
||||
|
||||
def test_residual_counters_reported_even_when_nothing_merged() -> None:
|
||||
"""Прогон, который не слил ничего, — ровно тот, чьему остатку нужно число."""
|
||||
db = _FakeDB(
|
||||
mapping_rows=[],
|
||||
residual={
|
||||
"residual_rows": 963,
|
||||
"residual_listings": 1765,
|
||||
"residual_no_geom": 326,
|
||||
"residual_far": 568,
|
||||
"residual_cross_fias": 8,
|
||||
"residual_mergeable": 61,
|
||||
},
|
||||
)
|
||||
out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type]
|
||||
assert out["losers_deleted"] == 0
|
||||
assert out["residual_rows"] == 963
|
||||
assert out["residual_listings"] == 1765
|
||||
assert out["residual_no_geom"] == 326
|
||||
assert out["residual_far"] == 568
|
||||
assert out["residual_cross_fias"] == 8
|
||||
assert out["residual_mergeable"] == 61
|
||||
|
||||
|
||||
def test_residual_census_failure_never_breaks_the_merge() -> None:
|
||||
"""Перепись — приборы, слияние — продукт: упавший счётчик не отменяет коммит."""
|
||||
db = _FakeDB(mapping_rows=[_Row(2, 1, "ул. мира, 10")], residual_raises=True)
|
||||
out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type]
|
||||
assert out["losers_deleted"] == 1
|
||||
assert out["residual_rows"] == 0 # неизвестно — и это видно, а не выдумано
|
||||
assert db.commits == 1
|
||||
|
||||
|
||||
def test_real_merge_commits() -> None:
|
||||
"""dry_run=False with dups → exactly one commit, no rollback."""
|
||||
rows = [_Row(2, 1, "ул. мира, 10")]
|
||||
|
|
|
|||
|
|
@ -138,7 +138,11 @@ async def test_unknown_house_type_skips_request_and_marks_house() -> None:
|
|||
patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval,
|
||||
patch.object(hib, "_mark_status") as mock_mark,
|
||||
):
|
||||
db.execute.return_value.mappings.return_value.all.return_value = houses
|
||||
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
|
||||
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
|
||||
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
|
||||
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
|
||||
db.execute.return_value.mappings.return_value.all.side_effect = [[], houses]
|
||||
result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0)
|
||||
|
||||
mock_eval.assert_not_called()
|
||||
|
|
|
|||
267
tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
Normal file
267
tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную.
|
||||
|
||||
Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон
|
||||
(`only_status`, дефолт 'pending'). Возможность повтора существовала и была
|
||||
задокументирована в admin-API, но за всю историю (41 прогон, 26.06–11.08.2026)
|
||||
её не передал никто: все прогоны ушли с 'pending'. Дома, упавшие по причине,
|
||||
которой больше нет (503/500 от браузерного сайдкара, #2698), лежали в
|
||||
'transient_error' без единого шанса вернуться — 1390 штук на 12.08.2026.
|
||||
|
||||
Что проверяем — по существу, а не по настройке:
|
||||
* дом в 'transient_error' ПОПАДАЕТ в пакет прогона с дефолтными параметрами
|
||||
(тот же вызов, что делает расписание) — на старом коде он не попадает;
|
||||
* pending при этом из пакета НЕ вытесняется;
|
||||
* дом, исчерпавший лимит попыток, в автоматический повтор не берётся, а
|
||||
ЯВНЫЙ only_status='transient_error' (ручной запрос оператора) берёт и его;
|
||||
* дом без пригодных параметров помечается no_params БЕЗ запроса к площадке.
|
||||
|
||||
Живой Postgres обязателен: проверяется поведение SQL-выборки, а не то, какие
|
||||
строки вернул мок. Без БД тест самоскипается (в CI БД есть — ci-tradein.yml
|
||||
поднимает Postgres и накатывает схему).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import house_imv_backfill as hib
|
||||
|
||||
# Фикстуры живут в собственном диапазоне id (ср. tests/test_house_dedup_merge.py).
|
||||
_H_STUCK = 900601 # transient_error, попыток 0 → обязан вернуться в очередь
|
||||
_H_EXHAUSTED = 900602 # transient_error, попытки исчерпаны → в автоповтор не берём
|
||||
_H_PENDING = 900603 # pending → не должен быть вытеснен retry-очередью
|
||||
_H_NO_PARAMS = 900604 # pending без объявлений с rooms+area → no_params без запроса
|
||||
_IDS = (_H_STUCK, _H_EXHAUSTED, _H_PENDING, _H_NO_PARAMS)
|
||||
|
||||
|
||||
def _live_session() -> Any | None:
|
||||
"""Session к достижимому не-заглушечному Postgres, иначе None."""
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return None
|
||||
engine = create_engine(dsn, future=True)
|
||||
conn = engine.connect()
|
||||
conn.execute(text("SELECT 1"))
|
||||
conn.close()
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _seed(db: Any) -> None:
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
# url/source/ext_house_id — NOT NULL в houses (002_core_tables).
|
||||
db.execute(
|
||||
_t(
|
||||
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, "
|
||||
" imv_status, imv_transient_attempts, last_imv_attempt_at) VALUES "
|
||||
"(:s, 'avito', 'EXT-2674-S', 'u', 'тестдом 2674, 1', 56.84, 60.60, "
|
||||
" 'transient_error', 0, NOW() - INTERVAL '10 days'),"
|
||||
"(:x, 'avito', 'EXT-2674-X', 'u', 'тестдом 2674, 2', 56.84, 60.60, "
|
||||
" 'transient_error', :cap, NOW() - INTERVAL '10 days'),"
|
||||
"(:p, 'avito', 'EXT-2674-P', 'u', 'тестдом 2674, 3', 56.84, 60.60, "
|
||||
" 'pending', 0, NULL),"
|
||||
"(:n, 'avito', 'EXT-2674-N', 'u', 'тестдом 2674, 4', 56.84, 60.60, "
|
||||
" 'pending', 0, NULL)"
|
||||
),
|
||||
{
|
||||
"s": _H_STUCK,
|
||||
"x": _H_EXHAUSTED,
|
||||
"p": _H_PENDING,
|
||||
"n": _H_NO_PARAMS,
|
||||
# Литерал, а не hib._MAX_TRANSIENT_ATTEMPTS: подготовка теста обязана
|
||||
# работать и на СТАРОМ коде, иначе красный прогон получится про
|
||||
# отсутствующий атрибут, а не про застрявший дом. Синхронность
|
||||
# литерала и константы стережёт test_transient_attempts_counter_*.
|
||||
"cap": 3,
|
||||
},
|
||||
)
|
||||
# Объявления с rooms+area+house_type — у всех, КРОМЕ _H_NO_PARAMS.
|
||||
# source_url / dedup_hash / price_rub — NOT NULL, dedup_hash UNIQUE.
|
||||
db.execute(
|
||||
_t(
|
||||
"INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, "
|
||||
" house_id_fk, rooms, area_m2, floor, total_floors, house_type) "
|
||||
"VALUES "
|
||||
"(:l1, 'avito', 'http://t/2674/1', 'L-2674-1', 'dh-2674-1', 5000000, :s, "
|
||||
" 2, 52.0, 3, 9, 'brick'),"
|
||||
"(:l2, 'avito', 'http://t/2674/2', 'L-2674-2', 'dh-2674-2', 5000000, :x, "
|
||||
" 2, 52.0, 3, 9, 'brick'),"
|
||||
"(:l3, 'avito', 'http://t/2674/3', 'L-2674-3', 'dh-2674-3', 5000000, :p, "
|
||||
" 2, 52.0, 3, 9, 'brick')"
|
||||
),
|
||||
{
|
||||
"l1": 910601,
|
||||
"l2": 910602,
|
||||
"l3": 910603,
|
||||
"s": _H_STUCK,
|
||||
"x": _H_EXHAUSTED,
|
||||
"p": _H_PENDING,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _cleanup(db: Any) -> None:
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db.rollback()
|
||||
db.execute(_t("DELETE FROM listings WHERE id IN (910601, 910602, 910603)"))
|
||||
db.execute(_t("DELETE FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), {"ids": list(_IDS)})
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
async def _run(db: Any, **kwargs: Any) -> Any:
|
||||
"""Прогнать backfill с замоканным вызовом площадки.
|
||||
|
||||
Мокаем ровно сетевую границу: evaluate_via_imv поднимает «адрес не найден»,
|
||||
поэтому дом уходит в not_found и путь сохранения (три таблицы) не нужен —
|
||||
проверяем именно ВЫБОРКУ. Флаг браузерного фетчера гасим, чтобы не поднимать
|
||||
сайдкар.
|
||||
"""
|
||||
err = hib.IMVAddressNotFoundError("address not found (test)")
|
||||
with (
|
||||
patch.object(hib.settings, "avito_imv_use_browser_fetcher", False),
|
||||
patch.object(hib, "evaluate_via_imv", AsyncMock(side_effect=err)),
|
||||
):
|
||||
return await hib.backfill_house_imv(db, request_delay_sec=0.0, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
async def test_stuck_transient_house_returns_to_the_queue_by_itself() -> None:
|
||||
"""Прогон с дефолтными параметрами (как у расписания) забирает застрявший дом.
|
||||
|
||||
На старом коде выборка — `WHERE imv_status = :status` с единственным
|
||||
значением 'pending', поэтому дом остаётся в 'transient_error' навсегда.
|
||||
"""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
if db.execute(_t("SELECT count(*) FROM houses")).scalar():
|
||||
pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
|
||||
try:
|
||||
_seed(db)
|
||||
|
||||
# Ровно то, что вызывает расписание: batch_size из default_params, статус по умолчанию.
|
||||
result = await _run(db, batch_size=50)
|
||||
|
||||
picked = {
|
||||
r.id: r.imv_status
|
||||
for r in db.execute(
|
||||
_t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"),
|
||||
{"ids": list(_IDS)},
|
||||
)
|
||||
}
|
||||
assert picked[_H_STUCK] != "transient_error", (
|
||||
"дом с временной ошибкой и непотраченным лимитом обязан попасть в пакет "
|
||||
f"обычного прогона; фактически статус {picked[_H_STUCK]!r} "
|
||||
f"(в пакет прогона попало домов: {result.checked})"
|
||||
)
|
||||
# pending не вытеснен повтором.
|
||||
assert picked[_H_PENDING] != "pending", "pending-дом обязан остаться в том же пакете"
|
||||
# Исчерпавший лимит в автоповтор не берётся — иначе вечный цикл.
|
||||
assert picked[_H_EXHAUSTED] == "transient_error"
|
||||
assert result.retried == 1
|
||||
# Дом без объявлений с rooms+area помечен ДО пакета, слот не потрачен.
|
||||
assert picked[_H_NO_PARAMS] == "no_params"
|
||||
assert result.premarked == 1
|
||||
finally:
|
||||
_cleanup(db)
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
async def test_explicit_only_status_still_takes_exhausted_houses() -> None:
|
||||
"""Явный only_status — ручной запрос оператора: лимит попыток его не режет."""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
if db.execute(_t("SELECT count(*) FROM houses")).scalar():
|
||||
pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
|
||||
try:
|
||||
_seed(db)
|
||||
|
||||
result = await _run(db, batch_size=50, only_status="transient_error")
|
||||
|
||||
statuses = {
|
||||
r.id: r.imv_status
|
||||
for r in db.execute(
|
||||
_t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"),
|
||||
{"ids": list(_IDS)},
|
||||
)
|
||||
}
|
||||
assert (
|
||||
statuses[_H_EXHAUSTED] != "transient_error"
|
||||
), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит"
|
||||
# Автоповтора поверх явного запроса нет: pending не тронут.
|
||||
assert statuses[_H_PENDING] == "pending"
|
||||
assert result.retried == 0
|
||||
finally:
|
||||
_cleanup(db)
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
async def test_transient_attempts_counter_only_counts_transient() -> None:
|
||||
"""Счётчик — это «сколько раз подряд упал по временной причине», и он обнуляем.
|
||||
|
||||
Без верхней границы у повтора не было бы выхода; без обнуления успехом дом,
|
||||
однажды поймавший 3 сетевых сбоя, навсегда терял бы право на повтор.
|
||||
"""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
if db.execute(_t("SELECT count(*) FROM houses")).scalar():
|
||||
pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
|
||||
try:
|
||||
_seed(db)
|
||||
assert hib._MAX_TRANSIENT_ATTEMPTS == 3, "литерал 'cap' в _seed рассинхронизирован"
|
||||
|
||||
def _attempts() -> int:
|
||||
return int(
|
||||
db.execute(
|
||||
_t("SELECT imv_transient_attempts FROM houses WHERE id = :i"),
|
||||
{"i": _H_STUCK},
|
||||
).scalar()
|
||||
)
|
||||
|
||||
hib._mark_status(db, _H_STUCK, "transient_error", "boom")
|
||||
assert _attempts() == 1
|
||||
hib._mark_status(db, _H_STUCK, "not_found", "адрес не найден")
|
||||
assert _attempts() == 1, "нетранзиентные исходы счётчик не двигают"
|
||||
db.execute(
|
||||
_t("UPDATE houses SET imv_status='ok', imv_transient_attempts=0 WHERE id = :i"),
|
||||
{"i": _H_STUCK},
|
||||
)
|
||||
db.commit()
|
||||
assert _attempts() == 0
|
||||
finally:
|
||||
_cleanup(db)
|
||||
|
||||
|
||||
def test_retry_queue_is_not_bound_to_only_status() -> None:
|
||||
"""Статика (бежит и без БД): retry-выборка не зависит от параметра only_status.
|
||||
|
||||
Ровно этот дефект и застрял на проде: единственный путь к 'transient_error'
|
||||
шёл через значение, которое никто не передавал.
|
||||
"""
|
||||
retry_sql = str(hib._RETRY_QUEUE_SQL.text)
|
||||
assert "imv_status = 'transient_error'" in retry_sql
|
||||
assert ":status" not in retry_sql, "retry-очередь обязана быть независимой от only_status"
|
||||
assert "imv_transient_attempts < :max_attempts" in retry_sql, "у повтора должен быть выход"
|
||||
# psycopg v3: только CAST(:x AS type), никогда :x::type.
|
||||
for sql in (retry_sql, str(hib._QUEUE_SQL.text), str(hib._PREMARK_UNUSABLE_SQL.text)):
|
||||
assert "::" not in sql
|
||||
|
|
@ -844,6 +844,111 @@ def test_tier2a_coord_less_bare_street_still_runs_tier2a():
|
|||
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# match_or_create_house — sweep-city guard for BARE oblast cards (#2777)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups():
|
||||
"""RED до #2777. Бескоординатная карточка областного формата («ул. Кирова,4» — города
|
||||
в адресе НЕТ) собрана развёрткой Серова. Ключ Tier-2a без координат вырождается в один
|
||||
нормализованный адрес, глобально уникальный в house_address_aliases, поэтому карточка
|
||||
села бы в одноимённый дом другого города (прод: 362.8 км, дом 380628). Признак города у
|
||||
развёртки ЕСТЬ — он же пишется в listings.city — и теперь доезжает до стража."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss (Tier 1)
|
||||
# Tier 2a/2b SKIPPED стражем, Tier 3 SKIPPED (нет координат)
|
||||
{"id": 2777}, # INSERT RETURNING id (New house)
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2777-serov", address="ул. Кирова,4", city="Серов"
|
||||
)
|
||||
assert (house_id, conf, method) == (2777, 1.0, "new")
|
||||
sqls = _executed_sqls(db)
|
||||
assert not any(
|
||||
"fingerprint = :fp" in s for s in sqls
|
||||
), "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»"
|
||||
assert not any("normalized_address = :na" in s for s in sqls)
|
||||
|
||||
|
||||
def test_sweep_city_ekb_keeps_tier2a_dedup():
|
||||
"""Контроль к предыдущему: тот же бескоординатный ключ, но развёртка ЕКБ — страж молчит,
|
||||
Tier-2a дедуп работает как раньше. Иначе правка ломала бы 31 663 ЕКБ-карточки ради 35."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss
|
||||
{"house_id": 55}, # Tier 2a fingerprint HIT
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2777-ekb", address="ул. Кирова,4", city="Екатеринбург"
|
||||
)
|
||||
assert (house_id, conf, method) == (55, 0.9, "fingerprint")
|
||||
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
|
||||
|
||||
|
||||
def test_sweep_city_ignored_when_card_has_coords():
|
||||
"""Карточка С координатами стражем города не трогается: у Tier-2a координаты уже в ключе,
|
||||
у Tier-2b свой гео-страж 3 км. Расширять на неё городской признак — значит ломать
|
||||
смежные ЕКБ/В.Пышма пары, которые в проде расходятся на 2-8 м (то же здание)."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss
|
||||
{"house_id": 66}, # Tier 2a fingerprint HIT (координаты в ключе)
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db,
|
||||
"avito",
|
||||
"ext-2777-coords",
|
||||
address="ул. Кирова,4",
|
||||
lat=59.60,
|
||||
lon=60.58,
|
||||
city="Верхняя Пышма",
|
||||
)
|
||||
assert (house_id, conf, method) == (66, 0.9, "fingerprint")
|
||||
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
|
||||
|
||||
|
||||
def test_sweep_city_guard_covers_cities_outside_the_token_list():
|
||||
"""Страж не должен зависеть от списка _CITY_TOKENS: развёртка Ревды (её в списке нет)
|
||||
всё равно не имеет права садиться на чужой алиас. Признак — имя города от развёртки,
|
||||
а не токен, вычитанный из адреса."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss
|
||||
{"id": 2778}, # INSERT RETURNING id (New house) — алиасы пропущены
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, _conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2777-revda", address="ул. Кирова,4", city="Ревда"
|
||||
)
|
||||
assert (house_id, method) == (2778, "new")
|
||||
assert not any("fingerprint = :fp" in s for s in _executed_sqls(db))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# match_or_create_listing — mock DB tier routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -107,3 +107,27 @@ def test_house_key_never_accepts_flat_cadastre() -> None:
|
|||
assert (
|
||||
"cad = building_cadastral_number\n" in src
|
||||
), "в ключ дома вернулся фолбэк на кадастр квартиры"
|
||||
|
||||
|
||||
def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None:
|
||||
"""Страж города бесполезен, пока признак не доезжает до него с настоящего вызова (#2777).
|
||||
|
||||
Тот же класс ошибки, что у `house_fias_id`: параметр в сигнатуре есть, передать его
|
||||
некому. Здесь граница — `scraper_kit.base._link_listing_to_house`, единственный боевой
|
||||
вызывающий пути создания домов; город он получает из `save_listings`, который его же
|
||||
пишет в `listings.city`.
|
||||
"""
|
||||
from scraper_kit.base import _link_listing_to_house, save_listings
|
||||
|
||||
assert "city" in _params(match_or_create_house)
|
||||
|
||||
hook_src = inspect.getsource(_link_listing_to_house)
|
||||
assert "city=city" in hook_src, (
|
||||
"_link_listing_to_house перестал передавать город в матчер — страж #2777 снова "
|
||||
"недостижим, а межгородская склейка молча вернётся"
|
||||
)
|
||||
# save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать
|
||||
# именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен.
|
||||
assert "city=lot_city" in inspect.getsource(
|
||||
save_listings
|
||||
), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
"""Static guards for migration 257 (#2838): лечение yandex source_url.
|
||||
|
||||
Живой БД у юнит-теста нет, поэтому проверяем то, что ломается тихо и навсегда:
|
||||
|
||||
1. Форма адреса не разъехалась с продюсером. В файле стоит ДОСЛОВНО
|
||||
`CANONICAL_URL_SQL` из app/tasks/yandex_detail_backfill.py, а тот уже сверен с
|
||||
`_canonical_source_url` продюсера в test_yandex_detail_backfill.py. Сцепление
|
||||
транзитивное: поменяют форму у продюсера — красным станет тот тест, поменяют в
|
||||
миграции — этот.
|
||||
2. Отбор — теми же константами, что у очереди (#2838), а не «похожими» regexp'ами.
|
||||
3. Бэкап прежних значений пишется ДО UPDATE. Переставь местами — таблица отката
|
||||
наполнится уже канонизированными адресами, и откат станет no-op'ом, который
|
||||
выглядит рабочим.
|
||||
4. Недеструктивность и транзакционность: одна колонка, BEGIN/COMMIT, lock_timeout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.tasks.yandex_detail_backfill import (
|
||||
CANONICAL_URL_SQL,
|
||||
OFFER_ID_PATTERN,
|
||||
OFFER_URL_PATTERN,
|
||||
)
|
||||
|
||||
_MIGRATION = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "data"
|
||||
/ "sql"
|
||||
/ "257_listings_backfill_yandex_source_url.sql"
|
||||
)
|
||||
|
||||
|
||||
def _executable_sql() -> str:
|
||||
"""SQL без `--`-комментариев: шапка объясняет прозой и упоминает всё подряд."""
|
||||
lines = [raw.split("--", 1)[0] for raw in _MIGRATION.read_text(encoding="utf-8").splitlines()]
|
||||
return "\n".join(line for line in lines if line.strip())
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def test_migration_exists() -> None:
|
||||
assert _MIGRATION.exists(), f"missing migration: {_MIGRATION}"
|
||||
|
||||
|
||||
def test_canonical_url_matches_producer_formula() -> None:
|
||||
"""Выражение UPDATE — тот же литерал, что CANONICAL_URL_SQL (и, значит, продюсер)."""
|
||||
assert _flat(CANONICAL_URL_SQL) in _flat(_executable_sql())
|
||||
|
||||
|
||||
def test_selects_rows_by_the_same_constants_as_the_queue() -> None:
|
||||
"""Отбор — константами очереди #2838, иначе миграция чинит не то множество."""
|
||||
sql = _executable_sql()
|
||||
assert f"'{OFFER_URL_PATTERN}'" in sql
|
||||
assert f"'{OFFER_ID_PATTERN}'" in sql
|
||||
assert "source = 'yandex'" in sql
|
||||
|
||||
|
||||
def test_backup_is_written_before_the_update() -> None:
|
||||
sql = _executable_sql()
|
||||
assert sql.index("INSERT INTO yandex_source_url_backfill_257") < sql.index("UPDATE listings")
|
||||
|
||||
|
||||
def test_transactional_and_lock_timeout_after_begin() -> None:
|
||||
sql = _executable_sql()
|
||||
assert "BEGIN;" in sql and "COMMIT;" in sql
|
||||
assert sql.index("BEGIN;") < sql.index("SET LOCAL lock_timeout")
|
||||
assert sql.index("SET LOCAL lock_timeout") < sql.index("UPDATE listings")
|
||||
|
||||
|
||||
def test_touches_only_source_url_and_deletes_nothing() -> None:
|
||||
sql = _executable_sql().upper()
|
||||
for forbidden in ("DELETE", "DROP", "TRUNCATE", "IS_ACTIVE"):
|
||||
assert forbidden not in sql, forbidden
|
||||
assert sql.count("SET SOURCE_URL") == 1
|
||||
|
|
@ -423,6 +423,86 @@ def test_build_listings_page_none_year_built_no_crash() -> None:
|
|||
assert "РЫНОК КВАРТИР" in html
|
||||
|
||||
|
||||
# ── #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10) ────────
|
||||
# n_analogs==0 (headline ceded to the ДКП deals corridor, estimator.py
|
||||
# `deals_headline_due_to_thin_listings`) with a non-empty `analogs` display list
|
||||
# (thin listings kept as reference cards) used to print "0 шт." above a
|
||||
# non-empty examples table — a client-visible contradiction that leaked into
|
||||
# the PDF handed to clients. See _build_listings_page / _deals_sourced_thin_
|
||||
# listings_note_html / _reliability_note_html.
|
||||
|
||||
|
||||
def test_listings_page_zero_analogs_shown_cards_no_false_zero_count() -> None:
|
||||
"""The exact bug: n_analogs=0 + 3 shown analogs must NOT print '0 шт.' —
|
||||
falls back to the actually-shown population (3) and adds an honest
|
||||
deals-sourced footnote."""
|
||||
analogs = [
|
||||
_analog(address="ул. Льва Толстого, 8А"),
|
||||
_analog(address="ул. Кирова, 4"),
|
||||
_analog(address="ул. Льва Толстого, 34"),
|
||||
]
|
||||
est = _estimate(n_analogs=0, analogs=analogs, sources_used=["avito"])
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "0 шт." not in html
|
||||
assert "3 шт." in html
|
||||
assert "Оценка построена по зарегистрированным сделкам Росреестра" in html
|
||||
assert "почти нет" in html
|
||||
|
||||
|
||||
def test_listings_page_zero_analogs_empty_cards_stays_honest_zero() -> None:
|
||||
"""Control: genuinely zero listings (no cards to show either) — '0 шт.' is
|
||||
honest here, and the deals-sourced footnote (which explains a MISMATCH)
|
||||
must NOT appear since there is nothing to reconcile."""
|
||||
est = _estimate(n_analogs=0, analogs=[])
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "0 шт." in html
|
||||
assert "Оценка построена по зарегистрированным сделкам Росреестра" not in html
|
||||
|
||||
|
||||
def test_listings_page_healthy_sample_keeps_full_n_analogs_not_capped_len() -> None:
|
||||
"""Control/regression guard for the max() choice: a healthy sample where
|
||||
n_analogs (15) EXCEEDS the capped display list (10, AggregatedEstimate's
|
||||
own top-10 cap) must keep printing the full honest count (15 шт.), NOT
|
||||
silently understate it to len(analogs) (10 шт.)."""
|
||||
analogs = [_analog(address=f"ул. Тест, {i}") for i in range(10)]
|
||||
est = _estimate(n_analogs=15, analogs=analogs, sources_used=["avito"])
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "15 шт." in html
|
||||
assert "10 шт." not in html
|
||||
|
||||
|
||||
def test_listings_page_relaxations_warning_shown_with_labels() -> None:
|
||||
"""relaxations non-empty → warning block present, names the labels, and
|
||||
reliability != 'ok' — mirrors what the web LowConfidenceBanner already
|
||||
shows (see AggregatedEstimate docstring)."""
|
||||
est = _estimate(relaxations=["учтены студии", "радиус расширен до 3000 м"], reliability="low")
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "Точность оценки снижена." in html
|
||||
assert "учтены студии" in html
|
||||
assert "радиус расширен до 3000 м" in html
|
||||
|
||||
|
||||
def test_listings_page_reliability_downgraded_no_relaxations_fallback_text() -> None:
|
||||
"""reliability != 'ok' but relaxations is empty (cascade couldn't grow a
|
||||
thin sample, estimator.py #oblast-F) → warning block still shown, with a
|
||||
fallback sentence (not an empty label list)."""
|
||||
est = _estimate(n_analogs=2, reliability="very_low", relaxations=[])
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "Точность оценки снижена." in html
|
||||
assert "небольшой выборке" in html
|
||||
|
||||
|
||||
def test_listings_page_no_warning_block_when_ok_and_no_relaxations() -> None:
|
||||
"""Control: the common/unrelaxed case (reliability='ok' default, no
|
||||
relaxations) — no warning block at all, byte-identical to the report
|
||||
before these fields existed."""
|
||||
est = _estimate()
|
||||
assert est.reliability == "ok"
|
||||
assert est.relaxations == []
|
||||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "Точность оценки снижена." not in html
|
||||
|
||||
|
||||
def test_build_deals_page_none_year_built_no_crash() -> None:
|
||||
snap = dict(_SNAPSHOT)
|
||||
snap["year_built"] = None
|
||||
|
|
|
|||
|
|
@ -156,7 +156,8 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None:
|
|||
with (
|
||||
patch.object(admin_module, "_probe_browser_health", fake_browser_health),
|
||||
patch.object(admin_module, "_probe_current_ip", fake_current_ip),
|
||||
patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]),
|
||||
# #2830: узел резолвится из пула по (db, source), а не из статичного env
|
||||
patch.object(admin_module, "_provider_proxy_url", lambda _db, s: proxy_urls[s]),
|
||||
patch.object(admin_module.settings, "scraper_fetch_mode", "browser"),
|
||||
):
|
||||
r = client.get("/api/v1/admin/scraper/health")
|
||||
|
|
@ -207,7 +208,7 @@ def test_health_browser_unreachable(client: TestClient) -> None:
|
|||
with (
|
||||
patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
|
||||
patch.object(admin_module, "_probe_current_ip", fake_current_ip),
|
||||
patch.object(admin_module, "_provider_proxy_url", lambda s: None),
|
||||
patch.object(admin_module, "_provider_proxy_url", lambda _db, _s: None),
|
||||
):
|
||||
r = client.get("/api/v1/admin/scraper/health")
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,64 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N
|
|||
assert extract_street_name(address) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,expected",
|
||||
[
|
||||
# Live-prod repro (2026-08-10): DaData format — abbreviations WITHOUT a
|
||||
# trailing dot ("ул Академика Парина", not "ул. ..."), plus a leading
|
||||
# postal index + admin parts ("620105, Свердловская обл, г
|
||||
# Екатеринбург, Академический р-н, ..."). Old `_STREET_KW_RE` required
|
||||
# the dot → keyword never matched on ANY DaData address → street-deals
|
||||
# / sales-vs-listings endpoints silently returned empty for every
|
||||
# DaData-geocoded request, not just this one.
|
||||
(
|
||||
"620105, Свердловская обл, г Екатеринбург, Академический р-н, "
|
||||
"ул Академика Парина, д 46/5",
|
||||
"Академика Парина",
|
||||
),
|
||||
# Same address, WITH the dot — must give the identical result (dot
|
||||
# optional, not dot-forbidden).
|
||||
(
|
||||
"620105, Свердловская обл, г Екатеринбург, Академический р-н, "
|
||||
"ул. Академика Парина, д 46/5",
|
||||
"Академика Парина",
|
||||
),
|
||||
# Same address, full word "улица" — the alternation-order/backtracking
|
||||
# concern: "ул\\.?" must NOT eat the "ул" prefix of "улица" and leave
|
||||
# "ица ..." behind.
|
||||
(
|
||||
"620105, Свердловская обл, г Екатеринбург, Академический р-н, "
|
||||
"улица Академика Парина, д 46/5",
|
||||
"Академика Парина",
|
||||
),
|
||||
# Without the leading postal index — same admin prefix otherwise.
|
||||
(
|
||||
"Свердловская обл, г Екатеринбург, Академический р-н, ул Академика Парина, д 46/5",
|
||||
"Академика Парина",
|
||||
),
|
||||
# Bare street+house, no admin prefix at all.
|
||||
("ул Академика Парина, д 46/5", "Академика Парина"),
|
||||
# Other dot-optional abbreviations from _STREET_KW_RE (пр/пер/ш/наб/пл/мкр).
|
||||
("г Екатеринбург, пр Ленина, 5", "Ленина"),
|
||||
("г Екатеринбург, пер Красный, 4", "Красный"),
|
||||
("г Екатеринбург, наб Реки Исеть, 1", "Реки Исеть"),
|
||||
# "ул. X" / "ул X" / "улица X" must all agree (no dot-optional regression).
|
||||
("Екатеринбург, ул. Малышева, 1", "Малышева"),
|
||||
("Екатеринбург, ул Малышева, 1", "Малышева"),
|
||||
("Екатеринбург, улица Малышева, 1", "Малышева"),
|
||||
],
|
||||
)
|
||||
def test_extract_street_name_dadata_no_dot_abbreviations(
|
||||
address: str | None, expected: str | None
|
||||
) -> None:
|
||||
"""#pdf-honesty/street-deals live-prod fix (2026-08-10): DaData addresses
|
||||
use dot-less abbreviations ("ул", "пр", "пер", "ш", "наб", "пл", "мкр")
|
||||
— _STREET_KW_RE must match them exactly like the dotted forms."""
|
||||
from app.services.estimator import extract_street_name
|
||||
|
||||
assert extract_street_name(address) == expected
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
99
tradein-mvp/backend/tests/test_version_api.py
Normal file
99
tradein-mvp/backend/tests/test_version_api.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Tests for GET /api/v1/trade-in/version (build metadata) — app/core/version.py +
|
||||
app/api/v1/version.py.
|
||||
|
||||
Isolated FastAPI app (no full app.main import, no DB) — same pattern as
|
||||
tests/test_geocode_reverse_api.py: mount only the router under test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import version as version_module
|
||||
from app.core import version as version_core
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> FastAPI:
|
||||
application = FastAPI()
|
||||
application.include_router(version_module.router, prefix="/api/v1/trade-in")
|
||||
return application
|
||||
|
||||
|
||||
# ── GET /api/v1/trade-in/version ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_version_endpoint_shape(app: FastAPI) -> None:
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/v1/trade-in/version")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body.keys()) == {"version", "sha", "built_at"}
|
||||
assert isinstance(body["version"], str) and body["version"]
|
||||
assert isinstance(body["sha"], str) and body["sha"]
|
||||
assert isinstance(body["built_at"], str) and body["built_at"]
|
||||
|
||||
|
||||
def test_version_endpoint_matches_core_constants(app: FastAPI) -> None:
|
||||
client = TestClient(app)
|
||||
body = client.get("/api/v1/trade-in/version").json()
|
||||
assert body["version"] == version_core.APP_VERSION
|
||||
assert body["sha"] == version_core.BUILD_SHA
|
||||
assert body["built_at"] == version_core.BUILD_DATE
|
||||
|
||||
|
||||
def test_version_path_is_public_no_auth_required() -> None:
|
||||
"""rbac_guard must let this path through without X-Authenticated-User /
|
||||
session — see app/core/rbac.py::_PUBLIC_PATHS. Not a secret, no DB call."""
|
||||
from app.core.rbac import _PUBLIC_PATHS
|
||||
|
||||
assert "/api/v1/trade-in/version" in _PUBLIC_PATHS
|
||||
|
||||
|
||||
# ── app/core/version.py — product_version_line / format_build_date_human ────
|
||||
|
||||
|
||||
def test_product_version_line_format() -> None:
|
||||
line = version_core.product_version_line("Мера")
|
||||
assert line.startswith("Мера v")
|
||||
parts = line.split(" · ")
|
||||
assert len(parts) == 3, f"expected 'name vX.Y.Z · sha · date', got {line!r}"
|
||||
|
||||
|
||||
def test_format_build_date_human_parses_iso_utc() -> None:
|
||||
assert version_core.format_build_date_human("2026-08-10T12:00:00Z") == "10.08.2026"
|
||||
|
||||
|
||||
def test_format_build_date_human_falls_back_on_garbage_without_raising() -> None:
|
||||
assert version_core.format_build_date_human("not-a-date") == "not-a-date"
|
||||
|
||||
|
||||
# ── Fallback when APP_VERSION/BUILD_SHA/BUILD_DATE env vars are absent ──────
|
||||
# (local `uvicorn` run without a Docker build — see module docstring in
|
||||
# app/core/version.py). Reloading the module re-executes its module-level
|
||||
# env reads; nothing here may raise.
|
||||
|
||||
|
||||
def test_module_import_falls_back_without_build_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("APP_VERSION", raising=False)
|
||||
monkeypatch.delenv("BUILD_SHA", raising=False)
|
||||
monkeypatch.delenv("BUILD_DATE", raising=False)
|
||||
|
||||
reloaded = importlib.reload(version_core)
|
||||
|
||||
assert reloaded.BUILD_SHA == "dev"
|
||||
assert reloaded.APP_VERSION # non-empty: VERSION file content or "0.0.0" default
|
||||
assert reloaded.BUILD_DATE.endswith("Z")
|
||||
# format/product helpers must still work off the fallback values (no crash).
|
||||
assert reloaded.product_version_line("Мера").startswith("Мера v")
|
||||
|
||||
# Reload once more so any test running later in this process sees a module
|
||||
# state consistent with whatever env pytest was actually invoked under.
|
||||
importlib.reload(version_core)
|
||||
|
|
@ -30,6 +30,29 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
|||
ARG NEXT_PUBLIC_ENABLE_PREVIEW=""
|
||||
ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW
|
||||
|
||||
# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть
|
||||
# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические
|
||||
# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не
|
||||
# видит tradein-mvp/VERSION (он на уровень выше, вне build context) — источник
|
||||
# правды читает CI ДО вызова `docker build` (.forgejo/workflows/deploy-tradein.yml,
|
||||
# job build-frontend) и передаёт сюда готовыми значениями. Пустые дефолты — для
|
||||
# локальной сборки без CI; фолбэк на "VERSION-файл/dev/дата сборки" делает уже
|
||||
# frontend-код, потребляющий эти env (Dockerfile сам файл не читает).
|
||||
ARG NEXT_PUBLIC_APP_VERSION=""
|
||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||
ARG NEXT_PUBLIC_BUILD_SHA=""
|
||||
ENV NEXT_PUBLIC_BUILD_SHA=$NEXT_PUBLIC_BUILD_SHA
|
||||
ARG NEXT_PUBLIC_BUILD_DATE=""
|
||||
ENV NEXT_PUBLIC_BUILD_DATE=$NEXT_PUBLIC_BUILD_DATE
|
||||
|
||||
# CHANGELOG.md — источник для /versions (src/lib/changelog.ts). Живёт на
|
||||
# уровень выше этого build context (tradein-mvp/CHANGELOG.md), поэтому CI
|
||||
# копирует его СЮДА (tradein-mvp/frontend/CHANGELOG.md) непосредственно
|
||||
# перед `docker build` (см. .forgejo/workflows/deploy-tradein.yml, job
|
||||
# build-frontend) — `COPY . .` ниже подхватывает её автоматически вместе с
|
||||
# остальным контекстом. Локальная сборка без этого шага CI просто не находит
|
||||
# файл — readChangelog() уже умеет деградировать (пустая история), сам
|
||||
# Docker-билд при этом не падает (см. glob-COPY в runner stage ниже).
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
|
@ -49,6 +72,13 @@ ENV NODE_ENV=production \
|
|||
COPY --from=builder --chown=node:node /app/public ./public
|
||||
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
||||
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
||||
# /versions — Server Component, statically prerendered at `npm run build`
|
||||
# (see src/app/versions/page.tsx) — CHANGELOG.md's content is already baked
|
||||
# into .next/standalone above. This is a defensive fallback ONLY, in case that
|
||||
# page ever stops being static: glob (trailing `*`) makes it a no-op when the
|
||||
# builder stage doesn't have the file either (local build without the CI
|
||||
# pre-copy step, see builder stage comment above) — never fails the build.
|
||||
COPY --from=builder --chown=node:node /app/CHANGELOG.md* ./
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google";
|
|||
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
|
||||
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
|
||||
import { pageBg } from "@/components/trade-in/v2/tokens";
|
||||
import { VersionFooter } from "@/components/trade-in/VersionFooter";
|
||||
|
||||
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
|
||||
// (no package.json change). Cyrillic + latin so RU labels render correctly.
|
||||
|
|
@ -53,6 +54,15 @@ export default function TradeInV2Layout({
|
|||
products without the МЕРА brand that don't need a support link. */}
|
||||
<SupportButton />
|
||||
</SupportChatProvider>
|
||||
{/* Real build-version indicator (task: показать реальную версию
|
||||
продукта «Мера» в вебе). Deliberately OUTSIDE SupportChatProvider —
|
||||
it needs no chat context — but still scoped to this /v2 layout for
|
||||
the same reason SupportButton is: other basePath routes
|
||||
(/scrapers/**, /sale-share) are unrelated products without the
|
||||
МЕРА brand. Portals to document.body itself (see VersionFooter.tsx
|
||||
docstring), so its position in this tree only matters for mount
|
||||
order, not DOM placement. */}
|
||||
<VersionFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import TopNav from "@/components/trade-in/v2/TopNav";
|
|||
import HeroBar from "@/components/trade-in/v2/HeroBar";
|
||||
import ParamsPanel from "@/components/trade-in/v2/ParamsPanel";
|
||||
import ResultPanel from "@/components/trade-in/v2/ResultPanel";
|
||||
import { LowConfidenceBanner } from "@/components/trade-in/v2/LowConfidenceBanner";
|
||||
import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
|
||||
import { LeadForm } from "@/components/trade-in/v2/LeadForm";
|
||||
import { Footer } from "@/components/trade-in/v2/Footer";
|
||||
|
|
@ -584,6 +585,18 @@ export default function TradeInV2Page() {
|
|||
// — no hydration drift, same reason the PDF control is gated behind `mounted`.
|
||||
const hasEstimate = mounted && estimate != null && !insufficient;
|
||||
|
||||
// fix (never-block estimate) — reliability/relaxations are optional on the
|
||||
// wire (old/cached estimates predate the backend fields), default to the
|
||||
// "nothing to disclose" values so a stale response never fabricates a
|
||||
// warning. LowConfidenceBanner mounts above the result whenever the sample
|
||||
// was thin (reliability !== "ok") or the backend had to relax the search to
|
||||
// produce a price at all (relaxations.length > 0) — never on insufficient
|
||||
// (no price at all — that stays InsufficientPanel, no banner to layer over).
|
||||
const reliability = estimate?.reliability ?? "ok";
|
||||
const relaxations = estimate?.relaxations ?? [];
|
||||
const showLowConfidenceBanner =
|
||||
!insufficient && (reliability !== "ok" || relaxations.length > 0);
|
||||
|
||||
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
|
||||
// geometry). ──────────────────────────────────────────────────────────
|
||||
const report = useMemo(
|
||||
|
|
@ -772,12 +785,31 @@ export default function TradeInV2Page() {
|
|||
/>
|
||||
);
|
||||
} else if (estimate && !insufficient && resultPanelData) {
|
||||
// Banner is a sibling ABOVE ResultPanel, not a change to ResultPanel
|
||||
// itself — the wrapper only replaces the direct grid child; ResultPanel's
|
||||
// own markup/props are untouched from before this fix.
|
||||
middleContent = (
|
||||
<ResultPanel
|
||||
data={resultPanelData}
|
||||
onNavigate={setNav}
|
||||
regionRef={resultRegionRef}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{showLowConfidenceBanner && (
|
||||
<LowConfidenceBanner
|
||||
nAnalogs={estimate.n_analogs}
|
||||
reliability={reliability}
|
||||
relaxations={relaxations}
|
||||
/>
|
||||
)}
|
||||
<ResultPanel
|
||||
data={resultPanelData}
|
||||
onNavigate={setNav}
|
||||
regionRef={resultRegionRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (estimate && insufficient) {
|
||||
middleContent = <InsufficientPanel />;
|
||||
|
|
|
|||
120
tradein-mvp/frontend/src/app/versions/page.tsx
Normal file
120
tradein-mvp/frontend/src/app/versions/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// /versions (→ `/trade-in/versions` behind basePath) — «История версий».
|
||||
//
|
||||
// Server Component, deliberately NOT "use client": `readChangelog()` reads
|
||||
// `tradein-mvp/CHANGELOG.md` off disk via `fs.readFileSync` at build/render
|
||||
// time and gets statically embedded — no client-side fetch, no network hop
|
||||
// (see src/lib/changelog.ts for the exact read/parse contract + a known
|
||||
// build-context gap, flagged there).
|
||||
//
|
||||
// Auth: this route has NO guard of its own — it lives inside the same
|
||||
// app-router segment as every other closed МЕРА page (history/, cache/,
|
||||
// team/), so `app/layout.tsx`'s `<RouteGuard>` already gates it exactly
|
||||
// like the rest of the product. No new RBAC path was added; whatever the
|
||||
// backend `auth/roles.yaml` wildcard already allows for `/trade-in/**`
|
||||
// covers this page too.
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import "@/components/trade-in/trade-in.css";
|
||||
import { APP_VERSION, formatRuDate } from "@/lib/buildInfo";
|
||||
import { readChangelog } from "@/lib/changelog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "История версий — МЕРА",
|
||||
};
|
||||
|
||||
export default function VersionsPage() {
|
||||
const entries = readChangelog();
|
||||
|
||||
return (
|
||||
<main className="page" style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
<Link href="/v2">← К оценке</Link>
|
||||
</p>
|
||||
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, marginBottom: 4 }}>
|
||||
История версий
|
||||
</h1>
|
||||
<p style={{ color: "var(--muted)", fontSize: 13, marginBottom: 24 }}>
|
||||
Текущая версия:{" "}
|
||||
{APP_VERSION === "dev" ? "dev-сборка" : `v${APP_VERSION}`}
|
||||
</p>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p style={{ color: "var(--muted)" }}>
|
||||
История изменений пока не опубликована.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
{entries.map((entry) => {
|
||||
const isCurrent = entry.version === APP_VERSION;
|
||||
return (
|
||||
<section key={`${entry.version}-${entry.date}`} className="card">
|
||||
<div className="card-head">
|
||||
<h2 style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
v{entry.version}
|
||||
{isCurrent && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--accent-ink)",
|
||||
background: "var(--accent-soft)",
|
||||
borderRadius: 999,
|
||||
padding: "2px 8px",
|
||||
}}
|
||||
>
|
||||
текущая
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="card-meta">{formatRuDate(entry.date)}</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{entry.sections.length === 0 ? (
|
||||
<p style={{ color: "var(--muted)", fontSize: 13 }}>
|
||||
Без описания изменений.
|
||||
</p>
|
||||
) : (
|
||||
entry.sections.map((section) => (
|
||||
<div key={section.title} style={{ marginBottom: 14 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
marginBottom: 6,
|
||||
color: "var(--fg-2)",
|
||||
}}
|
||||
>
|
||||
{section.title}
|
||||
</h3>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{section.items.map((item, i) => (
|
||||
<li
|
||||
key={`${section.title}-${i}`}
|
||||
style={{ fontSize: 13.5, color: "var(--fg)" }}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -185,6 +185,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
const [enrichRepairState, setEnrichRepairState] = useState<string>("");
|
||||
// Фото первого аналога с картинкой — вместо пустого серого плейсхолдера.
|
||||
const heroPhoto = estimate.analogs.find((a) => a.photo_url)?.photo_url ?? null;
|
||||
// fix (v1 stale-tail) — n_analogs=0 больше не значит «аналогов нет»: бэкенд
|
||||
// может посчитать headline по зарегистрированным сделкам ДКП, но всё равно
|
||||
// отдать тонкую выборку объявлений в estimate.analogs (её же показывает
|
||||
// ListingsCard ниже на этой странице) — «0 аналогов» рядом с видимыми
|
||||
// карточками было бы прямым противоречием. Тон — как у v2 LowConfidenceBanner.
|
||||
const analogsCaption =
|
||||
estimate.n_analogs > 0 || estimate.analogs.length === 0
|
||||
? `${estimate.n_analogs} аналогов`
|
||||
: "оценка построена по зарегистрированным сделкам";
|
||||
// Расчёт ширины для price bar (50% = середина): медиана внутри min/max
|
||||
const span = hi - lo;
|
||||
const medianPctRaw = span > 0 ? ((m - lo) / span) * 100 : 50;
|
||||
|
|
@ -292,7 +301,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
{heroPhoto
|
||||
? `фото аналога${estimate.sources_used[0] ? ` · ${sourceLabel(estimate.sources_used[0])}` : ""}`
|
||||
: estimate.sources_used.length > 0
|
||||
? `${sourceLabel(estimate.sources_used[0])} · ${estimate.n_analogs} аналогов`
|
||||
? `${sourceLabel(estimate.sources_used[0])} · ${analogsCaption}`
|
||||
: "Нет фото"}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,12 @@ export function ListingsCard({ estimate, estimateId }: Props) {
|
|||
<div className="count-cell">
|
||||
<div className="label">Объявлений по аналогам</div>
|
||||
<div className="value">
|
||||
<span data-tnum>{estimate.n_analogs}</span>
|
||||
{/* fix (v1 stale-tail) — n_analogs=0 больше не значит "объявлений
|
||||
нет": бэкенд может посчитать headline по сделкам ДКП, но
|
||||
всё равно отдать тонкую выборку объявлений в analogs (тот же
|
||||
массив рендерит таблица ниже, см. `lots`). Показываем реальную
|
||||
отображаемую популяцию, а не сырой n_analogs, когда он 0. */}
|
||||
<span data-tnum>{estimate.n_analogs > 0 ? estimate.n_analogs : lots.length}</span>
|
||||
<span className="unit">шт</span>
|
||||
</div>
|
||||
<div className="sub">из {estimate.sources_used.length} источников</div>
|
||||
|
|
@ -271,8 +276,21 @@ export function ListingsCard({ estimate, estimateId }: Props) {
|
|||
|
||||
<div className="table-foot">
|
||||
<span>
|
||||
Показано <b style={{ color: "var(--fg)" }}>{lots.length}</b> из {estimate.n_analogs}{" "}
|
||||
объявлений · отсортировано по расстоянию
|
||||
{/* fix (v1 stale-tail) — see count-strip comment above: n_analogs=0
|
||||
with a non-empty lots[] is the deals-fallback branch, not "0
|
||||
analogs shown". Drop the false "из 0" denominator and disclose
|
||||
the deals basis instead (same tone as v2 LowConfidenceBanner). */}
|
||||
{estimate.n_analogs > 0 ? (
|
||||
<>
|
||||
Показано <b style={{ color: "var(--fg)" }}>{lots.length}</b> из{" "}
|
||||
{estimate.n_analogs} объявлений · отсортировано по расстоянию
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Показано <b style={{ color: "var(--fg)" }}>{lots.length}</b> объявлений ·
|
||||
оценка построена по зарегистрированным сделкам · отсортировано по расстоянию
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
// VersionFooter — small build-version indicator for the МЕРА product,
|
||||
// showing the REAL deployed version. This is the SINGLE place on /trade-in/v2
|
||||
// that renders a version number — `v2/TopNav.tsx` and `v2/Footer.tsx` used to
|
||||
// each carry their own hardcoded "v2.0.6" literal (`./ui-config`'s `version`)
|
||||
// next to the МЕРА wordmark; both were removed (three independent "versions"
|
||||
// on one screen, see PR review) — the wordmark stays in both places, just
|
||||
// without a number attached. Values here come from build-time
|
||||
// `NEXT_PUBLIC_*` env vars via `@/lib/buildInfo` — no runtime API call, no
|
||||
// useEffect fetch.
|
||||
//
|
||||
// Mounted in `app/v2/layout.tsx` (not `app/v2/page.tsx` — that file is
|
||||
// off-limits for this change), right next to `<SupportButton />`.
|
||||
//
|
||||
// Portaled to document.body — same reasoning/pattern as SupportButton.tsx:
|
||||
// /v2 renders its HUD inside a fixed-size "artboard" that gets
|
||||
// `transform: scale(...)` on narrow viewports (app/v2/page.tsx), and a
|
||||
// `position: fixed` descendant of a transformed ancestor is positioned
|
||||
// relative to THAT ancestor, not the real viewport corner — portaling
|
||||
// sidesteps that entirely, exactly like the support button already does.
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { tokens } from "@/components/trade-in/v2/tokens";
|
||||
import { formatVersionLabel } from "@/lib/buildInfo";
|
||||
|
||||
const styles = `
|
||||
.version-footer{opacity:.72;transition:opacity .15s;}
|
||||
.version-footer:hover{opacity:1;}
|
||||
.version-footer a{color:${tokens.muted2};text-decoration:underline;text-underline-offset:2px;}
|
||||
.version-footer a:hover{color:${tokens.ink};}
|
||||
@media (max-width: 480px){
|
||||
.version-footer{left:10px !important;bottom:10px !important;padding:3px 7px !important;font-size:9px !important;gap:6px !important;}
|
||||
}
|
||||
`;
|
||||
|
||||
export function VersionFooter() {
|
||||
// Portal-mount guard (SSR-safe): `document` only exists after mount
|
||||
// (mirrors SupportButton.tsx / MapPicker.tsx).
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<style>{styles}</style>
|
||||
<div
|
||||
className="version-footer"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: 16,
|
||||
bottom: 16,
|
||||
// Below SupportButton (25) and every v2 HUD overlay — hides under
|
||||
// modals/drawers instead of floating on top of them, mirrors the
|
||||
// z-index reasoning documented in SupportButton.tsx.
|
||||
zIndex: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "4px 9px",
|
||||
borderRadius: 999,
|
||||
background: tokens.surface.w70,
|
||||
border: `1px solid ${tokens.lineSoft2}`,
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 10,
|
||||
letterSpacing: ".3px",
|
||||
color: tokens.muted4,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span>{formatVersionLabel()}</span>
|
||||
<Link href="/versions">История версий</Link>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
// Report footer for the /trade-in/v2 "МЕРА Оценка" design port.
|
||||
// Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439):
|
||||
// report id / date / valid-until on the left, a decorative centre line, and the
|
||||
// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`.
|
||||
// МЕРА wordmark on the right. Static markup, id/date/validUntil via `data`.
|
||||
// The trailing "v2.0.6" badge that used to sit next to the wordmark was a
|
||||
// hardcoded literal (./ui-config `version`), independent of the real deployed
|
||||
// build — removed. The real version is shown once, by `<VersionFooter />`
|
||||
// (see app/v2/layout.tsx), not duplicated here.
|
||||
|
||||
import { tokens } from "./tokens";
|
||||
import { version } from "./ui-config";
|
||||
import type { Report } from "./types";
|
||||
|
||||
interface FooterProps {
|
||||
|
|
@ -122,16 +125,6 @@ export function Footer({ data, hasEstimate }: FooterProps) {
|
|||
>
|
||||
МЕРА
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 9,
|
||||
letterSpacing: "1px",
|
||||
color: tokens.muted4,
|
||||
}}
|
||||
>
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
// LowConfidenceBanner — fix (never-block estimate). Renders ABOVE the result
|
||||
// block (ResultPanel) whenever the backend flags the analog sample as thin
|
||||
// (`reliability !== "ok"`) or had to relax the search just to produce a
|
||||
// price at all (`relaxations.length > 0`). It never blocks the estimate —
|
||||
// v2/page.tsx's `insufficient` gate (InsufficientPanel) still fires only
|
||||
// when there is truly no price (`insufficient_data`, median_price_rub <= 0).
|
||||
//
|
||||
// Root incident this fixes: a 23.1 m² studio in Екатеринбург got
|
||||
// median_price_rub=0 purely because studios were being folded into 1-room
|
||||
// analogs (see ParamsPanel's initRoomsLabel/rooms=0 fix) and the UI walled
|
||||
// the whole estimate behind "недостаточно данных". Product call: always show
|
||||
// the number, with an honest, visible caveat instead of a hard block.
|
||||
|
||||
import { tokens } from "./tokens";
|
||||
import { pluralRu } from "./mappers";
|
||||
import type { ReliabilityLevel } from "@/types/trade-in";
|
||||
|
||||
interface LowConfidenceBannerProps {
|
||||
// Caller (v2/page.tsx) resolves the optional backend fields to concrete
|
||||
// values (reliability ?? "ok", relaxations ?? []) and decides whether to
|
||||
// mount this component at all — kept required here so an omitted prop is a
|
||||
// TS error, not a silent fallback (same contract as ResultPanel/ObjectSummary).
|
||||
nAnalogs: number;
|
||||
reliability: ReliabilityLevel;
|
||||
relaxations: string[];
|
||||
}
|
||||
|
||||
// Same one-off "danger" tint pairing already used elsewhere in v2
|
||||
// (AnalyticsView's sell-time tier tiles: rgba fill + soft hex border, no
|
||||
// direct token equivalent) — kept identical here instead of inventing a new
|
||||
// hex; the actual label colour is the real tokens.danger semantic token.
|
||||
const bannerBg = "rgba(214,90,90,.08)";
|
||||
const bannerBorder = "1px solid #e6c3c3";
|
||||
|
||||
export function LowConfidenceBanner({
|
||||
nAnalogs,
|
||||
reliability,
|
||||
relaxations,
|
||||
}: LowConfidenceBannerProps) {
|
||||
const title =
|
||||
reliability === "very_low"
|
||||
? "Данные ограничены — оценка ориентировочная"
|
||||
: "Мало аналогов — точность снижена";
|
||||
|
||||
// The backend's deals-cession label duplicates the prose we already render
|
||||
// in the nAnalogs === 0 branch — drop it there so the caveat is stated once.
|
||||
const visibleRelaxations =
|
||||
nAnalogs > 0
|
||||
? relaxations
|
||||
: relaxations.filter((r) => !r.startsWith("оценка по сделкам"));
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
background: bannerBg,
|
||||
border: bannerBorder,
|
||||
borderRadius: 8,
|
||||
padding: "12px 16px",
|
||||
fontFamily: tokens.font.sans,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.4,
|
||||
color: tokens.danger,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{/* Body text stays on the high-contrast ink token (not the danger
|
||||
token) — tokens.danger (#cd6868) over this pale tint fails AA for
|
||||
body copy, ink2 is the codebase's established accessible-contrast
|
||||
choice (see tokens.ts comment block). */}
|
||||
<div style={{ fontSize: 12, lineHeight: 1.5, color: tokens.ink2 }}>
|
||||
{/* n_analogs === 0 with a price on screen is NOT an empty result: it is
|
||||
the deals-corridor headline (backend cedes the headline to ДКП when
|
||||
the listings sample is thin). Saying «найдено 0 аналогов» there
|
||||
would contradict both the shown price and the listing cards below,
|
||||
which are still rendered from the thin sample. */}
|
||||
{nAnalogs > 0 ? (
|
||||
<>
|
||||
Найдено {nAnalogs}{" "}
|
||||
{pluralRu(nAnalogs, ["аналог", "аналога", "аналогов"])} — оценка
|
||||
может быть неточной.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Оценка построена по зарегистрированным сделкам — подходящих
|
||||
объявлений рядом почти нет.
|
||||
</>
|
||||
)}
|
||||
{visibleRelaxations.length > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
Для расчёта расширили параметры поиска:{" "}
|
||||
{visibleRelaxations.join(", ")}.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -580,11 +580,15 @@ interface ParamsPanelProps {
|
|||
searchRadiusM?: number | null;
|
||||
}
|
||||
|
||||
// rooms number -> dropdown label. The design has no «Студия» option, so studio
|
||||
// (0) and 1-room both map to "1"; >=5 collapses to "5+". null -> design default.
|
||||
// rooms number -> dropdown label. fix (never-block estimate) — «Студия»
|
||||
// (rooms=0) is its own option, no longer collapsed into "1" (that collapse
|
||||
// sent rooms:1 on submit for real studios — root cause of a prod incident
|
||||
// where a 23.1 m² studio got a false "недостаточно данных"). >=5 still
|
||||
// collapses to "5+". null -> design default.
|
||||
function initRoomsLabel(rooms: number | null | undefined): string {
|
||||
if (rooms == null) return "2";
|
||||
if (rooms >= 5) return "5+";
|
||||
if (rooms === 0) return "Студия";
|
||||
if (rooms <= 1) return "1";
|
||||
return String(rooms);
|
||||
}
|
||||
|
|
@ -625,7 +629,30 @@ function initCityConfirmed(address: string | undefined): boolean {
|
|||
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
|
||||
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
|
||||
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
|
||||
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];
|
||||
// 3000/5000 added (support ticket "выбран мах радиус 2 000") — the dropdown
|
||||
// used to cap at 2000 m while the backend accepts up to 5000
|
||||
// (schemas/trade_in.py Field(ge=100, le=5000)), so a user in a sparse district
|
||||
// picking "2000 м" thinking it was the widest option actually CUT OFF the
|
||||
// cascade's own 3/5 km fallback steps (contract #2044 — an explicit radius
|
||||
// pick must never be silently widened past itself). See radiusHint below for
|
||||
// the disclosure that "Авто" is the option that widens automatically.
|
||||
const RADIUS_OPTIONS = [
|
||||
"Авто",
|
||||
"300 м",
|
||||
"500 м",
|
||||
"1000 м",
|
||||
"2000 м",
|
||||
"3000 м",
|
||||
"5000 м",
|
||||
];
|
||||
|
||||
// #support — "Авто" delegates radius selection to the backend's own two-tier
|
||||
// (and, since #2044, cascade) widening; any explicit pick is a hard ceiling
|
||||
// the cascade may not cross. Same zero-layout-cost title= pattern as
|
||||
// ResultPanel's confidence tooltip / HeroBar's location-index disclaimer —
|
||||
// a native hover tooltip, no modal, no panel re-layout.
|
||||
const radiusHint =
|
||||
"Авто — расширяем радиус сами, если аналогов мало. Явный радиус ограничивает поиск.";
|
||||
|
||||
// "Авто" sends no radius_m → the backend applies its two-tier default (1000 m
|
||||
// primary / 2000 m fallback, see RADIUS_OPTIONS comment above). The map circle
|
||||
|
|
@ -919,7 +946,7 @@ export default function ParamsPanel({
|
|||
onSubmit?.({
|
||||
address: trimmedAddress,
|
||||
area_m2: areaNum,
|
||||
rooms: rooms === "5+" ? 5 : Number(rooms),
|
||||
rooms: rooms === "5+" ? 5 : rooms === "Студия" ? 0 : Number(rooms),
|
||||
floor: floor.trim() ? Number(floor) : null,
|
||||
total_floors: totalFloors.trim() ? Number(totalFloors) : null,
|
||||
year_built: year.trim() ? Number(year) : undefined,
|
||||
|
|
@ -1404,6 +1431,7 @@ export default function ParamsPanel({
|
|||
: undefined
|
||||
}
|
||||
aria-label="Радиус анализа"
|
||||
title={radiusHint}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
|
|||
|
|
@ -419,10 +419,22 @@ export function SourcesMap({ estimate }: Props) {
|
|||
{/* Fix #1 — analogPoints is the top-10 display sample (only what the
|
||||
backend returns coords for); estimate.n_analogs is the true total
|
||||
used in the calc. "N из M" mirrors the deals-table "Показано N из
|
||||
M" pattern so this never contradicts the market KPI band above. */}
|
||||
M" pattern so this never contradicts the market KPI band above.
|
||||
fix (v2 stale-tail) — n_analogs=0 with analogPoints non-empty is
|
||||
the deals-fallback branch (headline built from ДКП сделки, thin
|
||||
listing sample still plotted) — "N из 0" would read as a lie.
|
||||
Drop the denominator and disclose the basis instead; kept short
|
||||
(map caption, not a paragraph) but same tone as elsewhere in
|
||||
this fix (HeroSummary/ListingsCard/LowConfidenceBanner). */}
|
||||
Объявлений: <b style={{ color: tokens.ink2 }}>{analogPoints.length}</b>
|
||||
{" из "}
|
||||
{estimate.n_analogs}
|
||||
{estimate.n_analogs > 0 || analogPoints.length === 0 ? (
|
||||
<>
|
||||
{" из "}
|
||||
{estimate.n_analogs}
|
||||
</>
|
||||
) : (
|
||||
" · по сделкам ДКП"
|
||||
)}
|
||||
{dealPoints.length > 0 && (
|
||||
<>
|
||||
{" · "}сделок: <b style={{ color: tokens.ink2 }}>{dealPoints.length}</b>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
|
||||
// Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port.
|
||||
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
|
||||
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
|
||||
// inline SVG logo + 5 nav tabs (active underline/triangle) + user menu.
|
||||
// Tabs change only local UI state via onNavigate; the user dropdown owns its
|
||||
// own useState. No data fetching — labels/version come from ./ui-config, the user
|
||||
// own useState. No data fetching — labels come from ./ui-config, the user
|
||||
// identity is fed in from the page (real useMe), colours from tokens.
|
||||
// The build-version badge that used to sit next to the logo (hardcoded
|
||||
// "v2.0.6") was removed — the real deployed version is shown once, by
|
||||
// `<VersionFooter />` (app/v2/layout.tsx), not duplicated here.
|
||||
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
|
@ -13,7 +16,7 @@ import type { CSSProperties } from "react";
|
|||
import { API_BASE_URL } from "@/lib/api";
|
||||
|
||||
import { tokens } from "./tokens";
|
||||
import { navLabels, version } from "./ui-config";
|
||||
import { navLabels } from "./ui-config";
|
||||
import { useSupportChat } from "./SupportChatContext";
|
||||
|
||||
// Real logged-in user identity, derived by the page from useMe()
|
||||
|
|
@ -70,15 +73,29 @@ const menuItemStyle: CSSProperties = {
|
|||
tokens.muted), что и остальные иконки этого дропдауна. */
|
||||
function UsersIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="5.3" cy="5" r="2.2" stroke={tokens.muted} strokeWidth="1.2" />
|
||||
<path
|
||||
d="M1 13c0-2.6 1.9-3.9 4.3-3.9S9.6 10.4 9.6 13"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path d="M9.3 1.8a2.1 2.1 0 0 1 0 4" stroke={tokens.muted} strokeWidth="1.2" />
|
||||
<path d="M11 9.5c1.9.4 3 1.6 3 3.5" stroke={tokens.muted} strokeWidth="1.2" />
|
||||
<path
|
||||
d="M9.3 1.8a2.1 2.1 0 0 1 0 4"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M11 9.5c1.9.4 3 1.6 3 3.5"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -129,7 +146,7 @@ export default function TopNav({
|
|||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{/* Logo + version */}
|
||||
{/* Logo */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "13px" }}>
|
||||
<svg
|
||||
width="184"
|
||||
|
|
@ -196,18 +213,6 @@ export default function TopNav({
|
|||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: "9.5px",
|
||||
letterSpacing: "1px",
|
||||
color: tokens.muted2,
|
||||
borderLeft: `1px solid ${tokens.line}`,
|
||||
paddingLeft: "12px",
|
||||
}}
|
||||
>
|
||||
{version}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav tabs */}
|
||||
|
|
@ -412,7 +417,13 @@ export default function TopNav({
|
|||
aria-disabled="true"
|
||||
title="Раздел «Профиль» скоро появится"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="7.5"
|
||||
cy="5"
|
||||
|
|
@ -430,13 +441,23 @@ export default function TopNav({
|
|||
</div>
|
||||
|
||||
<div className="tnav-menuitem" style={menuItemStyle}>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3 1.5h6l3 3v9H3z"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path d="M9 1.5v3h3" stroke={tokens.muted} strokeWidth="1.2" />
|
||||
<path
|
||||
d="M9 1.5v3h3"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
Мои отчёты{" "}
|
||||
<span
|
||||
|
|
@ -458,7 +479,13 @@ export default function TopNav({
|
|||
aria-disabled="true"
|
||||
title="Раздел «Настройки» скоро появится"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="7.5"
|
||||
cy="7.5"
|
||||
|
|
@ -499,7 +526,13 @@ export default function TopNav({
|
|||
openChat();
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="7.5"
|
||||
cy="7.5"
|
||||
|
|
@ -561,7 +594,13 @@ export default function TopNav({
|
|||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
|
||||
stroke="#cd6868"
|
||||
|
|
|
|||
|
|
@ -2105,7 +2105,12 @@ export function mapSources(
|
|||
|
||||
const marketAds: MarketAds = {
|
||||
kpi: {
|
||||
count: e != null ? String(e.n_analogs) : "—",
|
||||
// fix (v2 stale-tail) — n_analogs=0 with adRows non-empty is the
|
||||
// deals-fallback branch (headline built from ДКП сделки, thin listing
|
||||
// sample still shown in the table right below this KPI tile) — a bare
|
||||
// "0" here would directly contradict visible rows. Fall back to the
|
||||
// actual displayed population (same fix as ListingsCard's count-strip).
|
||||
count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—",
|
||||
median: e != null ? fmtMln(e.median_price_rub) : "—",
|
||||
ppm:
|
||||
e != null && Number.isFinite(e.median_price_per_m2)
|
||||
|
|
@ -2156,9 +2161,15 @@ export function mapSources(
|
|||
"возможных выбросов исключено",
|
||||
])} из расчёта разброса`
|
||||
: "";
|
||||
// fix (v2 stale-tail) — n_analogs=0 with a non-empty adRows[] is the
|
||||
// deals-fallback branch, not "0 analogs shown" (see marketAds.kpi.count
|
||||
// above). Drop the false "из 0" denominator and disclose the deals basis
|
||||
// instead, same tone as HeroSummary/ListingsCard/LowConfidenceBanner.
|
||||
const adsFootnote =
|
||||
e != null
|
||||
? `Показано ${adRows.length} из ${e.n_analogs} объявлений${outlierNote}`
|
||||
? e.n_analogs > 0
|
||||
? `Показано ${adRows.length} из ${e.n_analogs} объявлений${outlierNote}`
|
||||
: `Показано ${adRows.length} объявлений · оценка построена по зарегистрированным сделкам${outlierNote}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ import type { DropdownOptions } from "./types";
|
|||
// ---- INPUTS / DROPDOWNS ---------------------------------------------------
|
||||
|
||||
export const dropdownOptions: DropdownOptions = {
|
||||
rooms: ["1", "2", "3", "4", "5+"],
|
||||
// fix (never-block estimate) — «Студия» первым пунктом, маппится на rooms=0
|
||||
// (см. initRoomsLabel / handleSubmit в ParamsPanel.tsx). Раньше студии
|
||||
// схлопывались в «1» → на сабмите уходил rooms:1 вместо rooms:0 — корневая
|
||||
// причина прод-инцидента с 23.1 м² студией в ЕКБ (median=0 → ложная
|
||||
// «недостаточно данных»).
|
||||
rooms: ["Студия", "1", "2", "3", "4", "5+"],
|
||||
houseType: [
|
||||
"Не указано",
|
||||
"Панельный",
|
||||
|
|
@ -59,4 +64,10 @@ export const overlayTitles: string[] = [
|
|||
|
||||
export const overlayNums: string[] = ["", "04", "05", "06", "07"];
|
||||
|
||||
export const version = "v2.0.6";
|
||||
// `version` (hardcoded "v2.0.6") used to live here — TopNav.tsx + Footer.tsx
|
||||
// both rendered it next to the МЕРА wordmark, independently of the real
|
||||
// deployed build. Removed: the single source of truth for the displayed
|
||||
// product version is now `@/lib/buildInfo` (APP_VERSION / formatVersionLabel,
|
||||
// build-time NEXT_PUBLIC_* env baked in by frontend/Dockerfile), surfaced via
|
||||
// `<VersionFooter />` (see app/v2/layout.tsx) — do not reintroduce a literal
|
||||
// here.
|
||||
|
|
|
|||
37
tradein-mvp/frontend/src/lib/buildInfo.ts
Normal file
37
tradein-mvp/frontend/src/lib/buildInfo.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Build/version metadata for the МЕРА product — sourced from build-time
|
||||
// `NEXT_PUBLIC_*` env vars, baked in by `frontend/Dockerfile` via
|
||||
// `--build-arg` (see `.forgejo/workflows/deploy-tradein.yml`, build-frontend
|
||||
// job). Safe to import from BOTH server and client components: Next.js
|
||||
// inlines `NEXT_PUBLIC_*` references at build time into every bundle that
|
||||
// references them — there is no runtime env lookup in the browser.
|
||||
//
|
||||
// Fallback "dev" — a local `npm run dev` / `next build` without the
|
||||
// build-args set (i.e. every local run, and CI unless explicitly passed)
|
||||
// reads honestly as "not a tagged deploy" instead of leaking "undefined"
|
||||
// into the UI or crashing.
|
||||
|
||||
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
|
||||
export const BUILD_SHA = process.env.NEXT_PUBLIC_BUILD_SHA || "dev";
|
||||
export const BUILD_DATE = process.env.NEXT_PUBLIC_BUILD_DATE || "";
|
||||
|
||||
/**
|
||||
* ISO-8601 (`2026-08-10` or `2026-08-10T12:00:00Z`) → `10.08.2026`.
|
||||
*
|
||||
* Deliberately a plain regex, not `Date#toLocaleDateString("ru-RU")`: the
|
||||
* input is already a plain calendar date (no timezone conversion needed),
|
||||
* and a regex keeps formatting identical between server-render and
|
||||
* client-render without depending on locale data being present/consistent
|
||||
* in whichever runtime executes it.
|
||||
*/
|
||||
export function formatRuDate(iso: string | undefined | null): string {
|
||||
const match = iso ? /^(\d{4})-(\d{2})-(\d{2})/.exec(iso) : null;
|
||||
if (!match) return "dev";
|
||||
const [, year, month, day] = match;
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/** "Мера v1.0.0 · a1b2c3d · 10.08.2026" (or "Мера dev · dev · dev" locally). */
|
||||
export function formatVersionLabel(): string {
|
||||
const versionPart = APP_VERSION === "dev" ? "dev" : `v${APP_VERSION}`;
|
||||
return `Мера ${versionPart} · ${BUILD_SHA} · ${formatRuDate(BUILD_DATE)}`;
|
||||
}
|
||||
106
tradein-mvp/frontend/src/lib/changelog.ts
Normal file
106
tradein-mvp/frontend/src/lib/changelog.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Server-only: reads + parses `tradein-mvp/CHANGELOG.md` at build/request
|
||||
// time via `fs.readFileSync` (statically evaluated by the /versions page,
|
||||
// a Server Component — NEVER import this from a "use client" module, it
|
||||
// would try to bundle `node:fs` for the browser).
|
||||
//
|
||||
// Build-context note: the frontend Docker build context is
|
||||
// `tradein-mvp/frontend/` (see `.forgejo/workflows/deploy-tradein.yml`,
|
||||
// build-frontend job), which by itself does NOT include
|
||||
// `tradein-mvp/CHANGELOG.md` (one level above that context — Docker COPY
|
||||
// cannot reach outside its context). Closed via a CI-side staging step
|
||||
// (`Stage CHANGELOG.md into frontend build context` in that same workflow
|
||||
// job, runs `cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md`
|
||||
// right before `docker build`) — `frontend/Dockerfile`'s `COPY . .` then
|
||||
// picks it up like any other context file, landing at the second candidate
|
||||
// path below inside the image. `next build` run locally from a full
|
||||
// checkout (no CI staging step) still finds it via the FIRST candidate path
|
||||
// (one level up) instead. Either way `readChangelog()` degrades to an empty
|
||||
// array (not a crash) if somehow neither path resolves.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ChangelogEntry, ChangelogSection } from "@/types/version";
|
||||
|
||||
// Contract (Keep a Changelog, RU): version heading STRICTLY
|
||||
// `## <semver> — <YYYY-MM-DD>`. Em dash (—) per contract; en dash/hyphen
|
||||
// accepted too so a stray character in the dash doesn't silently blank the
|
||||
// whole page.
|
||||
const HEADING_RE = /^##\s+(\S+)\s+[—–-]\s+(\d{4}-\d{2}-\d{2})\s*$/;
|
||||
const SECTION_RE = /^###\s+(.+?)\s*$/;
|
||||
const LIST_ITEM_RE = /^[-*]\s+(.+)$/;
|
||||
|
||||
function candidatePaths(): string[] {
|
||||
return [
|
||||
// Contract path: repo-root-relative. `process.cwd()` during
|
||||
// `next build`/`next dev` is `tradein-mvp/frontend`, so `..` is
|
||||
// `tradein-mvp/`.
|
||||
path.resolve(process.cwd(), "..", "CHANGELOG.md"),
|
||||
// Fallback in case a future build-context fix copies it alongside the
|
||||
// frontend package instead.
|
||||
path.resolve(process.cwd(), "CHANGELOG.md"),
|
||||
];
|
||||
}
|
||||
|
||||
function readChangelogRaw(): string | null {
|
||||
for (const candidate of candidatePaths()) {
|
||||
try {
|
||||
return fs.readFileSync(candidate, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses CHANGELOG.md into structured entries, in file order (Keep a
|
||||
* Changelog convention: newest on top). Never throws — a missing or
|
||||
* malformed file returns an empty array so both the page render and the
|
||||
* build that statically embeds it degrade gracefully instead of failing.
|
||||
*/
|
||||
export function readChangelog(): ChangelogEntry[] {
|
||||
const raw = readChangelogRaw();
|
||||
if (!raw) return [];
|
||||
|
||||
const entries: ChangelogEntry[] = [];
|
||||
let current: ChangelogEntry | null = null;
|
||||
let currentSection: ChangelogSection | null = null;
|
||||
|
||||
for (const rawLine of raw.split(/\r?\n/)) {
|
||||
const heading = HEADING_RE.exec(rawLine);
|
||||
if (heading) {
|
||||
current = { version: heading[1], date: heading[2], sections: [] };
|
||||
entries.push(current);
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
if (!current) continue; // preamble before the first version heading
|
||||
|
||||
const section = SECTION_RE.exec(rawLine);
|
||||
if (section) {
|
||||
currentSection = { title: section[1], items: [] };
|
||||
current.sections.push(currentSection);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = rawLine.trim();
|
||||
const item = LIST_ITEM_RE.exec(trimmed);
|
||||
if (item && currentSection) {
|
||||
currentSection.items.push(item[1].trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Soft-wrapped continuation of the previous list item — Markdown lets a
|
||||
// bullet wrap across multiple lines with a leading indent (real
|
||||
// CHANGELOG.md entries do this for anything longer than ~80 chars).
|
||||
// Glue it onto the last item instead of silently truncating the bullet
|
||||
// to its first line.
|
||||
if (trimmed && currentSection && currentSection.items.length > 0) {
|
||||
const lastIdx = currentSection.items.length - 1;
|
||||
currentSection.items[lastIdx] =
|
||||
`${currentSection.items[lastIdx]} ${trimmed}`;
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
|
@ -42,6 +42,13 @@ export function asRepairState(v: string | null | undefined): RepairState | undef
|
|||
|
||||
export type ConfidenceLevel = "low" | "medium" | "high";
|
||||
|
||||
// fix (never-block estimate) — сигнал бэкенда о качестве выборки помимо
|
||||
// insufficient_data (которое теперь true ТОЛЬКО когда цены реально нет,
|
||||
// median_price_rub <= 0). "low"/"very_low" → UI показывает LowConfidenceBanner
|
||||
// НАД оценкой вместо блокировки. Optional: старый бэкенд/кешированные оценки
|
||||
// поле не отдают → UI фолбэк на "ok" (см. LowConfidenceBanner.tsx).
|
||||
export type ReliabilityLevel = "ok" | "low" | "very_low";
|
||||
|
||||
// Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2.
|
||||
export type AddressPrecision = "house" | "street" | "approximate";
|
||||
|
||||
|
|
@ -168,6 +175,14 @@ export interface AggregatedEstimate {
|
|||
confidence_explanation: string | null;
|
||||
n_analogs: number;
|
||||
insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных)
|
||||
// fix (never-block estimate) — оценка теперь показывается всегда, пока цена
|
||||
// посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по
|
||||
// сделкам ДКП). relaxations/reliability — как именно бэкенд ослабил поиск,
|
||||
// чтобы всё-таки посчитать цену; UI рендерит их в LowConfidenceBanner НАД
|
||||
// оценкой вместо блокирующей панели «недостаточно данных». Оба optional +
|
||||
// с дефолтами при чтении ([] / "ok") — старый бэкенд их не отдаёт.
|
||||
relaxations?: string[]; // готовые RU-подписи, напр. ["учтены студии", "радиус расширен до 3000 м"]
|
||||
reliability?: ReliabilityLevel;
|
||||
period_months: number; // 24
|
||||
analogs: AnalogLot[]; // top 5-10
|
||||
actual_deals: AnalogLot[]; // last 12 mo
|
||||
|
|
|
|||
34
tradein-mvp/frontend/src/types/version.ts
Normal file
34
tradein-mvp/frontend/src/types/version.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Types for the trade-in version / changelog UI (VersionFooter + the
|
||||
// /versions history page). See the contract this ships against:
|
||||
// - `GET /api/v1/trade-in/version` → { version, sha, built_at }
|
||||
// - `tradein-mvp/CHANGELOG.md` — Keep a Changelog (RU), version headings
|
||||
// STRICTLY `## <semver> — <YYYY-MM-DD>`, sub-sections `### Добавлено` /
|
||||
// `### Изменено` / `### Исправлено` with `- ` list items.
|
||||
|
||||
/**
|
||||
* Response shape of `GET /api/v1/trade-in/version`.
|
||||
*
|
||||
* NOT currently fetched by the UI — `VersionFooter` reads build-time
|
||||
* `NEXT_PUBLIC_*` env vars instead (no runtime call, no useEffect fetch).
|
||||
* Kept here as the documented contract for future callers (health checks,
|
||||
* support tooling, etc.) that DO need to hit the endpoint.
|
||||
*/
|
||||
export interface VersionInfo {
|
||||
version: string;
|
||||
sha: string;
|
||||
built_at: string; // ISO-8601
|
||||
}
|
||||
|
||||
/** One `### <title>` sub-section inside a CHANGELOG.md version entry. */
|
||||
export interface ChangelogSection {
|
||||
title: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
/** One `## <semver> — <YYYY-MM-DD>` version entry parsed from CHANGELOG.md. */
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
/** As written in the changelog heading, e.g. "2026-08-10" (no time part). */
|
||||
date: string;
|
||||
sections: ChangelogSection[];
|
||||
}
|
||||
|
|
@ -601,6 +601,17 @@ def save_listings(
|
|||
metro_stations = EXCLUDED.metro_stations,
|
||||
listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
|
||||
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2),
|
||||
-- #2777: ДОзаполнение адреса — порядок аргументов обратный остальным,
|
||||
-- существующее значение выигрывает. Адрес не обновлялся при конфликте
|
||||
-- вообще: строка, вставленная без адреса (SERP-вариант его не дал),
|
||||
-- оставалась безадресной НАВСЕГДА, даже когда следующий скрейп адрес
|
||||
-- приносил. Прод 2026-08-10: 862 строки с address IS NULL, у 95 из них
|
||||
-- при этом ЕСТЬ house_id_fk — матчинг в тот раз получил адрес и сматчил
|
||||
-- корректно (fingerprint/new без адреса невозможны), в колонке же
|
||||
-- остался NULL, и он же кормит геокодер мусором. Перезаписывать НЕЛЬЗЯ:
|
||||
-- миграции 062/108/124 чистят listings.address, свежий сырой адрес от
|
||||
-- площадки молча откатил бы эту чистку.
|
||||
address = COALESCE(listings.address, EXCLUDED.address),
|
||||
-- #2594: город развёртки — COALESCE, чтобы caller без city (ad-hoc
|
||||
-- admin/manual пути, city=None) не затирал уже известный город.
|
||||
city = COALESCE(EXCLUDED.city, listings.city),
|
||||
|
|
@ -714,6 +725,9 @@ def save_listings(
|
|||
metro_stations = CAST(:metro_stations AS jsonb),
|
||||
listing_date = COALESCE(:listing_date, listing_date),
|
||||
area_m2 = COALESCE(:area_m2, area_m2),
|
||||
-- #2777: см. ON CONFLICT выше — дозаполняем адрес,
|
||||
-- существующее значение выигрывает.
|
||||
address = COALESCE(address, :address),
|
||||
city = COALESCE(:city, city),
|
||||
kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2),
|
||||
ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m),
|
||||
|
|
@ -834,7 +848,7 @@ def save_listings(
|
|||
if listing_id is not None:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
_link_listing_to_house(db, listing_id, lot, matcher)
|
||||
_link_listing_to_house(db, listing_id, lot, matcher, city=lot_city)
|
||||
matched += 1
|
||||
except Exception as e:
|
||||
# Best-effort hook: log and continue so the listings batch isn't aborted.
|
||||
|
|
@ -872,7 +886,7 @@ def _to_json(value: Any) -> str:
|
|||
|
||||
|
||||
def _link_listing_to_house(
|
||||
db: Session, listing_id: int, lot: ScrapedLot, matcher: HouseMatcher
|
||||
db: Session, listing_id: int, lot: ScrapedLot, matcher: HouseMatcher, *, city: str | None = None
|
||||
) -> None:
|
||||
"""Hook scraped listing into matching service: resolve house, upsert listing_sources.
|
||||
|
||||
|
|
@ -890,6 +904,12 @@ def _link_listing_to_house(
|
|||
ext_id source: lot.source_id if present, else dedup_hash (Yandex without
|
||||
stable source_id falls back to URL-based dedup_hash — same hash on re-scrape).
|
||||
|
||||
`city` — город-цель ЭТОГО batch'а после гео-guard'а (`lot_city` в save_listings,
|
||||
он же попадает в `listings.city`). Отдаётся матчеру как независимое от строки адреса
|
||||
наблюдение города (#2777): бескоординатный ключ Tier-2a вырождается в один
|
||||
нормализованный адрес, а областной формат Avito SERP («ул. Кирова,4») города не
|
||||
называет — без этого признака карточка из Серова матчится в дом Каменска-Уральского.
|
||||
|
||||
Skips silently if:
|
||||
- lot has no source_id AND no address/lat/lon (cannot match house anyway)
|
||||
|
||||
|
|
@ -925,6 +945,7 @@ def _link_listing_to_house(
|
|||
# по-прежнему пишутся save_listings'ом, теряется только ложная идентичность.
|
||||
building_cadastral_number=lot.building_cadastral_number,
|
||||
source_url=lot.house_url or lot.source_url,
|
||||
city=city,
|
||||
)
|
||||
|
||||
# Mirror the resolved house into listings.house_id_fk so direct
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
"""Cian-specific exceptions для anti-bot detection."""
|
||||
|
||||
from scraper_kit.proxy_errors import ProxyBanError
|
||||
|
||||
|
||||
class CianBlockedError(ProxyBanError):
|
||||
"""HTTP 403 от Циана — узел, с которого мы пришли, отбит WAF'ом площадки.
|
||||
|
||||
Живая различающая проба 2026-08-10 (#2700), один и тот же detail-URL, один и тот
|
||||
же код, менялся ТОЛЬКО прокси-узел:
|
||||
|
||||
узел 1 (asocks-residential, 46.8.110.92) → HTTP 403, 21 564 байт,
|
||||
маркер `cian_waf_block`
|
||||
узел 9 (asocks-mobile-1, 5.227.16.0) → HTTP 200, 617 352 байт, state ok
|
||||
узел 10 (asocks-mobile-2, 95.104.183.29) → HTTP 200, 617 355 байт, state ok
|
||||
узел 11 (asocks-mobile-3, 95.55.49.98) → HTTP 200, 617 407 байт, state ok
|
||||
|
||||
То есть 403 — свойство ПАРЫ «узел × Циан», а не площадки вообще и не нашего
|
||||
запроса: detail-страница Циана авторизации не требует и отдаётся без единой куки
|
||||
(проба выше шла без них). Поэтому исключение наследует `ProxyBanError` — тот же
|
||||
приём, что у `AvitoBlockedError`/`DomClickBlockedError`: generic curl-слой
|
||||
(`providers/_proxy.py::curl_proxy_url`) увидит `isinstance(exc, ProxyBanError)` и
|
||||
снимет узел с выдачи ИМЕННО Циану (per-source бан, #2600 п.2), не трогая остальные
|
||||
источники.
|
||||
|
||||
Почему это заводится отдельным исключением, а не остаётся `return None`: пока 403
|
||||
гасился внутри `fetch_detail`, наружу не выходило НИЧЕГО — пул получал
|
||||
`mark_health(ok=True)` на отбитый узел и продолжал выдавать его Циану, а прогон
|
||||
писал `detail_failed=50` при `errors_count=0` и статусе `done` (#2700: 15 суток
|
||||
подряд, 50 из 50 отказов ежедневно).
|
||||
|
||||
404 сюда НЕ относится: удалённое объявление — не бан, узел за него наказывать
|
||||
нельзя. Остальные не-200 остаются прежним мягким отказом (`None` + WARNING).
|
||||
"""
|
||||
|
|
@ -66,9 +66,15 @@ class HouseMatcher(Protocol):
|
|||
year_built: int | None = ...,
|
||||
building_cadastral_number: str | None = ...,
|
||||
source_url: str | None = ...,
|
||||
city: str | None = ...,
|
||||
) -> tuple[int | None, float, str]:
|
||||
"""Найти или создать канонический дом.
|
||||
|
||||
NB (#2777): `city` — город-цель развёртки этой карточки (тот же, что уходит в
|
||||
`listings.city`). Единственное наблюдение города, НЕ выведенное из строки адреса;
|
||||
без него бескоординатная карточка областного формата («ул. Кирова,4») матчится в
|
||||
одноимённый дом другого города.
|
||||
|
||||
NB (#2674): `cadastral_number` (кадастр КВАРТИРЫ) из контракта УБРАН. Ключ дома —
|
||||
только `building_cadastral_number`; квартирный номер в нём породил бы по дому на
|
||||
квартиру, см. комментарий у `cad` в matching/houses.py.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue