- #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:
parent
86e9ea2937
commit
8279b0ee1f
7 changed files with 177 additions and 99 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue