diff --git a/nadlan_mcp/web_app.py b/nadlan_mcp/web_app.py index 7d91382..52d7172 100644 --- a/nadlan_mcp/web_app.py +++ b/nadlan_mcp/web_app.py @@ -59,6 +59,11 @@ class AppraisalsSearchRequest(BaseModel): search_text: 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) + # 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 ──────────────────────────────────────── @app.post("/api/search/appraisals") async def search_appraisals(req: AppraisalsSearchRequest): - """Search decisive-appraiser decisions (gov.il / Ministry of Justice).""" - try: - response = decisive_appraiser_client.search_decisions_paged( + """Search decisive-appraiser decisions (gov.il / Ministry of Justice). + + 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, - block=req.block, - plot=req.plot, + block=block, + plot=plot, decisive_appraiser=req.decisive_appraiser, committee=req.committee, decision_date_from=req.decision_date_from, @@ -228,24 +240,95 @@ def create_app() -> FastAPI: search_text=req.search_text, appraisal_header=req.appraisal_header, ) + + try: + exact_response = _run(req.block, req.plot) 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)) - decisions = [] - for idx, decision in enumerate(response.results, start=1): - d = decision.model_dump(mode="json", exclude_none=True) - documents = d.pop("documents", []) or [] - d["id"] = idx - d["pdf_url"] = documents[0]["file_url"] if documents else None - d["all_documents"] = documents - decisions.append(d) + # Dedupe across buckets by a composite key (gov.il doesn't expose a + # single stable id we can rely on). + seen_keys: set[tuple] = set() + + def _serialize(decisions_iter: Any, start_idx: int = 1) -> list[dict]: + out: list[dict] = [] + running = start_idx + 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 { - "total_in_db": response.total_results, - "returned": len(decisions), + "total_in_db": exact_response.total_results, + "returned": len(exact_decisions), "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 ─────────────────────────────────────────── diff --git a/web/src/App.tsx b/web/src/App.tsx index 71d86bd..699abf9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -68,6 +68,10 @@ function useAppraisals(query: SearchQuery | null, blockOverride?: string, plotOv plot, decisive_appraiser: appraiser, 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), }); @@ -192,7 +196,9 @@ export default function App() { שמאי מכריע {appraisals.data && ( - {appraisals.data.returned} + {appraisals.data.returned + + (appraisals.data.same_block?.count ?? 0) + + (appraisals.data.nearby_blocks?.count ?? 0)} )} @@ -227,6 +233,8 @@ export default function App() { )} {!appraisals.isFetching && !appraisals.error && !appraisals.data && ( diff --git a/web/src/api/types.ts b/web/src/api/types.ts index fb653d3..88ff207 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -72,6 +72,8 @@ export interface AppraisalsSearchRequest { search_text?: string; appraisal_header?: string; max_results?: number; + include_nearby?: boolean; + nearby_block_radius?: number; } export interface AppraisalDocument { @@ -96,9 +98,16 @@ export interface AppraisalDecision { all_documents: AppraisalDocument[]; } +export interface AppraisalsBucket { + count: number; + decisions: AppraisalDecision[]; +} + export interface AppraisalsSearchResponse { total_in_db: number; returned: number; filters: Record; decisions: AppraisalDecision[]; + same_block?: AppraisalsBucket; + nearby_blocks?: AppraisalsBucket; } diff --git a/web/src/components/AppraisalsTable.tsx b/web/src/components/AppraisalsTable.tsx index f004238..1549450 100644 --- a/web/src/components/AppraisalsTable.tsx +++ b/web/src/components/AppraisalsTable.tsx @@ -7,10 +7,94 @@ import { formatDate } from "../lib/utils"; interface Props { decisions: AppraisalDecision[]; totalInDb: number; + sameBlockDecisions?: AppraisalDecision[]; + nearbyBlockDecisions?: AppraisalDecision[]; } -export function AppraisalsTable({ decisions, totalInDb }: Props) { - if (!decisions.length) { +function DecisionsTable({ decisions }: { decisions: AppraisalDecision[] }) { + return ( +
+ + + + + + + + + + + + + + {decisions.map((d) => ( + + + + + + + + + + ))} + +
תאריך הכרעהשמאי מכריעועדהגוש/חלקהסוג שומהכותרת ההחלטהPDF
{formatDate(d.decision_date)}{d.decisive_appraiser || "—"}{d.committee || "—"} + {d.block ?? "—"} + {d.plot ? ` / ${d.plot}` : ""} + {d.appraisal_type || "—"} + {d.appraisal_header || "—"} + + {d.pdf_url ? ( +
+ + +
+ ) : ( + "—" + )} +
+
+ ); +} + +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 (
לא נמצאו החלטות שמאי מכריע באזור הזה @@ -19,78 +103,54 @@ export function AppraisalsTable({ decisions, totalInDb }: Props) { } return ( -
-
- נמצאו {decisions.length} מתוך {totalInDb.toLocaleString("he-IL")} החלטות במאגר -
-
- - - - - - - - - - - - - - {decisions.map((d) => ( - - - - - - - - - - ))} - -
תאריך הכרעהשמאי מכריעועדהגוש/חלקהסוג שומהכותרת ההחלטהPDF
{formatDate(d.decision_date)}{d.decisive_appraiser || "—"}{d.committee || "—"} - {d.block ?? "—"} - {d.plot ? ` / ${d.plot}` : ""} - {d.appraisal_type || "—"} - {d.appraisal_header || "—"} - - {d.pdf_url ? ( -
- - -
- ) : ( - "—" - )} -
-
+
+
+
+

החלטות תואמות

+
+ {hasMain + ? `נמצאו ${decisions.length} מתוך ${totalInDb.toLocaleString("he-IL")} החלטות במאגר` + : "לא נמצאו החלטות תואמות מדויקות"} +
+
+ {hasMain ? ( + + ) : ( +
+ אין החלטות בגוש/חלקה המבוקשים +
+ )} +
+ + {hasSameBlock && ( +
+
+

+ החלטות נוספות באותו גוש{" "} + (חלקות אחרות) +

+
+ {sameBlockDecisions!.length} החלטות +
+
+ +
+ )} + + {hasNearby && ( +
+
+

+ החלטות בגושים סמוכים{" "} + (±2) +

+
+ {nearbyBlockDecisions!.length} החלטות +
+
+ +
+ )}
); } diff --git a/web/src/components/DealsTable.tsx b/web/src/components/DealsTable.tsx index 839112e..96ff704 100644 --- a/web/src/components/DealsTable.tsx +++ b/web/src/components/DealsTable.tsx @@ -87,14 +87,14 @@ export function DealsTable({ deals, resolvedAddress, searchedBlock, searchedPlot תאריך כתובת סוג - חדרים - קומה - שטח (מ"ר) - מחיר - מחיר/מ"ר - גוש - חלקה - תת-חלקה + חדרים + קומה + שטח (מ"ר) + מחיר + מחיר/מ"ר + גוש + חלקה + תת-חלקה קרבה @@ -109,18 +109,18 @@ export function DealsTable({ deals, resolvedAddress, searchedBlock, searchedPlot {formatDate(d.deal_date)} {address || "—"} {d.property_type_description || "—"} - {d.rooms ?? "—"} - {d.floor ?? "—"} - {formatNumber(d.asset_area)} - + {d.rooms ?? "—"} + {d.floor ?? "—"} + {formatNumber(d.asset_area)} + {formatCurrencyILS(d.deal_amount)} - + {formatCurrencyILS(d.price_per_sqm)} - {d.gushNum ?? "—"} - {d.parcelNum ?? "—"} - {d.subParcelNum ?? "—"} + {d.gushNum ?? "—"} + {d.parcelNum ?? "—"} + {d.subParcelNum ?? "—"} {d.deal_source ? (