fix(week-review): cross-file добивка код-ревью (#1338, #1363, #1419, #1421, #1424, #1480)

- #1338 concepts.py: geometry.generate вынесен в run_in_threadpool (не блокирует event loop)
- #1363 house_imv_backfill.py + scrape_pipeline.py: периодический heartbeat в долгих IMV-фазах (reap_zombies не помечает живой sweep zombie)
- #1419 site-finder-api.ts: placeholderData keepPreviousData для bbox-запроса (KPI не мигает «0/0» при пане/зуме)
- #1421 site-finder-api.ts: area_ha nullable + адаптер не подставляет 0; null-гейт потребителей EntryMap.tsx, ParcelDrawer.tsx
- #1424/#1480 EstimateResult.tsx: блок «Параметры объекта» скрыт при восстановлении по shared-ссылке (нет мусора «0 м²/Студия/Этаж 0 из 0»)

Верификация: tsc --noEmit 0 ошибок; py_compile OK.
This commit is contained in:
Light1YT 2026-06-15 20:29:00 +05:00
parent 86e9ea2937
commit 8279b0ee1f
7 changed files with 177 additions and 99 deletions

View file

@ -1,6 +1,7 @@
import logging
from fastapi import APIRouter, HTTPException
from fastapi.concurrency import run_in_threadpool
from app.schemas.concept import ConceptInput, ConceptOutput
from app.services.generative import geometry
@ -23,7 +24,9 @@ async def create_concept(payload: ConceptInput) -> ConceptOutput:
422 rather than empty variants that is a bad request, not a valid empty result.
"""
try:
variants = geometry.generate(payload)
# geometry.generate — синхронный CPU-bound (Shapely/STRtree), мостим через
# run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и в chat.py).
variants = await run_in_threadpool(geometry.generate, payload)
except ParcelGeometryError as exc:
logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc

View file

@ -106,7 +106,7 @@ function ParcelMarkers({
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
<div style={{ color: "var(--fg-secondary)" }}>
{parcel.area_ha.toFixed(2)} га · {statusLabel}
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div>
<div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}>
Нажмите, чтобы открыть анализ
@ -129,7 +129,7 @@ function ParcelMarkers({
<div
style={{ color: "var(--fg-secondary)", marginBottom: 8 }}
>
{parcel.area_ha.toFixed(2)} га · {statusLabel}
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div>
<button
type="button"

View file

@ -196,7 +196,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
fontVariantNumeric: "tabular-nums",
}}
>
{parcel.area_ha.toFixed(2)}{" "}
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"}{" "}
<span
style={{
fontSize: 13,

View file

@ -99,6 +99,12 @@ interface Props {
export function EstimateResult({ estimate, input }: Props) {
const conf = CONFIDENCE_COLOR[estimate.confidence];
// When restoring an estimate from a shared link (GET /estimate/{id}) the
// original object parameters are unknown — page.tsx passes an empty input
// (area_m2 = 0, total_floors = 0). Don't render the "Параметры объекта" block
// with placeholder garbage ("0 м²", "Студия", "Этаж 0 из 0") in that case.
const hasInputParams = Boolean(input.area_m2) || input.total_floors > 0;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Hero card */}
@ -202,98 +208,101 @@ export function EstimateResult({ estimate, input }: Props) {
</div>
</Card>
{/* Section 1 — Cover / input snapshot */}
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
}
title="Параметры объекта"
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 10,
}}
>
{[
{ label: "Адрес", value: input.address },
{ label: "Площадь", value: `${input.area_m2} м²` },
{
label: "Комнат",
value: input.rooms === 0 ? "Студия" : String(input.rooms),
},
{
label: "Этаж",
value: `${input.floor} из ${input.total_floors}`,
},
...(input.year_built
? [{ label: "Год постройки", value: String(input.year_built) }]
: []),
...(input.house_type
? [
{
label: "Тип дома",
value:
HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
},
]
: []),
...(input.repair_state
? [
{
label: "Ремонт",
value:
REPAIR_STATE_LABELS[input.repair_state] ??
input.repair_state,
},
]
: []),
{
label: "Балкон",
value: input.has_balcony ? "Есть" : "Нет",
},
].map(({ label, value }) => (
<div key={label}>
<div
style={{
fontSize: 11,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: 0.3,
marginBottom: 2,
}}
{/* Section 1 — Cover / input snapshot.
Hidden when restoring from a shared link (params unknown). */}
{hasInputParams && (
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{label}
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
}
title="Параметры объекта"
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 10,
}}
>
{[
{ label: "Адрес", value: input.address },
{ label: "Площадь", value: `${input.area_m2} м²` },
{
label: "Комнат",
value: input.rooms === 0 ? "Студия" : String(input.rooms),
},
{
label: "Этаж",
value: `${input.floor} из ${input.total_floors}`,
},
...(input.year_built
? [{ label: "Год постройки", value: String(input.year_built) }]
: []),
...(input.house_type
? [
{
label: "Тип дома",
value:
HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
},
]
: []),
...(input.repair_state
? [
{
label: "Ремонт",
value:
REPAIR_STATE_LABELS[input.repair_state] ??
input.repair_state,
},
]
: []),
{
label: "Балкон",
value: input.has_balcony ? "Есть" : "Нет",
},
].map(({ label, value }) => (
<div key={label}>
<div
style={{
fontSize: 11,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: 0.3,
marginBottom: 2,
}}
>
{label}
</div>
<div
style={{
fontSize: 13,
color: "#1a1d23",
fontWeight: 500,
wordBreak: "break-word",
}}
>
{value}
</div>
</div>
<div
style={{
fontSize: 13,
color: "#1a1d23",
fontWeight: 500,
wordBreak: "break-word",
}}
>
{value}
</div>
</div>
))}
</div>
</Card>
))}
</div>
</Card>
)}
{/* Section 2 — Listings (analogs) */}
<Card>

View file

@ -51,7 +51,9 @@ export type ParcelVri =
export interface ParcelBboxItem {
cad_num: string;
address: string;
area_ha: number;
// #1421: nullable — the B1 by-bbox payload may omit area_m2; null означает
// «н/д», в отличие от реального 0 га. Потребители обязаны гейтить null.
area_ha: number | null;
status: ParcelStatus;
district: string;
vri: ParcelVri;
@ -77,7 +79,8 @@ export interface BboxCoords {
export interface RecentParcel {
cad_num: string;
address: string;
area_ha: number;
// #1421: nullable — площадь может быть неизвестна; null → «н/д», не «0 га».
area_ha: number | null;
district: string;
visited_at: string; // ISO string
}
@ -121,8 +124,20 @@ function applyFilters(
filters: ParcelBboxFilters,
): ParcelBboxItem[] {
return parcels.filter((p) => {
if (filters.min_area != null && p.area_ha < filters.min_area) return false;
if (filters.max_area != null && p.area_ha > filters.max_area) return false;
// #1421: area_ha теперь nullable («н/д»). Площадной фильтр применяем только
// к участкам с известной площадью; null проходит мимо min/max гейтов.
if (
filters.min_area != null &&
p.area_ha != null &&
p.area_ha < filters.min_area
)
return false;
if (
filters.max_area != null &&
p.area_ha != null &&
p.area_ha > filters.max_area
)
return false;
if (filters.status != null && p.status !== filters.status) return false;
if (
filters.district != null &&
@ -209,7 +224,9 @@ export function useParcelsBboxQuery(
cad_num: p.cad_num,
lat: p.centroid_lat,
lon: p.centroid_lon,
area_ha: p.area_m2 != null ? p.area_m2 / 10000 : 0,
// #1421: keep null when area_m2 is missing — не приземляем в 0, иначе
// карточка показывает «0 га» вместо «н/д».
area_ha: p.area_m2 != null ? p.area_m2 / 10000 : null,
status: p.status ?? "free",
district: "—",
vri: "other",
@ -230,6 +247,10 @@ export function useParcelsBboxQuery(
},
enabled: MOCK_PARCELS_BBOX || bbox != null,
staleTime: 30_000,
// #1419: bbox is part of the queryKey, so every pan/zoom is a fresh cache
// entry and `data` would blank to `undefined` mid-refetch → KPI flickers to
// «0 / 0». Hold the previous parcels on screen while the new bbox refetches.
placeholderData: keepPreviousData,
});
}

View file

@ -21,6 +21,7 @@ import asyncio
import json
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Literal
@ -37,6 +38,12 @@ from app.services.scrapers.avito_imv import (
logger = logging.getLogger(__name__)
# Каждые N домов дёргаем heartbeat-колбэк (если передан) внутри длинного per-house
# цикла. Без этого долгая IMV-фаза (десятки/сотни домов × request_delay_sec) не
# обновляет scrape_runs.heartbeat_at дольше ZOMBIE_THRESHOLD_HOURS, и scheduler
# ложно помечает живой run 'zombie' (после чего терминальный mark_done — no-op). См. #1363.
_HEARTBEAT_EVERY_N_HOUSES = 5
# ── house_type normalisation ─────────────────────────────────────────────────
_HOUSE_TYPE_TO_IMV: dict[str, str] = {
@ -351,6 +358,16 @@ class HouseIMVBackfillResult:
status_counts: dict[str, int] = field(default_factory=dict)
def _beat(heartbeat: Callable[[], None] | None) -> None:
"""Best-effort вызов heartbeat-колбэка. Сбой heartbeat не должен ронять backfill."""
if heartbeat is None:
return
try:
heartbeat()
except Exception:
logger.warning("house_imv_backfill: heartbeat callback failed (ignored)", exc_info=True)
async def backfill_house_imv(
db: Session,
*,
@ -358,6 +375,7 @@ async def backfill_house_imv(
request_delay_sec: float = 5.0,
only_status: str = "pending",
house_id: int | None = None,
heartbeat: Callable[[], None] | None = None,
) -> HouseIMVBackfillResult:
"""Run Avito IMV evaluation for each house in scope, save results.
@ -368,6 +386,10 @@ async def backfill_house_imv(
only_status: process houses with this imv_status (default 'pending').
Use 'transient_error' to retry failures.
house_id: process a single specific house (debug).
heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES
домов caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies
не пометил живой долгий run 'zombie' (#1363). Best-effort: исключения
из колбэка логируются и не прерывают backfill.
Returns:
HouseIMVBackfillResult with aggregate counters.
@ -442,6 +464,11 @@ async def backfill_house_imv(
status_str,
)
# Периодический heartbeat внутри длинного цикла — иначе reap_zombies
# может пометить живой run 'zombie' за время IMV-фазы (#1363).
if (i + 1) % _HEARTBEAT_EVERY_N_HOUSES == 0:
_beat(heartbeat)
if i < len(rows) - 1:
if status_str == "auth_error":
logger.warning("IMV auth error — extra delay 60s before next house")
@ -467,6 +494,7 @@ async def process_houses_imv_batch(
house_ids: set[int],
*,
request_delay_sec: float = 5.0,
heartbeat: Callable[[], None] | None = None,
) -> HouseIMVBackfillResult:
"""Обработать конкретный набор house_id (для sweep-интеграции).
@ -479,6 +507,10 @@ async def process_houses_imv_batch(
поэтому лучше не гонять лишних API-запросов, если данные свежие.
Для форс-обновления оставь это решение caller'у (sweep по умолчанию
обходит дома с ok, обновляя только pending/not_found/error/transient_error).
heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES домов,
чтобы scrape_runs.heartbeat_at обновлялся в течение долгой IMV-фазы и
reap_zombies не пометил живой run 'zombie' (#1363). Best-effort.
"""
result = HouseIMVBackfillResult()
t0 = time.time()
@ -547,6 +579,11 @@ async def process_houses_imv_batch(
status_str,
)
# Периодический heartbeat внутри длинного цикла — иначе reap_zombies
# может пометить живой run 'zombie' за время IMV-фазы (#1363).
if (i + 1) % _HEARTBEAT_EVERY_N_HOUSES == 0:
_beat(heartbeat)
if i < len(rows) - 1:
if status_str == "auth_error":
logger.warning("IMV auth error — extra delay 60s before next house")

View file

@ -599,10 +599,18 @@ async def run_avito_city_sweep(
len(all_touched_house_ids),
)
try:
# Периодический heartbeat внутри длинной IMV-фазы (#1363):
# без него десятки/сотни домов × request_delay_sec проходят
# без update_heartbeat, и reap_zombies помечает живой run
# 'zombie' → последующий mark_done становится no-op (дубль-sweep).
def _imv_heartbeat() -> None:
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
imv_result = await process_houses_imv_batch(
db,
all_touched_house_ids,
request_delay_sec=request_delay_sec,
heartbeat=_imv_heartbeat,
)
counters.imv_attempted += imv_result.checked
counters.imv_enriched += imv_result.saved