From 17d558b18cfffbbabd8683db9658b8766aedc23f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 20:44:11 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/yandex):=20=D1=81=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B7=D0=BD=D0=B0=D0=BA=20?= =?UTF-8?q?=C2=AB=D0=BF=D0=B0=D0=BD=D0=BE=D1=80=D0=B0=D0=BC=D0=B0=C2=BB=20?= =?UTF-8?q?=E2=80=94=20=D0=B5=D0=B3=D0=BE=20=D0=BD=D0=B5=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D0=B5=20(#2?= =?UTF-8?q?851)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/services/estimator.py | 77 ++------ .../services/matching/conflict_resolution.py | 6 +- .../data/sql/260_houses_drop_has_panorama.sql | 179 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + .../matching/test_conflict_resolution.py | 15 +- .../tests/test_dead_code_sweep_2674.py | 158 ++++------------ .../test_estimator_yandex_integration.py | 24 +-- .../tests/test_yandex_history_area_filter.py | 11 +- .../backend/tests/test_yandex_valuation.py | 17 +- .../tests/test_yandex_valuation_save.py | 4 +- .../scraper_kit/providers/yandex/valuation.py | 7 +- 11 files changed, 280 insertions(+), 219 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 3aacd92d..893da182 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import ( estimate_via_cian_valuation, ) from scraper_kit.providers.yandex.valuation import ( - ValuationHouseMeta, YandexValuationResult, YandexValuationScraper, ) @@ -987,13 +986,13 @@ def _save_yandex_history_items( Batch semantics: single try/except; on any failure the batch rolls back. - #2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по - пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но - без единого объявления в истории, до записи панорамы не доходила — на проде это - 1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена - переноса: match_or_create_house теперь вызывается и для таких страниц (может - СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на - остальных 90% — новых сущностей класс не появляется, появляется недостающая доля. + #2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше + возврат стоял первым, и страница, отрисованная идеально, но без единого объявления + в истории, дома не заводила — на проде это 1519 оценок против 1360 домов с + историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака + «панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам + match_or_create_house, то есть дом и строка house_sources для таких страниц. Это + тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%. """ # Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16] # — stable across re-runs, distinguishes pages for different addresses. @@ -1030,9 +1029,6 @@ def _save_yandex_history_items( result.address, ) - # Наблюдение о доме не зависит от того, есть ли на странице история объявлений. - _save_yandex_house_panorama(db, house_id, result.house) - if not result.history_items: return 0 @@ -1117,58 +1113,13 @@ def _save_yandex_history_items( return 0 -# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в -# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но -# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь — -# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету. -# -# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text -# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн, -# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет». -# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность -# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL. -def _save_yandex_house_panorama( - db: Session, - house_id: int | None, - meta: ValuationHouseMeta, -) -> None: - """Пишет houses.has_panorama по разобранной мете yandex_valuation. - - No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт - выше). Best-effort: ошибка логируется и глотается — оценка не должна падать из-за - справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает - только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история. - """ - if house_id is None: - return - if meta.year_built is None and meta.total_floors is None: - logger.debug( - "yandex_valuation: has_panorama не пишем для house_id=%s — " - "страница не подтверждена (нет ни года, ни этажности)", - house_id, - ) - return - try: - with db.begin_nested(): - db.execute( - text( - """ - UPDATE houses - SET has_panorama = CAST(:panorama AS boolean) - WHERE id = CAST(:hid AS bigint) - AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean) - """ - ), - {"hid": house_id, "panorama": meta.has_panorama}, - ) - db.commit() - except Exception as e: - logger.warning( - "yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s", - house_id, - e, - ) - db.rollback() +# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses +# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог +# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за +# 24.05–12.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200, +# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах, +# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова +# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего. # ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── diff --git a/tradein-mvp/backend/app/services/matching/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py index 89d71531..2e56f1eb 100644 --- a/tradein-mvp/backend/app/services/matching/conflict_resolution.py +++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py @@ -71,7 +71,11 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = { "commission_year": ["cian_serp", "yandex_realty_nb"], "commission_month": ["yandex_realty_nb"], # raw RU month name "developer_name": ["cian", "yandex_realty_nb"], - "has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag + # #2674 (хвост): запись про «панораму» удалена вместе с колонкой (мигр. 259). + # В отличие от ceiling_height ниже, правило было ИСПОЛНИМО — колонка существовала, + # источник её писал. Разрешать было нечего: yandex_valuation отдавал False всегда, + # потому что слова «панорам» на странице оценки нет (0 true из 1536 страниц на + # проде; живая проверка боевым трактом 13.08.2026 не нашла его и в сыром HTML). "yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории # Yandex Valuation enrichment (existing house attrs) "has_lift": ["cian_bti", "cian_detail", "yandex_valuation"], diff --git a/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql new file mode 100644 index 00000000..58fcaecc --- /dev/null +++ b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql @@ -0,0 +1,179 @@ +-- 259_houses_drop_has_panorama.sql +-- Issue #2674 (хвост) — снос houses.has_panorama: признака НЕТ в предметной области. +-- +-- Dependencies: 031_houses_alter_yandex.sql (завела колонку), +-- 154_market_contract_views.sql (внесла её в публичный контракт +-- market.v_houses), 155_reader_grants_to_contract_views.sql (грант +-- gendesign_reader на этот view). +-- Apply after: 258_houses_imv_transient_attempts.sql +-- Deploy order: код УЖЕ впереди схемы — писатель (_save_yandex_house_panorama), +-- парсер (ValuationHouseMeta.has_panorama) и правило разрешения конфликтов +-- (HOUSE_FIELD_PRIORITY) сняты тем же PR, что несёт этот файл. Обратный порядок +-- (снести колонку, оставить писателя) давал бы падающий UPDATE на каждой оценке +-- yandex_valuation — молча проглоченный, но с WARNING в логах. +-- +-- ── ЧТО ЗА НОЛЬ И ПОЧЕМУ ЭТО НЕ ДЕФЕКТ ────────────────────────────────────── +-- Колонка заполнялась `"Панорама" in body_text` по тексту страницы оценки Яндекса. +-- external_valuations (source='yandex_valuation', raw_payload->'house'), 24.05–12.08.2026: +-- страниц ............................................................. 1536 +-- has_panorama = true .................................................... 0 +-- has_panorama = false ................................................ 1536 +-- ключ отсутствует ....................................................... 0 +-- houses: 9468 строк, has_panorama непустых 12, из них true 0. +-- +-- Это НЕ «метка переехала» и НЕ «путь записи оборван». Живая проверка боевым трактом +-- 13.08.2026 (curl_cffi impersonate=chrome120 + прод-прокси, RealScraperConfig — тот же +-- клиент, что у estimator.py; только чтение) взяла три адреса Екатеринбурга, все HTTP 200: +-- Советская 51 ...... HTML 1 191 929 б — мета разобралась: 1974 г., 9 эт., панель, +-- 2,50 м потолки, 46 объектов +-- Парина 46/5 ....... HTML 1 185 458 б — 2020 г., 18 эт. +-- Сурикова 47 ....... 1977 г., 5 эт., кирпич, 184 объекта +-- Вхождений «анорам» (без учёта регистра) в ПОЛНОМ HTML: 0, 0, 0. Равно как panorama / +-- 3D-тур / Виртуальн / Street — 0. Переехать в атрибут, data-*, JSON-стейт или иную +-- вёрстку метка не могла: её нет в документе целиком. Словарь удобств дома на странице: +-- «Дом 1974 года · 9 этажей · Панельное здание · 2,50 м потолки · Газ · Лифт · +-- Мусоропровод», причём с ЯВНЫМИ отрицаниями («Лифт отсутствует», «Мусоропровода нет») — +-- будь панорама признаком дома, она печаталась бы в этом ряду и в отрицательной форме. +-- +-- Ноль был механически гарантирован самим кодом и о предметной области не говорил +-- ничего, кроме одного: измерять нечего. Третий вид нуля — НЕПРИМЕНИМО, лечится +-- удалением, а не починкой разбора. +-- +-- ОГОВОРКА ЧЕСТНОСТИ: сырой HTML прошлых сборов не хранится (raw_payload держит только +-- body_len/items_count), поэтому «метка была и исчезла в мае» доказательно не +-- опровергается. Но и положительных за всё окно 1536 страниц ноль — в измеренной +-- истории её тоже не было. +-- +-- ── ГЛАВНАЯ ЦЕНА: ЛОМАЕМ ПУБЛИЧНЫЙ КОНТРАКТ ──────────────────────────────── +-- has_panorama входит в market.v_houses (154), где сказано прямым текстом: «adding a +-- column later is backward compatible, renaming/removing one is not». Это осознанное +-- ломающее изменение контракта, а не недосмотр. Основание — консьюмер колонку не +-- читает: `git grep has_panorama` вне tradein-mvp пуст (в т.ч. +-- backend/app/services/etl/newbuilding_crossload.py, единственный живой читатель +-- контракта, #976/#2130). Держать в публичном обещании поле, которое всегда false и +-- никогда не станет ничем другим, — обещать данные, которых не существует. +-- +-- CREATE OR REPLACE VIEW удалить колонку не умеет, поэтому view пересоздаётся: +-- DROP VIEW → DROP COLUMN → CREATE VIEW. Порядок обязателен ещё и потому, что +-- DROP COLUMN без CASCADE упрётся в зависимость view (проверено на проде: единственный +-- зависимый объект — market.v_houses). CASCADE НЕ используем — он снёс бы и то, что +-- появится позже, без единого слова в логе. +-- +-- ГРАНТЫ ТЕРЯЮТСЯ ПРИ DROP VIEW (это уже кусало: C3, FDW-гранты после DROP ... CASCADE). +-- На проде на market.v_houses висит GRANT SELECT для gendesign_reader (155) — он +-- восстанавливается ниже явно, тем же стейтментом, что и в 155. Без этой строки +-- внешний ETL получил бы permission denied на следующем же прогоне. +-- +-- ── СТОИМОСТЬ БЛОКИРОВКИ И SET LOCAL lock_timeout ────────────────────────── +-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на houses. Удержание дёшево и не +-- зависит от размера: PostgreSQL не переписывает heap, а помечает attisdropped в +-- каталоге — единицы миллисекунд на 9468 строк. Дорого ОЖИДАНИЕ выдачи лока: ждущий +-- ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, и за ним начинают ждать +-- обычные SELECT приложения — ровно то, что 2026-08-07 положило деплой на 29 минут +-- (#2791, #2792). Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено +-- deadlock_timeout = 1 s на проде; на работу ПОД локом не влияет). Срабатывание = +-- честный красный деплой через 5 секунд, миграция не помечается применённой. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP VIEW IF EXISTS + DROP COLUMN IF EXISTS + CREATE VIEW после DROP — +-- безопасный re-run. +-- - Без CASCADE. +-- - Откат: колонку вернуть можно (ALTER TABLE houses ADD COLUMN has_panorama boolean), +-- данные не восстановятся — восстанавливать нечего, все 12 непустых значений false. +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. information_schema.columns по houses: has_panorama отсутствует. +-- 3. market.v_houses существует, has_panorama в нём нет, остальные 59 колонок на +-- месте и в том же порядке (прод до правки: 60), SELECT count(*) отдаёт 9468+ строк. +-- 4. information_schema.role_table_grants: gendesign_reader снова имеет SELECT на +-- market.v_houses. + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +DROP VIEW IF EXISTS market.v_houses; + +ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama; + +-- Пересоздание контракта БЕЗ has_panorama. Список колонок — копия 154 минус одна +-- строка; он и есть обещание стабильности, поэтому выписан явно, без SELECT *. +CREATE VIEW market.v_houses AS +SELECT + id, + source, + ext_house_id, + url, + slug, + address, + full_address, + short_address, + lat, + lon, + geom, + year_built, + house_type, + house_class, + material_walls, + material_floors, + series_name, + total_floors, + total_units, + entrances, + flat_count, + is_emergency, + passenger_elevators, + cargo_elevators, + has_concierge, + closed_yard, + has_playground, + hot_water, + heat_supply_type, + gas_supply_type, + overlap_type, + parking_type, + infrastructure_summary, + infrastructure_walk_distance, + developer_name, + developer_key, + management_company_id, + rating, + reviews_count, + rating_score, + rating_string, + transport_accessibility_rate, + advantages, + banks, + builders, + houses_by_turn, + corpus_count, + commission_year, + commission_month, + total_area_ha, + cadastral_number, + house_fias_id, + yandex_jk_id, + yandex_jk_slug, + cian_internal_house_id, + cian_zhk_url, + raw_payload, + first_seen_at, + last_scraped_at +FROM public.houses; + +COMMENT ON VIEW market.v_houses IS + 'Stable public contract over public.houses (#2130). Explicit column list is the ' + 'stability promise — do not SELECT * against the base table from external ' + 'consumers. raw_payload is included because it is read today by gendesign ETL ' + '#976 (newbuilding_crossload.py); scraper-internal QC/status/validated_at ' + 'bookkeeping columns are intentionally excluded. #2674 (хвост): has_panorama ' + 'убрана из контракта вместе с колонкой — ломающее изменение, принятое осознанно ' + '(0 true из 1536 страниц, признака нет на площадке, читателей вне tradein нет).'; + +-- DROP VIEW уничтожил гранты — восстанавливаем ровно то, что дала 155. +GRANT SELECT ON market.v_houses TO gendesign_reader; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 61f36e02..80ff61ee 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -248,3 +248,4 @@ 257_listings_backfill_yandex_source_url.sql 258_houses_imv_transient_attempts.sql 259_data_quality_drop_pct_cadastr.sql +260_houses_drop_has_panorama.sql diff --git a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py index 8e5e1163..4495830f 100644 --- a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py +++ b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py @@ -113,9 +113,17 @@ class TestYandexHousePriority: assert "ceiling_height" not in HOUSE_FIELD_PRIORITY - def test_house_has_panorama_yandex_valuation_only(self) -> None: - out = resolve_house_field("has_panorama", {"yandex_valuation": True}) - assert out is True + def test_has_panorama_removed_from_house_priority(self) -> None: + """#2674 (хвост): правило снято вместе с колонкой houses.has_panorama (мигр. 259). + + В отличие от ceiling_height выше, это правило было ИСПОЛНИМО — колонка + существовала, единственный источник её писал. Разрешать было нечего: + yandex_valuation отдавал False всегда (0 true из 1536 страниц на проде), + потому что слова «панорам» на странице оценки нет вовсе. + """ + from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY + + assert "has_panorama" not in HOUSE_FIELD_PRIORITY def test_house_yandex_total_listings_yandex_valuation_only(self) -> None: out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42}) @@ -217,7 +225,6 @@ class TestYandexListingPriority: "corpus_count", "total_area_ha", "commission_month", - "has_panorama", "yandex_total_listings", ] for key in yandex_keys: diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py index bdb6f976..5e6e0129 100644 --- a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -3,8 +3,6 @@ Каждая правка эпика — тест, который краснеет без неё: подключено: - - houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница - не подтверждена — иначе false «не смотрели» выдаётся за false «посмотрели»); - domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules — именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным; - filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash. @@ -15,7 +13,11 @@ читателя и писателя; - listings.merged_into, house_sources.raw_payload — колонки без писателя; - v_data_quality.price_disagreements_count — показатель, который не мог быть - ненулевым. + ненулевым; + - houses.has_panorama (хвост, мигр. 259) — признак, которого нет на площадке. + Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя; + подключённый писатель за 7 дней записал 12 значений, все false. Разница между + «проводка оборвана» и «мерить нечего» видна не по коду, а по площадке. задокументировано: - BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт — @@ -29,19 +31,13 @@ from __future__ import annotations import os import re from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from scraper_kit.providers.cian.valuation import _parse_valuation_state -from scraper_kit.providers.yandex.valuation import ( - ValuationHistoryItem, - ValuationHouseMeta, - YandexValuationResult, -) from app.services import estimator -from app.services.estimator import _save_yandex_history_items REPO_ROOT = Path(__file__).resolve().parents[3] TRADEIN = REPO_ROOT / "tradein-mvp" @@ -50,118 +46,7 @@ MIGRATION = SQL_DIR / "216_dead_code_sweep.sql" # ───────────────────────────────────────────────────────────────────────────── -# Подключено 1/3: houses.has_panorama -# ───────────────────────────────────────────────────────────────────────────── - - -def _result_with_meta(meta: ValuationHouseMeta) -> YandexValuationResult: - return YandexValuationResult( - address="Екатеринбург, ул. Куйбышева, 106", - offer_category="APARTMENT", - offer_type="SELL", - page=1, - source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test", - house=meta, - history_items=[ValuationHistoryItem(area_m2=50.0, rooms=2, floor=5, start_price=9_000_000)], - ) - - -def _panorama_updates(db: MagicMock) -> list[dict]: - """Параметры всех db.execute, которые обновляли houses.has_panorama.""" - found = [] - for call in db.execute.call_args_list: - sql = str(call.args[0]) - if "has_panorama" in sql and "UPDATE houses" in sql: - found.append(call.args[1]) - return found - - -def test_has_panorama_written_when_page_rendered() -> None: - """Разобранный флаг доезжает до houses — до #2674 он не доезжал ни одной строкой.""" - db = MagicMock() - result = _result_with_meta( - ValuationHouseMeta(year_built=2010, total_floors=16, has_panorama=True) - ) - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(99, 0.9, "fp"), - ): - _save_yandex_history_items(db, result) - - updates = _panorama_updates(db) - assert updates, "houses.has_panorama не записан — вернулась исходная болячка #2674" - assert updates[0] == {"hid": 99, "panorama": True} - - -def test_has_panorama_false_written_when_page_rendered() -> None: - """Отсутствие метки на ОТРИСОВАННОЙ странице — тоже наблюдение, пишем false.""" - db = MagicMock() - result = _result_with_meta( - ValuationHouseMeta(year_built=1998, total_floors=9, has_panorama=False) - ) - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(7, 0.9, "fp"), - ): - _save_yandex_history_items(db, result) - - assert _panorama_updates(db) == [{"hid": 7, "panorama": False}] - - -def test_has_panorama_written_when_page_has_no_history() -> None: - """Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде. - - Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие - страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение - о доме к наличию объявлений отношения не имеет. - """ - db = MagicMock() - result = _result_with_meta( - ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True) - ) - result.history_items = [] - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(42, 0.9, "fp"), - ): - assert _save_yandex_history_items(db, result) == 0 - - assert _panorama_updates(db) == [{"hid": 42, "panorama": True}] - - -def test_has_panorama_not_written_when_page_unconfirmed() -> None: - """Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false.""" - db = MagicMock() - result = _result_with_meta(ValuationHouseMeta(has_panorama=False)) - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(5, 0.9, "fp"), - ): - _save_yandex_history_items(db, result) - - assert _panorama_updates(db) == [], "false записан там, где мы ничего не наблюдали" - - -def test_has_panorama_not_written_without_house_id() -> None: - """Дом не сматчился → писать некуда, но и падать нельзя.""" - db = MagicMock() - result = _result_with_meta(ValuationHouseMeta(year_built=2010, total_floors=16)) - - with patch( - "app.services.estimator.match_or_create_house", - side_effect=RuntimeError("no house"), - ): - _save_yandex_history_items(db, result) - - assert _panorama_updates(db) == [] - - -# ───────────────────────────────────────────────────────────────────────────── -# Подключено 2/3: загрузчик ДОМ.РФ — оборванная проводка +# Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка # ───────────────────────────────────────────────────────────────────────────── @@ -217,7 +102,7 @@ def test_domrf_handler_reuses_loader_functions() -> None: # ───────────────────────────────────────────────────────────────────────────── -# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали +# Подключено 2/2: filters_hash лежит на уровень выше, чем его читали # ───────────────────────────────────────────────────────────────────────────── @@ -285,6 +170,10 @@ def test_dead_names_absent_from_live_code() -> None: "asking_to_sold_ratios_tiered", "asking_to_sold_tier_bounds", "price_disagreements_count", + # Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета + # источников. Гейт краснеет на любом из трёх, а не только на одном (первая + # редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет). + "has_panorama", ] offenders: list[str] = [] for path in _live_python_sources(): @@ -332,6 +221,29 @@ def test_migration_drops_exactly_what_was_declared_dead() -> None: assert "COMMENT ON VIEW v_price_divergence" in sql +def test_has_panorama_dropped_together_with_the_contract_column() -> None: + """Снос колонки обязан пересобрать market.v_houses и вернуть ему грант. + + has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку + не удаляет — значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три + части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен. + """ + sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8") + assert "DROP VIEW IF EXISTS market.v_houses" in sql + assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql + assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока" + + view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0] + assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой" + # Контракт — обещание стабильности: пересборка не должна заодно потерять соседей. + for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"): + assert kept in view_ddl, f"{kept} пропала из контракта при пересборке" + assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, ( + "DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит " + "permission denied на следующем прогоне" + ) + + def test_price_divergence_is_documented_as_structurally_empty() -> None: """Оставленный задел обязан говорить, чем он НЕ является сегодня.""" sql = MIGRATION.read_text(encoding="utf-8") diff --git a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py index b5d6ebbe..5962af60 100644 --- a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py +++ b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py @@ -178,21 +178,22 @@ def test_save_history_items_inserts_each(): ): saved = _save_yandex_history_items(db, result) assert saved == 2 - # 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE - # houses.has_panorama — считаем именно вставки истории, а не все execute. + # 1 batch INSERT (executemany). Фильтруем по SQL, а не по позиции вызова: + # #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой. rows = _history_rows(db) assert isinstance(rows, list) and len(rows) == 2 - # Два коммита: панорама (до истории) + батч истории. Раньше был один. - assert db.commit.call_count == 2 + # Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе + # с колонкой, хвост #2674, мигр. 259. + assert db.commit.call_count == 1 def test_save_history_items_empty_no_commit(): """Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего. #2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно - отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%). - Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается — - а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает. + отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь + match_or_create_house вызывается до возврата — а записей по-прежнему ноль: + истории нет, вставлять нечего. """ db = MagicMock() result = YandexValuationResult( @@ -246,9 +247,9 @@ def test_save_history_items_ext_id_stable_across_calls(): def test_save_history_items_db_error_rolls_back_batch(): """Any item failing rolls back the whole batch — batch semantics (finding #5). - #2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение - доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает), - а батч истории проходил бы успешно, и тест молча проверял бы не тот путь. + #2674: side_effect адресуем по SQL, а не по позиции вызова. Урок остаётся в силе + и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы + не тот путь, стоит появиться любому новому execute перед вставкой истории. """ db = MagicMock() @@ -266,5 +267,4 @@ def test_save_history_items_db_error_rolls_back_batch(): saved = _save_yandex_history_items(db, result) assert saved == 0 # whole batch rolled back db.rollback.assert_called_once() - # Панорама коммитится отдельно и раньше — её успех не отменяет отката истории. - assert db.commit.call_count == 1 + assert db.commit.call_count == 0 diff --git a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py index 44d6ac26..a21a9509 100644 --- a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py +++ b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py @@ -32,8 +32,8 @@ def _history_rows(db) -> list[dict]: """Строки батча house_placement_history из мока сессии. #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция - сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama - перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + сломалась, как только у функции появился второй execute перед вставкой истории. + Фильтруем по SQL: тест переживёт любой новый вызов. """ for call in db.execute.call_args_list: if "INSERT INTO house_placement_history" in str(call.args[0]): @@ -169,10 +169,9 @@ def test_all_invalid_area_returns_zero_no_crash() -> None: assert saved == 0 # db.execute не должен вызываться для пустого rows (нет INSERT) assert _history_rows(db) == [] - # Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись - # houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась - # ли история по площади. - assert db.commit.call_count == 2 + # Commit вызывается один раз (пустой батч истории), rollback — нет. Второй коммит + # (запись houses.has_panorama) ушёл вместе с колонкой — хвост #2674, мигр. 259. + assert db.commit.call_count == 1 db.rollback.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py index 21d6f605..c6196e84 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation.py @@ -1,7 +1,7 @@ """Unit tests for YandexValuationScraper — anonymous house-history scraper. Fixture HTML simulates the Yandex valuation page body text containing: -- House meta block (year, floors, type, ceiling, lift, total objects, panorama) +- House meta block (year, floors, type, ceiling, lift, total objects) - 2-3 historical offer entries with full structure Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг @@ -78,7 +78,6 @@ def test_parse_house_meta_full(): assert meta.ceiling_height == 2.50 assert meta.has_lift is True assert meta.total_objects == 12 - assert meta.has_panorama is False def test_parse_house_meta_no_lift(): @@ -91,10 +90,16 @@ def test_parse_house_meta_no_lift(): assert meta.ceiling_height == 3.0 -def test_parse_house_meta_with_panorama(): - text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание" +def test_parse_house_meta_brick_with_lift(): + """#2674 (хвост): фикстура больше не содержит «Панорама». + + Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором; + зелёный он был по построению. На настоящей странице оценки этого слова нет — + проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов. + Остаток теста осмысленный: кирпич + лифт на короткой мета-строке. + """ + text = "7 объектов Дом 2010 года Лифт Кирпичное здание" meta = YandexValuationScraper._parse_house_meta(text) - assert meta.has_panorama is True assert meta.has_lift is True assert meta.house_type == "brick" @@ -275,7 +280,7 @@ def test_total_floors_extracted_from_dom_meta_not_items(): Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item. """ text = ( - "Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт " + "Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт " "1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже " "2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже" ) diff --git a/tradein-mvp/backend/tests/test_yandex_valuation_save.py b/tradein-mvp/backend/tests/test_yandex_valuation_save.py index 308d4cb1..4b90d5ef 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation_save.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation_save.py @@ -30,8 +30,8 @@ def _history_rows(db) -> list[dict]: """Строки батча house_placement_history из мока сессии. #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция - сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama - перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + сломалась, как только у функции появился второй execute перед вставкой истории. + Фильтруем по SQL: тест переживёт любой новый вызов. """ for call in db.execute.call_args_list: if "INSERT INTO house_placement_history" in str(call.args[0]): diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py index 0c8c883c..589be38e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py @@ -62,7 +62,11 @@ class ValuationHouseMeta(BaseModel): ceiling_height: float | None = None # in meters, e.g. 2.5 has_lift: bool | None = None total_objects: int | None = None # 'N объектов' (full archive count) - has_panorama: bool = False # 'Панорама' label present + # #2674 (хвост, мигр. 259): признак «панорама» снят с разбора. Слова «панорам» в + # любом падеже на странице оценки нет вовсе — проверено боевым трактом 13.08.2026 + # на трёх адресах ЕКБ (полный HTML ~1.19 МБ, вхождений 0/0/0), при том что мета + # разбиралась целиком (год/этажность/материал/потолки/объекты). Признак не + # «переехал» — его в предметной области нет; на проде 0 true из 1536 страниц. def validate_match( self, @@ -331,7 +335,6 @@ class YandexValuationScraper(BaseScraper): ceiling_height=(float(ceiling_m.group(1).replace(",", ".")) if ceiling_m else None), has_lift="Лифт" in body_text, total_objects=int(objects_m.group(1)) if objects_m else None, - has_panorama="Панорама" in body_text, ) def _parse_history_items(self, tree: HTMLParser, body_text: str) -> list[ValuationHistoryItem]: