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

# Conflicts:
#	tradein-mvp/backend/data/sql/_manifest_applied.txt
#	tradein-mvp/backend/tests/skip_allowlist.txt
This commit is contained in:
bot-backend 2026-08-12 23:06:41 +05:00
commit 2c8dc46205
106 changed files with 9978 additions and 710 deletions

View file

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

View file

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

View file

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

View file

@ -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()

View file

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

View file

@ -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} />

View file

@ -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);
});
});

View file

@ -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>
))}

View file

@ -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 && (

View file

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

View file

@ -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 — хуки вернутся из истории (мертвее они там не станут).

View file

@ -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
View 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
View file

@ -0,0 +1 @@
2.1.0

View file

@ -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, а некоторые

View file

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

View file

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

View 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}

View file

@ -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 в таске) —

View file

@ -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) перед

View 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()}"

View file

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

View file

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

View file

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

View file

@ -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). Раньше здесь везде

View file

@ -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' (highmedium;
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",
)

View file

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

View file

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

View file

@ -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 (loserkeeper). 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()

View file

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

View file

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

View file

@ -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): успех — это
# «сделали то, что собирались», а не «не поймали известное исключение».

View 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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
такие URL регуляркой ДО сети это не капча, а предрешённый parseNone. Идут они
пачками, поэтому «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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,41 @@
-- 258_houses_imv_transient_attempts.sql
-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674).
--
-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь
-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию
-- 'pending'), и за всю историю (41 прогон, 26.0611.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;

View file

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

View file

@ -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, р-н Академический"
)

View 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)

View file

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

View file

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

View file

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

View file

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

View 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

View 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()

View file

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

View 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"]

View file

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

View 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

View 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)]

View 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

View file

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

View file

@ -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-полями не найден"

View file

@ -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'."""

View file

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

View 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

View file

@ -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 , 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}"
)

View file

@ -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
(studio1-комн) 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

View file

@ -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 "")

View 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

View 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"

View file

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

View file

@ -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()

View file

@ -0,0 +1,267 @@
"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную.
Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон
(`only_status`, дефолт 'pending'). Возможность повтора существовала и была
задокументирована в admin-API, но за всю историю (41 прогон, 26.0611.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

View file

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

View file

@ -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 соседнего города обойдён"

View file

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

View file

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

View file

@ -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")

View file

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

View 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)

View file

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

View file

@ -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>
);
}

View file

@ -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 />;

View 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>
);
}

View file

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

View file

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

View file

@ -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,
);
}

View file

@ -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>
);

View file

@ -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>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View 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)}`;
}

View 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;
}

View file

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

View 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[];
}

View file

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

View file

@ -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).
"""

View file

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