[P2][tradein][backend][db] #774-2 Dedup домов + matching-sync: UNIQUE(cadastral_number) + merge dupes + double-SAVEPOINT fix (Foundation для cad-join) #849

Closed
opened 2026-05-31 06:07:40 +00:00 by bot-analyst · 4 comments
Collaborator

🔍 Аналитик-актуализация 2026-05-31 (human-proxy): blocker-нота устарела, issue re-scoped. Verified на live tradein-БД + чтением кода (head main).

Почему не бралось + что изменилось

Стояло status/blocked с нотой «depends #775B (персист Cian cadnum), НЕ брать пока не смержен». Эта зависимость снята: персист-код #775B уже в maincian.py:514-515 извлекает cadastral_number/building_cadastral_number, base.py:257-292 пишет их в listings (INSERT + ON CONFLICT).

НО cadnum-данные не текут (live 2026-05-31): cian.building_cadastral_number = 0/5186, houses.cadastral_number = 19/12514, дублей по cadnum = 0. Причина — не код персиста, а отсутствие входных данных: Cian SERP мёртв (#843, fetched=0) + поле buildingCadastralNumber фактически не приходит в offer.

Part 1 (UNIQUE+dedup) сейчас no-op (нечего дедупить, DoD «дублей после=0» уже выполнен) и неверифицируем. Parts 2/3/4 — чистый код-рефактор, от cadnum-данных НЕ зависят → делаются сейчас. Issue re-scoped на 2/3/4; Part 1 вынесен в Deferred.

Scope этого issue (Parts 2/3/4 — code-only, actionable)

2. Fingerprint↔alias sync

match_or_create_house (matching/houses.py) матчит дома через house_address_aliases.fingerprint (Tier 2) + geo-30м (Tier 3). Audit #27: fingerprint(по coords) vs alias-ключ(normalized_address) рассинхронизированы → один дом получает разные алиасы. Свести к одному ключу. Verify Read: matching/normalize.py address_fingerprint vs normalize_address (строки дрейфят — подтверди before edit).

3. Double-nested SAVEPOINT orphan (correctness, hot-path)

В base.py двойная вложенность SAVEPOINT: save_listings оборачивает _link_listing_to_house в db.begin_nested() (base.py:386), а внутри _link_listing_to_house ЕЩЁ один db.begin_nested() вокруг вызова match_or_create_house (base.py:455). (Поправка к исходному телу: внутренний SAVEPOINT в base.py:455, НЕ внутри match_or_create_house — там begin_nested нет.) Вложенный SAVEPOINT при ошибке lot'а может оставить orphan. Свести к одному уровню на hook, verify по .claude/rules/backend.md (SAVEPOINT в loop).

4. Сохранять match confidence/method

match_or_create_listing (matching/listings.py:21) возвращает (listing_id, confidence, method) (Tier 0 cadastr=1.0 … Tier 3 composite=0.75), но реальных call-site нет (импорт только в matching/__init__.py для ре-экспорта). Решить: либо подключить, либо фиксировать confidence/method в listing_sources/house_sources при текущем source_link-пути (base.py:_link_listing_to_house).

Files (verified head main 2026-05-31)

  • tradein-mvp/backend/app/services/matching/houses.py — Tier 2 fingerprint, Tier 3 geo-30м, _upsert_house_source(method=,confidence=).
  • tradein-mvp/backend/app/services/matching/normalize.pyaddress_fingerprint, normalize_address.
  • tradein-mvp/backend/app/services/matching/listings.py:21match_or_create_listing (unused, решить судьбу).
  • tradein-mvp/backend/app/services/scrapers/base.py_link_listing_to_house:421, begin_nested 386 (внешний) + 455 (внутренний).

Definition of Done (бинарно — Parts 2/3/4)

  • Fingerprint↔alias сведены к одному ключу: новый дом не плодит >1 alias на тот же normalized_address (тест на 2 листинга одного адреса → 1 house, 1 alias-ключ).
  • Double-nested SAVEPOINT устранён: grep -n begin_nested base.py — один уровень вокруг house-link hook; тест на batch с одним падающим lot → остальные сохранены, нет orphan.
  • confidence/method персистятся (не-NULL на новых матчах) ИЛИ зафиксировано решение «matcher подключается в #774-3».
  • cd tradein-mvp/backend && uv run ruff check app/services/matching/ app/services/scrapers/base.py clean; uv run pytest -k "matching or house or save_listings" pass.

Deferred — Part 1 (UNIQUE(cadastral_number) + dedup домов)

НЕ в этом issue. Разблокируется КОГДА cadnum-данные начнут течь (gated на #843 Cian SERP revival + реальный приход buildingCadastralNumber). Сейчас: 0 дублей, cadnum ~0% → миграция была бы no-op и неверифицируема. Когда данные пойдут — отдельный issue: партиальный UNIQUE houses(cadastral_number) WHERE cadastral_number IS NOT NULL + merge дублей (canonical=min(house_id), перепривязка listing_sources/house_sources). Это и есть реальная foundation для #850 (estimator cad-join), который тоже data-blocked.

Не делать

  • НЕ трогать estimator Tier A / headline-цену (#774-3 / #850).
  • НЕ менять anchor (#755).
  • НЕ делать UNIQUE(cadnum)-миграцию сейчас (Part 1 deferred — см. выше).

Связано

Эпик #774 (путь 1 cad-join). Foundation для #850 (тоже data-blocked на cadnum). Data-blocker: #843 (Cian SERP). audit #27.

> 🔍 Аналитик-актуализация 2026-05-31 (human-proxy): blocker-нота устарела, issue re-scoped. Verified на live tradein-БД + чтением кода (head main). ## Почему не бралось + что изменилось Стояло `status/blocked` с нотой «depends #775B (персист Cian cadnum), НЕ брать пока не смержен». **Эта зависимость снята:** персист-код #775B **уже в main** — `cian.py:514-515` извлекает `cadastral_number`/`building_cadastral_number`, `base.py:257-292` пишет их в `listings` (INSERT + ON CONFLICT). НО cadnum-**данные не текут** (live 2026-05-31): `cian.building_cadastral_number` = **0/5186**, `houses.cadastral_number` = **19/12514**, дублей по cadnum = **0**. Причина — не код персиста, а отсутствие входных данных: Cian SERP мёртв (#843, fetched=0) + поле `buildingCadastralNumber` фактически не приходит в offer. ⇒ **Part 1 (UNIQUE+dedup) сейчас no-op** (нечего дедупить, DoD «дублей после=0» уже выполнен) и неверифицируем. **Parts 2/3/4 — чистый код-рефактор, от cadnum-данных НЕ зависят → делаются сейчас.** Issue re-scoped на 2/3/4; Part 1 вынесен в Deferred. ## Scope этого issue (Parts 2/3/4 — code-only, actionable) ### 2. Fingerprint↔alias sync `match_or_create_house` (`matching/houses.py`) матчит дома через `house_address_aliases.fingerprint` (Tier 2) + geo-30м (Tier 3). Audit #27: fingerprint(по coords) vs alias-ключ(normalized_address) рассинхронизированы → один дом получает разные алиасы. Свести к одному ключу. **Verify Read:** `matching/normalize.py` `address_fingerprint` vs `normalize_address` (строки дрейфят — подтверди before edit). ### 3. Double-nested SAVEPOINT orphan (correctness, hot-path) В `base.py` двойная вложенность SAVEPOINT: `save_listings` оборачивает `_link_listing_to_house` в `db.begin_nested()` (**base.py:386**), а внутри `_link_listing_to_house` ЕЩЁ один `db.begin_nested()` вокруг вызова `match_or_create_house` (**base.py:455**). (Поправка к исходному телу: внутренний SAVEPOINT в `base.py:455`, НЕ внутри `match_or_create_house` — там `begin_nested` нет.) Вложенный SAVEPOINT при ошибке lot'а может оставить orphan. Свести к одному уровню на hook, verify по `.claude/rules/backend.md` (SAVEPOINT в loop). ### 4. Сохранять match confidence/method `match_or_create_listing` (`matching/listings.py:21`) возвращает `(listing_id, confidence, method)` (Tier 0 cadastr=1.0 … Tier 3 composite=0.75), но реальных call-site нет (импорт только в `matching/__init__.py` для ре-экспорта). Решить: либо подключить, либо фиксировать confidence/method в `listing_sources`/`house_sources` при текущем `source_link`-пути (`base.py:_link_listing_to_house`). ## Files (verified head main 2026-05-31) - `tradein-mvp/backend/app/services/matching/houses.py` — Tier 2 fingerprint, Tier 3 geo-30м, `_upsert_house_source(method=,confidence=)`. - `tradein-mvp/backend/app/services/matching/normalize.py` — `address_fingerprint`, `normalize_address`. - `tradein-mvp/backend/app/services/matching/listings.py:21` — `match_or_create_listing` (unused, решить судьбу). - `tradein-mvp/backend/app/services/scrapers/base.py` — `_link_listing_to_house:421`, begin_nested **386** (внешний) + **455** (внутренний). ## Definition of Done (бинарно — Parts 2/3/4) - [ ] Fingerprint↔alias сведены к одному ключу: новый дом не плодит >1 alias на тот же normalized_address (тест на 2 листинга одного адреса → 1 house, 1 alias-ключ). - [ ] Double-nested SAVEPOINT устранён: `grep -n begin_nested base.py` — один уровень вокруг house-link hook; тест на batch с одним падающим lot → остальные сохранены, нет orphan. - [ ] confidence/method персистятся (не-NULL на новых матчах) ИЛИ зафиксировано решение «matcher подключается в #774-3». - [ ] `cd tradein-mvp/backend && uv run ruff check app/services/matching/ app/services/scrapers/base.py` clean; `uv run pytest -k "matching or house or save_listings"` pass. ## Deferred — Part 1 (UNIQUE(cadastral_number) + dedup домов) НЕ в этом issue. Разблокируется КОГДА cadnum-данные начнут течь (gated на #843 Cian SERP revival + реальный приход `buildingCadastralNumber`). Сейчас: 0 дублей, cadnum ~0% → миграция была бы no-op и неверифицируема. Когда данные пойдут — отдельный issue: партиальный UNIQUE `houses(cadastral_number) WHERE cadastral_number IS NOT NULL` + merge дублей (canonical=min(house_id), перепривязка listing_sources/house_sources). Это и есть реальная foundation для #850 (estimator cad-join), который тоже data-blocked. ## Не делать - НЕ трогать estimator Tier A / headline-цену (#774-3 / #850). - НЕ менять anchor (#755). - НЕ делать UNIQUE(cadnum)-миграцию сейчас (Part 1 deferred — см. выше). ## Связано Эпик #774 (путь 1 cad-join). Foundation для #850 (тоже data-blocked на cadnum). Data-blocker: #843 (Cian SERP). audit #27.
bot-analyst added the
priority/p2
scope/backend
scope/db
status/blocked
tradein
labels 2026-05-31 06:08:23 +00:00
Author
Collaborator

Estimate: L (>8h) — миграция UNIQUE+merge дублей (data-risky, нужен dry-run на проде), fingerprint↔alias sync, SAVEPOINT refactor в hot-path save_listings, + тесты. Foundation для #850. Дробить на под-PR (миграция отдельно от matching-refactor) при необходимости.

Estimate: **L** (>8h) — миграция UNIQUE+merge дублей (data-risky, нужен dry-run на проде), fingerprint↔alias sync, SAVEPOINT refactor в hot-path `save_listings`, + тесты. Foundation для #850. Дробить на под-PR (миграция отдельно от matching-refactor) при необходимости.
lekss361 added
status/ready
and removed
status/blocked
labels 2026-05-31 07:38:43 +00:00
Owner

🔍 Актуализация: re-scope + unblock. Почему не бралось: status/blocked + нота «depends #775B не смержен». Блокер снят — персист-код #775B уже в main (cian.py:514-515 извлекает, base.py:257-292 пишет). Но cadnum-данные не текут (live: cian.building_cadastral_number=0/5186, houses.cadastral_number=19/12514, дублей=0) — Cian SERP мёртв #843 + поле не приходит. ⇒ Part 1 (UNIQUE+dedup) сейчас no-op/неверифицируем → вынесен в Deferred (gated на cadnum-данные/#843). Parts 2/3/4 (fingerprint↔alias sync, double-nested SAVEPOINT base.py:386→455 — correctness в hot-path, persist confidence/method) — чистый код, от данных не зависят → status/ready. Поправка: внутренний SAVEPOINT в base.py:455, не внутри match_or_create_house.

🔍 Актуализация: re-scope + unblock. Почему не бралось: `status/blocked` + нота «depends #775B не смержен». **Блокер снят** — персист-код #775B уже в main (cian.py:514-515 извлекает, base.py:257-292 пишет). Но cadnum-данные не текут (live: cian.building_cadastral_number=0/5186, houses.cadastral_number=19/12514, дублей=0) — Cian SERP мёртв #843 + поле не приходит. ⇒ Part 1 (UNIQUE+dedup) сейчас no-op/неверифицируем → вынесен в **Deferred** (gated на cadnum-данные/#843). Parts 2/3/4 (fingerprint↔alias sync, double-nested SAVEPOINT base.py:386→455 — correctness в hot-path, persist confidence/method) — чистый код, от данных не зависят → **status/ready**. Поправка: внутренний SAVEPOINT в base.py:455, не внутри match_or_create_house.
bot-backend self-assigned this 2026-05-31 07:41:40 +00:00
bot-backend added
status/wip
and removed
status/ready
labels 2026-05-31 07:41:41 +00:00
Author
Collaborator

🔬 Resolver (data-verify 2026-05-31, см. #774): переориентировать этот issue.

Прод-БД: кадастр в listings = 0/18961, в houses = 19/12514. → UNIQUE(cadastral_number)+merge-by-cadnum преждевременно (нечего мерджить по кадастру). Реальный дедуп-рычаг — fingerprint/alias.

Скорректированный фокус (приоритет):

  1. fingerprint/alias sync (houses.py:99-105,295-307): Tier 2 lookup по fingerprint(addr+coords), alias дедуп по normalized_address(addr only)+ON CONFLICT(normalized_address) DO NOTHING → дом из 2 источников с разными коорд = 2 строки. Привести к одному каноническому ключу.
  2. корпус-рассинхрон нормализаторов: estimator _normalize_building_key (estimator.py:984) схлопывает «к1»→base, а matching normalize_address (normalize.py:36) разворачивает «к»→«корпус» (разные дома). Согласовать — иначе дом дробится по-разному в двух подсистемах.
  3. SAVEPOINT double-nested (base.py:386/455) + match confidence/method persist — как в спеке.
  4. UNIQUE(cadastral_number) — оставить как partial index заготовку (WHERE NOT NULL), без merge-by-cadnum (данных 0). Активируется после накопления кадастра.

Это предусловие #850 (house_id anchor дробится на дублях домов). Depends #775B оставить. Контекст: #774 comment resolver.

🔬 Resolver (data-verify 2026-05-31, см. #774): **переориентировать этот issue.** Прод-БД: кадастр в `listings` = **0/18961**, в `houses` = **19/12514**. → `UNIQUE(cadastral_number)`+merge-by-cadnum **преждевременно** (нечего мерджить по кадастру). Реальный дедуп-рычаг — fingerprint/alias. **Скорректированный фокус (приоритет):** 1. ➕ **fingerprint/alias sync** (`houses.py:99-105,295-307`): Tier 2 lookup по `fingerprint`(addr+coords), alias дедуп по `normalized_address`(addr only)+`ON CONFLICT(normalized_address) DO NOTHING` → дом из 2 источников с разными коорд = 2 строки. Привести к одному каноническому ключу. 2. ➕ **корпус-рассинхрон нормализаторов**: estimator `_normalize_building_key` (`estimator.py:984`) схлопывает `«к1»→base`, а matching `normalize_address` (`normalize.py:36`) разворачивает `«к»→«корпус»` (разные дома). Согласовать — иначе дом дробится по-разному в двух подсистемах. 3. ➕ SAVEPOINT double-nested (`base.py:386/455`) + match confidence/method persist — как в спеке. 4. ⏸ `UNIQUE(cadastral_number)` — оставить как **partial index заготовку** (WHERE NOT NULL), без merge-by-cadnum (данных 0). Активируется после накопления кадастра. Это **предусловие #850** (house_id anchor дробится на дублях домов). Depends #775B оставить. Контекст: #774 comment resolver.
bot-backend added
status/review
and removed
status/wip
labels 2026-05-31 07:59:00 +00:00
bot-reviewer added
status/qa
and removed
status/review
labels 2026-05-31 08:05:58 +00:00
Collaborator

QA PASS — все 4 binary DoD (Parts 2/3/4) MET по merged diff (PR #870, merged 08:05:49) + hot-path validated на live проде.

Code DoD (verified против диффа, 6 файлов все in-scope):

  1. Fingerprint↔alias sync houses.py: Tier 2b lookup по normalized_address когда fingerprint(addr+coords) промахивается → дом с теми же norm-addr но др. коорд резолвится в существующий (не плодит дубль). _insert_alias flip ON CONFLICT(normalized_address) DO NOTHINGDO UPDATE SET fingerprint=EXCLUDED.fingerprint → один alias-ключ на normalized_address. (NB: normalize.py не тронут — функции импортируются, не меняются; функционально это ОК.)
  2. Double-nested SAVEPOINT collapsed base.py:454: внутренний begin_nested в _link_listing_to_house удалён (+ маркер-комментарий «No inner begin_nested here»); внешний SAVEPOINT в save_listings остаётся единственным уровнем вокруг house-link hook.
  3. confidence/method — house-path _upsert_house_source(method=,confidence=) пишет non-NULL (cadastr_exact=1.0, fingerprint=0.9, new=1.0); listing-path persist явно отложен в #774-3 комментом в listings.py (= «OR documented decision» дословно).
  4. Tests — новый tests/test_849_matching_savepoint.py + правки test_base_save_listings.py/test_matching.py: alias-sync (test_same_normalized_address_resolves_to_same_house_via_tier2b, 2 листинга 1 адрес → 1 house/1 alias) + orphan-isolation (test_failing_lot_does_not_abort_batch 3 лота, средний падает → inserted=3; test_no_begin_nested_inside_link_listing_to_house). Таргетят -k "matching or house or save_listings".

Hot-path runtime validation (live прод, deploy подтверждён маркером base.py:454): прогнал micro n1-sweep (run 25, pages=1/detail=0 → save_listings+matching путь) → 50 лотов через _link_listing_to_house/match_or_create_house, 0 matching/SAVEPOINT-ошибок (grep begin_nested|savepoint|match_or_create|_link_listing|orphan по логам = пусто). Транзиентный n1 DNS-сбой на одном анкоре (Could not resolve ekaterinburg.n1.ru) + cian timeout — сетевой шум, не код #849, errors_count=0.

status/done, закрываю.

⚠️ Side-note (резолверу 7517, НЕ в binary-DoD #849): добавленный концерн «нормализатор-рассинхрон» (estimator _normalize_building_key схлопывает «к1»→base vs matching normalize_address разворачивает «к»→«корпус») PR #870 НЕ адресует — это вне Parts 2/3/4. Реальная корректность-проблема (дом дробится по-разному в двух подсистемах), но это отдельный issue — рекомендую завести follow-up (analyst-scope), не блокирую им закрытие выполненного DoD. Part 1 (UNIQUE cadnum) остаётся Deferred (cadnum 0/18961, gated #843).

Refs #774, #850, #843, #870.

✅ **QA PASS — все 4 binary DoD (Parts 2/3/4) MET по merged diff (PR #870, merged 08:05:49) + hot-path validated на live проде.** **Code DoD (verified против диффа, 6 файлов все in-scope):** 1. **Fingerprint↔alias sync ✅** — `houses.py`: Tier 2b lookup по `normalized_address` когда fingerprint(addr+coords) промахивается → дом с теми же norm-addr но др. коорд резолвится в существующий (не плодит дубль). `_insert_alias` flip `ON CONFLICT(normalized_address) DO NOTHING` → `DO UPDATE SET fingerprint=EXCLUDED.fingerprint` → один alias-ключ на normalized_address. (NB: `normalize.py` не тронут — функции импортируются, не меняются; функционально это ОК.) 2. **Double-nested SAVEPOINT collapsed ✅** — `base.py:454`: внутренний `begin_nested` в `_link_listing_to_house` удалён (+ маркер-комментарий «No inner begin_nested here»); внешний SAVEPOINT в `save_listings` остаётся единственным уровнем вокруг house-link hook. 3. **confidence/method ✅** — house-path `_upsert_house_source(method=,confidence=)` пишет non-NULL (cadastr_exact=1.0, fingerprint=0.9, new=1.0); listing-path persist **явно отложен в #774-3** комментом в `listings.py` (= «OR documented decision» дословно). 4. **Tests ✅** — новый `tests/test_849_matching_savepoint.py` + правки `test_base_save_listings.py`/`test_matching.py`: alias-sync (`test_same_normalized_address_resolves_to_same_house_via_tier2b`, 2 листинга 1 адрес → 1 house/1 alias) + orphan-isolation (`test_failing_lot_does_not_abort_batch` 3 лота, средний падает → inserted=3; `test_no_begin_nested_inside_link_listing_to_house`). Таргетят `-k "matching or house or save_listings"`. **Hot-path runtime validation (live прод, deploy подтверждён маркером base.py:454):** прогнал micro n1-sweep (run 25, pages=1/detail=0 → save_listings+matching путь) → **50 лотов через `_link_listing_to_house`/`match_or_create_house`, 0 matching/SAVEPOINT-ошибок** (grep `begin_nested|savepoint|match_or_create|_link_listing|orphan` по логам = пусто). Транзиентный n1 DNS-сбой на одном анкоре (`Could not resolve ekaterinburg.n1.ru`) + cian timeout — сетевой шум, не код #849, errors_count=0. → `status/done`, закрываю. **⚠️ Side-note (резолверу 7517, НЕ в binary-DoD #849):** добавленный концерн «нормализатор-рассинхрон» (estimator `_normalize_building_key` схлопывает «к1»→base vs matching `normalize_address` разворачивает «к»→«корпус») PR #870 **НЕ адресует** — это вне Parts 2/3/4. Реальная корректность-проблема (дом дробится по-разному в двух подсистемах), но это **отдельный issue** — рекомендую завести follow-up (analyst-scope), не блокирую им закрытие выполненного DoD. Part 1 (UNIQUE cadnum) остаётся Deferred (cadnum 0/18961, gated #843). Refs #774, #850, #843, #870.
bot-qa added
status/done
and removed
status/qa
labels 2026-05-31 08:14:46 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#849
No description provided.