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 import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.concurrency import run_in_threadpool
from app.schemas.concept import ConceptInput, ConceptOutput from app.schemas.concept import ConceptInput, ConceptOutput
from app.services.generative import geometry 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. 422 rather than empty variants that is a bad request, not a valid empty result.
""" """
try: 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: except ParcelGeometryError as exc:
logger.warning("concept generation rejected parcel: %s", exc) logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from 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={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div> <div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
<div style={{ color: "var(--fg-secondary)" }}> <div style={{ color: "var(--fg-secondary)" }}>
{parcel.area_ha.toFixed(2)} га · {statusLabel} {parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div> </div>
<div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}> <div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}>
Нажмите, чтобы открыть анализ Нажмите, чтобы открыть анализ
@ -129,7 +129,7 @@ function ParcelMarkers({
<div <div
style={{ color: "var(--fg-secondary)", marginBottom: 8 }} style={{ color: "var(--fg-secondary)", marginBottom: 8 }}
> >
{parcel.area_ha.toFixed(2)} га · {statusLabel} {parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div> </div>
<button <button
type="button" type="button"

View file

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

View file

@ -99,6 +99,12 @@ interface Props {
export function EstimateResult({ estimate, input }: Props) { export function EstimateResult({ estimate, input }: Props) {
const conf = CONFIDENCE_COLOR[estimate.confidence]; 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 ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Hero card */} {/* Hero card */}
@ -202,98 +208,101 @@ export function EstimateResult({ estimate, input }: Props) {
</div> </div>
</Card> </Card>
{/* Section 1 — Cover / input snapshot */} {/* Section 1 — Cover / input snapshot.
<Card> Hidden when restoring from a shared link (params unknown). */}
<SectionHeader {hasInputParams && (
icon={ <Card>
<svg <SectionHeader
width="16" icon={
height="16" <svg
viewBox="0 0 24 24" width="16"
fill="none" height="16"
stroke="currentColor" viewBox="0 0 24 24"
strokeWidth="2" fill="none"
strokeLinecap="round" stroke="currentColor"
strokeLinejoin="round" strokeWidth="2"
aria-hidden="true" strokeLinecap="round"
> strokeLinejoin="round"
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" /> aria-hidden="true"
<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} <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>
<div ))}
style={{ </div>
fontSize: 13, </Card>
color: "#1a1d23", )}
fontWeight: 500,
wordBreak: "break-word",
}}
>
{value}
</div>
</div>
))}
</div>
</Card>
{/* Section 2 — Listings (analogs) */} {/* Section 2 — Listings (analogs) */}
<Card> <Card>

View file

@ -51,7 +51,9 @@ export type ParcelVri =
export interface ParcelBboxItem { export interface ParcelBboxItem {
cad_num: string; cad_num: string;
address: 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; status: ParcelStatus;
district: string; district: string;
vri: ParcelVri; vri: ParcelVri;
@ -77,7 +79,8 @@ export interface BboxCoords {
export interface RecentParcel { export interface RecentParcel {
cad_num: string; cad_num: string;
address: string; address: string;
area_ha: number; // #1421: nullable — площадь может быть неизвестна; null → «н/д», не «0 га».
area_ha: number | null;
district: string; district: string;
visited_at: string; // ISO string visited_at: string; // ISO string
} }
@ -121,8 +124,20 @@ function applyFilters(
filters: ParcelBboxFilters, filters: ParcelBboxFilters,
): ParcelBboxItem[] { ): ParcelBboxItem[] {
return parcels.filter((p) => { return parcels.filter((p) => {
if (filters.min_area != null && p.area_ha < filters.min_area) return false; // #1421: area_ha теперь nullable («н/д»). Площадной фильтр применяем только
if (filters.max_area != null && p.area_ha > filters.max_area) return false; // к участкам с известной площадью; 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.status != null && p.status !== filters.status) return false;
if ( if (
filters.district != null && filters.district != null &&
@ -209,7 +224,9 @@ export function useParcelsBboxQuery(
cad_num: p.cad_num, cad_num: p.cad_num,
lat: p.centroid_lat, lat: p.centroid_lat,
lon: p.centroid_lon, 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", status: p.status ?? "free",
district: "—", district: "—",
vri: "other", vri: "other",
@ -230,6 +247,10 @@ export function useParcelsBboxQuery(
}, },
enabled: MOCK_PARCELS_BBOX || bbox != null, enabled: MOCK_PARCELS_BBOX || bbox != null,
staleTime: 30_000, 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 json
import logging import logging
import time import time
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal from typing import Literal
@ -37,6 +38,12 @@ from app.services.scrapers.avito_imv import (
logger = logging.getLogger(__name__) 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 normalisation ─────────────────────────────────────────────────
_HOUSE_TYPE_TO_IMV: dict[str, str] = { _HOUSE_TYPE_TO_IMV: dict[str, str] = {
@ -351,6 +358,16 @@ class HouseIMVBackfillResult:
status_counts: dict[str, int] = field(default_factory=dict) 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( async def backfill_house_imv(
db: Session, db: Session,
*, *,
@ -358,6 +375,7 @@ async def backfill_house_imv(
request_delay_sec: float = 5.0, request_delay_sec: float = 5.0,
only_status: str = "pending", only_status: str = "pending",
house_id: int | None = None, house_id: int | None = None,
heartbeat: Callable[[], None] | None = None,
) -> HouseIMVBackfillResult: ) -> HouseIMVBackfillResult:
"""Run Avito IMV evaluation for each house in scope, save results. """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'). only_status: process houses with this imv_status (default 'pending').
Use 'transient_error' to retry failures. Use 'transient_error' to retry failures.
house_id: process a single specific house (debug). 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: Returns:
HouseIMVBackfillResult with aggregate counters. HouseIMVBackfillResult with aggregate counters.
@ -442,6 +464,11 @@ async def backfill_house_imv(
status_str, 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 i < len(rows) - 1:
if status_str == "auth_error": if status_str == "auth_error":
logger.warning("IMV auth error — extra delay 60s before next house") 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], house_ids: set[int],
*, *,
request_delay_sec: float = 5.0, request_delay_sec: float = 5.0,
heartbeat: Callable[[], None] | None = None,
) -> HouseIMVBackfillResult: ) -> HouseIMVBackfillResult:
"""Обработать конкретный набор house_id (для sweep-интеграции). """Обработать конкретный набор house_id (для sweep-интеграции).
@ -479,6 +507,10 @@ async def process_houses_imv_batch(
поэтому лучше не гонять лишних API-запросов, если данные свежие. поэтому лучше не гонять лишних API-запросов, если данные свежие.
Для форс-обновления оставь это решение caller'у (sweep по умолчанию Для форс-обновления оставь это решение caller'у (sweep по умолчанию
обходит дома с ok, обновляя только pending/not_found/error/transient_error). обходит дома с 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() result = HouseIMVBackfillResult()
t0 = time.time() t0 = time.time()
@ -547,6 +579,11 @@ async def process_houses_imv_batch(
status_str, 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 i < len(rows) - 1:
if status_str == "auth_error": if status_str == "auth_error":
logger.warning("IMV auth error — extra delay 60s before next house") 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), len(all_touched_house_ids),
) )
try: 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( imv_result = await process_houses_imv_batch(
db, db,
all_touched_house_ids, all_touched_house_ids,
request_delay_sec=request_delay_sec, request_delay_sec=request_delay_sec,
heartbeat=_imv_heartbeat,
) )
counters.imv_attempted += imv_result.checked counters.imv_attempted += imv_result.checked
counters.imv_enriched += imv_result.saved counters.imv_enriched += imv_result.saved