Phase 2.1 minimal foundation for #113 Layout Analysis (Макс feedback 12.05).
Без layout_type/balcony_count (отсутствуют в БД, ждут B2B Объектив #52).
Added:
- 7 Pydantic schemas в schemas/parcel.py: LayoutSignature, BestLayoutsRequest,
TopLayoutRow, LayoutTzMixRow, LayoutTzRecommendation, LayoutDataQuality,
BestLayoutsResponse
- services/site_finder/layout_signature.py: room_bucket_from_flat /
area_bin / layout_signature (3 pure-Python helpers, no DB)
- tests/test_layout_signature.py: 27 unit tests (parametrized)
No DB / API changes — foundation для PR B (mv_layout_velocity) и PR C
(POST /best-layouts endpoint).
Three distinct SQL syntax errors caught from worker logs:
1. **upsert_zouit** (bulk_harvest.py:940) — trailing comma after 4326
in ST_Transform(..., 3857), 4326,) → 'syntax error at or near ")"'.
Hit on 8+ quarters in 0108 block (66:41:0108055/61/69/77 etc).
Likely introduced by ruff-format auto-comma on closing paren.
2. **nspd_sync** (nspd_sync.py:120) — `:fetched_at_utc::timestamptz`
psycopg double-colon trap → 'syntax error at or near ":"'.
Crashed legacy on-demand harvest_quarter called from analyze.
3. **job_settings** (job_settings.py:224) — `:extra_config::jsonb`
same double-colon trap in admin settings UPDATE.
All converted to CAST(:x AS type) pattern (psycopg v3 safe).
40 tests still passing.
Co-authored-by: lekss361 <claudestars@proton.me>
cad_parcels.geom is geometry(Polygon, 4326) — strict schema. NSPD
occasionally returns Point geometry for parcels without detailed
boundary, causing INSERT failure:
psycopg.errors.InvalidParameterValue:
Geometry type (Point) does not match column type (Polygon)
This killed pilot v8 at quarter 66:41:0104002 — worker autoretry
exhausted, job hung at 25/50 (heartbeat stale).
Fix: filter geometry.type at Python level in upsert_parcel + upsert_zouit
(same pattern as upsert_quarter_geom_from_feature). Non-Polygon
geometry → geom=NULL, raw_props preserved.
Tables with permissive GEOMETRY schema (cad_buildings, cad_constructions,
cad_enk, cad_oncs) unaffected — they accept any geometry type.
Tests: 3 new (Point parcel → geom=NULL, Polygon happy path, LineString zouit).
Co-authored-by: lekss361 <claudestars@proton.me>
cadastre_jobs.phase_state is shared between ALL parallel Celery workers
of the same job. The early-exit triggered as soon as the FIRST worker
wrote phase=done via progress_cb — all subsequent workers (reading same
shared row) bailed without work.
Effect: pilot v7 made 5 NSPD requests for 50 quarters (=0.1/quarter).
Full ekb_full job #8: 4950 req / 2408 quarters = 2/quarter — 95% workers
early-exited without doing snapshot phase.
Root cause of 1.6% parcels coverage. Fix A/B/C only partially helped
because most workers never reached Phase 1/1.5.
Solution: delete the early-exit. Idempotency is guaranteed by
ON CONFLICT DO UPDATE in every upsert_* — re-enqueued tasks
write same rows without duplicates.
Closes part of #168 (root-cause fix for race condition revealed by #182 metrics).
Co-authored-by: lekss361 <claudestars@proton.me>
scrape_cadastre.py previously incremented requests_count only by
grid_walk_requests, so snapshot-only quarters (and the entire ekb_full
job after PR #179 skips grid-walk on broken geom) reported 0 NSPD
requests — misleading for observability.
Add HarvestResult.snapshot_requests counter (Phase 1 search + Phase 1.5
per-cat probes) and a total_requests property. Update Celery task to
write total_requests into cadastre_jobs.requests_count.
Tests added for the new property + Phase 1 snapshot_requests=1 assertion.
Co-authored-by: lekss361 <claudestars@proton.me>
72% of EKB quarters (1735/2408) have broken micro-precision geom in
cad_quarters_geom. NSPD returns valid quarter polygon in CAT_QUARTER_STATS
(36381) feature but Phase 4 was only consuming stats.
Add upsert_quarter_geom_from_feature: CTE-based UPDATE that transforms
3857 polygon to 4326 and writes only when existing geom is NULL or broken
(bbox width outside 100-10000m), verified via SQL-side sanity check.
Wire into Phase 4 with begin_nested savepoint: malformed NSPD GeoJSON
(self-intersecting ring) rollbacks only geom UPDATE, preserves
upsert_quarter_stats.
Closes part of #168 (follow-up data quality fix for #179).
Co-authored-by: lekss361 <claudestars@proton.me>
Switch base search_by_quarter from thematicSearchId=1 to thematic=1
(includes ZOUIT in features). Add optional category_id param that routes
ZOUIT (36940/469039/469040/469042) -> thematicSearchId=5,
ENK (39663) -> thematicSearchId=15.
Add Phase 1.5 in harvest_quarter: for each PER_CATEGORY_PROBE_CATS
category present in meta_counts (meta_total>0), run dedicated
per-category search call. Skips if category in GRID_WALK_CATS and
bbox is valid (grid-walk gives better coverage).
Curl-probe confirmed NSPD API caps at 20/category with no pagination;
categoryId param is server-ignored; thematic=1 strictly better than
thematicSearchId=1 (includes ZOUIT).
Closes part of #168 (ZOUIT/ENK coverage gap).
Co-authored-by: lekss361 <claudestars@proton.me>
* fix(cadastre): NSPD response parse — properties.category + top-level meta (#168)
* fix(cadastre): test fixtures use real NSPD shape — properties.category + top-level meta (#168)
Bot review of #177 flagged that fixtures still used legacy properties.categoryId
+ data.meta — meaning CI exercised only fallback branches, not primary paths
where the actual bug lived. Pattern of 3 consecutive "live-only" parse fixes
(#175 verify, #176 headers, #177 parse) confirmed need for test coverage.
Changes:
- test_nspd_bulk_client.py: sample_quarter_response → properties.category +
meta moved to top-level (real NSPD shape verified live)
- test_cadastre_bulk.py: 7 fixtures categoryId → category (regex replace)
- test_nspd_bulk_feature_parse_basic: primary path now exercised
Plus schema hardening per bot review:
- NSPDBulkFeature.category_id: `is not None` check (not truthy `or`)
to avoid edge case category=0; int() wrapped in try/except so
non-numeric ID (e.g. "ЗУ") doesn't crash upsert_features loop.
35/35 tests pass locally.
---------
Co-authored-by: lekss361 <claudestars@proton.me>
* fix(parcels): _parse_floors handle int (post-migration #169 schema change)
After PR #169 cad_buildings schema migration, `floors` column is INT
(was TEXT in legacy schema). Existing `_parse_floors(r.get("floors"))`
call in analyze_parcel → _neighbors_summary crashes with:
AttributeError: 'int' object has no attribute 'strip'
Fix: type union str | int | None. If int → return directly (no strip).
Preserve TEXT range parsing ("5-7" → 7) for backwards-compat with
any legacy data still in cad_buildings_old_apr26.
* test(smoke): production smoke tests for post-deploy regressions (#168)
Add tests/smoke/test_prod_smoke.py covering known regression surfaces.
Marks: prod_smoke + slow. Env: PROD_SMOKE_BASE_URL, PROD_SMOKE_ADMIN_TOKEN.
Run manually: cd backend && uv run pytest tests/smoke/ -m prod_smoke -v
* fix(cadastre): exact-match headers to nspd_lite to bypass NSPD WAF (#168)
Pilot v2 (job_id=2) failed 50/50 with HTTP 403 WAF block. Comparing
nspd_bulk_client.DEFAULT_HEADERS vs legacy nspd_lite.HEADERS (which works
on VPS IP since April 2026):
PascalCase keys → lowercase keys
Chrome/148 UA → Chrome/144 UA
No cache-control / pragma → "no-cache" both
accept-language ru first → en first
No origin → "https://nspd.gov.ru"
referer "/map" → "/map?thematic=PKK"
NSPD WAF (BotShield-class) likely fingerprints на header order + values
combined with TLS fingerprint. Matching legacy exactly minimizes deltas.
Test plan: retry pilot job after deploy, expect 0 WAF blocks for first
5 quarters.
* fix(test): exclude prod_smoke tests by default (#168)
CI ran tests/smoke/test_prod_smoke.py and they hit production
https://gendsgn.ru/api/v1/parcels/.../analyze which currently returns 500
(parse_floors regression — exactly what this PR fixes). Catch-22: PR can't
merge because smoke tests fail against pre-merge prod.
Fix: add `addopts = ["-m", "not prod_smoke"]` so default pytest excludes
them. Run manually post-deploy with: pytest -m prod_smoke -v
---------
Co-authored-by: lekss361 <claudestars@proton.me>
Pilot run #1 failed on all 50 quarters with:
NspdBulkError: [SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate
in certificate chain
NSPD prod chain on Beget VPS contains an internal/self-signed CA →
httpx default verify=True trips. Reuse pattern from legacy
app.services.scrapers.nspd_lite (uses ssl._create_unverified_context()
for same reason since April 2026).
Risk profile: NSPD public gov data, no sensitive payload outbound,
admin token не уходит к NSPD endpoints. MITM risk minimal.
Co-authored-by: lekss361 <claudestars@proton.me>
SQLAlchemy text() parser confuses ':window_interval' (named param) followed
by '::interval' (cast operator) — exactly как было в PR #152 с :weights::jsonb.
Result: psycopg видит literal ':window_interval' в SQL → syntax error →
exception caught by velocity catch → return None → UI shows null.
## Fix
':window_interval::interval' → 'CAST(:window_interval AS interval)' (2 мест:
sales_rows query + _get_ekb_median percentile).
Pre-push code-reviewer должен был catch — добавим в feedback что для каждого
text() SQL grep ':[a-z]*::' before push.
Refs: PR #158 deploy verify, e2e velocity = null root cause
Co-authored-by: lekss361 <claudestars@proton.me>
Production POST /parcels/{cad}/analyze падает с 500:
sqlalchemy.exc.InternalError: (psycopg.errors.InFailedSqlTransaction)
current transaction is aborted, commands ignored until end of transaction block
## Root cause
`compute_velocity()` SQL fails (probably на cad's без conkurrentов / sparse
sale_graph data) → exception caught в try/except → НО db.rollback() отсутствует
→ transaction остаётся в aborted state.
Следующая query (_geotech_risk на line 828) пытается выполниться → крашится
с InFailedSqlTransaction.
## Fix
Wrap velocity в `with db.begin_nested()` — SAVEPOINT pattern (consistent
with PR #124 pzz_loader fix). Failure внутри savepoint:
- Rollbacks ТОЛЬКО savepoint
- Outer transaction остаётся clean
- Subsequent queries (_geotech_risk и пр.) работают
Pattern matches feedback_subagent_delegation audit recommendation для
in-loop / per-section exception handling.
## Impact
POST /parcels/{cad}/analyze больше не 500 при velocity failure. Возвращает
`velocity: null` + остальные fields normal.
Refs: user report 2026-05-15 InFailedSqlTransaction
Co-authored-by: lekss361 <claudestars@proton.me>
Per code review audit (May 14): real SQL injection vector в `enqueue_geo_job`.
Manual `chr(39)*2` quote escape НЕ injection-safe — не защищает от backslash
escape, Unicode quote tricks. Если cad_num когда-либо придёт из user input —
exploitable.
## Change
`backend/app/workers/tasks/nspd_geo.py:298-310` — заменил f-string VALUES
concatenation на SQLAlchemy `text(...)` + list of dicts (psycopg v3 handles
batch executemany под капотом).
Pattern consistent с остальными INSERT'ами в этом же файле (_log,
_start_job, _save_quarter, _save_parcel, _save_building).
## Preserved
- ON CONFLICT (job_id, cad_num, thematic_id) DO NOTHING — verbatim
- Return value (int(job_id))
- Surrounding tx/commit/close logic
- DB schema, function signature, caller interface — unchanged
## Tests
- No test_nspd_geo.py существует — skipped
- ruff check passed
## Vault
`fixes/Bug_Nspd_Geo_Sql_Injection_May14.md` — created (status: resolved).
No backward compat / migration concerns — pure in-place query rewrite.
Co-authored-by: lekss361 <claudestars@proton.me>
Forward-fix после incident May 14 (см. vault events/Incident_Prod_Down_May14_2026).
PR #117 был полностью reverted PR #119 (Caddy depends_on frontend: service_healthy
+ failing wget --spider / → Caddy не стартовал → vsё прод down ~25 мин).
Этот PR делает то же что планировал #117, но корректно:
### Frontend healthcheck — TCP probe через node (НЕ HTTP)
```yaml
test: ["CMD", "node", "-e",
"require('net').createConnection({port:3000,host:'127.0.0.1'})
.once('connect',function(){process.exit(0)})
.once('error',function(){process.exit(1)})"]
start_period: 60s
retries: 6
interval: 15s
```
- НЕ зависит от HTTP status (200/404/308 — все OK если порт слушает)
- `node` гарантированно в alpine image (запускает server.js)
- `127.0.0.1` — никакой DNS ambiguity (busybox alpine localhost IPv6 vs IPv4)
- 60s + 6×15s = 150s window — с запасом на Next.js standalone cold start
### Caddy depends_on — backend healthy, frontend started (НЕ healthy)
```yaml
caddy:
depends_on:
backend: { condition: service_healthy } # backend имеет /health endpoint
frontend: { condition: service_started } # safety net — НЕ блокирует Caddy
```
Lesson from incident: frontend health flaky (Next.js cold start variable).
Strict service_healthy для frontend → одна misconfig = full outage обоих доменов
(gendsgn.ru + obsidian.gendsgn.ru через тот же Caddy на shared network).
### backend/worker/beat redis: service_started → service_healthy
Redis уже имеет `redis-cli ping` healthcheck. Tightening безопасно — был
artifact pre-healthcheck Redis.
### Pin rosreestr2coord>=5.0.0 (lock уже 5.3.3)
v5 убрала delay параметр; код адаптирован (rate limit на worker уровне).
Защита от silent downgrade при `uv lock --upgrade`.
### Что НЕ повторено из #117
- Caddy depends_on frontend: service_healthy — заменено на service_started
- wget --spider / — заменено на node TCP probe
### Vault обновлён
- `events/Incident_Prod_Down_May14_2026.md` — постмортем + timeline + lessons
- Update Changelog в `domains/infra/infra-MOC.md` (если есть)
Closes incident. Re-applies #117 goals safely.
Co-authored-by: lekss361 <claudestars@proton.me>
* feat(site-finder): auto-fetch cadastre geometry on-demand (#93)
Когда пользователь вводит cad-номер которого нет в БД (cad_quarters_geom /
cad_buildings / cad_parcels_geom), вместо 404 «Загрузи через NSPD geo» (dead-
end для non-admin) — теперь backend автоматически инициирует NSPD fetch,
frontend показывает loading state с polling /fetch-status каждые 2с.
Backend:
- Новый модуль app/services/site_finder/cadastre_fetch.py с helpers:
find_or_enqueue_fetch (atomic check + enqueue с дедупликацией по cad),
fetch_status (smart polling endpoint — отличает not_in_nspd от failed),
detect_thematic_id (3-сегм quarter / 4-сегм parcel / 5-сегм building),
validate_cad_format.
- Reuse: enqueue_geo_job + process_nspd_geo_job (workers/tasks/nspd_geo).
source_kind='auto_on_demand' отличает от bulk; rate_ms=200, priority=9.
- POST /parcels/{cad}/analyze graceful fallback: inline await до 25s
(fast path), затем 202 + job_id + eta_seconds для polling, либо
400/404/503 в зависимости от статуса (с Retry-After 60s на 503).
- GET /parcels/{cad}/fetch-status новый endpoint для polling.
Frontend:
- useSiteAnalysis расширен: fetchingState + cancel(). POST analyze + при 202
polling каждые 2с (max 60 итераций = 2 мин cap). status=ready → re-trigger
analyze; not_in_nspd/failed/invalid_format → typed errors.
- apiFetchWithStatus<T> + HTTPError в lib/api.ts — status-aware variant
для 200 vs 202.
- FetchingState.tsx: animated spinner, progress bar (linear до etaSeconds,
asymptote после), elapsed counter, cancel button. Светло-голубая
scheme отличается от обычного pending skeleton.
- site-finder/page.tsx: FetchingState когда fetchingState активен; обычный
pending skeleton — только при первичном analyze без 202.
Edge cases (per #93 acceptance):
- cad валидный в НСПД, fetch <25s → inline 200 OK
- cad валидный, fetch >25s → 202 + frontend polls → ready → analyze
- cad валидный, не в НСПД → 404 с понятным сообщением + формат hint
- cad invalid format → 400 + формат hint
- NSPD rate-limited / failed → 503 + Retry-After 60s
- Параллельные запросы на тот же cad → один job, оба клиента poll'ят (дедуп
через find_active_on_demand_job).
Closes#93.
* fix(site-finder): address PR #95 auto-review minor feedback
Backend (cadastre_fetch.py):
1. (race condition) Advisory lock `pg_try_advisory_xact_lock(hashtext(cad_num))`
обёрнут вокруг шагов "check active job → enqueue" в find_or_enqueue_fetch.
Lock transaction-scoped, released at COMMIT. Параллельные запросы на тот
же cad: первый получает lock и enqueue; второй lock=false → re-check
active job (уже виден после первого COMMIT) → возвращает тот же job_id.
Docstring обновлён, упоминание SELECT FOR UPDATE удалено.
Backend (parcels.py):
3. (threadpool exhaustion) _INLINE_FETCH_WAIT_S снижен 25 → 15s с подробным
комментарием: tradeoff про default Starlette anyio threadpool (40 slots)
и concurrent burst saturation. 15s баланс: НСПД avg 5-15s для quarter,
~70% fast path; остальные 30% получают 202 + polling без блока.
Data (87_on_demand_indexes.sql):
2. (missing index) New migration:
- `nspd_geo_targets_cad_num_idx ON nspd_geo_targets(cad_num)` — для
find_active/recent_completed_job (existing UNIQUE composite не покрывает
WHERE cad_num=:c).
- `nspd_geo_jobs_source_status_idx ON nspd_geo_jobs(source_kind, status)`
composite для filter auto_on_demand + queued/running.
Idempotent (CREATE INDEX IF NOT EXISTS), не блокер при текущем размере,
критично при росте on-demand traffic.
Frontend (useSiteAnalysis.ts):
4. (UI flicker) setFetchingState(null) перемещён ПОСЛЕ `await second
apiFetch`. Иначе между clear и resolve есть момент когда mutation
isPending=true + fetchingState=null → пустой экран ~1 RTT.
NOT addressed (rebuttal):
5. (Tailwind convention) — проверил: в проекте нет ни globals.css, ни
@tailwind directives. ВСЕ существующие site-finder components используют
inline styles (ConfidenceBadge, GeotechRiskBlock, ScoreBreakdownPanel etc).
Tailwind в deps но не wired up. Keep inline styles для consistency.
6. (animate-spin) — требует Tailwind globals (см. #5). `<style jsx>`
keyframes — built-in Next.js, работает.
Per auto-review on 2252236.
---------
Co-authored-by: lekss361 <claudestars@proton.me>
* feat(site-finder): D4 pipeline 24mo — future competition (#36)
Backend (parcels.py):
- Запрос к domrf_kn_objects в радиусе 5км с ready_dt BETWEEN NOW() AND NOW()+24mo.
- _aggregate_pipeline() — сводка: objects_count, flats_total, by_class (эконом/
комфорт/бизнес/...), by_quarter (хронологически, для UI bar), severity
(low <500 / medium <3000 / high) per spec, top_objects (десятка по flat_count desc).
- Поле analyze.pipeline_24mo. Backward-compat — optional.
Frontend:
- Pipeline24moBlock.tsx — severity badge + 3 summary numbers (объектов, квартир,
горизонт/радиус), by-class chips, гистограмма bar по кварталам сдачи
(нормирована на max), разворачиваемый top-N список с классом + датой сдачи.
- Добавлен в MarketTab выше "Market trend".
- TS типы: Pipeline24mo, PipelineObject, PipelineQuarterSlot.
Closes#36. Relates to #19 (Конкурентный 360 — закрыт ранее в #36 scope).
* fix(site-finder): address PR #90 auto-review feedback
Must-fix (3):
1. distance_m falsy guard: `if obj.get("distance_m") is not None` вместо
`if obj.get("distance_m")` — centroid-on-building даёт 0.0 (falsy float),
raw Decimal иначе упал бы в JSON serialization.
2. SQL plan note добавлен про seq scan ~3000 строк OK; при росте — нужен
GIST/index на (latitude, longitude) — отдельный issue для database-expert
(будет создан separately).
3. obj_class NULL bug помечен в docstring _aggregate_pipeline с reference на
fixes/Bug_Kn_API_Obj_Class_Always_Null_OPEN. D6/#38 — fix плановый.
Cleanup (3 из 5):
4. CLASS_LABEL.null dead key убран — JSON null приходит as absent key, не
"null" string.
6. Magic numbers вынесены: PIPELINE_RADIUS_M=5000, PIPELINE_HORIZON_MONTHS=24,
PIPELINE_SEVERITY_MEDIUM_THRESHOLD=500, PIPELINE_SEVERITY_HIGH_THRESHOLD=3000,
PIPELINE_TOP_OBJECTS_LIMIT=10. SQL query теперь через f-string подставляет
их (защищённое от injection — это int литералы).
8. obj.ready_dt formatting через fmtMonth() с new Date + toLocaleDateString —
robust к datetime suffix vs date-only, fallback к substring(0,7) при NaN.
Не сделано (defer):
5. Async 3 HTTP calls (pre-existing pattern, нужен ThreadPoolExecutor refactor
отдельным PR — затрагивает weather/air_quality fetch architecture).
7. ST_GeomFromText дважды — CSE справляется на этом масштабе.
Per auto-review on ade511b.
* fix(site-finder): address PR #90 auto-review minor feedback
1. TS PipelineObject.distance_m — `number | null` для отражения defensive
Python guard (`if obj.get("distance_m") is not None`). Comment объясняет
почему.
2. Pipeline SQL: `text(f"...")` → `text("...")` + parameters. radius_m и
horizon_months через `:param` placeholders + `cast(:horizon_months || ' months'
AS interval)`. Consistency с остальными SQL в файле, plus защита от
accidental injection при будущих изменениях.
3. top_objects: explicit field selection вместо `dict(r) for r in rows`.
Раньше leak'ило все колонки из CTE `SELECT *` (latitude/longitude/
snapshot_date/region_cd/dev_id) в API response. Теперь только nominated
fields: obj_id, comm_name, dev_name, obj_class, flat_count, ready_dt,
distance_m. Schema clean.
Per auto-review on 4e431bf.
---------
Co-authored-by: lekss361 <claudestars@proton.me>
Backend (parcels.py):
- POI scoring loop teper строит score_breakdown_detailed: per-factor list с
verbal explain (через _verbal_for_poi helper) и группировкой
(_POI_GROUP: Социалка / Торговля / Парки / Транспорт / Шум/трамвай / Локация).
- center_bonus добавлен как synthetic factor группы "Локация" с weight=1.0
(decay не применяется — bonus IS the value).
- factor key включает enumerate idx — prevents React key collision когда
два POI одной категории совпадают по округлённому расстоянию.
- Skip факторов с |contribution| < 0.01 (POI > 1км) — UI шуму не нужен.
- abs_total fallback на 1.0 — защита от division-by-zero для empty factors.
- Top-3 positives/negatives: explicit ascending sort для негативов
("most-negative first" очевидно из кода).
- score_by_group: stacked-bar данные с count + contribution_pct.
- group_totals type: dict[str, dict[str, float | int]] (count это int).
Frontend:
- Новый ScoreBreakdownPanel.tsx: stacked-bar по группам с tooltip + legend,
топ-3 плюса (▲ зелёный) / топ-3 минуса (▼ красный) с verbal, отдельная
строка "Снижают балл — Шум/трамвай: ..." для negative groups, разворачиваемая
таблица всех факторов (sticky thead, scrollable).
- Интегрирован в OverviewTab под секцией "Балл".
- TS типы: FactorContribution, ScoreGroupTotal.
Closes#47.
NB: branch создан заново из-за rebase mess (см. PR #87 comments). Логически
эквивалентно но history clean.
Co-authored-by: lekss361 <claudestars@proton.me>
* feat(site-finder): P1 geometry suitability score (#45)
Backend (parcels.py):
- _polygon_suitability() — Shapely metrics on parcel WGS84 polygon:
area_ha/m2 (projected via cos(lat) к UTM-like meters), perimeter_m,
aspect_ratio (long/short side of MABR), convex_hull_ratio (area / hull area),
min_inscribed_rect_dim_m.
- Composite suitability score 0..1:
base = area_subscore (0 при <0.2 ha, linear → 1 при ≥0.5 ha)
−0.3 если aspect_ratio > 5 (вытянутый)
−0.3 если convex_hull_ratio < 0.65 (изрезанный)
−0.5 если min_inscribed_rect_dim_m < 30 (узкий)
- Label: микро / подходящий / сложная форма / слабо подходит
- Recommendation: "для МКД 16+ нужно >0.3 ha + минимум 40м"
- В analyze response → geometry_suitability.
Frontend:
- Новый GeometrySuitabilityBlock.tsx с color-coded badge + метрики grid +
penalties + recommendation
- Добавлен в LandTab (выше "Зонирование (ПЗЗ)")
- TS типы расширены: GeometrySuitability
Closes#45.
* fix(site-finder): address PR #89 auto-review minor feedback
Backend (parcels.py):
1. Фактическая ошибка фикс: recommendation теперь различает "строительный
минимум 30м" (physical penalty trigger) и "комфорт МКД 12-16эт 40м"
(recommendation level). Пользователю чётко видны 2 порога.
2. Lazy imports → module-level: `from shapely import wkt as _shp_wkt`,
`from shapely.geometry import Polygon`.
3. MABR inner exception теперь логирует: `logger.debug("MABR computation
failed, falling back to sqrt(area): %s", mabr_err)`.
4. Magic numbers вынесены в константы: _GEOM_MIN_AREA_HA, _GEOM_AREA_SCORE_FULL_HA,
_GEOM_ASPECT_PENALTY_THRESHOLD, _GEOM_CONVEX_PENALTY_THRESHOLD,
_GEOM_MIN_WIDTH_PHYSICAL_M, _GEOM_MIN_WIDTH_COMFORT_M, _GEOM_LABEL_MICRO_HA,
_GEOM_LABEL_GOOD, _GEOM_LABEL_MEDIUM, плюс penalty константы.
5. Label "микро" теперь комбинируется с penalties: "микро, узкий" — UI видит
обе проблемы. Pure "микро" остаётся когда нет penalty.
Frontend (GeometrySuitabilityBlock.tsx + types):
6. Заменил emoji ⚠ (U+26A0) на текстовый <strong>"Проблемы формы:"</strong> —
стабильнее в WeasyPrint PDF-экспорте и cross-platform.
- TS типы: GeometrySuitabilityBaseLabel экспортирован, label расширен до
`string` для допуска combo-labels; helper colorForLabel() парсит base часть.
Per auto-review on f4e7491.
* fix(site-finder): P1 #45 — extract 0.3 ha to _GEOM_AREA_COMFORT_HA
Auto-review нашёл: recommendation упоминала "от 0.3 га" но в constants block
не было 0.3 — magic literal в строке. Тот же класс проблемы что 30/40м inconsistency
из прошлого review, только в другом поле.
Fix: новая константа `_GEOM_AREA_COMFORT_HA = 0.3` с комментарием
"рекомендуемая комфортная площадь МКД (recommendation)". Размещена между
_GEOM_MIN_AREA_HA (физический минимум) и _GEOM_AREA_SCORE_FULL_HA (premium) —
третий semantic threshold. Recommendation теперь f-string использует константу.
Per auto-review on ae5e8de.
---------
Co-authored-by: lekss361 <claudestars@proton.me>
* 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>