diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 8f4039e1..440e7725 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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: | diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 74bfb5c9..191ed5c4 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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, diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 08a253d1..7639c02d 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -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 diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index ad7a2248..cc61f0ef 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -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() diff --git a/backend/tests/test_weight_profiles.py b/backend/tests/test_weight_profiles.py index 9cbb97c0..87b62883 100644 --- a/backend/tests/test_weight_profiles.py +++ b/backend/tests/test_weight_profiles.py @@ -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 diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 03be7b0d..41cd3e4d 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -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 | null>(null); + + return ( + + + + ); +} + +function AnalysisPageBody({ + cad, + appliedWeights, + onWeightsApply, +}: Props & { + appliedWeights: Record | null; + onWeightsApply: (weights: Record) => void; +}) { const [horizon, setHorizon] = useState(12); const queryClient = useQueryClient(); @@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) { {/* ── Группа «Стройка и рынок» ──────────────────────────────── */} - {/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */} - + {/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1 + поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор + пересчитывается по ползункам во ВСЕХ секциях (#2790). */} + {/* 5. Атмосфера — IMPLEMENTED in A11 */} diff --git a/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx new file mode 100644 index 00000000..4cc7e35e --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx @@ -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 | undefined> = []; + +const fetchMock = vi.fn(); + +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) + : 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( + + + , + ); +} + +/** Ползунок конкретной категории по подписи строки в панели весов. */ +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; + expect(applied.park).toBe(3); + expect(applied.tram_stop).toBe(-2); + // Нетронутые категории уходят как есть — бэкенд мержит поверх системных, + // но панель отправляет полный набор, чтобы ответ совпадал с ползунками. + expect(applied.school).toBe(1.5); + }); +}); diff --git a/frontend/src/components/site-finder/WeightProfilePanel.tsx b/frontend/src/components/site-finder/WeightProfilePanel.tsx index 60fcbe5b..5d5731f5 100644 --- a/frontend/src/components/site-finder/WeightProfilePanel.tsx +++ b/frontend/src/components/site-finder/WeightProfilePanel.tsx @@ -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) => ( ))} diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx index 670fb483..6c616440 100644 --- a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx +++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx @@ -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 | null; + /** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */ + onWeightsApply: (weights: Record) => 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 | null; + onWeightsApply: (weights: Record) => void; }) { - const [weights, setWeights] = useState>( - () => ({ ...POI_DEFAULT_WEIGHTS }), - ); - function toggleChip(key: keyof Omit) { onFiltersChange({ ...filters, [key]: !filters[key] }); } - function handleWeightsChange( - newWeights: Record, - _profileId: number | null, - ) { - setWeights(newWeights); - } - const chips: Array<{ key: keyof Omit; label: string; @@ -136,8 +133,8 @@ function Section31Settings({ margin: "4px 0 0", }} > - Фильтры применяются к конкурентам локально — без повторного запроса к - бэкенду + Радиус и фильтры применяются к конкурентам локально. Веса POI — + пересчёт анализа на бэкенде по кнопке «Применить»

@@ -259,8 +256,8 @@ function Section31Settings({ Профиль весов POI @@ -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({ radiusKm: 2, onlyUnderConstruction: false, @@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { {/* Sub-sections */}
- + {/* Competitor table — moved before 3.2/3.3 for context */} {filteredCompetitors.length > 0 && ( diff --git a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts index f84f2033..81f0e074 100644 --- a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts +++ b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts @@ -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; diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts index 4aa28bd7..1534e8ce 100644 --- a/frontend/src/lib/api/weightProfiles.ts +++ b/frontend/src/lib/api/weightProfiles.ts @@ -27,14 +27,18 @@ export interface WeightProfileCreate { description?: string | null; } -export interface WeightProfileUpdate { - profile_name?: string; - weights?: Record; - 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({ queryKey: ["weight-profiles", userId], queryFn: () => apiFetch( - `${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({ - mutationFn: (payload) => - apiFetch( - `${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({ - mutationFn: (profileId) => - apiFetch( - `${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 — хуки вернутся из истории (мертвее они там не станут). diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index ca64a76d..ebd0542b 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -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 | 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; } diff --git a/tradein-mvp/CHANGELOG.md b/tradein-mvp/CHANGELOG.md new file mode 100644 index 00000000..0e70377d --- /dev/null +++ b/tradein-mvp/CHANGELOG.md @@ -0,0 +1,46 @@ +# История версий «МЕРА» + +Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и +[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно +`## ` (машинно читается страницей истории версий). + +## 2.1.0 — 2026-08-10 + +Первая версия с явным версионированием. Номер продолжает ряд, который до этого +показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние +отчёты. + +### Добавлено + +- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и + реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень + уверенности в оценке. +- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав + аналогов и сделок, формирование выкупной стоимости. +- Аналитика по дому — история размещений объявлений и продаж в доме. +- История прошлых оценок в личном кабинете, автодополнение адреса при поиске. +- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история). +- Чат поддержки на сайте, в том числе без входа в личный кабинет. +- Публичный лендинг «МЕРА». +- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта + страница истории версий. + +### Изменено + +- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более + раннего технического макета. + +### Исправлено + +- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе + комнатности не было варианта «Студия», из-за чего для студии подбирались + однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась. +- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор + автоматически расширяется (студии, срок объявлений, новостройки, радиус), + а над результатом показывается предупреждение о сниженной точности и о том, + какие параметры пришлось расширить. +- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части + адресов улица не распознавалась, и разделы оставались пустыми. +- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов. +- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки + на обложке, некорректные допущения о сроке экспозиции. diff --git a/tradein-mvp/VERSION b/tradein-mvp/VERSION new file mode 100644 index 00000000..7ec1d6db --- /dev/null +++ b/tradein-mvp/VERSION @@ -0,0 +1 @@ +2.1.0 diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile index 4958161c..24d65162 100644 --- a/tradein-mvp/backend/Dockerfile +++ b/tradein-mvp/backend/Dockerfile @@ -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, а некоторые diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 89d6ef8d..aa251ffe 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -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": , "reason": } """ 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(), diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 4aa9934c..45d1a561 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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, diff --git a/tradein-mvp/backend/app/api/v1/version.py b/tradein-mvp/backend/app/api/v1/version.py new file mode 100644 index 00000000..86729727 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/version.py @@ -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} diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index b0f5b791..1c2ad8c1 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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 в таске) — diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index e8c5fc04..0dd654ff 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -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) перед diff --git a/tradein-mvp/backend/app/core/version.py b/tradein-mvp/backend/app/core/version.py new file mode 100644 index 00000000..44101a3a --- /dev/null +++ b/tradein-mvp/backend/app/core/version.py @@ -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()}" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 7e99bbf5..347cad8c 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -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"]) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 4f3d4996..923a7607 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/app/services/cian_price_history.py b/tradein-mvp/backend/app/services/cian_price_history.py index fdf1518f..0e23310e 100644 --- a/tradein-mvp/backend/app/services/cian_price_history.py +++ b/tradein-mvp/backend/app/services/cian_price_history.py @@ -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", diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py index 6e227881..f40378e2 100644 --- a/tradein-mvp/backend/app/services/cian_session.py +++ b/tradein-mvp/backend/app/services/cian_session.py @@ -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). Раньше здесь везде diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index eeced17f..7f386bb9 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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"(? NOW() - (:fresh_days || ' days')::interval @@ -4956,8 +5424,13 @@ _COMMON_WHERE = """ -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает -- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента — - -- это вторичка или неклассифицированный объект). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki + -- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) — + -- дефолт False сохраняет канон-guard byte-identical. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled, -- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒ -- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает @@ -5012,6 +5485,14 @@ def _fetch_analogs( cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive target_house_id: int | None = None, # #6: canonical house for same-building Tier S + # #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты + # byte-identical старому поведению (exact rooms match / 14 дней / без + # новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО + # когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring. + rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms + rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms + fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED + allow_novostroyki: bool = False, # #oblast-F (c) ) -> tuple[list[dict[str, Any]], bool, str]: """SELECT аналогов — трёхуровневый house-match (S → H → W). @@ -5052,21 +5533,29 @@ def _fetch_analogs( """ area_min = area * (1 - area_tolerance) area_max = area * (1 + area_tolerance) + # #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms), + # byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a + # widened range only past HEADLINE_LISTINGS_MIN_N thinness. + eff_rooms_min = rooms if rooms_min is None else rooms_min + eff_rooms_max = rooms if rooms_max is None else rooms_max # #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL # (безвредно без фильтра); статический фрагмент управляет только применением # `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3 # bind-параметры не задействованы, инъекции нет. dup_filter = "AND rn_dup = 1" base_params: dict[str, Any] = { - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, "cohort_year_max": cohort_year_max, # #2012: is_apartments hard-filter — see _COMMON_WHERE comment above. "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, + # #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above. + "allow_novostroyki": allow_novostroyki, } # ── Tier S (canonical): same building via house_id_fk ───────────────────── @@ -5391,7 +5880,8 @@ def _fetch_analogs( FROM listings WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) AND (geo_precision IS DISTINCT FROM 'city') - AND rooms = :rooms + -- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же. + AND rooms BETWEEN :rooms_min AND :rooms_max AND area_m2 BETWEEN :area_min AND :area_max AND is_active = true AND scraped_at > NOW() - (:fresh_days || ' days')::interval @@ -5408,7 +5898,11 @@ def _fetch_analogs( ) -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий -- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению). AND ( @@ -5450,16 +5944,18 @@ def _fetch_analogs( "lat": lat, "lon": lon, "radius": radius_m, - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "target_year": year_built, "target_house_type": house_type, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, # NEW "cohort_year_max": cohort_year_max, # NEW "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012 + "allow_novostroyki": allow_novostroyki, # #oblast-F (c) }, ) .mappings() @@ -6285,6 +6781,52 @@ def _downgrade_confidence(confidence: str) -> str: return {"high": "medium", "medium": "low", "low": "low"}.get(confidence, confidence) +_CONFIDENCE_ORDER: dict[str, int] = {"low": 0, "medium": 1, "high": 2} + + +def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str: + """#conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11). + + `confidence` (this function's sibling `_compute_confidence` — unique- + address/IQR metric) and `reliability` (estimate_quality — n_analogs-bucket + + #oblast-F relaxation-cascade metric) used to be computed fully + independently. The #oblast-F cascade's room-adjacency/freshness/ + novostroyki steps never set `fallback_used`, so `_compute_confidence` + stayed blind to them and could keep scoring a post-relaxation sample + (wide, tight-IQR) as "high" while `reliability` — which DOES see the + relaxations — honestly read "low". Live prod repro: n_analogs=39, + confidence='high', reliability='low', relaxations=[3 items] — a client + could see a "высокая уверенность" badge next to a "точность снижена" + banner on the SAME estimate. + + Rule (applied ONCE, by the caller, after both values are final — NOT + spread across the relaxation cascade): + reliability == 'very_low' → confidence forced to 'low'. + reliability == 'low' → confidence capped at 'medium' (high→medium; + medium/low pass through unchanged). + reliability == 'ok' → confidence untouched (unaffected — this is + the common, unrelaxed case; byte-identical + to pre-#conf-cap behaviour). + + Never RAISES confidence — only caps/lowers it. Unknown confidence/ + reliability values pass through unchanged (defensive, mirrors + _downgrade_confidence above). + """ + if reliability == "very_low": + return "low" + if reliability == "low": + current = _CONFIDENCE_ORDER.get(confidence) + cap = _CONFIDENCE_ORDER["medium"] + if current is not None and current > cap: + return "medium" + return confidence + + +def _radius_ru(radius_m: int) -> str: + """«2 км» / «5 км» / «1500 м» — целые километры словом, остальное метрами.""" + return f"{radius_m // 1000} км" if radius_m % 1000 == 0 else f"{radius_m} м" + + def _compute_confidence( n_analogs: int, median_ppm2: float, @@ -6293,6 +6835,7 @@ def _compute_confidence( fallback_radius_used: bool, area_widened: bool = False, listings: list[dict] | None = None, + radius_used_m: int | None = None, ) -> tuple[str, str]: """Confidence + explanation string. @@ -6326,7 +6869,12 @@ def _compute_confidence( iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0 notes = [] if fallback_radius_used: - notes.append("расширили радиус до 2 км") + # #2632: НАЗЫВАЕМ фактический радиус. До этого здесь было прибито «2 км» + # — константа старого 1км→2км fallback'а, а каскад #oblast-F уходит до + # 3/5 км: текст утверждал 2 км там, где поиск реально шёл по 5 км + # (прод, 2026-08-11: relaxations «радиус расширен до 5000 м», самый + # дальний аналог 4381 м, объяснение — «расширили радиус до 2 км»). + notes.append(f"расширили радиус до {_radius_ru(radius_used_m or FALLBACK_RADIUS_M)}") if area_widened: notes.append("расширили допуск по площади до ±25%") fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else "" @@ -6510,6 +7058,7 @@ def _empty_estimate( confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, + relaxations, reliability, created_by, expires_at, consent, client_ip, consent_policy_version, consent_text_snapshot @@ -6522,6 +7071,11 @@ def _empty_estimate( 'low', :explanation, 0, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, + -- #oblast-F: поиск аналогов вообще не выполнялся (geocode failed / + -- no coords) — каскад послаблений не запускался, relaxations честно + -- пуст; reliability='very_low' зеркалит то, что возвращает Python + -- ниже (см. AggregatedEstimate(..., reliability="very_low")). + '[]'::jsonb, 'very_low', :created_by, :expires_at, :consent, CAST(:client_ip AS inet), :consent_policy_version, @@ -6570,4 +7124,7 @@ def _empty_estimate( # Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна. address_precision=None, analog_tier=None, # нет данных при empty estimate + # #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся + # (geocode failed / no coords), а не просто "мало нашлось". + reliability="very_low", ) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 8282f83c..a89eadf6 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -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'
' + f"{_html.escape(product_version_line())}
" + ) + return ( + f"
" f'
' + f'padding-bottom:6pt;margin-bottom:3pt;">' f"{mark_html}" f'{meta_html}' f"
" + f"{version_html}" + f"
" ) @@ -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()} - - {_REPORT_ENGINE_VERSION}
@@ -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'

' + "Оценка построена по зарегистрированным сделкам Росреестра — подходящих " + "объявлений поблизости почти нет. Объявления ниже приведены справочно, " + "для наглядности рынка.

" + ) + + +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""" +
+ Точность оценки снижена. + {detail} — данные ниже приведены с этой оговоркой. +
+""" + + # ── 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"""
@@ -1320,6 +1398,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra Количество объявлений по аналогичным объектам {_mono(f"{n_total} шт.")} + {deals_sourced_note}
Источники данных
{sources_html}
@@ -1344,6 +1423,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra + {reliability_note}

Диапазон цен в объявлениях

diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index f3cdb12e..7785116a 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -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: diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index 0f244b97..511386a1 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -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 = , i.e. it IS the ГАР guid for + 4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback + — keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon, + 193 come through the fallback, and 30 of the 31 pairs >250 m apart do too. + source+ext_house_id, cian_internal_house_id, yandex_jk_id + distinct by construction / 39 / 0 rows → nothing to cluster. + coordinates a real independent observation, but not an IDENTITY: neighbours share a + yard. It is already used the only way it can be — as the guard. + year_built+total_floors + a FALSE witness, not a corroborator: of the 391 same-canon pairs the + guard cannot judge, only 18 agree on both fields (357 have a NULL), while + 306 pairs the guard rejected at >250 m DO agree — it would confirm merges + that are provably wrong. + + Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the + canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued — see the + residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the + guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart + (median 1 084 m) — those are not duplicates at all, the canon key is wrong about them. + IDEMPOTENCY: Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the mapping is empty → every statement touches 0 rows → no-op. Re-running is safe. @@ -165,6 +204,14 @@ _COMPLETENESS_EXPR = """ # правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую # корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома / # этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно. +# +# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле — +# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина +# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния). +# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого +# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить +# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом +# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей». _KEEPER_ORDER = f""" (h.geom IS NOT NULL) DESC, listing_cnt DESC NULLS LAST, @@ -199,37 +246,17 @@ _CANON_KEY_EXPR = """ """ -def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: - """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. +def _ranked_cte(cluster_key_case: str) -> str: + """Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row. - Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers - are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered - row shares one fias by construction). - - apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True. - - CANON pass → True: the canon strips город/район, so same-street-number buildings in - different region-66 towns share a canon; the guard stops the cross-town over-merge. - - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks - proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the - geom-first keeper rule simultaneously repairs the broken coordinate). + Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census + (`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about — a census + built from its own copy of the clustering would drift from the pass it describes and the + drift would be invisible (it is the same class of error as #2690's cluster key: two + expressions that look alike and are not). `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection. """ - geo_guard = ( - """ - -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two - -- different buildings sharing a street+number canon («Ленина 5» in different region-66 - -- towns) collapse to one cluster_key. A loser merges only when geographically next to the - -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at - -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL - -- geom on either side, → left as separate rows (conservative — never over-merges). - AND keeper_geom IS NOT NULL - AND loser_geom IS NOT NULL - AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250""" - if apply_geo_guard - else "" - ) return f""" - CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS WITH clustered AS ( SELECT id, @@ -281,7 +308,41 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: FROM dup_houses dh JOIN houses h ON h.id = dh.id LEFT JOIN listing_counts lc ON lc.house_id = dh.id + )""" + + +def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: + """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. + + Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers + are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered + row shares one fias by construction). + + apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True. + - CANON pass → True: the canon strips город/район, so same-street-number buildings in + different region-66 towns share a canon; the guard stops the cross-town over-merge. + - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks + proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the + geom-first keeper rule simultaneously repairs the broken coordinate). + `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection. + """ + geo_guard = ( + """ + -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two + -- different buildings sharing a street+number canon («Ленина 5» in different region-66 + -- towns) collapse to one cluster_key. A loser merges only when geographically next to the + -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at + -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL + -- geom on either side, → left as separate rows (conservative — never over-merges). + AND keeper_geom IS NOT NULL + AND loser_geom IS NOT NULL + AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250""" + if apply_geo_guard + else "" ) + return f""" + CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS + {_ranked_cte(cluster_key_case)} -- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but -- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon -- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per @@ -314,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR)) # merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords). _BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False)) +# ── RESIDUAL CENSUS (#2690 п.2/п.4) ─────────────────────────────────────────── +# +# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY. +# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered +# out is counted here, bucketed by the reason it survived. +# +# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there +# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a +# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the +# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821. +# +# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard +# rejected» are opposite facts: +# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN. +# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are +# NOT duplicates — the canon key is wrong about them (prod 2026-08-10: +# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt. +# residual_cross_fias — provably different buildings (two different ФИАС UUIDs). +# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real +# run; non-zero is a tripwire on the pass itself, not a census entry. +# residual_listings is the user-visible size of the remainder (listings hanging on those rows). +_RESIDUAL_SQL = text( + f""" + {_ranked_cte(_CANON_KEY_EXPR)} + SELECT + count(*) FILTER (WHERE rn > 1) AS residual_rows, + COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings, + count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL) + AS residual_no_geom, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250) + AS residual_mergeable + FROM ( + SELECT rn, + COALESCE(lc.listing_cnt, 0) AS lcnt, + CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL + THEN ST_DistanceSphere(loser_geom, keeper_geom) + END AS dist, + (NULLIF(loser_fias, '') IS NOT NULL + AND NULLIF(keeper_fias, '') IS NOT NULL + AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias + FROM ranked + LEFT JOIN listing_counts lc ON lc.house_id = ranked.id + ) r + """ +) + # Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op. _STEPS: list[tuple[str, str]] = [ # ── Plain re-point (no UNIQUE on the FK column) ─────────────────────────── @@ -726,6 +835,15 @@ class DedupMergeResult: listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children children_repointed: int = 0 # survivor child rows moved loser→keeper + # Residual census (#2690): same-canon rows STILL in the table after this run, by reason. + # Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with, + # so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL. + residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets) + residual_listings: int = 0 # listings hanging on them (the user-visible size) + residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates + residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building + residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings + residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0 dry_run: bool = False duration_sec: float = field(default=0.0) @@ -736,6 +854,12 @@ class DedupMergeResult: "listings_repointed": self.listings_repointed, "children_deleted": self.children_deleted, "children_repointed": self.children_repointed, + "residual_rows": self.residual_rows, + "residual_listings": self.residual_listings, + "residual_no_geom": self.residual_no_geom, + "residual_far": self.residual_far, + "residual_cross_fias": self.residual_cross_fias, + "residual_mergeable": self.residual_mergeable, "dry_run": int(self.dry_run), "duration_sec": int(self.duration_sec), } @@ -853,6 +977,49 @@ def _run_merge_pass( db.execute(_BACKFILL_ALIASES_SQL) +def _measure_residual(db: Session, result: DedupMergeResult) -> None: + """Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690). + + Read-only; runs after both passes, so it describes the table as the run leaves it (under + dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of + `_run_merge_pass` because the census is about the CANON key only and must be taken once per + call, not once per pass. + + Never fails the merge: the merge itself is the product, the census is instrumentation, and a + census that can abort a committed-by-now transaction would be worse than a missing number. + """ + try: + rows = db.execute(_RESIDUAL_SQL).all() + except Exception: + logger.exception("merge_duplicate_houses: residual census failed — counters left at 0") + return + if not rows: + return + r = rows[0] + result.residual_rows = int(r.residual_rows or 0) + result.residual_listings = int(r.residual_listings or 0) + result.residual_no_geom = int(r.residual_no_geom or 0) + result.residual_far = int(r.residual_far or 0) + result.residual_cross_fias = int(r.residual_cross_fias or 0) + result.residual_mergeable = int(r.residual_mergeable or 0) + logger.info( + "merge_duplicate_houses: residual rows=%d listings=%d " + "(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)", + result.residual_rows, + result.residual_listings, + result.residual_no_geom, + result.residual_far, + result.residual_cross_fias, + result.residual_mergeable, + ) + if result.residual_mergeable: + logger.warning( + "merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass " + "left work on the table (expected 0)", + result.residual_mergeable, + ) + + def merge_duplicate_houses( db: Session, *, @@ -908,6 +1075,10 @@ def merge_duplicate_houses( result=result, ) + # Census of what is LEFT (read-only). Runs before the no-op early return on purpose: + # a run that merged nothing is exactly the run whose remainder needs a number. + _measure_residual(db, result) + if result.losers_deleted == 0: # Clean table — both passes empty. Roll back (we only opened temp tables). db.rollback() diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index edb02137..7dd6f1b9 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -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, ) diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 413b82ca..1effa723 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -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, ) diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 64ba11e2..3abf3360 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -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): успех — это # «сделали то, что собирались», а не «не поймали известное исключение». diff --git a/tradein-mvp/backend/app/services/proxy_egress.py b/tradein-mvp/backend/app/services/proxy_egress.py new file mode 100644 index 00000000..c28426a4 --- /dev/null +++ b/tradein-mvp/backend/app/services/proxy_egress.py @@ -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() diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index c83f178e..0ecf5ee2 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -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`). + + Признак — собственная бухгалтерия фазы: `_failed == _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( diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 2192cd8c..01bf055a 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -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( diff --git a/tradein-mvp/backend/app/services/yandex_address_backfill.py b/tradein-mvp/backend/app/services/yandex_address_backfill.py index 1b5984f3..b4a660c5 100644 --- a/tradein-mvp/backend/app/services/yandex_address_backfill.py +++ b/tradein-mvp/backend/app/services/yandex_address_backfill.py @@ -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( diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index d206d869..a45af2d3 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -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 diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py index abd51d13..bf71333d 100644 --- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py @@ -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, ) diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 6538c7c8..48771f0b 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе ведёт на сайт застройщика, а не на realty.yandex.ru/offer//. Парсер отвергает такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон -целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может -разобрать, а размер отброшенного видно в counters.unenrichable_pending. +целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать. + +Но «не по тому URL» ≠ «нечего обогащать» (разобрано 2026-08-12, см. комментарий +у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по +собранному из него каноническому URL страница отдаётся и парсится. Поэтому в +очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены: +url_from_offer_id — сколько ждёт починки адреса, unenrichable_pending — сколько +не адресуемо вообще (ни offer-URL, ни числового source_id). Why curl_cffi and not YandexDetailScraper.fetch_detail: fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS @@ -45,12 +51,14 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings from app.services import scrape_runs as runs_mod +from app.services.proxy_egress import resolve_proxy_url logger = logging.getLogger(__name__) __all__ = [ + "CANONICAL_URL_SQL", + "OFFER_ID_PATTERN", "OFFER_URL_PATTERN", "YandexDetailBackfillResult", "run_yandex_detail_backfill", @@ -63,8 +71,7 @@ __all__ = [ # # Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют # source_url на сайт застройщика (macroserver.ru, prospect-federation.ru, -# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из -# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer//. +# 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 diff --git a/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql new file mode 100644 index 00000000..61120223 --- /dev/null +++ b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql @@ -0,0 +1,155 @@ +-- 254_listings_backfill_avito_rating_glued_address.sql +-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814). +-- +-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же

+-- в 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; diff --git a/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql new file mode 100644 index 00000000..de19a1f4 --- /dev/null +++ b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql new file mode 100644 index 00000000..499f2f79 --- /dev/null +++ b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql new file mode 100644 index 00000000..05f29a7c --- /dev/null +++ b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql @@ -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//) +-- Живая проба прод-трактом 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; diff --git a/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql new file mode 100644 index 00000000..12f72b4b --- /dev/null +++ b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql @@ -0,0 +1,41 @@ +-- 258_houses_imv_transient_attempts.sql +-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674). +-- +-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь +-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию +-- 'pending'), и за всю историю (41 прогон, 26.06–11.08) ни один не был запущен +-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из +-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All +-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой +-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе +-- 108 из 108 POST /fetch-json = 200). +-- +-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически +-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из +-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт +-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд. +-- +-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error', +-- прежние разрезы по imv_status/imv_error_reason работают как работали, а +-- «застряли окончательно» — это +-- SELECT count(*) FROM houses +-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3; +-- +-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по +-- imv_status IN ('pending','transient_error') с сортировкой по +-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на +-- выборке в тысячи строк. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE houses + ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN houses.imv_transient_attempts IS + 'Сколько раз подряд домовая IMV-оценка падала в transient_error. ' + 'Растёт только на transient_error, обнуляется успехом. ' + '>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index a040e084..b11e827e 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -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, diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py new file mode 100644 index 00000000..2e9da4bf --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py @@ -0,0 +1,59 @@ +"""Адрес Авито не должен утаскивать за собой рейтинг дома и число отзывов. + +Прод 2026-08-10: 1 123 активных объявления Авито с адресом вида +«ул. Ткачей,17·5,0 · 4 отзыва», и у 1 123 из 1 123 нет координат — доля 100%. +У объявлений с чистым адресом координаты есть у 4 766. Хвост появился 27.07.2026, +когда Авито начал рендерить рейтинг внутри того же

в 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, р-н Академический" + ) diff --git a/tradein-mvp/backend/tests/services/test_proxy_egress.py b/tradein-mvp/backend/tests/services/test_proxy_egress.py new file mode 100644 index 00000000..cf303b2b --- /dev/null +++ b/tradein-mvp/backend/tests/services/test_proxy_egress.py @@ -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) diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index b7aa5c5c..8ccffeea 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -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 diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index 333da637..29cc9f48 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -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), ): diff --git a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py index 4df0d729..95ae2c4a 100644 --- a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py @@ -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) diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py index 2d9f320a..d4d34cae 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py new file mode 100644 index 00000000..9dd3b3b0 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_2670_stale_source_digest.py b/tradein-mvp/backend/tests/test_2670_stale_source_digest.py new file mode 100644 index 00000000..baf2ec4a --- /dev/null +++ b/tradein-mvp/backend/tests/test_2670_stale_source_digest.py @@ -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() diff --git a/tradein-mvp/backend/tests/test_2687_pool_exhaustion_keeps_checkpoint.py b/tradein-mvp/backend/tests/test_2687_pool_exhaustion_keeps_checkpoint.py new file mode 100644 index 00000000..6dd54899 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2687_pool_exhaustion_keeps_checkpoint.py @@ -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"] diff --git a/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py new file mode 100644 index 00000000..d6637486 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py @@ -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"] diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py index 81056835..fea61b82 100644 --- a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py +++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py new file mode 100644 index 00000000..69b93713 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py new file mode 100644 index 00000000..a6fb3820 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py @@ -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 = '

ЖК

' + 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, "ничего") is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(9, True)] diff --git a/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py new file mode 100644 index 00000000..884a7c8a --- /dev/null +++ b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py index 5ac78324..8a386aa4 100644 --- a/tradein-mvp/backend/tests/test_backfill_wave2.py +++ b/tradein-mvp/backend/tests/test_backfill_wave2.py @@ -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) diff --git a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py index 1c44a0e6..1210e5ec 100644 --- a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py +++ b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py @@ -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-полями не найден" diff --git a/tradein-mvp/backend/tests/test_cian_session.py b/tradein-mvp/backend/tests/test_cian_session.py index ee4e50e6..4468e8ed 100644 --- a/tradein-mvp/backend/tests/test_cian_session.py +++ b/tradein-mvp/backend/tests/test_cian_session.py @@ -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'.""" diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index a9ed8d31..099d39d0 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -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] diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py new file mode 100644 index 00000000..d41f2e4d --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimate_revival.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py new file mode 100644 index 00000000..16815488 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py @@ -0,0 +1,263 @@ +"""#2827 — confidence↔reliability consistency (live-prod smoke, 2026-08-11). + +`confidence` (`_compute_confidence` — unique-address/IQR metric) and +`reliability` (`estimate_quality` — n_analogs-bucket + #oblast-F relaxation- +cascade metric, see #2823) were computed fully independently. The cascade's +room-adjacency/freshness/novostroyki steps never set `fallback_used`, so +`_compute_confidence` stayed blind to them and could keep scoring a wide, +tight-IQR (post-relaxation) sample as "high", while `reliability` — which DOES +see the relaxations — honestly read "low". + +Live prod repro (customer address, rooms=1, 23.1 m², radius=2000): + n_analogs: 39 + confidence: high + reliability: low + relaxations: ['снят фильтр по году постройки', 'учтены студии', 'площадь ±25%'] + +A client could see a "высокая уверенность" badge next to a "точность снижена" +banner on the SAME estimate — exactly the class of contradiction cleaned up +from the PDF/counters earlier in this cycle (#2824-adjacent work), now leaking +through the two headline confidence signals themselves. + +Fix: `_cap_confidence_by_reliability` (estimator.py), applied ONCE in +`estimate_quality` right after both `confidence` and `reliability` are final +— NOT spread across the #oblast-F cascade steps. Rule: + reliability == 'very_low' → confidence forced to 'low' + reliability == 'low' → confidence capped at 'medium' + reliability == 'ok' → confidence untouched (common case, unaffected) + +Two layers: + 1. `_cap_confidence_by_reliability` direct unit tests — the 3 rules in + isolation, no DB/estimate_quality overhead. + 2. `estimate_quality` integration tests — the prod repro shape (confidence + downgraded + confidence_explanation restructured to LEAD with the + accuracy-reduced summary) and the byte-identical-when-unrelaxed control. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.estimator import _cap_confidence_by_reliability +from app.services.geocoder import GeocodeResult + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 1 — `_cap_confidence_by_reliability` direct unit tests +# ───────────────────────────────────────────────────────────────────────────── + + +def test_very_low_reliability_forces_confidence_low() -> None: + """reliability == 'very_low' → confidence forced to 'low', regardless of + what _compute_confidence originally scored.""" + for original in ("high", "medium", "low"): + assert ( + _cap_confidence_by_reliability(original, "very_low") == "low" + ), f"original={original!r} must be forced to 'low' under very_low reliability" + + +def test_low_reliability_caps_confidence_at_medium() -> None: + """reliability == 'low' → confidence capped at 'medium': 'high' is pulled + down to 'medium'; 'medium'/'low' pass through unchanged (already <= cap).""" + assert _cap_confidence_by_reliability("high", "low") == "medium" + assert _cap_confidence_by_reliability("medium", "low") == "medium" + assert _cap_confidence_by_reliability("low", "low") == "low" + + +def test_ok_reliability_leaves_confidence_untouched() -> None: + """reliability == 'ok' — the common, unrelaxed case — must NOT change + confidence at all (byte-identical to pre-#2827 behaviour).""" + for original in ("high", "medium", "low"): + assert _cap_confidence_by_reliability(original, "ok") == original + + +def test_cap_never_raises_confidence() -> None: + """Sanity: the cap only lowers/holds — never raises 'low' to something + higher under any reliability value.""" + for reliability in ("ok", "low", "very_low"): + assert _cap_confidence_by_reliability("low", reliability) == "low" + + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path) +# ───────────────────────────────────────────────────────────────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Академика Парина, 46/5", + provider="nominatim", + ) + + +def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 23.1) -> dict[str, Any]: + return { + "source": "avito", + "source_url": f"https://avito.ru/offer/{address}", + "address": address, + "lat": 56.838, + "lon": 60.595, + "rooms": 1, + "area_m2": area_m2, + "floor": 5, + "total_floors": 9, + "price_rub": price_per_m2 * area_m2, + "price_per_m2": price_per_m2, + "listing_date": datetime(2026, 5, 1), + "days_on_market": 10, + "photo_urls": [], + "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), + "distance_m": 150.0, + "relevance_score": 0.1, + } + + +def _tight_price_pool(n: int, base_ppm2: float = 195_000.0) -> list[dict[str, Any]]: + """n listings, distinct addresses, tightly clustered price/m² (~±2%) — + engineered to clear _compute_confidence's 'high' bar (unique_addr_count>=7 + AND iqr_pct<0.15) once assembled, exactly like the live prod repro (n=39, + confidence='high' pre-fix).""" + return [ + _make_listing( + price_per_m2=base_ppm2 + (i - n / 2) * 200, + address=f"ул. Академика Парина, {i + 1}", + ) + for i in range(n) + ] + + +def _payload_with_year() -> Any: + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="ЕКБ, ул. Академика Парина, 46/5", + area_m2=23.1, + rooms=1, + floor=5, + total_floors=9, + year_built=2010, + city_hint="Екатеринбург", + radius_m=2000, + ) + + +def _run_estimate( + *, + payload: Any, + fetch_analogs_side_effect: Any, + dkp_raw: dict[str, Any] | None = None, +) -> Any: + from app.services.estimator import estimate_quality + + db = MagicMock() + geo = _geo() + + async def _run() -> Any: + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + side_effect=fetch_analogs_side_effect, + ), + patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_e2e_prod_repro_high_confidence_low_reliability_gets_capped() -> None: + """Live prod repro shape: cohort (year_built) filter gets dropped by the + Tier-0 cascade (empty cohort-call → fallback), landing on a 39-listing, + tight-price, 39-unique-address pool — a healthy sample that + `_compute_confidence` would score 'high' on its own. Because a relaxation + WAS applied to get there, `reliability` reads 'low' — the cap must pull + `confidence` down to 'medium' (not leave it at the contradictory 'high'), + and `confidence_explanation` must LEAD with the accuracy-reduced summary.""" + pool = _tight_price_pool(39) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + # Tier 0 (with cohort filter) → empty, forces the "drop cohort" fallback. + if kwargs.get("cohort_year_min") is not None: + return [], False, "W" + # Every subsequent (no-cohort) call → the full healthy pool. + return list(pool), False, "W" + + est = _run_estimate(payload=_payload_with_year(), fetch_analogs_side_effect=_fetch_analogs_stub) + + assert est.n_analogs == 39 + assert est.reliability == "low" + assert "снят фильтр по году постройки" in est.relaxations + assert est.confidence == "medium", ( + f"confidence={est.confidence!r} must be capped to 'medium' under reliability='low' " + "(was 'high' pre-#2827 — the exact prod contradiction)" + ) + explanation = est.confidence_explanation + assert explanation is not None + assert explanation.startswith( + "Оценка построена с расширенными параметрами подбора — точность снижена." + ), f"explanation must LEAD with the accuracy-reduced summary, got: {explanation!r}" + assert "Найдено 39 аналогов" in explanation, "original detail must be preserved" + assert "Применены послабления подбора: снят фильтр по году постройки." in explanation + + +def test_e2e_unrelaxed_estimate_is_byte_identical_control() -> None: + """#3 (task spec): no relaxations, reliability=='ok' → confidence and + confidence_explanation must be UNCHANGED by #2827 — this is the common + path most estimates take, and it must not regress.""" + pool = _tight_price_pool(39) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + # No cohort filter this time (payload has no year_built) — Tier 0 is + # skipped outright (cohort_range is None), so no relaxation fires. + return list(pool), False, "W" + + from app.schemas.trade_in import TradeInEstimateInput + + payload = TradeInEstimateInput( + address="ЕКБ, ул. Академика Парина, 46/5", + area_m2=23.1, + rooms=1, + floor=5, + total_floors=9, + city_hint="Екатеринбург", + ) + est = _run_estimate(payload=payload, fetch_analogs_side_effect=_fetch_analogs_stub) + + assert est.n_analogs == 39 + assert est.relaxations == [] + assert est.reliability == "ok" + assert est.confidence == "high", "unrelaxed healthy sample must keep its real confidence" + assert est.confidence_explanation is not None + assert not est.confidence_explanation.startswith("Оценка построена с расширенными параметрами") + assert "Применены послабления подбора" not in est.confidence_explanation + assert est.confidence_explanation.startswith("Найдено 39 аналогов"), ( + "unrelaxed explanation must keep its original (pre-#2827) leading sentence, got: " + f"{est.confidence_explanation!r}" + ) diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py index 3ae13ef7..a4033a0f 100644 --- a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py +++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py @@ -1,22 +1,55 @@ -"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02). +"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority +RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product +decision, 2026-08-10). -Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings → -headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring -street in the same town swung ±66% on 1-2 different random listings. Каменск- -Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match -either, with no honest refusal surfaced. Первоуральск (0 listings) already fell -back to the (pre-existing) ДКП deals-headline fallback correctly — this gate -routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME, -already-tested path instead of trusting a 1-4-lot median as the headline. +History: + 1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1) + listings sample to a literal zero, forcing the anchor/#oblast-D-deals- + fallback/insufficient_data chain to take over — motivated by a live + Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of + 54 126 ₽/м²). + 2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE — + a thin sample always kept its own median, even when a much more reliable + deals corridor was available. That accidentally REOPENED the exact Серов + bug #oblast-E existed to close. + 3. #oblast-E priority RESTORED (2026-08-10, same day, product correction): + "никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3 + объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in + `_price_from_inputs`'s gate: + - n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected). + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor + exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) → + listings aggregate suppressed to zero, headline ceded to the + #oblast-D deals-headline-fallback chain (original #oblast-E + behaviour, restored). `PricingResult.deals_headline_due_to_thin_ + listings=True` — estimate_quality() adds relaxation label "оценка по + сделкам — мало объявлений рядом" and caps reliability at 'low'. + Listings display cards are NOT hidden (unlike original #oblast-E) — + `listings_clean` stays intact and estimate_quality() still surfaces + them as context even though they no longer drive n_analogs/median. + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor → + #oblast-F: keep the real thin median (never refuse outright). + Real refusal ("недостаточно данных") now happens ONLY at genuine n=0 + (no listings AND no usable anchor/deals) — the never-block requirement + with an honest, priority-ordered source selection. + +`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F +relaxation cascade (room-adjacency / freshness / novostroyki / radius, see +estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in +Layer 1) ever runs the 3-way gate above — `listings` here is whatever that +cascade could find. Two layers: 1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) — - boundary behaviour of the gate itself. + boundary behaviour of the gate itself: the 3-way rule, low-reliability + wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_ + thin_listings bookkeeping. 2. `estimate_quality` integration tests — proves the money-path invariants - that matter to a caller: literal 0 never leaks as a "confident" price, - display `analogs` cards never outnumber what `n_analogs` claims, and the - explanation text describes what actually happened (not a stock "аналогов - не найдено" when some WERE found, just too few). + that matter to a caller: thin+usable-deals routes to the deals corridor + (Серов repro), thin+no-deals keeps its own median, display `analogs` + cards are shown either way, and the #oblast-F room-adjacency relaxation + (studio↔1-комн) actually grows a thin sample and is reported via + `AggregatedEstimate.relaxations` / `reliability`. """ from __future__ import annotations @@ -103,15 +136,14 @@ def test_threshold_is_five_not_lower() -> None: assert HEADLINE_LISTINGS_MIN_N == 5 -def test_four_listings_below_threshold_suppressed_no_fallback() -> None: - """n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state, - NOT the naive median of 4 listings.""" +def test_four_listings_below_threshold_kept_not_suppressed() -> None: + """#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision + 2026-08-10 — never zero out a thin-but-real sample), just flagged low.""" pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0])) - assert pr.median_ppm2 == 0.0 - assert pr.median_price == 0 - assert pr.n_analogs == 0 - assert pr.range_low == 0 - assert pr.range_high == 0 + assert pr.median_ppm2 == 215_000.0 + assert pr.n_analogs == 4 + assert pr.median_price == round(215_000.0 * 45.0) + assert pr.confidence == "low" def test_five_listings_at_threshold_not_suppressed() -> None: @@ -122,18 +154,21 @@ def test_five_listings_at_threshold_not_suppressed() -> None: assert pr.median_price == round(210_000.0 * 45.0) -def test_one_listing_below_threshold_suppressed() -> None: - """n=1 — the sharpest form of the Серов bug (a single random lot deciding - the whole headline) — must be suppressed exactly like n=4.""" +def test_one_listing_below_threshold_kept_not_suppressed() -> None: + """#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot) + median rather than being zeroed; confidence stays 'low'.""" pr = _call(listings=_lots([200_000.0])) - assert pr.median_ppm2 == 0.0 - assert pr.n_analogs == 0 + assert pr.median_ppm2 == 200_000.0 + assert pr.n_analogs == 1 + assert pr.confidence == "low" def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: - """n=3 listings (thin) + a usable ДКП corridor → headline comes from the - deal corridor median, NOT the 3-listing median (live Серов repro: 3 - listings gave 42 391 vs the honest ДКП-based ~54 126).""" + """#oblast-E priority RESTORED (2026-08-10 product correction): a thin + (n=3) listings sample must NOT outrank a usable ДКП deals corridor — this + is the exact live Серов repro #oblast-E exists for (3 noisy listings gave + 42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline + comes from the deal corridor median, NOT the 3-listing median.""" dkp_raw = { "count": 54, "low_ppm2": 44_000, @@ -151,12 +186,23 @@ def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: ) assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline" assert pr.confidence == "low" + assert pr.deals_headline_due_to_thin_listings is True + assert pr.listings_clean, "listings_clean must stay intact — display cards still show them" + # #4: explanation must not falsely claim "рядом нет объявлений" (some WERE + # found, just ceded priority to the more reliable deals corridor) and must + # NOT also carry the separate "Оценка построена по N аналогам" thin-kept + # wording (that phrasing is reserved for the no-usable-corridor branch). + assert pr.explanation is not None + assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "сделкам росреестра" in pr.explanation.lower() + assert "оценка построена по 3" not in pr.explanation.lower() -def test_thin_sample_with_insufficient_deals_stays_zero() -> None: +def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None: """n=3 listings (thin) + a ДКП corridor that is ITSELF too thin - (< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero, - not a fabricated number from either side.""" + (< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so + #oblast-F's never-block rule applies: the real listings median is kept + rather than refusing (neither source alone would justify a hard zero).""" dkp_raw = { "count": 1, "low_ppm2": 40_000, @@ -165,49 +211,51 @@ def test_thin_sample_with_insufficient_deals_stays_zero() -> None: "period_months": 12, } pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw) - assert pr.median_ppm2 == 0.0 - assert pr.median_price == 0 - assert pr.n_analogs == 0 + assert pr.median_ppm2 == 42_391.0 + assert pr.n_analogs == 3 + assert pr.deals_headline_due_to_thin_listings is False -def test_thin_sample_explanation_is_honest_about_count() -> None: - """The explanation for a thin-but-nonzero sample must say HOW MANY listings - were found (not the generic 'ничего не найдено' text used for a genuine - zero-listing case) — #4 in the task: explanation must match reality.""" +def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None: + """#4 (task spec): the explanation for a thin-but-real sample must read as + "small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для + оценки по рынку" copy, and NOT the generic zero-analogs text.""" pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2 assert pr.explanation is not None assert "2" in pr.explanation - assert "недостаточно" in pr.explanation.lower() - # Must NOT reuse the "nothing found at all" copy — 2 listings WERE found. + assert "выборка мала" in pr.explanation.lower() + assert "точность снижена" in pr.explanation.lower() + assert "минимум для оценки по рынку" not in pr.explanation.lower() assert "не найдено аналогов" not in pr.explanation.lower() -def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None: - """#4: once the ДКП fallback fires for a thin (not zero) sample, the - explanation must not falsely claim 'рядом нет объявлений' — some WERE - found, just not enough to trust.""" +def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None: + """Control: the #oblast-D deals-headline-fallback path is UNCHANGED for + GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin + case, not the true-zero case, which still needs a fallback source.""" dkp_raw = { - "count": 20, - "low_ppm2": 40_000, - "median_ppm2": 60_000, - "high_ppm2": 80_000, + "count": 54, + "low_ppm2": 44_000, + "median_ppm2": 65_957, + "high_ppm2": 89_000, "period_months": 12, } - pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw) + pr = _call(listings=[], dkp_raw=dkp_raw) + assert pr.median_ppm2 == 65_957.0 + assert pr.n_analogs == 0 + assert pr.confidence == "low" assert pr.explanation is not None - assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "рядом нет актуальных объявлений" in pr.explanation.lower() assert "сделкам росреестра" in pr.explanation.lower() -def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None: - """Regression guard: the gate must suppress the AGGREGATE (median/n_analogs) - without clearing `listings_clean` itself — the same-building anchor's own - ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell - "genuinely zero nearby listings" from "some nearby, just too few to trust - as headline", and conflating the two was caught regressing - test_estimator_split_corridor_1871.py during this change.""" +def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None: + """listings_clean stays intact (unchanged invariant — same-building anchor's + ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is + the REAL count (not zeroed) while listings_headline_thin_n still marks the + sample as thin for the low-reliability note upstream.""" pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0])) - assert pr.n_analogs == 0 + assert pr.n_analogs == 3 assert len(pr.listings_clean) == 3 assert pr.listings_headline_thin_n == 3 @@ -219,6 +267,42 @@ def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None: assert pr.listings_headline_thin_n == 0 +def test_repair_coefficient_now_applies_to_thin_sample() -> None: + """#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a + thin sample because the headline was already zeroed (applying it would be a + no-op). Now that the real median is kept, the coefficient must apply.""" + pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin + pr = _price_from_inputs( + listings=_lots([200_000.0, 210_000.0]), + area_m2=45.0, + rooms=2, + repair_state="excellent", + floor=5, + total_floors=9, + target_year=None, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=[], + anchor_tier_fetched=None, + dkp_raw=None, + imv_anchor=None, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=lambda _appm2: (None, None), + quarter_index_lookup=lambda q: None, + quarter_indexes_lookup=lambda qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + assert ( + pr.median_price != pr_no_repair.median_price + ), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample" + + # ───────────────────────────────────────────────────────────────────────────── # Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path) # ───────────────────────────────────────────────────────────────────────────── @@ -261,24 +345,31 @@ def _serov_payload() -> Any: def _run_estimate( *, - analogs: list[dict[str, Any]], + analogs: list[dict[str, Any]] | None = None, dkp_raw: dict[str, Any] | None, + fetch_analogs_side_effect: Any = None, + payload: Any = None, + geo: GeocodeResult | None = None, ) -> Any: from app.services.estimator import estimate_quality db = MagicMock() - payload = _serov_payload() + payload = payload or _serov_payload() + geo = geo or _geo() + + fetch_analogs_kwargs: dict[str, Any] = ( + {"side_effect": fetch_analogs_side_effect} + if fetch_analogs_side_effect is not None + else {"return_value": (list(analogs or []), False, "W")} + ) async def _run() -> Any: with ( - patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), - patch( - "app.services.estimator._fetch_analogs", - return_value=(list(analogs), False, "W"), - ), + patch("app.services.estimator._fetch_analogs", **fetch_analogs_kwargs), patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), patch("app.services.estimator._fetch_deals", return_value=[]), patch( @@ -301,40 +392,47 @@ def _run_estimate( return anyio.run(_run) -def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None: - """Каменск-Уральский-style repro: thin listings, no usable ДКП corridor — - median_price_rub must be 0 AND insufficient_data must be True TOGETHER - (the AggregatedEstimate.insufficient_data computed_field invariant that - stops a literal 0 ₽ reaching the user as a confident number).""" +def test_e2e_thin_sample_no_relaxation_help_keeps_real_median() -> None: + """#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always + returns the SAME 2 listings regardless of relaxation params (none of them + help) — median_price_rub must be the REAL non-zero 2-listing median, + insufficient_data False, n_analogs=2, confidence='low', reliability + 'very_low' (n<3), relaxations empty (nothing actually helped).""" analogs = [ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), ] est = _run_estimate(analogs=analogs, dkp_raw=None) - assert est.median_price_rub == 0 - assert est.insufficient_data is True - assert est.n_analogs == 0 + assert est.median_price_rub == round(205_000.0 * 45.0) + assert est.insufficient_data is False + assert est.n_analogs == 2 assert est.confidence == "low" + assert est.relaxations == [] + assert est.reliability == "very_low" -def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None: - """The 2 thin listings must NOT be surfaced as `analogs` display cards while - n_analogs reports 0 — that would be the same dishonesty (confident-looking - UI) this whole gate exists to remove.""" +def test_e2e_thin_sample_display_cards_match_n_analogs() -> None: + """#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin + sample — they must match n_analogs exactly (both = 2), never hidden.""" analogs = [ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), ] est = _run_estimate(analogs=analogs, dkp_raw=None) - assert est.n_analogs == 0 - assert est.analogs == [] + assert est.n_analogs == 2 + assert len(est.analogs) == 2 def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: - """Live Серов repro (n=3 scraped listings, wide ДКП corridor available): - headline must come from the deal corridor, not the noisy 3-listing median, - and the estimate must be honestly non-'insufficient' (a real number, low - confidence, deals-sourced).""" + """Live Серов repro (n=3 scraped listings, wide ДКП corridor available) — + #oblast-E priority RESTORED: headline must come from the deal corridor, + not the noisy 3-listing median. Also proves the #4 task-spec requirements + layered on top of the restored priority: the estimate is honestly non- + 'insufficient' (a real number, low confidence), reliability is capped at + 'low' (not 'very_low' — a 54-deal corridor is real signal), the + relaxation label names the source switch, AND the 3 thin listings are + still shown as display cards (not discarded) even though they no longer + drive n_analogs/median.""" analogs = [ _make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"), _make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"), @@ -354,12 +452,15 @@ def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: assert est.confidence == "low" assert est.confidence_explanation is not None assert "сделкам росреестра" in est.confidence_explanation.lower() + assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'" + assert "оценка по сделкам — мало объявлений рядом" in est.relaxations + assert len(est.analogs) == 3, "thin listings must still surface as display cards" def test_e2e_sufficient_five_analogs_unaffected_control() -> None: """Control (mirrors the Екатеринбург prod check in the PR): a sample that clears the threshold is priced exactly as before — headline is the real - listings median, all 5 analogs counted.""" + listings median, all 5 analogs counted, no relaxations needed.""" analogs = [ _make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"), @@ -371,3 +472,78 @@ def test_e2e_sufficient_five_analogs_unaffected_control() -> None: assert est.median_price_per_m2 == 210_000 assert est.n_analogs == 5 assert est.insufficient_data is False + assert est.relaxations == [] + assert est.reliability == "low" # n=5 falls in the 3..7 bucket + + +def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None: + """#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin + sample (studio-adjacent building, live prod repro Академика Парина 46/5) → + cascade retries with rooms IN (0,1) and finds a trustworthy sample there. + Asserts: studios pulled in, `relaxations` names it, real non-zero median, + reliability downgraded to 'low' (thin base sample).""" + from app.schemas.trade_in import TradeInEstimateInput + + exact_rooms1 = [ + _make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0), + _make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0), + ] + studio_pool = [ + *exact_rooms1, + _make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0), + _make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0), + _make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0), + ] + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1: + return list(studio_pool), False, "W" + return list(exact_rooms1), False, "W" + + geo = GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5", + provider="nominatim", + ) + payload = TradeInEstimateInput( + address="ЕКБ, ул. Парина, 46/5", + area_m2=23.1, + rooms=1, + ) + + est = _run_estimate( + dkp_raw=None, + fetch_analogs_side_effect=_fetch_analogs_stub, + payload=payload, + geo=geo, + ) + + assert "учтены студии" in est.relaxations + assert est.median_price_rub > 0 + assert est.reliability == "low" + assert est.n_analogs == 5 + + +def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None: + """#oblast-F step (d) contract: when the user explicitly picked radius_m + (#2044), the cascade must NOT auto-expand past it — mirrors the existing + radius-fallback contract above (no auto-expansion beyond user's choice).""" + from app.schemas.trade_in import TradeInEstimateInput + + thin = [ + _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), + _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), + ] + payload = TradeInEstimateInput( + address="Серов, ул. Ленина, 5", + area_m2=45.0, + rooms=2, + floor=5, + total_floors=9, + city_hint="Серов", + radius_m=1500, + ) + est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload) + assert not any("радиус расширен" in r for r in est.relaxations) + assert est.search_radius_m == 1500 diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py new file mode 100644 index 00000000..d92a9ab9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py @@ -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 "") diff --git a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py new file mode 100644 index 00000000..5b824132 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_geocoder_city_substitution.py b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py new file mode 100644 index 00000000..b6626d94 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py @@ -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" diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index 63db7822..4355e323 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -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")] diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py index c36aa891..98f057aa 100644 --- a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py +++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py @@ -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() diff --git a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py new file mode 100644 index 00000000..b414aefc --- /dev/null +++ b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py @@ -0,0 +1,267 @@ +"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную. + +Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон +(`only_status`, дефолт 'pending'). Возможность повтора существовала и была +задокументирована в admin-API, но за всю историю (41 прогон, 26.06–11.08.2026) +её не передал никто: все прогоны ушли с 'pending'. Дома, упавшие по причине, +которой больше нет (503/500 от браузерного сайдкара, #2698), лежали в +'transient_error' без единого шанса вернуться — 1390 штук на 12.08.2026. + +Что проверяем — по существу, а не по настройке: + * дом в 'transient_error' ПОПАДАЕТ в пакет прогона с дефолтными параметрами + (тот же вызов, что делает расписание) — на старом коде он не попадает; + * pending при этом из пакета НЕ вытесняется; + * дом, исчерпавший лимит попыток, в автоматический повтор не берётся, а + ЯВНЫЙ only_status='transient_error' (ручной запрос оператора) берёт и его; + * дом без пригодных параметров помечается no_params БЕЗ запроса к площадке. + +Живой Postgres обязателен: проверяется поведение SQL-выборки, а не то, какие +строки вернул мок. Без БД тест самоскипается (в CI БД есть — ci-tradein.yml +поднимает Postgres и накатывает схему). +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import house_imv_backfill as hib + +# Фикстуры живут в собственном диапазоне id (ср. tests/test_house_dedup_merge.py). +_H_STUCK = 900601 # transient_error, попыток 0 → обязан вернуться в очередь +_H_EXHAUSTED = 900602 # transient_error, попытки исчерпаны → в автоповтор не берём +_H_PENDING = 900603 # pending → не должен быть вытеснен retry-очередью +_H_NO_PARAMS = 900604 # pending без объявлений с rooms+area → no_params без запроса +_IDS = (_H_STUCK, _H_EXHAUSTED, _H_PENDING, _H_NO_PARAMS) + + +def _live_session() -> Any | None: + """Session к достижимому не-заглушечному Postgres, иначе None.""" + try: + from sqlalchemy import create_engine, text + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _seed(db: Any) -> None: + from sqlalchemy import text as _t + + # url/source/ext_house_id — NOT NULL в houses (002_core_tables). + db.execute( + _t( + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, " + " imv_status, imv_transient_attempts, last_imv_attempt_at) VALUES " + "(:s, 'avito', 'EXT-2674-S', 'u', 'тестдом 2674, 1', 56.84, 60.60, " + " 'transient_error', 0, NOW() - INTERVAL '10 days')," + "(:x, 'avito', 'EXT-2674-X', 'u', 'тестдом 2674, 2', 56.84, 60.60, " + " 'transient_error', :cap, NOW() - INTERVAL '10 days')," + "(:p, 'avito', 'EXT-2674-P', 'u', 'тестдом 2674, 3', 56.84, 60.60, " + " 'pending', 0, NULL)," + "(:n, 'avito', 'EXT-2674-N', 'u', 'тестдом 2674, 4', 56.84, 60.60, " + " 'pending', 0, NULL)" + ), + { + "s": _H_STUCK, + "x": _H_EXHAUSTED, + "p": _H_PENDING, + "n": _H_NO_PARAMS, + # Литерал, а не hib._MAX_TRANSIENT_ATTEMPTS: подготовка теста обязана + # работать и на СТАРОМ коде, иначе красный прогон получится про + # отсутствующий атрибут, а не про застрявший дом. Синхронность + # литерала и константы стережёт test_transient_attempts_counter_*. + "cap": 3, + }, + ) + # Объявления с rooms+area+house_type — у всех, КРОМЕ _H_NO_PARAMS. + # source_url / dedup_hash / price_rub — NOT NULL, dedup_hash UNIQUE. + db.execute( + _t( + "INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, " + " house_id_fk, rooms, area_m2, floor, total_floors, house_type) " + "VALUES " + "(:l1, 'avito', 'http://t/2674/1', 'L-2674-1', 'dh-2674-1', 5000000, :s, " + " 2, 52.0, 3, 9, 'brick')," + "(:l2, 'avito', 'http://t/2674/2', 'L-2674-2', 'dh-2674-2', 5000000, :x, " + " 2, 52.0, 3, 9, 'brick')," + "(:l3, 'avito', 'http://t/2674/3', 'L-2674-3', 'dh-2674-3', 5000000, :p, " + " 2, 52.0, 3, 9, 'brick')" + ), + { + "l1": 910601, + "l2": 910602, + "l3": 910603, + "s": _H_STUCK, + "x": _H_EXHAUSTED, + "p": _H_PENDING, + }, + ) + db.commit() + + +def _cleanup(db: Any) -> None: + from sqlalchemy import text as _t + + db.rollback() + db.execute(_t("DELETE FROM listings WHERE id IN (910601, 910602, 910603)")) + db.execute(_t("DELETE FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), {"ids": list(_IDS)}) + db.commit() + db.close() + + +async def _run(db: Any, **kwargs: Any) -> Any: + """Прогнать backfill с замоканным вызовом площадки. + + Мокаем ровно сетевую границу: evaluate_via_imv поднимает «адрес не найден», + поэтому дом уходит в not_found и путь сохранения (три таблицы) не нужен — + проверяем именно ВЫБОРКУ. Флаг браузерного фетчера гасим, чтобы не поднимать + сайдкар. + """ + err = hib.IMVAddressNotFoundError("address not found (test)") + with ( + patch.object(hib.settings, "avito_imv_use_browser_fetcher", False), + patch.object(hib, "evaluate_via_imv", AsyncMock(side_effect=err)), + ): + return await hib.backfill_house_imv(db, request_delay_sec=0.0, **kwargs) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_stuck_transient_house_returns_to_the_queue_by_itself() -> None: + """Прогон с дефолтными параметрами (как у расписания) забирает застрявший дом. + + На старом коде выборка — `WHERE imv_status = :status` с единственным + значением 'pending', поэтому дом остаётся в 'transient_error' навсегда. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + + # Ровно то, что вызывает расписание: batch_size из default_params, статус по умолчанию. + result = await _run(db, batch_size=50) + + picked = { + r.id: r.imv_status + for r in db.execute( + _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), + {"ids": list(_IDS)}, + ) + } + assert picked[_H_STUCK] != "transient_error", ( + "дом с временной ошибкой и непотраченным лимитом обязан попасть в пакет " + f"обычного прогона; фактически статус {picked[_H_STUCK]!r} " + f"(в пакет прогона попало домов: {result.checked})" + ) + # pending не вытеснен повтором. + assert picked[_H_PENDING] != "pending", "pending-дом обязан остаться в том же пакете" + # Исчерпавший лимит в автоповтор не берётся — иначе вечный цикл. + assert picked[_H_EXHAUSTED] == "transient_error" + assert result.retried == 1 + # Дом без объявлений с rooms+area помечен ДО пакета, слот не потрачен. + assert picked[_H_NO_PARAMS] == "no_params" + assert result.premarked == 1 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_explicit_only_status_still_takes_exhausted_houses() -> None: + """Явный only_status — ручной запрос оператора: лимит попыток его не режет.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + + result = await _run(db, batch_size=50, only_status="transient_error") + + statuses = { + r.id: r.imv_status + for r in db.execute( + _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), + {"ids": list(_IDS)}, + ) + } + assert ( + statuses[_H_EXHAUSTED] != "transient_error" + ), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит" + # Автоповтора поверх явного запроса нет: pending не тронут. + assert statuses[_H_PENDING] == "pending" + assert result.retried == 0 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_transient_attempts_counter_only_counts_transient() -> None: + """Счётчик — это «сколько раз подряд упал по временной причине», и он обнуляем. + + Без верхней границы у повтора не было бы выхода; без обнуления успехом дом, + однажды поймавший 3 сетевых сбоя, навсегда терял бы право на повтор. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + assert hib._MAX_TRANSIENT_ATTEMPTS == 3, "литерал 'cap' в _seed рассинхронизирован" + + def _attempts() -> int: + return int( + db.execute( + _t("SELECT imv_transient_attempts FROM houses WHERE id = :i"), + {"i": _H_STUCK}, + ).scalar() + ) + + hib._mark_status(db, _H_STUCK, "transient_error", "boom") + assert _attempts() == 1 + hib._mark_status(db, _H_STUCK, "not_found", "адрес не найден") + assert _attempts() == 1, "нетранзиентные исходы счётчик не двигают" + db.execute( + _t("UPDATE houses SET imv_status='ok', imv_transient_attempts=0 WHERE id = :i"), + {"i": _H_STUCK}, + ) + db.commit() + assert _attempts() == 0 + finally: + _cleanup(db) + + +def test_retry_queue_is_not_bound_to_only_status() -> None: + """Статика (бежит и без БД): retry-выборка не зависит от параметра only_status. + + Ровно этот дефект и застрял на проде: единственный путь к 'transient_error' + шёл через значение, которое никто не передавал. + """ + retry_sql = str(hib._RETRY_QUEUE_SQL.text) + assert "imv_status = 'transient_error'" in retry_sql + assert ":status" not in retry_sql, "retry-очередь обязана быть независимой от only_status" + assert "imv_transient_attempts < :max_attempts" in retry_sql, "у повтора должен быть выход" + # psycopg v3: только CAST(:x AS type), никогда :x::type. + for sql in (retry_sql, str(hib._QUEUE_SQL.text), str(hib._PREMARK_UNUSABLE_SQL.text)): + assert "::" not in sql diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py index f0a0c371..e0066101 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py index 80bca572..b16be85b 100644 --- a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -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 соседнего города обойдён" diff --git a/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py new file mode 100644 index 00000000..6a8ce9b3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index da5722ec..7d6d21ed 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index ee2fface..813d78a2 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -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") diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py index c1ea1249..003883e1 100644 --- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py +++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py @@ -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 ─────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_version_api.py b/tradein-mvp/backend/tests/test_version_api.py new file mode 100644 index 00000000..767e6b87 --- /dev/null +++ b/tradein-mvp/backend/tests/test_version_api.py @@ -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) diff --git a/tradein-mvp/frontend/Dockerfile b/tradein-mvp/frontend/Dockerfile index 9abbd35a..5def62b7 100644 --- a/tradein-mvp/frontend/Dockerfile +++ b/tradein-mvp/frontend/Dockerfile @@ -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 diff --git a/tradein-mvp/frontend/src/app/v2/layout.tsx b/tradein-mvp/frontend/src/app/v2/layout.tsx index bf603195..c7d0543a 100644 --- a/tradein-mvp/frontend/src/app/v2/layout.tsx +++ b/tradein-mvp/frontend/src/app/v2/layout.tsx @@ -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. */} + {/* 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. */} +
); } diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 49cc2846..f0164c23 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -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 = ( - +
+ {showLowConfidenceBanner && ( + + )} + +
); } else if (estimate && insufficient) { middleContent = ; diff --git a/tradein-mvp/frontend/src/app/versions/page.tsx b/tradein-mvp/frontend/src/app/versions/page.tsx new file mode 100644 index 00000000..7c82a2ab --- /dev/null +++ b/tradein-mvp/frontend/src/app/versions/page.tsx @@ -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 `` 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 ( +
+

+ ← К оценке +

+ +

+ История версий +

+

+ Текущая версия:{" "} + {APP_VERSION === "dev" ? "dev-сборка" : `v${APP_VERSION}`} +

+ + {entries.length === 0 ? ( +

+ История изменений пока не опубликована. +

+ ) : ( +
+ {entries.map((entry) => { + const isCurrent = entry.version === APP_VERSION; + return ( +
+
+

+ v{entry.version} + {isCurrent && ( + + текущая + + )} +

+
{formatRuDate(entry.date)}
+
+
+ {entry.sections.length === 0 ? ( +

+ Без описания изменений. +

+ ) : ( + entry.sections.map((section) => ( +
+

+ {section.title} +

+
    + {section.items.map((item, i) => ( +
  • + {item} +
  • + ))} +
+
+ )) + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index b55e7e62..1b89abe9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -185,6 +185,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const [enrichRepairState, setEnrichRepairState] = useState(""); // Фото первого аналога с картинкой — вместо пустого серого плейсхолдера. 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}` : "Нет фото"} diff --git a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx index b78b0f4a..632e3863 100644 --- a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx @@ -118,7 +118,12 @@ export function ListingsCard({ estimate, estimateId }: Props) {
Объявлений по аналогам
- {estimate.n_analogs} + {/* fix (v1 stale-tail) — n_analogs=0 больше не значит "объявлений + нет": бэкенд может посчитать headline по сделкам ДКП, но + всё равно отдать тонкую выборку объявлений в analogs (тот же + массив рендерит таблица ниже, см. `lots`). Показываем реальную + отображаемую популяцию, а не сырой n_analogs, когда он 0. */} + {estimate.n_analogs > 0 ? estimate.n_analogs : lots.length} шт
из {estimate.sources_used.length} источников
@@ -271,8 +276,21 @@ export function ListingsCard({ estimate, estimateId }: Props) {
- Показано {lots.length} из {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 ? ( + <> + Показано {lots.length} из{" "} + {estimate.n_analogs} объявлений · отсортировано по расстоянию + + ) : ( + <> + Показано {lots.length} объявлений · + оценка построена по зарегистрированным сделкам · отсортировано по расстоянию + + )}
diff --git a/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx new file mode 100644 index 00000000..426e46df --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx @@ -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 ``. +// +// 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( + <> + +
+ {formatVersionLabel()} + История версий +
+ , + document.body, + ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx index 9dab743a..3b04d0b7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx @@ -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 `` +// (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) { > МЕРА - - {version} -
); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx new file mode 100644 index 00000000..a1275bf1 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx @@ -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 ( +
+
+ {title} +
+ {/* 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). */} +
+ {/* 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(", ")}. + + )} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 9d6b75a0..3a1fa2d2 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -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", diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx index 3c836620..35cafb7d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx @@ -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). */} Объявлений: {analogPoints.length} - {" из "} - {estimate.n_analogs} + {estimate.n_analogs > 0 || analogPoints.length === 0 ? ( + <> + {" из "} + {estimate.n_analogs} + + ) : ( + " · по сделкам ДКП" + )} {dealPoints.length > 0 && ( <> {" · "}сделок: {dealPoints.length} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index 9bf5a19d..70674679 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -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 +// `` (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 ( -
-
- {version} -
{/* Nav tabs */} @@ -412,7 +417,13 @@ export default function TopNav({ aria-disabled="true" title="Раздел «Профиль» скоро появится" > -
- -