Add: Decisive appraiser (שמאי מכריע) search via gov.il public API

Adds a new MCP tool `search_decisive_appraisals` that queries the
Ministry of Justice public registry (~30K published decisions) by
block (גוש), plot (חלקה), appraiser name, committee, decision/publicity
date ranges, or free text — and returns metadata + direct PDF URLs.

Implementation notes:
- New `nadlan_mcp/govil/` package, parallel to `nadlan_mcp/govmap/`,
  for gov.il APIs that are not Govmap. Pydantic v2 models match the
  upstream PascalCase response via aliases.
- Upstream sits behind an F5 WAF that rejects standard `requests`;
  uses `curl_cffi` with Chrome 120 impersonation to traverse it.
- Static `x-client-id` header (issued to the gov.il SPA, public, visible
  in any DevTools session) is required by the gateway — without it
  every call returns a generic 500.
- 14 unit tests cover model parsing, body shape, pagination, and the
  500-is-fatal contract (configuration error, not retryable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 10:45:25 +00:00
parent b87cb268d5
commit 8d6639bc4c
7 changed files with 726 additions and 30 deletions
+110
View File
@@ -14,6 +14,7 @@ from mcp.server.fastmcp import FastMCP
from starlette.responses import JSONResponse
from nadlan_mcp.config import get_config
from nadlan_mcp.govil import DecisiveAppraiserClient
from nadlan_mcp.govmap import GovmapClient
from nadlan_mcp.govmap.models import Deal
from nadlan_mcp.govmap.outlier_detection import filter_deals_for_analysis
@@ -28,6 +29,9 @@ mcp = FastMCP("nadlan-mcp")
# Initialize the Govmap client
client = GovmapClient()
# Initialize the Decisive Appraiser (gov.il / Ministry of Justice) client
decisive_appraiser_client = DecisiveAppraiserClient()
def conditional_tool(config_flag: str):
"""
@@ -1310,6 +1314,112 @@ def get_market_activity_metrics(address: str, years_back: int = 2, radius_meters
return f"Error analyzing market activity: {str(e)}"
@conditional_tool("tool_search_decisive_appraisals_enabled")
def search_decisive_appraisals(
block: Optional[str] = None,
plot: Optional[str] = None,
decisive_appraiser: Optional[str] = None,
committee: Optional[str] = None,
decision_date_from: Optional[str] = None,
decision_date_to: Optional[str] = None,
publicity_date_from: Optional[str] = None,
publicity_date_to: Optional[str] = None,
search_text: Optional[str] = None,
appraisal_header: Optional[str] = None,
max_results: int = 30,
) -> str:
"""Search published "decisive appraiser" (שמאי מכריע) decisions from the Israeli
Ministry of Justice public registry. The registry holds 30,000+ written
decisions on land betterment levy, expropriation, real-estate disputes etc.
All filters are optional. Use block + plot for property-level lookups
(גוש + חלקה), or `decisive_appraiser` to find every decision by a given
appraiser. Page size is fixed by the upstream at 10; this tool pages
automatically up to `max_results`.
Args:
block: Land block number, גוש (e.g. "6212"). Exact match.
plot: Land plot number, חלקה (e.g. "894"). Use with block.
decisive_appraiser: Appraiser name in Hebrew (e.g. "דדון דוד").
committee: Local planning committee (e.g. "תל אביב-יפו").
decision_date_from / decision_date_to: Date range for the decision date.
Format: dd-MM-yyyy (e.g. "01-01-2024"). Either bound is optional.
publicity_date_from / publicity_date_to: Date range for publication.
search_text: Free-text search inside the decision document.
appraisal_header: Search within the decision title.
max_results: Maximum number of decisions to return (default 30).
Returns:
JSON string with `total_results` (database-wide), `returned`, the
applied `filters`, and a `decisions` array. Each decision contains
the appraiser name, block/plot, committee, dates, the decision title
and a `pdf_url` to download the original PDF.
"""
log_mcp_call(
"search_decisive_appraisals",
block=block,
plot=plot,
decisive_appraiser=decisive_appraiser,
committee=committee,
decision_date_from=decision_date_from,
decision_date_to=decision_date_to,
publicity_date_from=publicity_date_from,
publicity_date_to=publicity_date_to,
search_text=search_text,
appraisal_header=appraisal_header,
max_results=max_results,
)
try:
response = decisive_appraiser_client.search_decisions_paged(
max_results=max_results,
block=block,
plot=plot,
decisive_appraiser=decisive_appraiser,
committee=committee,
decision_date_from=decision_date_from,
decision_date_to=decision_date_to,
publicity_date_from=publicity_date_from,
publicity_date_to=publicity_date_to,
search_text=search_text,
appraisal_header=appraisal_header,
)
decisions = []
for idx, decision in enumerate(response.results, start=1):
data = decision.model_dump(mode="json", exclude_none=True)
documents = data.pop("documents", []) or []
primary_pdf = documents[0]["file_url"] if documents else None
data["id"] = idx
data["pdf_url"] = primary_pdf
data["all_documents"] = documents
decisions.append(data)
return json.dumps(
{
"total_results": response.total_results,
"returned": len(decisions),
"filters": {
"block": block,
"plot": plot,
"decisive_appraiser": decisive_appraiser,
"committee": committee,
"decision_date_from": decision_date_from,
"decision_date_to": decision_date_to,
"publicity_date_from": publicity_date_from,
"publicity_date_to": publicity_date_to,
"search_text": search_text,
"appraisal_header": appraisal_header,
},
"decisions": decisions,
},
ensure_ascii=False,
indent=None,
)
except Exception as e:
logger.error(f"Error in search_decisive_appraisals: {e}", exc_info=True)
return f"Error searching decisive appraisals: {str(e)}"
# Health check endpoint for HTTP deployments
@mcp.custom_route("/health", methods=["GET"])
async def health_check(request):