1. score_top_3_negatives — rewrite to unambiguously "most-negative first" via
explicit `sorted(negatives, key=contribution)[:3]` (ascending sort). Раньше
использовался trick `[-3:][::-1]` на desc-sorted — давал тот же результат
для N>=3 negatives, но неинтуитивно читать.
2. _compute_confidence(recent_deals_count) — guard `int(... or 0)` через
try/except (ValueError, TypeError). Защищает от non-numeric строки в
external/legacy market_trend payload.
3. Style: `import datetime as _dt` перенесён из function-scope в module-level.
Per auto-review on d0fa8a3.
PR #87 auto-review нашёл: два POI одной категории на одинаковом округлённом
расстоянии (например two аптеки 450м в плотном районе центра ЕКБ) давали
duplicate factor key "pharmacy_450m" → silent React reconciliation errors.
Fix: добавил enumerate index в slug: f"{cat}_{round(distance_m)}m_{idx}".
1 строка, backward compat (factor — opaque slug, не парсится клиентом).
Per auto-review on 97dc4ba.
Backend (parcels.py):
- Centrality factor: weight=1.0 вместо weight=center_bonus (semantic — decay
для bonus не применяется, contribution = bonus IS the value). Защищает
future PDF-export/UI который мог бы показать weight отдельно от contribution.
- group_totals type: dict[str, dict[str, float | int]] — count это int,
contribution и contribution_pct это float. Уточняет hint для future mypy.
- Top-3 sort convention — добавлен inline-комментарий: positives "[:3] от
descending" (most-positive first); negatives "[-3:][::-1]" (most-negative
first). Оба "dominant first".
Frontend (ScoreBreakdownPanel.tsx):
- Stacked bar legend orphan fix: positive groups идут в legend под баром (как
до того); negative groups показываются отдельной строкой ниже "Снижают балл —
Шум/трамвай: −0.46". Никаких swatch'ей без bar-сегмента.
Per auto-review on 1d1c169.
* feat(site-finder): P2 cad_buildings соседи + overlap check (#46)
Backend (parcels.py):
- _parse_floors() helper для TEXT column (cad_buildings.floors хранится как
строка, могут быть диапазоны "5-7"). Возвращает верхнюю границу.
- _neighbors_summary(db, geom_wkt, our_cad) — query соседей в 100м (GIST):
cad_num, building_name, floors, year_built, cost_value, area, address, distance.
Aggregate: avg_floors_100m, max_floors_100m, median_cost_per_m2_100m,
count_buildings_100m. Outliers cost/m² фильтруются (1k < x < 500k).
- Overlap check: ST_Intersects + ST_Area(ST_Intersection) > 50 m² (transformed
to UTM 32641 для метров). Если есть → has_existing_buildings: true +
overlap_buildings list.
- В response → neighbors_summary.
Frontend:
- Новый NeighborsBlock.tsx: hard red warn block для overlap (с building names +
overlap_m2 + "Инвестиции невозможны без сноса"); summary metrics (avg/max
floors, median price); toggle "Показать N ближайших" → таблица.
- Border меняется на красный при has_existing_buildings — visual cue.
- Добавлен в LandTab выше "Зонирование (ПЗЗ)".
- TS типы: NeighborBuilding, OverlapBuilding, NeighborsSummary.
Closes#46. Closes#21 (cad_buildings в Site Finder фильтрах).
* fix(site-finder): address PR #91 auto-review minor feedback
Backend (parcels.py):
1. (medium) Aggregation loop _neighbors_summary теперь обёрнут в try/except
(ValueError, TypeError) с fallback к data_available:False + log warning.
Защищает от non-numeric cost_value/area придёт в строке (e.g. "N/A") —
ранее весь endpoint падал 500.
2. Magic numbers вынесены: _COST_PER_M2_MIN=1000, _COST_PER_M2_MAX=500_000.
3. _parse_floors docstring + inline note про malformed parts ("5а-7" filter,
multi-range "1-2-3" max acceptable degradation).
Frontend (NeighborsBlock.tsx):
5. Русский plural fix: pluralBuildings(n) helper — 1 здание, 2-4 здания,
5+/11-14 зданий. Раньше "3 зданий" — теперь "3 здания".
Не сделано (defer):
4. ST_Area для overlap query — практически 0-5 buildings в ЕКБ, GIST scan OK.
6. Discriminated union для NeighborsSummary — refactor а не bug.
7. Vault entry для P2 — добавится batch'ем после merge всех текущих PR.
Per auto-review on 60d53bb.
---------
Co-authored-by: lekss361 <claudestars@proton.me>
Backend (parcels.py):
- _compute_confidence() composite score 0..1 from 7 subscores: poi_freshness,
geom_source (parcel vs quarter), district, market_trend (rosreestr_deals depth),
competitors, environment (noise/air/weather availability), zoning (placeholder
до G1).
- confidence_label: high (>0.75) / medium (0.4-0.75) / low (<0.4)
- confidence_caveats: list of конкретных проблем для UI
- confidence_breakdown: per-subscore 0..1 для прозрачности
Это stub-версия (полная — после G1/G2/D1/D2). Использует только текущие сигналы.
Frontend:
- Новый ConfidenceBadge.tsx — color-coded (green/yellow/red) badge с %
- Caveats для low — показываются сразу; для medium/high — под toggle
- Toggle "Подробнее" → breakdown per-subscore + полный список caveats
- Размещён в начале OverviewTab (выше "Район")
- TS типы расширены: confidence, confidence_label, confidence_breakdown, confidence_caveats
Closes#48.
Co-authored-by: lekss361 <claudestars@proton.me>
- nspd_geo: add _save_parcel() for thematic_id=1 → cad_parcels_geom (UPSERT,
ST_Transform from Web Mercator); _persist_target now handles 1/2/5
- parcels.py: analyze endpoint geom lookup extended with cad_parcels_geom as
3rd fallback source (after cad_quarters_geom, cad_buildings); both SELECT
and WKT subqueries updated
- parcels.py: POI score_breakdown items now include lat/lon for map markers
- poi_loader: OSM_CATEGORIES expanded — college+university→school,
hypermarket→shop_supermarket; coverage +3 tag pairs
The comparables block on /analytics/recommend was showing the same ЖК
multiple times (ЖК СТАРТ × 3, ЭХО ЛЕСА × 2 etc) because domrf_kn_objects
has ~3 historical snapshots per obj_id. The previous query joined all
rows and LIMIT 5 cherry-picked duplicates by flat_count.
Fix: wrap base in CTE `latest_obj` with `DISTINCT ON (obj_id) ORDER BY
obj_id, snapshot_date DESC` to pick only the latest snapshot per object
before sorting by flat_count.
Root cause of "auto-resume never fired": the kn_scrape_runs resume section
hit `if not rows: return` (and similar `return` in except) before reaching
the nspd_geo_jobs resume section. Whenever there were no zombie kn-runs
(the normal case), the handler bailed out and geo jobs stayed forever
'running' with stale heartbeats — users had to manual cancel/resume after
every deploy.
Fix: don't return early. Initialize `ids = []`, only run UPDATE if rows
exist, drop the inner `return` from exception branch. The for-loop over
ids becomes a no-op when empty, and execution falls through to the geo
section. Same pattern as the breadcrumb above — fail soft, continue.
cleanup_zombies beat task (added in caa467f) stays as belt-and-suspenders
in case worker_ready signal ever misbehaves again.
worker_ready signal handler was NOT firing in our setup (verified via
DB breadcrumb after 3 deploys — zero rows of stage='worker_ready' in
nspd_geo_log). Root cause of unreliability unknown — possibly Celery
internals, possibly compose recreate timing. Either way, after every
redeploy users had to manually cancel/resume jobs to keep them moving.
Replace signal-based resume with periodic beat task:
- cleanup_zombies runs every minute (* * * * *)
- Finds nspd_geo_jobs in status running/paused with heartbeat >2 min stale
- Sets status='queued' + apply_async with queue=geo
- Idempotent — if no zombies, no-op
worker_ready handler kept (with FK-fix breadcrumb on NULL job_id) for
diagnostic purposes — if signal ever does fire, we'll have evidence.
Two related fixes:
1. worker_process_init handler disposes the SQLAlchemy engine in each
prefork child. Without this, child processes inherit open psycopg
sockets from the parent. First use in a child raises
ProgrammingError: can't change 'autocommit' now: connection in
transaction status INTRANS. This was killing 1 of every 5 parallel
geo jobs on cold start (job 13 in latest bulk run).
2. Add logger.info at start/end of worker_ready resume handler so we
can see in worker logs whether it actually fired and how many jobs
it resumed.
Auto-resume bug: kn and nspd_geo resume-on-worker_ready required
heartbeat >5min / >10min stale. After redeploy worker boots in
~1-2 min, so jobs killed seconds before deploy had fresh heartbeat
and were NEVER auto-resumed — required manual cancel + resume.
Fix: drop time threshold entirely. On worker_ready ANY 'running'
or 'paused' job is by definition a zombie (no worker exists yet),
safe to resume all of them.
Concurrency bump: 5 -> 8 prefork slots. Headroom for 5 geo jobs +
1 kn sweep + 2 objective tasks running simultaneously. Each slot
~150MB RSS -> ~1.2GB total, well within 6GB VPS RAM budget.
- POST /api/v1/admin/scrape/geo/bulk — splits pending Sverdlovsk cad-nums
into N chunks (parallelism 1..10, default 5), creates N jobs and enqueues
each in queue=geo. source_kind='rosreestr_pending_chunk' for tracking.
- analytics_queries.complex_buildings(db, obj_id) — returns list of buildings
from cad_buildings (cad_num, floors, area, purpose, name, address, geom).
- object_detail: LEFT JOIN v_complex_buildings, adds buildings_count.
- top_developers: adds complexes_count via correlated subquery.
- GET /api/v1/analytics/object/{obj_id}/buildings → list[ComplexBuilding].
- JobSetting ORM model (JSONB extra_config) + Pydantic schemas
- GET/PUT /api/v1/admin/jobs/settings + /{job_type} (X-Admin-Token auth)
- celery_app.py builds beat_schedule from job_settings DB (env fallback
retained for safety on first deploy / DB unreachable)
- nspd_geo task reads rate_ms from job_settings when per-job row has
no override
- enqueue/resume geo jobs route to queue_name from job_settings
- Worker container: --queues=celery,scrape_kn,geo (one container,
three named queues — kn sweep no longer blocks nspd_geo)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_save_quarter использовал str(dict).replace("'", '"') как hack для GeoJSON
→ JSON. Это ломалось на Python booleans (True/False/None), которые в JSON
должны быть true/false/null. После dumps=False фикса rosreestr2coord
возвращает Python dict с булями в properties (is_actual=True) → INSERT
падал с psycopg.errors.InvalidTextRepresentation. _save_building уже
использовал json.dumps корректно.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three incremental bugs surfaced while wiring rosreestr2coord v5 into our
worker. All in backend/app/services/scrapers/nspd_lite.py.
1. `Area.__init__()` removed `delay` kwarg in v5 (was throttling). Drop
it from our call. Rate limit now happens in worker via
time.sleep(rate_ms/1000) after each fetch.
2. `Area(..., use_cache=True)` default writes media cache to ./tmp/
relative to CWD. In our docker image CWD=/app (owned by root), worker
runs as non-root → PermissionError on every target. Fix:
use_cache=False, media_path="/tmp/rosreestr2coord".
3. `Area.to_geojson_poly(dumps=True)` default returns JSON-serialized
STRING, not dict (changed in v5). Worker's _persist_target expects
dict with .get("properties") → AttributeError. Fix: dumps=False.
Vault entries:
- fixes/Bug_Rosreestr2coord_Delay_Arg_v5_Fixed.md
- fixes/Bug_Nspd_Geo_Tmp_Permission_Fixed.md
- fixes/Bug_Nspd_Geo_Str_Object_No_Get_Fixed.md
rosreestr2coord.Area() by default creates ./tmp/ media cache relative
to CWD. In our worker container CWD=/app (owned by root), runs as
non-root user `app` → PermissionError: '/app/tmp' on every target.
Fix: pass explicit kwargs to Area():
- use_cache=False (we don't need cache — each cad_num is unique)
- media_path='/tmp/rosreestr2coord' (world-writable in container)
Vault: fixes/Bug_Nspd_Geo_Tmp_Permission_Fixed.md
Объединяю несколько связанных изменений вокруг NSPD geo bulk-fetcher:
Adapt to rosreestr2coord v5 API:
- nspd_lite.fetch_via_rosreestr2coord: drop `delay` kwarg from Area()
(removed upstream in v5); keep it in our function signature for
backward-compat, comment why.
- nspd_geo worker: add explicit time.sleep(rate_ms/1000) after lib-branch
fetch — in v4 the library throttled internally via delay, in v5
rate limiting is the caller's job. Без этого получали Area.__init__()
unexpected kwarg `delay` на каждом target.
Drop use_rosreestr2coord switch:
- Removed urllib-vs-lib choice everywhere. We always use community
rosreestr2coord library — авторы регулярно обновляют WAF-tricks,
наш urllib-fetcher (fetch_geoportal) уже неактуален.
- admin_scrape.py: Pydantic schema, INSERT, SELECT, API response
cleaned of `use_rosreestr2coord`.
- nspd_geo.enqueue_geo_job: param dropped, INSERT shrunk.
- worker process loop: dropped `if use_lib:` branch + import of
fetch_geoportal.
- frontend/geo/page.tsx: removed checkbox + GeoJob.use_rosreestr2coord
field + POST body field.
DB column drop:
- data/sql/78_drop_use_rosreestr2coord.sql (NEW):
DROP COLUMN nspd_geo_jobs.use_rosreestr2coord + CREATE OR REPLACE
VIEW v_scrape_runs_unified (которая depended on the column).
- data/sql/77_nspd_geo_jobs.sql: cleaned historical DDL for fresh setups.
- Migration applied to prod (in-conversation via postgres MCP).
Frontend polish:
- Thematic ID changed from free-form number input to labeled select
(1=parcel / 2=quarter / 4=admin / 5=building / 7=zone / 15=complex).
- Auto-sync thematic_id from Job kind on change (override possible).
- ScrapeLogsPanel: extended union type with "nspd_geo" + fixed
/admin/scrape/geo to pass scraperType="nspd_geo" (was "nspd",
filtering empty legacy nspd_scrape_log table; real logs live in
nspd_geo_log via v_scrape_log_unified).
Verified: ruff ✓, tsc --noEmit ✓, migration ran (BEGIN..COMMIT clean).
Deploy order safe: prod column уже удалена → новый backend код, который
не INSERT'ит use_rosreestr2coord, совпадёт со схемой после deploy.
Two fixes for the NSPD geo bulk-fetcher:
1. rosreestr2coord was listed in pyproject.toml but missing from uv.lock,
so Docker's `uv sync --frozen` didn't install it. NSPD jobs failed every
target with `No module named 'rosreestr2coord'`. Ran `uv lock` to add it
(v5.3.3) plus requests + charset-normalizer transitive deps.
2. /admin/scrape/geo and /admin/scrape/objective lacked the log panel that
/admin/scrape (DomRF) had. Extracted ScrapeLogsPanel component over the
unified /api/v1/admin/scrape/all/logs endpoint with scraper_type filter
and wired into both pages.
Tests: ruff ✓, tsc --noEmit ✓
Worker крашился на старте с `ModuleNotFoundError: No module named 'psycopg2'`
при импорте app/workers/tasks/objective_etl, потому что app/services/objective_etl
писал на psycopg2 API, а в pyproject.toml есть только psycopg[binary]>=3.2.0.
Изменения:
- psycopg2 → psycopg (v3)
- psycopg2.extras.execute_values → _bulk_upsert helper, эмулирующий тот же
behavior через cur.executemany (в psycopg v3 это pipeline-mode, overhead
на сетевые round-trips минимален)
- psycopg.connect совместим с тем же connection string
Тесты: AST parse ✓, ruff ✓, import ✓
ОТКРЫТИЕ: stdlib urllib проходит WAF nspd.gov.ru (TLS-fingerprint stdlib
отличается от mainstream HTTP-clients). Это позволяет полностью убрать
Playwright + Chromium для NSPD-задач.
* nspd_lite.py: urllib + ssl._create_unverified_context(). 4 публичных
fetcher'а (geoportal/quarter/parcel/building) + fetch_via_rosreestr2coord
fallback на community-lib
* schema 77: nspd_geo_jobs (журнал) + nspd_geo_targets (cad-номера со
статусом pending/done/failed). Resume-state в БД.
* tasks/nspd_geo.py: Celery task process_nspd_geo_job(job_id). Heartbeat
каждые 5 items, WAF backoff 30s × 2^N (max 8 → paused). UPSERT в
cad_quarters_geom / cad_buildings → идемпотентно.
* worker_ready hook: stale jobs (>10min без heartbeat) автоматически
re-enqueue после redeploy. Никаких потерь прогресса.
* 4 admin endpoint + UI /admin/scrape/geo с формой запуска (manual_list /
rosreestr_pending для авто-наполнения из ДДУ-кварталов), progress-bars
и cancel/resume кнопками per job.
* settings: use_nspd_lite=True, nspd_lite_rate_ms=600.
* dep: rosreestr2coord>=4.0.0 (community lib для fallback).
TODO следующих шагов:
- smoke-test на проде через SSH (validation RU-IP не банит urllib)
- feature toggle USE_NSPD_LITE в scrape_nspd.py для переключения с старого
Playwright-пути
- docker lean image (-300 MB после убирания Chromium)
- расширение SCRAPE_NSPD_DEFAULT_REGIONS на 66,74,72,59 (Челябинск/Тюмень/Пермь)
Objective API (api.objctv.ru) интегрирован как новый source of truth для
per-flat данных по новостройкам УрФО. Заменяет промежуточный Anton-SQLite
(legacy bootstrap-ETL остался как fallback в свёрнутом блоке UI).
Schema (data/sql/68_v2 — applied на проде, 6 таблиц):
- objective_lots (UPSERT по lot_id; 303 677 rows)
- objective_corpus_room_month (long-формат месяц×корпус×room_bucket; 19 738 rows)
- objective_lots_history (append-only weekly snapshots для elasticity)
- objective_complex_mapping (Objective ComplexName ↔ domrf_kn_objects.obj_id)
- objective_raw_reports (jsonb страховка на смену схемы API)
- objective_scrape_runs (журнал прогонов)
+ data/sql/72_objective_sync_config (single-row динамический конфиг)
Backend:
- services/scrapers/objective.py: ObjectiveClient — Bearer-токен (Redis +
in-memory fallback), retry на 401/429/5xx, Retry-After header support
- services/objective_etl.py: ETL SQLite Антона → PG (legacy)
- services/objective_sync_config.py: read/update single-row config
- workers/tasks/scrape_objective.py:
* sync_objective_group: 2 рабочих отчёта (corp_sum, lots_pf), inline-парсинг
* sync_all_groups: wrapper, перебирает группы из БД-конфига с
inter-group паузой; PATCH-merge explicit args > DB config
- workers/tasks/objective_etl.py: Celery task для legacy bootstrap
- workers/celery_app.py: beat читает cron из БД при старте (fallback на env)
- api/v1/admin_scrape.py: 5 новых endpoints для /objective/*
Frontend (frontend/src/app/admin/scrape/objective/page.tsx):
- PRIMARY blue блок «🌐 Наш sync» с input для override групп
- Collapsible «⚙️ Настройки» с формой (cron + 8 параметров) → PUT в БД
- Coverage-панель с PG counts + строка про SQLite Антона как legacy
- Collapsible «🛠 Bootstrap ETL» — legacy-инструмент
Beat schedule: вторник 06:00 МСК, ~10-15 мин на 4 группы (Свердл.обл +
Челябинск + Тюмень + Пермь = ~700K квартир УрФО). Расписание и параметры
меняются через админку без редеплоя (cron требует restart beat).
Эмпирические находки об API (probe 2026-05-10):
- 13 из 21 проверенных group_name доступны на тарифе (включая «Свердловская
область», «Челябинск», «Тюмень», «Пермь», но НЕ «Свердловская обл» —
формат имени строгий)
- ComplexName требует БЕЗ префикса «ЖК» и БЕЗ кавычек
- Поле «Банк» для всех 303k = NULL (тариф не отдаёт), но «Тип обременения»
работает (36% строк = ипотека) → ipoteka_share возможен, банковская
атрибуция — нет
Docker:
- bind-mount /opt/gendesign/site-finder:/data/anton-sqlite:ro в worker
GitHub backlog: добавлены #22-25 (recommend_mix v3 — 4 сабтаска по
улучшению алгоритма на основе фидбэка про POI / границы районов /
конкурентов / окно данных / success-driven mix).
Knowledge graph (memory/memory-gendesign.jsonl): обновлены entities
Objective_Integration_May07_2026, Schema_Objective_v2_May07,
Objective_API_Findings_May07, Module_Objective_Client + новый
Session_End_May07_2026.
TODO для прода: прописать OBJECTIVE_API_KEY=<key> в backend/.env +
docker compose restart worker beat.