From 485c221c3bcd3ecb3e1d0f990810e441826e5e96 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Tue, 12 May 2026 00:43:25 +0300 Subject: [PATCH 1/5] docs(claude): formalize Auto-review passed as approval signal (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(claude): formalize "Auto-review passed" as approval signal + polling loop Per user feedback after PR #84 merge: - Critical rule 5: "Auto-review passed" / "LGTM" от auto-review bot = approval (наряду с явным "merge it" / "approved" от human user) - Add "Polling loop для PR" section: 60s ScheduleWakeup pattern, decisive states (merged / approved / changes / no-news), cap 30 iters This formalizes what main session already did: after fixup commit, poll comments, detect auto-review verdict, and auto-merge if passed. * fix(claude): address PR #85 auto-review feedback - Fix cyrillic/latin typo (rule 5 second sub-bullet) - Add SHA marker validation to polling loop (race condition on stale LGTM after fixup-push) - Add explicit "Auto-merge scope" rule 6 — allowlist (docs/UI/.claude) vs blocklist (migrations/API/infra/secrets) - Sync "Когда auto-mode" section with new merge approval signals - Lower cap to 5 iters for scope-blocked PRs (human required anyway) - Self-modifying CLAUDE.md rules → always require human Per auto-review on e66e1ea. --------- Co-authored-by: lekss361 --- CLAUDE.md | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c650312e..4eddb7f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,8 +222,39 @@ User approves / requests changes 2. **Worker-agents (backend/frontend/devops/database) НЕ делают `git commit` сами.** Они оставляют изменения staged — main session коммитит на feature branch. 3. **Никогда не используй `--no-verify` / `--no-edit` / `--amend`** — pre-commit hooks обязательны. Если hook падает — fix root cause, не bypass. 4. **Никогда не пуш с `--force` ни в main ни в feature branch без approval.** -5. **Не merge PR без явного "merge it" / "ok merge" / "approved" от пользователя.** -6. **Не вызывай destructive команды без явного approval**: `git reset --hard`, `git clean -fdx`, `DROP TABLE`, `TRUNCATE`, `rm -rf` за пределами `node_modules/.next`. +5. **Merge PR только по approval.** Approval сигналы: + - **Human user всегда валиден:** "merge it" / "ok merge" / "approved" / "залей" / "мердж" + - **Auto-review bot LGTM = approval, НО с ограничениями** (см. "Auto-merge scope" ниже). Bot LGTM валиден **только** если SHA marker в теле комментария совпадает с current PR HEAD SHA — иначе это устаревший approval до fixup-push. +6. **Auto-merge scope (где боту разрешено self-merge без человека):** + - ✅ **Разрешено** (PR diff целиком в этих путях): + - `CLAUDE.md`, `README.md`, `docs/**` (документация / правила) + - `frontend/src/app/**` UI-only changes без новых API endpoints + - `frontend/public/**` (статика) + - `.claude/agents/**`, `memory/feedback_*.md` (workflow rules) + - ❌ **Запрещено — нужен human approval** (любой файл из списка → block auto-merge): + - `data/sql/**`, `backend/alembic/versions/**` (миграции / схема DB) + - `backend/app/api/v1/**`, `backend/app/services/**` (бизнес-логика API) + - `backend/app/scrapers/**` (внешние интеграции) + - `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**` (deploy / infra) + - Любые файлы с упоминанием secret / token / password / credential / X-Admin-Token + - PRs которые меняют `CLAUDE.md` рядом с `Critical workflow rules` / `Auto-merge scope` / auth — это саморасширение правил, **нужен human** + - Если PR touches both — берётся more restrictive (block). +7. **Не вызывай destructive команды без явного approval**: `git reset --hard`, `git clean -fdx`, `DROP TABLE`, `TRUNCATE`, `rm -rf` за пределами `node_modules/.next`. + +### Polling loop для PR + +После создания PR / fixup commits — запускай ScheduleWakeup с 60с интервалом: +1. `gh pr view --json state,mergeable,comments,headRefOid` → возьми `headRefOid` (current SHA) и latest_comment. +2. `state == MERGED` → stop polling. +3. **Approval check** (новый comment от auto-review): + - Распарси HTML marker: `` + - **SHA guard:** `marker.sha7 == headRefOid[:7]` — иначе это устаревший approval до fixup-push, игнорируй. + - **Scope guard:** проверь `gh pr view --json files` — если хоть один путь попадает в "запрещённый" список (см. rule 6 "Auto-merge scope") → block auto-merge, ping user. + - `verdict=approve` + SHA match + scope OK → `gh pr merge --squash --delete-branch`, stop. + - Если в комменте только текст "Auto-review passed" / "LGTM" без HTML marker — НЕ доверяй (legacy / поддельный сигнал), ping user. +4. `verdict=changes` → fixup commits на той же ветке, push, reply, re-poll. +5. Нет новых комментов с last polled timestamp → re-schedule 60с poll. +6. **Cap:** 30 итераций без resolution → stop, ask user. Для PR со scope-запрещёнными файлами cap = 5 итераций (не имеет смысла долго ждать, всё равно нужен human). ### Когда auto-mode @@ -233,7 +264,8 @@ User approves / requests changes - `git push -u origin ` - `gh pr create` - Прогонять code-reviewer -- **НО — финальный `gh pr merge` только по явному approval от пользователя.** +- **Auto-merge** через `gh pr merge --squash` — только если PR в "Auto-merge scope ✅" (см. rule 6) И auto-review bot вернул `verdict=approve` с валидным SHA marker. +- **Финальный merge для PR из scope ❌** (миграции / API / infra / secrets) — только по явному approval от пользователя ("merge it" / "залей" / etc). ## Pre-commit hooks From 3777a77a424becf2c6607bbe4d33e4e6b7cda864 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Tue, 12 May 2026 01:04:25 +0300 Subject: [PATCH 2/5] feat(site-finder): X2 confidence indicator + caveats (#48) (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/v1/parcels.py | 134 +++++++++++++- .../site-finder/ConfidenceBadge.tsx | 167 ++++++++++++++++++ .../components/site-finder/OverviewTab.tsx | 11 ++ frontend/src/types/site-finder.ts | 5 + 4 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/site-finder/ConfidenceBadge.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 173e1af8..88142ab0 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -140,7 +140,7 @@ def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None: "period": "1995-2024 (30 лет)", "model": "MRI-AGCM3-2-S", "source": "open-meteo-climate", - "note": ("Климатические нормали. " "Текущая погода — отдельный API."), + "note": ("Климатические нормали. Текущая погода — отдельный API."), } except Exception as e: logger.warning("seasonal weather fetch failed: %s", e) @@ -240,6 +240,21 @@ def _score_label(s: float) -> str: return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично" +def _confidence_label(c: float) -> str: + """Текстовая интерпретация confidence (0..1). + + Пороги: + high — c > 0.75 (плотные актуальные данные) + medium — 0.4-0.75 + low — c < 0.4 (caveats обязательны) + """ + if c >= 0.75: + return "high" + if c >= 0.4: + return "medium" + return "low" + + # Веса POI-категорий для scoring (Максим: трамвай = минус) _POI_WEIGHTS: dict[str, float] = { "school": 1.5, @@ -321,6 +336,105 @@ def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any } +def _compute_confidence( + *, + source: str, + poi_rows: list[dict[str, Any]], + district_row: dict[str, Any] | None, + competitor_rows: list[dict[str, Any]], + noise_sources_count: int, + air_q: dict[str, Any] | None, + weather: dict[str, Any] | None, + market_trend: dict[str, Any] | None, + zoning: dict[str, Any], +) -> dict[str, Any]: + """X2 (#48) — composite confidence score 0..1 + caveats. + + Stub-версия (до реализации G1/G2/D1/D2): использует сигналы которые уже + доступны на main. Композитный балл = avg of subscore'ов; caveats — list + конкретных проблем для UI ("Нет данных N, score K ненадёжен"). + """ + import datetime as _dt + + caveats: list[str] = [] + subscores: dict[str, float] = {} + + # 1) POI freshness — % POI с last_osm_edit_date в последние 2 года. + # Для участков с малым числом POI (<5) — снижаем confidence как coverage. + poi_total = len(poi_rows) + if poi_total == 0: + subscores["poi_freshness"] = 0.0 + caveats.append("OSM POI не найдены в радиусе 1км — скоринг неприменим") + else: + cutoff = _dt.date.today() - _dt.timedelta(days=730) + fresh = sum( + 1 for p in poi_rows if p.get("last_osm_edit_date") and p["last_osm_edit_date"] >= cutoff + ) + ratio = fresh / poi_total + # coverage penalty: <5 POI слабая статистика + coverage_factor = min(1.0, poi_total / 10.0) + subscores["poi_freshness"] = round(ratio * coverage_factor, 2) + if poi_total < 5: + caveats.append(f"Мало OSM POI в радиусе 1км ({poi_total}) — социалка-фактор ненадёжен") + elif ratio < 0.5: + caveats.append("Большая часть POI (>50%) старше 2 лет — данные OSM требуют обновления") + + # 2) Geometry source confidence — участок > квартал + subscores["geom_source"] = 0.9 if source == "cad_building" else 0.6 + if source == "cad_quarter": + caveats.append( + "Геометрия quartal-level (нет parcel shape) — окружение усреднено по кварталу" + ) + + # 3) District context — известен ли район + subscores["district"] = 1.0 if district_row else 0.3 + if not district_row: + caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны") + + # 4) Market trend — есть ли rosreestr_deals + if market_trend and market_trend.get("recent_deals_count"): + n_recent = int(market_trend["recent_deals_count"]) + # порог 5 сделок за 6 мес — достаточно для тренда + subscores["market_trend"] = min(1.0, n_recent / 10.0) + if n_recent < 5: + caveats.append(f"Мало ДДУ за 6 мес ({n_recent}) — тренд рынка статистически слабый") + else: + subscores["market_trend"] = 0.0 + caveats.append("Нет ДДУ в 3км — тренд рынка недоступен") + + # 5) Competitors coverage + n_competitors = len(competitor_rows) + subscores["competitors"] = min(1.0, n_competitors / 5.0) + if n_competitors == 0: + caveats.append("Нет конкурентов-ЖК в 3км — низкая урбанизация / окраина") + + # 6) Environmental data freshness + env_ok = sum([bool(noise_sources_count > 0), bool(air_q), bool(weather)]) + subscores["environment"] = env_ok / 3.0 + if noise_sources_count == 0: + caveats.append("Шумовая карта не загружена — noise score = stub") + if not air_q: + caveats.append("Air Quality API недоступен — exposure unknown") + + # 7) ПЗЗ coverage — placeholder до G1 + has_zoning = bool(zoning.get("data_available")) if zoning else False + subscores["zoning"] = 1.0 if has_zoning else 0.2 + if not has_zoning: + caveats.append( + "ПЗЗ zone_code не известен — нельзя оценить разрешённое использование (G1 pending)" + ) + + composite = sum(subscores.values()) / len(subscores) + composite = round(max(0.0, min(1.0, composite)), 2) + + return { + "value": composite, + "label": _confidence_label(composite), + "breakdown": subscores, + "caveats": caveats, + } + + @router.post("/search", response_model=ParcelSearchResponse) async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse: """Search parcels by filters + scoring. @@ -906,6 +1020,19 @@ def analyze_parcel( score_final = score + center_bonus + # X2 (#48): composite confidence + caveats + confidence_info = _compute_confidence( + source=source, + poi_rows=[dict(p) for p in poi_rows], + district_row=dict(district_row) if district_row else None, + competitor_rows=[dict(c) for c in competitor_rows], + noise_sources_count=len(noise_rows), + air_q=air_q, + weather=weather, + market_trend=market_trend, + zoning=zoning, + ) + return { "cad_num": cad_num, "source": source, @@ -946,6 +1073,11 @@ def analyze_parcel( "zoning": zoning, "success_recommendation": success_recommendation, "isochrones_available": bool(settings.openrouteservice_api_key), + # X2 (#48) — confidence indicator + "confidence": confidence_info["value"], + "confidence_label": confidence_info["label"], + "confidence_breakdown": confidence_info["breakdown"], + "confidence_caveats": confidence_info["caveats"], } diff --git a/frontend/src/components/site-finder/ConfidenceBadge.tsx b/frontend/src/components/site-finder/ConfidenceBadge.tsx new file mode 100644 index 00000000..195e18a7 --- /dev/null +++ b/frontend/src/components/site-finder/ConfidenceBadge.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; + +interface Props { + value: number; + label: "high" | "medium" | "low"; + breakdown?: Record; + caveats?: string[]; +} + +const LABEL_RU: Record = { + high: "высокая", + medium: "средняя", + low: "низкая", +}; + +const COLOR: Record< + Props["label"], + { bg: string; fg: string; border: string } +> = { + high: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" }, + medium: { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" }, + low: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" }, +}; + +const BREAKDOWN_RU: Record = { + poi_freshness: "Свежесть OSM POI", + geom_source: "Точность геометрии", + district: "Известность района", + market_trend: "Глубина ДДУ", + competitors: "Покрытие конкурентами", + environment: "Экологические данные", + zoning: "ПЗЗ / зонирование", +}; + +export function ConfidenceBadge({ value, label, breakdown, caveats }: Props) { + const [expanded, setExpanded] = useState(false); + const c = COLOR[label]; + const pct = Math.round(value * 100); + const hasDetails = + (caveats && caveats.length > 0) || + (breakdown && Object.keys(breakdown).length > 0); + + return ( +
+
+
+ + Достоверность + + + {pct}% · {LABEL_RU[label]} + +
+ {hasDetails && ( + + )} +
+ + {/* Caveats — показываем сразу для low, под toggle для medium/high */} + {caveats && caveats.length > 0 && (label === "low" || expanded) && ( +
    + {caveats.map((cv, i) => ( +
  • {cv}
  • + ))} +
+ )} + + {/* Breakdown — под toggle */} + {expanded && breakdown && Object.keys(breakdown).length > 0 && ( +
+ {Object.entries(breakdown).map(([k, v]) => ( +
+ {BREAKDOWN_RU[k] ?? k} + + {Math.round(v * 100)}% + +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/OverviewTab.tsx b/frontend/src/components/site-finder/OverviewTab.tsx index d5631f7b..70e47687 100644 --- a/frontend/src/components/site-finder/OverviewTab.tsx +++ b/frontend/src/components/site-finder/OverviewTab.tsx @@ -2,6 +2,7 @@ import type { FeatureCollection } from "geojson"; import type { ParcelAnalysis } from "@/types/site-finder"; +import { ConfidenceBadge } from "./ConfidenceBadge"; import { IsochronesPanel } from "./IsochronesPanel"; interface Props { @@ -37,6 +38,16 @@ export function OverviewTab({ data, onIsochronesResult }: Props) { return (
+ {/* X2 (#48): confidence indicator на самом верху Overview */} + {data.confidence !== undefined && data.confidence_label && ( + + )} + {/* District info */} {data.district && (
; + confidence_caveats?: string[]; } export type PoiCategory = From 843314c0407ba0812f7c791cb0cdba1530aa7ad3 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Tue, 12 May 2026 01:30:53 +0300 Subject: [PATCH 3/5] =?UTF-8?q?feat(site-finder):=20P2=20cad=5Fbuildings?= =?UTF-8?q?=20=D1=81=D0=BE=D1=81=D0=B5=D0=B4=D0=B8=20+=20overlap=20check?= =?UTF-8?q?=20(#46)=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- backend/app/api/v1/parcels.py | 178 +++++++++ .../src/components/site-finder/LandTab.tsx | 11 +- .../components/site-finder/NeighborsBlock.tsx | 371 ++++++++++++++++++ frontend/src/types/site-finder.ts | 36 ++ 4 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/site-finder/NeighborsBlock.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 88142ab0..13bac11e 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -289,6 +289,182 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = { } +# P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда +# содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны +# эмпирически для ЕКБ. +_COST_PER_M2_MIN = 1000 # ₽/м² — ниже скорее всего ошибка ввода +_COST_PER_M2_MAX = 500_000 # ₽/м² — выше скорее всего outlier + + +def _parse_floors(raw: str | None) -> int | None: + """cad_buildings.floors хранится TEXT (могут быть диапазоны '1-2', '5-7'). + + Возвращаем верхнюю границу (более консервативный сосед-высотка). + NB: `isdigit()` намеренно фильтрует malformed parts типа "5а-7"; для + multi-range "1-2-3" возвращается max(1,2,3)=3 (acceptable degradation). + """ + if not raw: + return None + raw = raw.strip() + # range like "5-7" → 7 + if "-" in raw: + parts = raw.split("-") + try: + return max(int(p.strip()) for p in parts if p.strip().isdigit()) + except ValueError: + return None + # single int + try: + return int(raw) + except ValueError: + return None + + +def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str, Any]: + """P2 (#46) — cad_buildings соседи в 100м + overlap check. + + Возвращает aggregate (avg/max floors, median cost/m², count) + плоский + список соседей для UI + флаг has_existing_buildings (overlap >50 м²). + + Использует GIST на cad_buildings.geom (уже создан в schema). + """ + try: + neighbor_rows = ( + db.execute( + text(""" + SELECT cad_num, + building_name, + floors, + year_built, + cost_value, + area, + readable_address, + ST_Distance( + b.geom::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) AS distance_m + FROM cad_buildings b + WHERE ST_DWithin( + b.geom::geography, + ST_GeomFromText(:wkt, 4326)::geography, + 100 + ) + AND b.cad_num != :our_cad + ORDER BY distance_m ASC + LIMIT 30 + """), + {"wkt": geom_wkt, "our_cad": our_cad_num}, + ) + .mappings() + .all() + ) + except Exception as e: + logger.warning("neighbors query failed: %s", e) + return {"data_available": False, "note": f"neighbors query failed: {e}"} + + # Aggregate floors + cost. Дефенсивный try/except: если cost_value/area + # придёт как non-numeric (e.g. "N/A"), float() бросит ValueError и без + # этого guard весь endpoint вернёт 500. + try: + floors_parsed: list[int] = [] + costs_per_m2: list[float] = [] + for r in neighbor_rows: + f = _parse_floors(r.get("floors")) + if f is not None and f > 0: + floors_parsed.append(f) + if r.get("cost_value") and r.get("area") and float(r["area"]) > 0: + cost_per_m2 = float(r["cost_value"]) / float(r["area"]) + if _COST_PER_M2_MIN < cost_per_m2 < _COST_PER_M2_MAX: + costs_per_m2.append(cost_per_m2) + + avg_floors = round(sum(floors_parsed) / len(floors_parsed), 1) if floors_parsed else None + max_floors = max(floors_parsed) if floors_parsed else None + median_cost = round(sorted(costs_per_m2)[len(costs_per_m2) // 2]) if costs_per_m2 else None + except (ValueError, TypeError) as e: + logger.warning("neighbors aggregation failed: %s", e) + return { + "data_available": False, + "note": f"neighbors aggregation failed: {e}", + } + + # Overlap check — что-то построено непосредственно на нашем участке. + # Если хоть один building пересекается с площадью >50 м² — hard warn. + try: + overlap_row = ( + db.execute( + text(""" + SELECT cad_num, + building_name, + floors, + readable_address, + ST_Area( + ST_Intersection( + ST_Transform(b.geom, 32641), + ST_Transform(ST_GeomFromText(:wkt, 4326), 32641) + ) + ) AS overlap_m2 + FROM cad_buildings b + WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326)) + AND b.cad_num != :our_cad + ORDER BY overlap_m2 DESC NULLS LAST + LIMIT 5 + """), + {"wkt": geom_wkt, "our_cad": our_cad_num}, + ) + .mappings() + .all() + ) + except Exception as e: + logger.warning("overlap check failed: %s", e) + overlap_row = [] + + overlap_buildings = [ + { + "cad_num": o["cad_num"], + "building_name": o.get("building_name"), + "floors": o.get("floors"), + "readable_address": o.get("readable_address"), + "overlap_m2": round(float(o["overlap_m2"])) if o.get("overlap_m2") else None, + } + for o in overlap_row + if o.get("overlap_m2") and float(o["overlap_m2"]) > 50 + ] + has_existing = len(overlap_buildings) > 0 + + return { + "data_available": True, + "radius_m": 100, + "count_buildings_100m": len(neighbor_rows), + "avg_floors_100m": avg_floors, + "max_floors_100m": max_floors, + "median_cost_per_m2_100m": median_cost, + "neighbors": [ + { + "cad_num": r["cad_num"], + "building_name": r.get("building_name"), + "floors": r.get("floors"), + "floors_parsed": _parse_floors(r.get("floors")), + "year_built": r.get("year_built"), + "area_m2": round(float(r["area"])) if r.get("area") else None, + "cost_per_m2": ( + round(float(r["cost_value"]) / float(r["area"])) + if r.get("cost_value") and r.get("area") and float(r["area"]) > 0 + else None + ), + "distance_m": round(float(r["distance_m"])), + "readable_address": r.get("readable_address"), + } + for r in neighbor_rows[:20] + ], + "has_existing_buildings": has_existing, + "overlap_buildings": overlap_buildings, + "note": ( + "Cad_buildings 100м radius. Floors хранится как TEXT (диапазоны типа '5-7') — " + "agg использует верхнюю границу. Cost/m² — кадастровая стоимость, не рыночная." + ), + } + + def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]: """Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость. @@ -1069,6 +1245,8 @@ def analyze_parcel( "hydrology": hydrology, "utilities": utilities, "geotech_risk": _geotech_risk(66, db, geom_wkt), + # P2 (#46) — соседи-здания + overlap check + "neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num), "market_trend": market_trend, "zoning": zoning, "success_recommendation": success_recommendation, diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 20273656..7cd76b64 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -3,16 +3,25 @@ import type { ParcelAnalysis } from "@/types/site-finder"; import { GeologyBlock } from "./GeologyBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; +import { NeighborsBlock } from "./NeighborsBlock"; interface Props { data: ParcelAnalysis; } export function LandTab({ data }: Props) { - const hasAny = data.geotech_risk !== undefined || data.geology !== undefined; + const hasAny = + data.geotech_risk !== undefined || + data.geology !== undefined || + data.neighbors_summary !== undefined; return (
+ {/* P2 (#46) — Соседи + overlap warning */} + {data.neighbors_summary && ( + + )} + {/* Zoning note */}
= 1000) { + return `${(n / 1000).toFixed(0)} тыс ₽/м²`; + } + return `${n.toLocaleString("ru-RU")} ₽/м²`; +} + +function fmtYearBuilt(y: number | null | undefined): string { + if (y == null || y === 0) return "—"; + return String(y); +} + +// Русский plural: 1 → "здание", 2-4 → "здания", 5+ → "зданий". +// Также корректно для 11-14 (zданий), 21 (здание), 22 (здания) etc. +function pluralBuildings(n: number): string { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod100 >= 11 && mod100 <= 14) return "зданий"; + if (mod10 === 1) return "здание"; + if (mod10 >= 2 && mod10 <= 4) return "здания"; + return "зданий"; +} + +export function NeighborsBlock({ data }: Props) { + const [expanded, setExpanded] = useState(false); + + if (!data.data_available) { + return ( +
+
+ Соседи (100 м) +
+
+ {data.note ?? "Данные о соседних зданиях недоступны"} +
+
+ ); + } + + const count = data.count_buildings_100m ?? 0; + const hasOverlap = !!data.has_existing_buildings; + + return ( +
+
+ + Соседи (100 м) + + + {count} {pluralBuildings(count)} + +
+ + {/* Overlap warning — hard warn */} + {hasOverlap && + data.overlap_buildings && + data.overlap_buildings.length > 0 && ( +
+
+ На участке уже есть здания (overlap >50 м²) +
+
    + {data.overlap_buildings.map((b) => ( +
  • + {b.building_name ?? b.readable_address ?? b.cad_num} + {b.floors ? `, ${b.floors} эт.` : ""} + {b.overlap_m2 ? ` — пересечение ~${b.overlap_m2} м²` : ""} +
  • + ))} +
+
+ Инвестиции невозможны без сноса. +
+
+ )} + + {/* Summary metrics */} + {count > 0 && ( +
+
+
+ Средн. этажность +
+
+ {data.avg_floors_100m ?? "—"} +
+
+
+
Макс. этажей
+
+ {data.max_floors_100m ?? "—"} +
+
+
+
+ Медиана цены +
+
+ {fmtPrice(data.median_cost_per_m2_100m)} +
+
+
+ )} + + {/* Toggle neighbor list */} + {data.neighbors && data.neighbors.length > 0 && ( +
+ + {expanded && ( +
+ + + + + + + + + + + + {data.neighbors.map((n) => ( + + + + + + + + ))} + +
+ Здание + + Эт. + + Год + + ₽/м² + + Дист. +
+ {n.building_name ?? n.readable_address ?? n.cad_num} + + {n.floors ?? "—"} + + {fmtYearBuilt(n.year_built)} + + {fmtPrice(n.cost_per_m2)} + + {n.distance_m} м +
+
+ )} +
+ )} + + {data.note && ( +
+ {data.note} +
+ )} +
+ ); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index a2a83ec1..a9310812 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -127,6 +127,40 @@ export interface GeotechRisk { note: string; } +// P2 (#46) — cad_buildings соседи + overlap +export interface NeighborBuilding { + cad_num: string; + building_name: string | null; + floors: string | null; + floors_parsed: number | null; + year_built: number | null; + area_m2: number | null; + cost_per_m2: number | null; + distance_m: number; + readable_address: string | null; +} + +export interface OverlapBuilding { + cad_num: string; + building_name: string | null; + floors: string | null; + readable_address: string | null; + overlap_m2: number | null; +} + +export interface NeighborsSummary { + data_available: boolean; + radius_m?: number; + count_buildings_100m?: number; + avg_floors_100m?: number | null; + max_floors_100m?: number | null; + median_cost_per_m2_100m?: number | null; + neighbors?: NeighborBuilding[]; + has_existing_buildings?: boolean; + overlap_buildings?: OverlapBuilding[]; + note?: string; +} + export interface MarketTrend { recent_avg_price_per_m2: number; prior_avg_price_per_m2: number; @@ -184,6 +218,8 @@ export interface ParcelAnalysis { score_without_center?: number; location?: ParcelLocation; success_recommendation?: ParcelSuccessRecommendation | null; + // P2 (#46) — cad_buildings соседи + overlap check + neighbors_summary?: NeighborsSummary; // X2 (#48) — confidence indicator confidence?: number; confidence_label?: "high" | "medium" | "low"; From 09611dc690f6cdb835e0c980bf13790d8d133a72 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Tue, 12 May 2026 08:00:12 +0300 Subject: [PATCH 4/5] feat(site-finder): P1 geometry suitability score (#45) (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) на текстовый "Проблемы формы:" — стабильнее в 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 --- backend/app/api/v1/parcels.py | 163 +++++++++++++++ .../site-finder/GeometrySuitabilityBlock.tsx | 196 ++++++++++++++++++ .../src/components/site-finder/LandTab.tsx | 9 +- frontend/src/types/site-finder.ts | 26 +++ 4 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 13bac11e..babb03f7 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -5,6 +5,8 @@ from typing import Annotated, Any import httpx from fastapi import APIRouter, Depends, HTTPException, Query +from shapely import wkt as _shp_wkt +from shapely.geometry import Polygon from sqlalchemy import text from sqlalchemy.orm import Session @@ -289,6 +291,165 @@ GEOTECH_BY_REGION: dict[int, dict[str, Any]] = { } +# P1 (#45) — constants for polygon suitability (строительные нормы Свердл/общие +# для ЖК; будут править — храним в одном месте) +_GEOM_MIN_AREA_HA = 0.2 # ниже → area_subscore = 0 (физический минимум) +_GEOM_AREA_COMFORT_HA = 0.3 # рекомендуемая комфортная площадь МКД (recommendation) +_GEOM_AREA_SCORE_FULL_HA = 0.5 # ≥ → area_subscore = 1.0 (premium) +_GEOM_ASPECT_PENALTY_THRESHOLD = 5.0 # выше → вытянутый +_GEOM_ASPECT_PENALTY = 0.3 +_GEOM_CONVEX_PENALTY_THRESHOLD = 0.65 # ниже → изрезанный +_GEOM_CONVEX_PENALTY = 0.3 +# Строительный минимум — physical possibility (под penalty) +_GEOM_MIN_WIDTH_PHYSICAL_M = 30 +_GEOM_NARROW_PENALTY = 0.5 +# Комфорт МКД — recommendation level (помещается типовой корпус 12-16 эт) +_GEOM_MIN_WIDTH_COMFORT_M = 40 +_GEOM_LABEL_MICRO_HA = 0.05 # ниже → label "микро" (комбинируется с penalties) +_GEOM_LABEL_GOOD = 0.7 +_GEOM_LABEL_MEDIUM = 0.4 + + +def _polygon_suitability(geom_wkt: str) -> dict[str, Any]: + """P1 (#45) — physical suitability участка по метрикам shape. + + Метрики: + - area_ha — площадь в гектарах (locally-projected metres via cos(lat)) + - perimeter_m — периметр + - aspect_ratio — длина / ширина минимального ограничивающего прямоугольника + - convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный) + - min_inscribed_rect_dim_m — длина короткой стороны MABR + + Suitability score 0..1 — composite (пороги — см. _GEOM_* константы): + - area_subscore: <_GEOM_MIN_AREA_HA → 0.0, ≥_GEOM_AREA_SCORE_FULL_HA → 1.0, linear + - −_GEOM_ASPECT_PENALTY если aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD + - −_GEOM_CONVEX_PENALTY если convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD + - −_GEOM_NARROW_PENALTY если short_side < _GEOM_MIN_WIDTH_PHYSICAL_M + + UI label: микро / подходящий / сложная форма / слабо подходит. Label "микро" + комбинируется с penalties — "микро · узкий" — чтобы пользователь увидел + обе проблемы сразу. + """ + try: + # Парсим WGS84 polygon (shapely imports теперь module-level) + poly = _shp_wkt.loads(geom_wkt) + if poly.is_empty or poly.geom_type not in ("Polygon", "MultiPolygon"): + return {"data_available": False, "note": "Геометрия не Polygon/MultiPolygon"} + + # Берём наибольший компонент для MultiPolygon + if poly.geom_type == "MultiPolygon": + poly = max(poly.geoms, key=lambda g: g.area) + assert isinstance(poly, Polygon) + + # Equirectangular-projection в метры через centroid-anchor. + # На широте ~57° деформация <1% в радиусе 50км (parcel-scale OK). + centroid = poly.centroid + lat_rad = math.radians(centroid.y) + m_per_deg_lon = 111_320.0 * math.cos(lat_rad) + m_per_deg_lat = 110_540.0 + ext = list(poly.exterior.coords) + ext_m = [ + ( + (x - centroid.x) * m_per_deg_lon, + (y - centroid.y) * m_per_deg_lat, + ) + for x, y in ext + ] + poly_m = Polygon(ext_m) + area_m2 = poly_m.area + area_ha = area_m2 / 10_000.0 + perimeter_m = poly_m.length + + # Convex hull ratio + hull = poly_m.convex_hull + convex_hull_ratio = area_m2 / hull.area if hull.area > 0 else 1.0 + + # MABR (minimum area bounding rectangle) → aspect_ratio + short side + try: + mabr = poly_m.minimum_rotated_rectangle + mabr_coords = list(mabr.exterior.coords) + # 4 уникальные точки в MABR (closed ring → 5 points) → две стороны + side_lens: list[float] = [] + for i in range(4): + p1 = mabr_coords[i] + p2 = mabr_coords[i + 1] + side_lens.append(math.hypot(p2[0] - p1[0], p2[1] - p1[1])) + short_side = min(side_lens) + long_side = max(side_lens) + aspect_ratio = long_side / short_side if short_side > 0 else 1.0 + except Exception as mabr_err: + logger.debug("MABR computation failed, falling back to sqrt(area): %s", mabr_err) + short_side = math.sqrt(area_m2) + aspect_ratio = 1.0 + + # Suitability score composite + if area_ha >= _GEOM_AREA_SCORE_FULL_HA: + area_subscore = 1.0 + elif area_ha <= _GEOM_MIN_AREA_HA: + area_subscore = 0.0 + else: + # linear: _GEOM_MIN_AREA_HA → 0, _GEOM_AREA_SCORE_FULL_HA → 1.0 + area_subscore = (area_ha - _GEOM_MIN_AREA_HA) / ( + _GEOM_AREA_SCORE_FULL_HA - _GEOM_MIN_AREA_HA + ) + + suitability = area_subscore + penalties: list[str] = [] + if aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD: + suitability -= _GEOM_ASPECT_PENALTY + penalties.append(f"вытянутый (aspect>{_GEOM_ASPECT_PENALTY_THRESHOLD:.0f})") + if convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD: + suitability -= _GEOM_CONVEX_PENALTY + penalties.append(f"изрезанный (convex<{_GEOM_CONVEX_PENALTY_THRESHOLD})") + if short_side < _GEOM_MIN_WIDTH_PHYSICAL_M: + suitability -= _GEOM_NARROW_PENALTY + penalties.append(f"узкий (короткая сторона {short_side:.0f}м)") + suitability = max(0.0, min(1.0, suitability)) + + # Label — combine "микро" с penalties чтобы UI видел всё + is_micro = area_ha < _GEOM_LABEL_MICRO_HA + if suitability >= _GEOM_LABEL_GOOD and not is_micro: + label = "подходящий" + elif is_micro: + # combine с penalties: "микро" + первая penalty (для краткости) + if penalties: + label = f"микро, {penalties[0].split(' (')[0]}" + else: + label = "микро" + elif suitability >= _GEOM_LABEL_MEDIUM: + label = "сложная форма" + else: + label = "слабо подходит" + + return { + "data_available": True, + "area_ha": round(area_ha, 3), + "area_m2": round(area_m2), + "perimeter_m": round(perimeter_m), + "aspect_ratio": round(aspect_ratio, 2), + "convex_hull_ratio": round(convex_hull_ratio, 2), + "min_inscribed_rect_dim_m": round(short_side), + "suitability_score": round(suitability, 2), + "label": label, + "penalties": penalties, + "recommendation": ( + f"Строительный минимум короткой стороны — {_GEOM_MIN_WIDTH_PHYSICAL_M}м, " + f"комфорт типового МКД 12-16 этажей — от {_GEOM_MIN_WIDTH_COMFORT_M}м " + f"и площадь от {_GEOM_AREA_COMFORT_HA} га." + ), + "note": ( + "Оценка по форме участка (Shapely). Учитывает площадь, " + "вытянутость, изрезанность, минимальную ширину MABR." + ), + } + except Exception as e: + logger.warning("polygon suitability failed: %s", e) + return { + "data_available": False, + "note": f"Не удалось проанализировать геометрию: {e}", + } + + # P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда # содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны # эмпирически для ЕКБ. @@ -1245,6 +1406,8 @@ def analyze_parcel( "hydrology": hydrology, "utilities": utilities, "geotech_risk": _geotech_risk(66, db, geom_wkt), + # P1 (#45) — physical suitability участка + "geometry_suitability": _polygon_suitability(geom_wkt), # P2 (#46) — соседи-здания + overlap check "neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num), "market_trend": market_trend, diff --git a/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx b/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx new file mode 100644 index 00000000..a61103c0 --- /dev/null +++ b/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx @@ -0,0 +1,196 @@ +"use client"; + +import type { + GeometrySuitability, + GeometrySuitabilityBaseLabel, +} from "@/types/site-finder"; + +interface Props { + data: GeometrySuitability; +} + +const LABEL_COLOR: Record< + GeometrySuitabilityBaseLabel, + { bg: string; fg: string; border: string } +> = { + подходящий: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" }, + "сложная форма": { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" }, + "слабо подходит": { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" }, + микро: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" }, +}; + +// label может быть combo "микро, узкий" — берём первую часть как ключ для цвета. +function colorForLabel(label: string | undefined) { + if (!label) return LABEL_COLOR["сложная форма"]; + const base = label.split(",")[0].trim() as GeometrySuitabilityBaseLabel; + return LABEL_COLOR[base] ?? LABEL_COLOR["сложная форма"]; +} + +function fmtArea(ha: number | undefined, m2: number | undefined): string { + if (ha !== undefined && ha >= 0.1) { + return `${ha.toFixed(2)} га`; + } + if (m2 !== undefined) { + return `${m2.toLocaleString("ru-RU")} м²`; + } + return "—"; +} + +export function GeometrySuitabilityBlock({ data }: Props) { + if (!data.data_available) { + return ( +
+
+ Геометрия участка +
+
{data.note}
+
+ ); + } + + const c = colorForLabel(data.label); + const score = data.suitability_score ?? 0; + const scorePct = Math.round(score * 100); + + return ( +
+
+ + Геометрия участка + + + {data.label} · {scorePct}% + +
+ + {/* Метрики */} +
+
+
Площадь
+
+ {fmtArea(data.area_ha, data.area_m2)} +
+
+
+
Периметр
+
+ {data.perimeter_m ?? "—"} м +
+
+
+
+ Соотношение сторон +
+
+ {data.aspect_ratio?.toFixed(2) ?? "—"} +
+
+
+
+ Выпуклость +
+
+ {data.convex_hull_ratio !== undefined + ? `${(data.convex_hull_ratio * 100).toFixed(0)}%` + : "—"} +
+
+
+
+ Мин. ширина +
+
+ {data.min_inscribed_rect_dim_m ?? "—"} м +
+
+
+ + {/* Penalties */} + {data.penalties && data.penalties.length > 0 && ( +
+ Проблемы формы: {data.penalties.join(", ")} +
+ )} + + {/* Recommendation */} + {data.recommendation && ( +
+ {data.recommendation} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 7cd76b64..8176c7f4 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -2,6 +2,7 @@ import type { ParcelAnalysis } from "@/types/site-finder"; import { GeologyBlock } from "./GeologyBlock"; +import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; import { NeighborsBlock } from "./NeighborsBlock"; @@ -13,15 +14,21 @@ export function LandTab({ data }: Props) { const hasAny = data.geotech_risk !== undefined || data.geology !== undefined || + data.geometry_suitability !== undefined || data.neighbors_summary !== undefined; return (
- {/* P2 (#46) — Соседи + overlap warning */} + {/* P2 (#46) — Соседи + overlap warning (hard warn если overlap) */} {data.neighbors_summary && ( )} + {/* P1 (#45) — Geometry suitability */} + {data.geometry_suitability !== undefined && ( + + )} + {/* Zoning note */}
" +export type GeometrySuitabilityBaseLabel = + | "микро" + | "подходящий" + | "сложная форма" + | "слабо подходит"; + +export interface GeometrySuitability { + data_available: boolean; + area_ha?: number; + area_m2?: number; + perimeter_m?: number; + aspect_ratio?: number; + convex_hull_ratio?: number; + min_inscribed_rect_dim_m?: number; + suitability_score?: number; + // string — допускаем combo-label "микро, узкий"; см. GeometrySuitabilityBaseLabel + label?: string; + penalties?: string[]; + recommendation?: string; + note: string; +} + export interface SuccessRankingBucket { bucket: string; success_score: number; @@ -218,6 +242,8 @@ export interface ParcelAnalysis { score_without_center?: number; location?: ParcelLocation; success_recommendation?: ParcelSuccessRecommendation | null; + // P1 (#45) — physical suitability участка + geometry_suitability?: GeometrySuitability; // P2 (#46) — cad_buildings соседи + overlap check neighbors_summary?: NeighborsSummary; // X2 (#48) — confidence indicator From 1f8fd77825d0254b90555fed3c119558a3fce104 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Tue, 12 May 2026 08:17:00 +0300 Subject: [PATCH 5/5] feat(site-finder): X1 score breakdown + verbal explain (#47) (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/v1/parcels.py | 158 ++++++- .../components/site-finder/OverviewTab.tsx | 12 + .../site-finder/ScoreBreakdownPanel.tsx | 391 ++++++++++++++++++ frontend/src/types/site-finder.ts | 26 ++ 4 files changed, 578 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/site-finder/ScoreBreakdownPanel.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index babb03f7..adb42328 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1,3 +1,4 @@ +import datetime as _dt import json import logging import math @@ -272,6 +273,55 @@ _POI_WEIGHTS: dict[str, float] = { "tram_stop": -0.5, # негативный вес — шум / вибрация } +# Человеко-читаемые имена категорий для verbal breakdown (X1). +_POI_CATEGORY_RU: dict[str, str] = { + "school": "Школа", + "kindergarten": "Детсад", + "pharmacy": "Аптека", + "hospital": "Больница", + "shop_mall": "ТЦ", + "shop_supermarket": "Супермаркет", + "shop_small": "Магазин", + "park": "Парк", + "bus_stop": "Автобус", + "metro_stop": "Метро", + "tram_stop": "Трамвай", +} + +# Группировка POI по тематическим эшелонам — для stacked-bar % contribution +# (X1 score breakdown). Расширяй по мере добавления новых категорий. +_POI_GROUP: dict[str, str] = { + "school": "Социалка", + "kindergarten": "Социалка", + "pharmacy": "Социалка", + "hospital": "Социалка", + "shop_mall": "Торговля", + "shop_supermarket": "Торговля", + "shop_small": "Торговля", + "park": "Парки", + "bus_stop": "Транспорт", + "metro_stop": "Транспорт", + "tram_stop": "Шум/трамвай", +} + + +def _verbal_for_poi( + cat: str, + name: str | None, + distance_m: float, + contribution: float, +) -> str: + """Сгенерировать verbal explain для одного POI-вклада. + + Пример: "Школа №125 в 400м — +0.90 баллов". + Для отрицательного вклада (трамваи): "Трамвай Ленина в 80м — −0.46 баллов". + """ + label = _POI_CATEGORY_RU.get(cat, cat) + safe_name = (name or "").strip() + name_part = f" «{safe_name}»" if safe_name and safe_name != "—" else "" + sign = "+" if contribution >= 0 else "−" + return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов" + # Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет). # Добавляй регионы по мере расширения географии продукта. @@ -691,8 +741,6 @@ def _compute_confidence( доступны на main. Композитный балл = avg of subscore'ов; caveats — list конкретных проблем для UI ("Нет данных N, score K ненадёжен"). """ - import datetime as _dt - caveats: list[str] = [] subscores: dict[str, float] = {} @@ -728,9 +776,15 @@ def _compute_confidence( if not district_row: caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны") - # 4) Market trend — есть ли rosreestr_deals - if market_trend and market_trend.get("recent_deals_count"): - n_recent = int(market_trend["recent_deals_count"]) + # 4) Market trend — есть ли rosreestr_deals. + # Guard `int(... or 0)` — recent_deals_count иногда приходит как non-numeric + # из external/legacy paths; без guard int() крашнет 500. + n_recent_raw = (market_trend or {}).get("recent_deals_count") + try: + n_recent = int(n_recent_raw) if n_recent_raw is not None else 0 + except (ValueError, TypeError): + n_recent = 0 + if n_recent > 0: # порог 5 сделок за 6 мес — достаточно для тренда subscores["market_trend"] = min(1.0, n_recent / 10.0) if n_recent < 5: @@ -912,16 +966,20 @@ def analyze_parcel( # 4) Scoring: weighted sum с distance decay score = 0.0 by_category: dict[str, list[dict[str, Any]]] = {} - for p in poi_rows: + # X1 (#47): per-POI breakdown с verbal explain для UI + factors_detailed: list[dict[str, Any]] = [] + for idx, p in enumerate(poi_rows): cat: str = p["category"] w = _POI_WEIGHTS.get(cat, 0.0) # distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м - decay = max(0.0, 1.0 - float(p["distance_m"]) / 1000.0) - score += w * decay + distance_m = float(p["distance_m"]) + decay = max(0.0, 1.0 - distance_m / 1000.0) + contribution = w * decay + score += contribution by_category.setdefault(cat, []).append( { "name": p["name"], - "distance_m": round(float(p["distance_m"])), + "distance_m": round(distance_m), "lat": float(p["lat"]) if p["lat"] is not None else None, "lon": float(p["lon"]) if p["lon"] is not None else None, "last_edit": ( @@ -929,6 +987,26 @@ def analyze_parcel( ), } ) + # Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен. + if abs(contribution) < 0.01: + continue + factors_detailed.append( + { + # Include idx чтобы избежать React key collision: два POI одной + # категории на одинаково округлённом расстоянии иначе дали бы + # дубль (например, two аптеки 450м в плотном районе). + "factor": f"{cat}_{round(distance_m)}m_{idx}", + "category": cat, + "category_ru": _POI_CATEGORY_RU.get(cat, cat), + "group": _POI_GROUP.get(cat, "Прочее"), + "value": round(distance_m, 1), + "weight": w, + "contribution": round(contribution, 2), + "verbal": _verbal_for_poi(cat, p["name"], distance_m, contribution), + "lat": float(p["lat"]) if p["lat"] is not None else None, + "lon": float(p["lon"]) if p["lon"] is not None else None, + } + ) # 5) Конкуренты в радиусе 3 км из DOM.РФ. # NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по @@ -993,6 +1071,28 @@ def analyze_parcel( else: center_bonus = 0.0 + # X1 (#47): centrality как отдельный synthetic factor в breakdown. + # NB: для centrality decay не применяется (bonus IS the value), поэтому + # weight=1.0 семантически — "no decay multiplier"; contribution = center_bonus. + if center_bonus > 0: + factors_detailed.append( + { + "factor": f"center_bonus_{round(dist_to_center_km)}km", + "category": "centrality", + "category_ru": "Центральность", + "group": "Локация", + "value": round(dist_to_center_km, 2), + "weight": 1.0, + "contribution": round(center_bonus, 2), + "verbal": ( + f"Близость к центру ЕКБ ({dist_to_center_km:.1f}км) — " + f"+{center_bonus:.2f} баллов" + ), + "lat": None, + "lon": None, + } + ) + # 7) Noise score — шумовые источники в радиусе 2 км noise_rows = ( db.execute( @@ -1357,6 +1457,41 @@ def analyze_parcel( score_final = score + center_bonus + # X1 (#47): расчёт contribution_pct + top-3 / by-group для UI. + # Базис для процентов — сумма абсолютных значений всех факторов; это даёт + # стабильное соотношение независимо от знака и не делится на 0. + abs_total = sum(abs(f["contribution"]) for f in factors_detailed) or 1.0 + for f in factors_detailed: + f["contribution_pct"] = round(100.0 * abs(f["contribution"]) / abs_total, 1) + + factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True) + # Convention: оба top-list'а отсортированы "dominant first": + # positives → most-positive first (factors_sorted desc → [:3]) + # negatives → most-negative first (sort negatives asc → [:3]) + # Раньше использовался trick [-3:][::-1] на desc-sorted — это давало тот же + # результат для N>=3 negatives, но был неинтуитивно читать; explicit sort asc + # короче и не зависит от тонкостей slicing. + score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3] + negatives_only = [f for f in factors_sorted if f["contribution"] < 0] + score_top_3_negatives = sorted(negatives_only, key=lambda x: x["contribution"])[:3] + + # By-group totals — для stacked-bar в UI. count это int, contribution* — float. + group_totals: dict[str, dict[str, float | int]] = {} + for f in factors_detailed: + g = group_totals.setdefault( + f["group"], {"contribution": 0.0, "count": 0, "contribution_pct": 0.0} + ) + g["contribution"] += f["contribution"] + g["count"] += 1 + group_abs_total = sum(abs(g["contribution"]) for g in group_totals.values()) or 1.0 + for g_val in group_totals.values(): + g_val["contribution"] = round(g_val["contribution"], 2) + g_val["contribution_pct"] = round(100.0 * abs(g_val["contribution"]) / group_abs_total, 1) + score_by_group = [ + {"group": k, **v} + for k, v in sorted(group_totals.items(), key=lambda kv: -abs(kv[1]["contribution"])) + ] + # X2 (#48): composite confidence + caveats confidence_info = _compute_confidence( source=source, @@ -1384,6 +1519,11 @@ def analyze_parcel( ">40 = редко, типичный город. центр 15-30." ), "score_breakdown": by_category, + # X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group. + "score_breakdown_detailed": factors_sorted, + "score_top_3_positives": score_top_3_positives, + "score_top_3_negatives": score_top_3_negatives, + "score_by_group": score_by_group, "poi_count": len(poi_rows), "location": { "distance_to_center_km": round(dist_to_center_km, 2), diff --git a/frontend/src/components/site-finder/OverviewTab.tsx b/frontend/src/components/site-finder/OverviewTab.tsx index 70e47687..eaffd4c4 100644 --- a/frontend/src/components/site-finder/OverviewTab.tsx +++ b/frontend/src/components/site-finder/OverviewTab.tsx @@ -4,6 +4,7 @@ import type { FeatureCollection } from "geojson"; import type { ParcelAnalysis } from "@/types/site-finder"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { IsochronesPanel } from "./IsochronesPanel"; +import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel"; interface Props { data: ParcelAnalysis; @@ -165,6 +166,17 @@ export function OverviewTab({ data, onIsochronesResult }: Props) { )}
+ {/* X1 (#47): per-factor score breakdown с verbal explain */} + {data.score_breakdown_detailed && + data.score_breakdown_detailed.length > 0 && ( + + )} + {/* POI breakdown */}
= { + Социалка: "#0ea5e9", + Торговля: "#a855f7", + Парки: "#16a34a", + Транспорт: "#eab308", + "Шум/трамвай": "#dc2626", + Локация: "#1d4ed8", + Прочее: "#94a3b8", +}; + +function fmtContribution(v: number): string { + const sign = v >= 0 ? "+" : "−"; + return `${sign}${Math.abs(v).toFixed(2)}`; +} + +export function ScoreBreakdownPanel({ + topPositives, + topNegatives, + byGroup, + detailed, +}: Props) { + const [expanded, setExpanded] = useState(false); + + // Stacked bar — только positive groups (для визуальной шкалы вклада) + const positiveGroups = byGroup.filter((g) => g.contribution > 0); + const totalPositive = + positiveGroups.reduce((s, g) => s + g.contribution, 0) || 1; + + return ( +
+
+ Почему такой балл +
+ + {/* Stacked bar — % contribution по группам */} + {positiveGroups.length > 0 && ( +
+
+ {positiveGroups.map((g) => { + const widthPct = (g.contribution / totalPositive) * 100; + return ( +
+ {widthPct >= 10 ? `${Math.round(widthPct)}%` : ""} +
+ ); + })} +
+
+ {/* Positive groups — visible в баре */} + {positiveGroups.map((g) => ( +
+ + + {g.group}:{" "} + + {fmtContribution(g.contribution)} + + +
+ ))} +
+ {/* Negative groups — отдельной "drag" линией под баром (legend для bar + использует только positive, чтобы не было orphan swatches без сегмента) */} + {byGroup + .filter((g) => g.contribution < 0) + .map((g) => ( +
+ + + Снижают балл — {g.group}:{" "} + + {fmtContribution(g.contribution)} + + +
+ ))} +
+ )} + + {/* Top-3 positive */} + {topPositives.length > 0 && ( +
+
+ Топ-3 плюса +
+
    + {topPositives.map((f) => ( +
  • + + ▲ + + {f.verbal} +
  • + ))} +
+
+ )} + + {/* Top-3 negative */} + {topNegatives.length > 0 && ( +
+
+ Топ-3 минуса +
+
    + {topNegatives.map((f) => ( +
  • + + ▼ + + {f.verbal} +
  • + ))} +
+
+ )} + + {/* Toggle full breakdown */} + {detailed.length > 0 && ( +
+ + {expanded && ( +
+ + + + + + + + + + {detailed.map((f) => ( + + + + + + ))} + +
+ Фактор + + Вклад + + % +
+ {f.verbal} + = 0 ? "#16a34a" : "#dc2626", + fontVariantNumeric: "tabular-nums", + fontWeight: 600, + }} + > + {fmtContribution(f.contribution)} + + {f.contribution_pct.toFixed(1)}% +
+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 5161efdd..66186f14 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -217,6 +217,28 @@ export interface ParcelSuccessRecommendation { note: string; } +// X1 (#47) — per-factor breakdown с verbal explain +export interface FactorContribution { + factor: string; + category: string; + category_ru: string; + group: string; + value: number; + weight: number; + contribution: number; + contribution_pct: number; + verbal: string; + lat: number | null; + lon: number | null; +} + +export interface ScoreGroupTotal { + group: string; + contribution: number; + count: number; + contribution_pct: number; +} + export interface ParcelAnalysis { cad_num: string; source: "cad_quarter" | "cad_building"; @@ -228,6 +250,10 @@ export interface ParcelAnalysis { score_explanation?: string; market_trend?: MarketTrend | null; score_breakdown: Record; + score_breakdown_detailed?: FactorContribution[]; + score_top_3_positives?: FactorContribution[]; + score_top_3_negatives?: FactorContribution[]; + score_by_group?: ScoreGroupTotal[]; poi_count: number; competitors: ParcelAnalysisCompetitor[]; noise: ParcelAnalysisNoise | null;