feat(appraisals): tiered nearby search; center-align numeric columns

UI:
- Center-align all numeric table columns (deals + appraisals) for better
  visual balance — text-start was crowding numbers against the right edge.

Backend (search_appraisals):
- Add include_nearby + nearby_block_radius flags. When set, fetches three
  buckets in one request:
  1. exact match (block + plot, original behavior)
  2. same block, other plots
  3. nearby blocks (block ± radius, default ±2)
- Composite-key dedupe across buckets (gov.il has no stable decision id).

Frontend:
- AppraisalsTable now renders the three buckets as stacked sections with
  Hebrew headers ("החלטות תואמות" / "באותו גוש" / "בגושים סמוכים").
- Tab counter sums all three buckets.

Auto-enables include_nearby whenever a block is in scope (address mode
via gush_helka lookup, or block_plot mode directly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:49:03 +00:00
parent 73f41758a6
commit 85df33a537
5 changed files with 268 additions and 108 deletions
+100 -17
View File
@@ -59,6 +59,11 @@ class AppraisalsSearchRequest(BaseModel):
search_text: str | None = Field(None, max_length=200) search_text: str | None = Field(None, max_length=200)
appraisal_header: str | None = Field(None, max_length=200) appraisal_header: str | None = Field(None, max_length=200)
max_results: int = Field(30, ge=1, le=100) max_results: int = Field(30, ge=1, le=100)
# When true and `block` is supplied, also fetch decisions from other
# plots in the same block and from neighbouring blocks. Returned in
# separate buckets in the response.
include_nearby: bool = Field(False)
nearby_block_radius: int = Field(2, ge=1, le=10)
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
@@ -213,12 +218,19 @@ def create_app() -> FastAPI:
# ── /api/search/appraisals ──────────────────────────────────────── # ── /api/search/appraisals ────────────────────────────────────────
@app.post("/api/search/appraisals") @app.post("/api/search/appraisals")
async def search_appraisals(req: AppraisalsSearchRequest): async def search_appraisals(req: AppraisalsSearchRequest):
"""Search decisive-appraiser decisions (gov.il / Ministry of Justice).""" """Search decisive-appraiser decisions (gov.il / Ministry of Justice).
try:
response = decisive_appraiser_client.search_decisions_paged( If `include_nearby=True` and a block is provided, also runs follow-up
searches for the same block (different plots) and for adjacent blocks,
returned in separate buckets. The buckets are mutually exclusive
(deduped by upstream decision id).
"""
def _run(block: str | None, plot: str | None) -> Any:
return decisive_appraiser_client.search_decisions_paged(
max_results=req.max_results, max_results=req.max_results,
block=req.block, block=block,
plot=req.plot, plot=plot,
decisive_appraiser=req.decisive_appraiser, decisive_appraiser=req.decisive_appraiser,
committee=req.committee, committee=req.committee,
decision_date_from=req.decision_date_from, decision_date_from=req.decision_date_from,
@@ -228,24 +240,95 @@ def create_app() -> FastAPI:
search_text=req.search_text, search_text=req.search_text,
appraisal_header=req.appraisal_header, appraisal_header=req.appraisal_header,
) )
try:
exact_response = _run(req.block, req.plot)
except Exception as e: except Exception as e:
logger.exception("search_decisions_paged failed") logger.exception("search_decisions_paged (exact) failed")
raise HTTPException(status_code=502, detail=str(e)) raise HTTPException(status_code=502, detail=str(e))
decisions = [] # Dedupe across buckets by a composite key (gov.il doesn't expose a
for idx, decision in enumerate(response.results, start=1): # single stable id we can rely on).
d = decision.model_dump(mode="json", exclude_none=True) seen_keys: set[tuple] = set()
documents = d.pop("documents", []) or []
d["id"] = idx def _serialize(decisions_iter: Any, start_idx: int = 1) -> list[dict]:
d["pdf_url"] = documents[0]["file_url"] if documents else None out: list[dict] = []
d["all_documents"] = documents running = start_idx
decisions.append(d) for decision in decisions_iter:
key = (
decision.appraisal_header,
decision.decision_date,
decision.decisive_appraiser,
decision.block,
decision.plot,
)
if key in seen_keys:
continue
seen_keys.add(key)
d = decision.model_dump(mode="json", exclude_none=True)
documents = d.pop("documents", []) or []
d["id"] = running
d["pdf_url"] = documents[0]["file_url"] if documents else None
d["all_documents"] = documents
out.append(d)
running += 1
return out
exact_decisions = _serialize(exact_response.results)
same_block_decisions: list[dict] = []
nearby_block_decisions: list[dict] = []
if req.include_nearby and req.block:
# Same block, different plots — only meaningful if a plot was given;
# otherwise the exact search already covers the whole block.
if req.plot:
try:
sb = _run(req.block, None)
same_block_decisions = _serialize(
sb.results, start_idx=len(exact_decisions) + 1
)
except Exception:
logger.exception("same-block appraisals lookup failed")
# Neighbouring blocks (numeric ±radius). Israeli cadastral blocks
# are usually sequential within a city, so this is a reasonable
# heuristic for "nearby parcels" without needing a spatial query.
try:
block_int = int(req.block)
except ValueError:
block_int = None
if block_int is not None:
base_idx = len(exact_decisions) + len(same_block_decisions) + 1
deltas = list(range(1, req.nearby_block_radius + 1))
ordered_neighbours = []
for delta in deltas:
ordered_neighbours.extend([block_int - delta, block_int + delta])
for nb in ordered_neighbours:
if nb <= 0:
continue
try:
nbr = _run(str(nb), None)
chunk = _serialize(nbr.results, start_idx=base_idx)
nearby_block_decisions.extend(chunk)
base_idx += len(chunk)
except Exception:
logger.exception(f"nearby block {nb} lookup failed")
return { return {
"total_in_db": response.total_results, "total_in_db": exact_response.total_results,
"returned": len(decisions), "returned": len(exact_decisions),
"filters": req.model_dump(exclude_none=True), "filters": req.model_dump(exclude_none=True),
"decisions": decisions, "decisions": exact_decisions,
"same_block": {
"count": len(same_block_decisions),
"decisions": same_block_decisions,
},
"nearby_blocks": {
"count": len(nearby_block_decisions),
"decisions": nearby_block_decisions,
},
} }
# ── /api/appraisals/pdf ─────────────────────────────────────────── # ── /api/appraisals/pdf ───────────────────────────────────────────
+9 -1
View File
@@ -68,6 +68,10 @@ function useAppraisals(query: SearchQuery | null, blockOverride?: string, plotOv
plot, plot,
decisive_appraiser: appraiser, decisive_appraiser: appraiser,
max_results: 30, max_results: 30,
// Include same-block-other-plots and nearby-block tiers when we have
// a block to anchor on.
include_nearby: Boolean(block),
nearby_block_radius: 2,
}), }),
enabled: Boolean(block || appraiser), enabled: Boolean(block || appraiser),
}); });
@@ -192,7 +196,9 @@ export default function App() {
שמאי מכריע שמאי מכריע
{appraisals.data && ( {appraisals.data && (
<span className="ms-2 text-xs bg-muted-foreground/15 rounded-full px-2 py-0.5"> <span className="ms-2 text-xs bg-muted-foreground/15 rounded-full px-2 py-0.5">
{appraisals.data.returned} {appraisals.data.returned +
(appraisals.data.same_block?.count ?? 0) +
(appraisals.data.nearby_blocks?.count ?? 0)}
</span> </span>
)} )}
</TabsTrigger> </TabsTrigger>
@@ -227,6 +233,8 @@ export default function App() {
<AppraisalsTable <AppraisalsTable
decisions={appraisals.data.decisions} decisions={appraisals.data.decisions}
totalInDb={appraisals.data.total_in_db} totalInDb={appraisals.data.total_in_db}
sameBlockDecisions={appraisals.data.same_block?.decisions}
nearbyBlockDecisions={appraisals.data.nearby_blocks?.decisions}
/> />
)} )}
{!appraisals.isFetching && !appraisals.error && !appraisals.data && ( {!appraisals.isFetching && !appraisals.error && !appraisals.data && (
+9
View File
@@ -72,6 +72,8 @@ export interface AppraisalsSearchRequest {
search_text?: string; search_text?: string;
appraisal_header?: string; appraisal_header?: string;
max_results?: number; max_results?: number;
include_nearby?: boolean;
nearby_block_radius?: number;
} }
export interface AppraisalDocument { export interface AppraisalDocument {
@@ -96,9 +98,16 @@ export interface AppraisalDecision {
all_documents: AppraisalDocument[]; all_documents: AppraisalDocument[];
} }
export interface AppraisalsBucket {
count: number;
decisions: AppraisalDecision[];
}
export interface AppraisalsSearchResponse { export interface AppraisalsSearchResponse {
total_in_db: number; total_in_db: number;
returned: number; returned: number;
filters: Record<string, unknown>; filters: Record<string, unknown>;
decisions: AppraisalDecision[]; decisions: AppraisalDecision[];
same_block?: AppraisalsBucket;
nearby_blocks?: AppraisalsBucket;
} }
+134 -74
View File
@@ -7,10 +7,94 @@ import { formatDate } from "../lib/utils";
interface Props { interface Props {
decisions: AppraisalDecision[]; decisions: AppraisalDecision[];
totalInDb: number; totalInDb: number;
sameBlockDecisions?: AppraisalDecision[];
nearbyBlockDecisions?: AppraisalDecision[];
} }
export function AppraisalsTable({ decisions, totalInDb }: Props) { function DecisionsTable({ decisions }: { decisions: AppraisalDecision[] }) {
if (!decisions.length) { return (
<div className="overflow-x-auto rounded-md border">
<table dir="rtl" className="w-full text-sm">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 text-start font-medium">תאריך הכרעה</th>
<th className="p-3 text-start font-medium">שמאי מכריע</th>
<th className="p-3 text-start font-medium">ועדה</th>
<th className="p-3 text-center font-medium">גוש/חלקה</th>
<th className="p-3 text-start font-medium">סוג שומה</th>
<th className="p-3 text-start font-medium">כותרת ההחלטה</th>
<th className="p-3 text-start font-medium">PDF</th>
</tr>
</thead>
<tbody>
{decisions.map((d) => (
<tr key={d.id} className="border-t hover:bg-accent/40 align-top">
<td className="p-3 whitespace-nowrap">{formatDate(d.decision_date)}</td>
<td className="p-3 whitespace-nowrap">{d.decisive_appraiser || "—"}</td>
<td className="p-3 whitespace-nowrap">{d.committee || "—"}</td>
<td className="p-3 whitespace-nowrap tabular-nums text-center">
{d.block ?? "—"}
{d.plot ? ` / ${d.plot}` : ""}
</td>
<td className="p-3">{d.appraisal_type || "—"}</td>
<td className="p-3 max-w-md">
<span className="line-clamp-2">{d.appraisal_header || "—"}</span>
</td>
<td className="p-3 text-start whitespace-nowrap">
{d.pdf_url ? (
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
asChild
title="פתח PDF בלשונית חדשה"
>
<a
href={api.pdfProxyUrl(d.pdf_url)}
target="_blank"
rel="noopener noreferrer"
>
<FileText />
פתח
</a>
</Button>
<Button
size="sm"
variant="ghost"
asChild
title="הורד PDF"
>
<a
href={api.pdfProxyUrl(d.pdf_url) + "&download=1"}
download={`${d.block || "appraisal"}-${d.plot || "0"}-${d.id}.pdf`}
>
<Download />
</a>
</Button>
</div>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function AppraisalsTable({
decisions,
totalInDb,
sameBlockDecisions,
nearbyBlockDecisions,
}: Props) {
const hasMain = decisions.length > 0;
const hasSameBlock = (sameBlockDecisions?.length ?? 0) > 0;
const hasNearby = (nearbyBlockDecisions?.length ?? 0) > 0;
if (!hasMain && !hasSameBlock && !hasNearby) {
return ( return (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
לא נמצאו החלטות שמאי מכריע באזור הזה לא נמצאו החלטות שמאי מכריע באזור הזה
@@ -19,78 +103,54 @@ export function AppraisalsTable({ decisions, totalInDb }: Props) {
} }
return ( return (
<div className="space-y-3"> <div className="space-y-8">
<div className="text-sm text-muted-foreground"> <section className="space-y-3">
נמצאו {decisions.length} מתוך {totalInDb.toLocaleString("he-IL")} החלטות במאגר <div className="flex items-baseline justify-between">
</div> <h3 className="text-base font-semibold">החלטות תואמות</h3>
<div className="overflow-x-auto rounded-md border"> <div className="text-sm text-muted-foreground">
<table dir="rtl" className="w-full text-sm"> {hasMain
<thead className="bg-muted text-muted-foreground"> ? `נמצאו ${decisions.length} מתוך ${totalInDb.toLocaleString("he-IL")} החלטות במאגר`
<tr> : "לא נמצאו החלטות תואמות מדויקות"}
<th className="p-3 text-start font-medium">תאריך הכרעה</th> </div>
<th className="p-3 text-start font-medium">שמאי מכריע</th> </div>
<th className="p-3 text-start font-medium">ועדה</th> {hasMain ? (
<th className="p-3 text-start font-medium">גוש/חלקה</th> <DecisionsTable decisions={decisions} />
<th className="p-3 text-start font-medium">סוג שומה</th> ) : (
<th className="p-3 text-start font-medium">כותרת ההחלטה</th> <div className="text-center py-6 text-sm text-muted-foreground border rounded-md">
<th className="p-3 text-start font-medium">PDF</th> אין החלטות בגוש/חלקה המבוקשים
</tr> </div>
</thead> )}
<tbody> </section>
{decisions.map((d) => (
<tr key={d.id} className="border-t hover:bg-accent/40 align-top"> {hasSameBlock && (
<td className="p-3 whitespace-nowrap">{formatDate(d.decision_date)}</td> <section className="space-y-3">
<td className="p-3 whitespace-nowrap">{d.decisive_appraiser || "—"}</td> <div className="flex items-baseline justify-between">
<td className="p-3 whitespace-nowrap">{d.committee || "—"}</td> <h3 className="text-base font-semibold">
<td className="p-3 whitespace-nowrap tabular-nums"> החלטות נוספות באותו גוש{" "}
{d.block ?? "—"} <span className="text-muted-foreground font-normal">(חלקות אחרות)</span>
{d.plot ? ` / ${d.plot}` : ""} </h3>
</td> <div className="text-sm text-muted-foreground">
<td className="p-3">{d.appraisal_type || "—"}</td> {sameBlockDecisions!.length} החלטות
<td className="p-3 max-w-md"> </div>
<span className="line-clamp-2">{d.appraisal_header || "—"}</span> </div>
</td> <DecisionsTable decisions={sameBlockDecisions!} />
<td className="p-3 text-start whitespace-nowrap"> </section>
{d.pdf_url ? ( )}
<div className="flex justify-end gap-1">
<Button {hasNearby && (
size="sm" <section className="space-y-3">
variant="outline" <div className="flex items-baseline justify-between">
asChild <h3 className="text-base font-semibold">
title="פתח PDF בלשונית חדשה" החלטות בגושים סמוכים{" "}
> <span className="text-muted-foreground font-normal">(±2)</span>
<a </h3>
href={api.pdfProxyUrl(d.pdf_url)} <div className="text-sm text-muted-foreground">
target="_blank" {nearbyBlockDecisions!.length} החלטות
rel="noopener noreferrer" </div>
> </div>
<FileText /> <DecisionsTable decisions={nearbyBlockDecisions!} />
פתח </section>
</a> )}
</Button>
<Button
size="sm"
variant="ghost"
asChild
title="הורד PDF"
>
<a
href={api.pdfProxyUrl(d.pdf_url) + "&download=1"}
download={`${d.block || "appraisal"}-${d.plot || "0"}-${d.id}.pdf`}
>
<Download />
</a>
</Button>
</div>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
); );
} }
+16 -16
View File
@@ -87,14 +87,14 @@ export function DealsTable({ deals, resolvedAddress, searchedBlock, searchedPlot
<th className="p-3 text-start font-medium">תאריך</th> <th className="p-3 text-start font-medium">תאריך</th>
<th className="p-3 text-start font-medium">כתובת</th> <th className="p-3 text-start font-medium">כתובת</th>
<th className="p-3 text-start font-medium">סוג</th> <th className="p-3 text-start font-medium">סוג</th>
<th className="p-3 text-start font-medium">חדרים</th> <th className="p-3 text-center font-medium">חדרים</th>
<th className="p-3 text-start font-medium">קומה</th> <th className="p-3 text-center font-medium">קומה</th>
<th className="p-3 text-start font-medium">שטח (מ"ר)</th> <th className="p-3 text-center font-medium">שטח (מ"ר)</th>
<th className="p-3 text-start font-medium">מחיר</th> <th className="p-3 text-center font-medium">מחיר</th>
<th className="p-3 text-start font-medium">מחיר/מ"ר</th> <th className="p-3 text-center font-medium">מחיר/מ"ר</th>
<th className="p-3 text-start font-medium">גוש</th> <th className="p-3 text-center font-medium">גוש</th>
<th className="p-3 text-start font-medium">חלקה</th> <th className="p-3 text-center font-medium">חלקה</th>
<th className="p-3 text-start font-medium">תת-חלקה</th> <th className="p-3 text-center font-medium">תת-חלקה</th>
<th className="p-3 text-start font-medium">קרבה</th> <th className="p-3 text-start font-medium">קרבה</th>
</tr> </tr>
</thead> </thead>
@@ -109,18 +109,18 @@ export function DealsTable({ deals, resolvedAddress, searchedBlock, searchedPlot
<td className="p-3 whitespace-nowrap">{formatDate(d.deal_date)}</td> <td className="p-3 whitespace-nowrap">{formatDate(d.deal_date)}</td>
<td className="p-3">{address || "—"}</td> <td className="p-3">{address || "—"}</td>
<td className="p-3">{d.property_type_description || "—"}</td> <td className="p-3">{d.property_type_description || "—"}</td>
<td className="p-3 text-start tabular-nums">{d.rooms ?? "—"}</td> <td className="p-3 text-center tabular-nums">{d.rooms ?? "—"}</td>
<td className="p-3 text-start tabular-nums">{d.floor ?? "—"}</td> <td className="p-3 text-center tabular-nums">{d.floor ?? "—"}</td>
<td className="p-3 text-start tabular-nums">{formatNumber(d.asset_area)}</td> <td className="p-3 text-center tabular-nums">{formatNumber(d.asset_area)}</td>
<td className="p-3 text-start tabular-nums"> <td className="p-3 text-center tabular-nums">
{formatCurrencyILS(d.deal_amount)} {formatCurrencyILS(d.deal_amount)}
</td> </td>
<td className="p-3 text-start tabular-nums"> <td className="p-3 text-center tabular-nums">
{formatCurrencyILS(d.price_per_sqm)} {formatCurrencyILS(d.price_per_sqm)}
</td> </td>
<td className="p-3 text-start tabular-nums">{d.gushNum ?? "—"}</td> <td className="p-3 text-center tabular-nums">{d.gushNum ?? "—"}</td>
<td className="p-3 text-start tabular-nums">{d.parcelNum ?? "—"}</td> <td className="p-3 text-center tabular-nums">{d.parcelNum ?? "—"}</td>
<td className="p-3 text-start tabular-nums">{d.subParcelNum ?? "—"}</td> <td className="p-3 text-center tabular-nums">{d.subParcelNum ?? "—"}</td>
<td className="p-3"> <td className="p-3">
{d.deal_source ? ( {d.deal_source ? (
<span <span