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 diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index c05c1134..babb03f7 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -242,6 +242,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, @@ -435,6 +450,182 @@ def _polygon_suitability(geom_wkt: str) -> 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) + промышленная близость. @@ -482,6 +673,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. @@ -1067,6 +1357,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, @@ -1105,10 +1408,17 @@ def analyze_parcel( "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, "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/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 7e4e0d79..8176c7f4 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -4,6 +4,7 @@ import type { ParcelAnalysis } from "@/types/site-finder"; import { GeologyBlock } from "./GeologyBlock"; import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; +import { NeighborsBlock } from "./NeighborsBlock"; interface Props { data: ParcelAnalysis; @@ -13,10 +14,16 @@ export function LandTab({ data }: Props) { const hasAny = data.geotech_risk !== undefined || data.geology !== undefined || - data.geometry_suitability !== undefined; + data.geometry_suitability !== undefined || + data.neighbors_summary !== undefined; return (
+ {/* P2 (#46) — Соседи + overlap warning (hard warn если overlap) */} + {data.neighbors_summary && ( + + )} + {/* P1 (#45) — Geometry suitability */} {data.geometry_suitability !== undefined && ( diff --git a/frontend/src/components/site-finder/NeighborsBlock.tsx b/frontend/src/components/site-finder/NeighborsBlock.tsx new file mode 100644 index 00000000..f83ec87a --- /dev/null +++ b/frontend/src/components/site-finder/NeighborsBlock.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useState } from "react"; + +import type { NeighborsSummary } from "@/types/site-finder"; + +interface Props { + data: NeighborsSummary; +} + +function fmtPrice(n: number | null | undefined): string { + if (n == null) return "—"; + if (n >= 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/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 =