Files
nadlan-mcp/nadlan_mcp/web_app.py
T
chaim 5e9963f51b Add: Web UI (React 19 + Vite + shadcn/ui) + FastAPI REST layer
Adds a self-contained web interface that wraps the MCP tools so end users
don't need to issue MCP commands. Single Docker container serves:

  /              → React SPA (RTL Hebrew, Heebo font, shadcn/ui components)
  /api/*         → FastAPI REST endpoints used by the SPA
  /api/docs      → OpenAPI / Swagger
  /mcp           → existing FastMCP streamable-http endpoint
  /health        → liveness probe

REST endpoints (in nadlan_mcp/web_app.py):
  GET  /api/search/address?q=...  — autocomplete + best-effort gush/חלקה
                                    via Govmap PARCEL_ALL layer
  POST /api/search/deals          — Govmap deals around an address
  POST /api/search/appraisals     — Decisive-appraiser search (gov.il)
  GET  /api/appraisals/pdf?url=.. — Stream PDFs through this server,
                                    carrying the upstream x-client-id
                                    header (a normal browser fetch fails)

UI flow:
  - Address search → resolves gush/חלקה → shows BOTH deals (Govmap) AND
    appraiser decisions (gov.il) for the same parcel side-by-side in tabs.
  - Block+plot search → goes straight to appraisals.
  - Appraiser-name search → all decisions by that appraiser.

Stack chosen for RTL-first usability and longevity: React 19, Vite 6,
TypeScript, Tailwind, shadcn/ui (Radix primitives), TanStack Query,
Lucide icons. ~295KB gzipped JS, no SSR overhead.

Multi-stage Dockerfile: Node 22 builds web/ in stage 1, Python 3.13
runtime serves the dist + the FastAPI app in stage 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:35:08 +00:00

414 lines
17 KiB
Python

"""
Web application: FastAPI REST endpoints + FastMCP mount + React static.
Layout:
/ → React SPA (index.html + assets)
/api/... → FastAPI REST endpoints used by the React UI
/api/docs → Swagger UI for the REST API
/mcp → FastMCP streamable-http endpoint (for LLM clients)
/health → liveness probe (also mounted under /api/health)
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.responses import FileResponse, JSONResponse
from nadlan_mcp.fastmcp_server import (
client as govmap_client,
)
from nadlan_mcp.fastmcp_server import (
decisive_appraiser_client,
mcp,
)
from nadlan_mcp.govil.client import ALLOWED_PDF_HOSTS
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────
# Request / response schemas
# ──────────────────────────────────────────────────────────────────────
class DealsSearchRequest(BaseModel):
address: str = Field(..., min_length=2, max_length=200)
years_back: int = Field(2, ge=1, le=10)
radius_meters: int = Field(100, ge=10, le=2000)
max_deals: int = Field(50, ge=1, le=200)
deal_type: int = Field(2, ge=1, le=2)
class AppraisalsSearchRequest(BaseModel):
block: str | None = Field(None, max_length=20)
plot: str | None = Field(None, max_length=20)
decisive_appraiser: str | None = Field(None, max_length=100)
committee: str | None = Field(None, max_length=100)
decision_date_from: str | None = Field(None, max_length=20)
decision_date_to: str | None = Field(None, max_length=20)
publicity_date_from: str | None = Field(None, max_length=20)
publicity_date_to: str | None = Field(None, max_length=20)
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)
# ──────────────────────────────────────────────────────────────────────
# App factory
# ──────────────────────────────────────────────────────────────────────
def create_app() -> FastAPI:
"""
Build the combined FastAPI + FastMCP application.
The static-file mount at "/" must be the LAST mount so that more
specific paths (/api, /mcp, /health) take precedence.
"""
# FastMCP needs its lifespan to run, so we forward it to FastAPI.
if hasattr(mcp, "streamable_http_app"):
mcp_app = mcp.streamable_http_app()
elif hasattr(mcp, "http_app"):
mcp_app = mcp.http_app()
else:
raise RuntimeError("FastMCP version does not expose an HTTP app")
app = FastAPI(
title="Nadlan-MCP Web",
description=(
"REST API for Israeli real-estate (Govmap) and decisive-appraiser "
"(Ministry of Justice) data. Powers the React UI; also exposes the "
"raw FastMCP endpoint at /mcp for LLM clients."
),
version="2.1.0",
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
lifespan=mcp_app.router.lifespan_context,
)
# CORS — only relevant during local dev when Vite serves on :5173.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
# ── Health ────────────────────────────────────────────────────────
@app.get("/api/health")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "service": "nadlan-mcp"}
# ── /api/search/address ───────────────────────────────────────────
@app.get("/api/search/address")
async def search_address(q: str = Query(..., min_length=2, max_length=120)):
"""Autocomplete an Israeli address. Also returns coordinates and (when
available) the gush/חלקה for the top match."""
try:
response = govmap_client.autocomplete_address(q)
except Exception as e:
logger.exception("autocomplete_address failed")
raise HTTPException(status_code=502, detail=str(e))
results = []
for r in response.results:
item: dict[str, Any] = {
"text": r.text,
"type": r.type,
"score": r.score,
"id": r.id,
}
if r.coordinates:
item["coordinates"] = {
"longitude": r.coordinates.longitude,
"latitude": r.coordinates.latitude,
}
results.append(item)
# Best-effort gush/חלקה for the top result. Use PARCEL_ALL layer
# explicitly — the default layer 16 in get_gush_helka is `nadlan`
# (deals) which has null gush/parcel for many addresses.
gush_helka = None
if response.results and response.results[0].coordinates:
try:
coords = response.results[0].coordinates
gh = _query_parcel_layer(govmap_client, coords.longitude, coords.latitude)
gush_helka = _extract_gush_helka(gh)
except Exception as e:
logger.warning(f"parcel lookup failed for '{q}': {e}")
return {"query": q, "results": results, "gush_helka": gush_helka}
# ── /api/search/deals ─────────────────────────────────────────────
@app.post("/api/search/deals")
async def search_deals(req: DealsSearchRequest):
"""Find recent real-estate deals around an address (Govmap)."""
try:
deals = govmap_client.find_recent_deals_for_address(
req.address,
req.years_back,
req.radius_meters,
req.max_deals,
req.deal_type,
)
except Exception as e:
logger.exception("find_recent_deals_for_address failed")
raise HTTPException(status_code=502, detail=str(e))
# Search-center coords for the map (best-effort).
search_coords = None
try:
ar = govmap_client.autocomplete_address(req.address)
if ar.results and ar.results[0].coordinates:
c = ar.results[0].coordinates
search_coords = {"longitude": c.longitude, "latitude": c.latitude}
except Exception:
pass
# Strip bloat to keep payload small for the UI.
bloat = {
"shape",
"sourceorder",
"objectid",
"priority",
"source_polygon_id",
"settlementId",
"streetCode",
"dealId",
"polygonId",
}
deal_dicts = []
for idx, deal in enumerate(deals, start=1):
d = deal.model_dump(mode="json", exclude_none=True)
d = {k: v for k, v in d.items() if k not in bloat}
d["id"] = idx
d["deal_source"] = getattr(deal, "deal_source", None)
deal_dicts.append(d)
return {
"search": {
"address": req.address,
"coordinates": search_coords,
"years_back": req.years_back,
"radius_meters": req.radius_meters,
"deal_type": req.deal_type,
},
"total": len(deal_dicts),
"deals": deal_dicts,
}
# ── /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(
max_results=req.max_results,
block=req.block,
plot=req.plot,
decisive_appraiser=req.decisive_appraiser,
committee=req.committee,
decision_date_from=req.decision_date_from,
decision_date_to=req.decision_date_to,
publicity_date_from=req.publicity_date_from,
publicity_date_to=req.publicity_date_to,
search_text=req.search_text,
appraisal_header=req.appraisal_header,
)
except Exception as e:
logger.exception("search_decisions_paged 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)
return {
"total_in_db": response.total_results,
"returned": len(decisions),
"filters": req.model_dump(exclude_none=True),
"decisions": decisions,
}
# ── /api/appraisals/pdf ───────────────────────────────────────────
@app.get("/api/appraisals/pdf")
async def proxy_appraisal_pdf(url: str = Query(..., min_length=20)):
"""
Stream a decisive-appraiser PDF through this server.
Required because the upstream PDF host demands the same x-client-id
header as the search API; a normal browser fetch would fail.
"""
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.hostname not in ALLOWED_PDF_HOSTS:
raise HTTPException(status_code=400, detail="URL host not allowed")
try:
# Reuse the curl_cffi session that already carries x-client-id.
response = decisive_appraiser_client._session.get(
url,
timeout=(
decisive_appraiser_client.config.connect_timeout,
decisive_appraiser_client.config.read_timeout * 4,
),
headers={"Accept": "application/pdf,*/*"},
)
except Exception as e:
logger.exception("PDF proxy upstream call failed")
raise HTTPException(status_code=502, detail=str(e))
if response.status_code != 200:
raise HTTPException(
status_code=response.status_code,
detail=f"Upstream returned {response.status_code}",
)
if not response.content.startswith(b"%PDF"):
raise HTTPException(status_code=502, detail="Upstream did not return a PDF")
# Suggest a reasonable filename for the browser.
filename = parsed.path.rstrip("/").split("/")[-1] or "appraisal"
if not filename.lower().endswith(".pdf"):
filename = f"{filename}.pdf"
def _iter() -> Any:
yield response.content
return StreamingResponse(
_iter(),
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{filename}"',
"Cache-Control": "private, max-age=3600",
},
)
# ── /mcp/ → FastMCP ───────────────────────────────────────────────
app.mount("/mcp", mcp_app)
# ── / → React static (must be LAST) ───────────────────────────────
web_dist = Path(__file__).resolve().parent.parent / "web" / "dist"
if web_dist.is_dir():
app.mount(
"/assets",
StaticFiles(directory=web_dist / "assets"),
name="assets",
)
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str):
# Unknown paths fall back to index.html so React Router can take over.
index = web_dist / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse({"detail": "UI not built"}, status_code=404)
else:
logger.warning(
f"Web build directory not found at {web_dist}; UI will not be served. "
f"Run `cd web && pnpm build` to populate it."
)
@app.get("/", include_in_schema=False)
async def no_ui():
return JSONResponse(
{
"service": "nadlan-mcp",
"ui": "not built",
"api_docs": "/api/docs",
"mcp": "/mcp",
}
)
return app
# ──────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────
def _query_parcel_layer(client: Any, longitude: float, latitude: float) -> dict[str, Any]:
"""
Query Govmap's PARCEL_ALL layer at a point. Returns the raw JSON.
The default `client.get_gush_helka` queries layer 16 (`nadlan`), which has
null gush/parcel for many address points. PARCEL_ALL is the cadastral layer
that reliably exposes them.
"""
url = f"{client.base_url}/layers-catalog/entitiesByPoint"
payload = {
"point": [longitude, latitude],
"layers": [{"layerId": "PARCEL_ALL"}],
"tolerance": 5,
}
timeout = (client.config.connect_timeout, client.config.read_timeout)
response = client.session.post(url, json=payload, timeout=timeout)
response.raise_for_status()
return response.json()
def _extract_gush_helka(govmap_response: Any) -> dict[str, Any] | None:
"""
Pull the (gush, helka) pair out of the layered Govmap response.
Real shape (layerId 16 / nadlan layer):
{"data": [{"name": "...", "entities": [
{"fields": [{"fieldName": "גוש", "fieldValue": "6212"},
{"fieldName": "חלקה", "fieldValue": "894"}, ...]}
]}]}
Values can be null when the layer has no data at that point — we skip
those and keep looking through every entity in every layer.
"""
if not isinstance(govmap_response, dict):
return None
block: str | None = None
plot: str | None = None
for layer in govmap_response.get("data") or []:
if not isinstance(layer, dict):
continue
for entity in layer.get("entities") or []:
if not isinstance(entity, dict):
continue
for f in entity.get("fields") or []:
name = (f.get("fieldName") or "").strip()
value = f.get("fieldValue")
if value is None:
continue
value = str(value).strip()
if not value:
continue
if (
name in ("גוש", "מספר גוש", "gush", "block", "GUSH_NUM", "gush_num")
and not block
):
block = value
elif (
name in ("חלקה", "מספר חלקה", "helka", "parcel", "PARCEL", "PARCEL_NUM")
and not plot
):
plot = value
if block and plot:
return {"block": block, "plot": plot}
if block or plot:
return {"block": block, "plot": plot}
return None