Improve: MCP efficiency & fix radius filtering

- Remove bloat fields (shape, objectid, etc), add sequential IDs
- Add lang param (he/en) for Hebrew/English text values
- Reduce JSON whitespace (indent=None)
- Fix distance_meters: extract centroid from WKT shape geometry
- Add search_coordinates to all address-based tool responses
- Fix radius filtering: properly filter deals beyond radius_meters
- Change default radius from 30m to 50m
- Fix get_deal_statistics: return NO deals (stats only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nitzan Pomerantz
2025-12-08 09:34:42 +02:00
parent 4de1a4822b
commit b87cb268d5
4 changed files with 368 additions and 117 deletions
+200 -93
View File
@@ -74,32 +74,73 @@ def log_mcp_call(func_name: str, **params):
) )
def strip_bloat_fields(deals: List[Deal]) -> List[Dict[str, Any]]: def strip_bloat_fields(deals: List[Deal], lang: str = "he") -> List[Dict[str, Any]]:
""" """
Remove bloat fields from Deal models to reduce token usage in MCP responses. Remove bloat fields from Deal models to reduce token usage in MCP responses.
Converts Deal models to dictionaries and removes: Converts Deal models to dictionaries and removes unnecessary fields:
- shape: Large MULTIPOLYGON coordinate data (~40-50% of tokens, not useful for LLM analysis) - shape: Large MULTIPOLYGON coordinate data
- sourceorder: Internal ordering field - sourceorder, objectid, priority: Internal fields
- source_polygon_id: Internal reference field (only when it's a UUID string) - source_polygon_id, settlementId, streetCode, dealId, polygonId: Internal IDs
- deal_type_description: Redundant (kept in search_parameters only)
Adds sequential ID for LLM reference and selects language for text fields.
Args: Args:
deals: List of Deal model instances deals: List of Deal model instances
lang: Language for text values ("he" for Hebrew, "en" for English)
Returns: Returns:
List of deal dictionaries with bloat fields removed List of deal dictionaries with bloat removed and sequential IDs
""" """
bloat_fields = {"shape", "sourceorder"} bloat_fields = {
# Note: We keep source_polygon_id if it was added by our processing logic "shape",
"sourceorder",
"objectid",
"priority",
"source_polygon_id",
"settlementId",
"streetCode",
"dealId",
"polygonId",
"deal_type_description",
}
result = [] result = []
for deal in deals: for idx, deal in enumerate(deals, start=1):
# Convert Deal model to dict, excluding None values for cleaner output # Convert Deal model to dict, excluding None values
# Use mode='json' to serialize dates as ISO strings
deal_dict = deal.model_dump(mode="json", exclude_none=True) deal_dict = deal.model_dump(mode="json", exclude_none=True)
# Remove bloat fields # Remove bloat fields
filtered_dict = {k: v for k, v in deal_dict.items() if k not in bloat_fields} filtered_dict = {k: v for k, v in deal_dict.items() if k not in bloat_fields}
# Add sequential ID for LLM reference
filtered_dict["id"] = idx
# Select language for text fields (Hebrew or English)
if lang == "en":
# Use English values if available
if "settlementNameEng" in filtered_dict:
filtered_dict["settlement_name"] = filtered_dict.pop("settlementNameEng")
filtered_dict.pop("settlementNameHeb", None)
if "streetNameEng" in filtered_dict:
filtered_dict["street_name"] = filtered_dict.pop("streetNameEng")
filtered_dict.pop("streetNameHeb", None)
if "assetTypeEng" in filtered_dict:
filtered_dict["asset_type"] = filtered_dict.pop("assetTypeEng")
filtered_dict.pop("assetTypeHeb", None)
else: # Default to Hebrew
# Use Hebrew values
if "settlementNameHeb" in filtered_dict:
filtered_dict["settlement_name"] = filtered_dict.pop("settlementNameHeb")
filtered_dict.pop("settlementNameEng", None)
if "streetNameHeb" in filtered_dict:
filtered_dict["street_name"] = filtered_dict.pop("streetNameHeb")
filtered_dict.pop("streetNameEng", None)
if "assetTypeHeb" in filtered_dict:
filtered_dict["asset_type"] = filtered_dict.pop("assetTypeHeb")
filtered_dict.pop("assetTypeEng", None)
result.append(filtered_dict) result.append(filtered_dict)
return result return result
@@ -141,7 +182,7 @@ def autocomplete_address(search_text: str) -> str:
formatted_results.append(result_dict) formatted_results.append(result_dict)
return json.dumps(formatted_results, ensure_ascii=False, indent=2) return json.dumps(formatted_results, ensure_ascii=False, indent=None)
except Exception as e: except Exception as e:
logger.error(f"Error in autocomplete_address: {e}", exc_info=True) logger.error(f"Error in autocomplete_address: {e}", exc_info=True)
@@ -182,7 +223,7 @@ def get_deals_by_radius(latitude: float, longitude: float, radius_meters: int =
"polygons": polygons, # Return dicts directly, no stripping needed "polygons": polygons, # Return dicts directly, no stripping needed
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
except Exception as e: except Exception as e:
@@ -236,10 +277,10 @@ def get_street_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> s
"deal_type": deal_type, "deal_type": deal_type,
"deal_type_description": deal_type_desc, "deal_type_description": deal_type_desc,
"market_statistics": stats, "market_statistics": stats,
"deals": strip_bloat_fields(deals), "deals": strip_bloat_fields(deals, lang="he"),
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
except Exception as e: except Exception as e:
@@ -251,22 +292,25 @@ def get_street_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> s
def find_recent_deals_for_address( def find_recent_deals_for_address(
address: str, address: str,
years_back: int = 2, years_back: int = 2,
radius_meters: int = 30, radius_meters: int = 50,
max_deals: int = 100, max_deals: int = 100,
deal_type: int = 2, deal_type: int = 2,
lang: str = "he",
) -> str: ) -> str:
"""Find recent real estate deals for a specific address. """Find recent real estate deals for a specific address.
Args: Args:
address: The address to search for (in Hebrew or English) address: The address to search for (in Hebrew or English)
years_back: How many years back to search (default: 2) years_back: How many years back to search (default: 2)
radius_meters: Search radius in meters from the address (default: 30) radius_meters: Search radius in meters from the address (default: 50)
Small radius since street deals cover the entire street anyway Deals beyond this radius are filtered out, EXCEPT same-building deals
which are always included regardless of distance.
max_deals: Maximum number of deals to return (default: 100, provides good context for LLM analysis) max_deals: Maximum number of deals to return (default: 100, provides good context for LLM analysis)
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2) deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
lang: Language for text values ("he" for Hebrew [default], "en" for English)
Returns: Returns:
JSON string containing recent real estate deals for the address JSON string containing recent real estate deals within radius_meters of the address
""" """
log_mcp_call( log_mcp_call(
"find_recent_deals_for_address", "find_recent_deals_for_address",
@@ -277,6 +321,14 @@ def find_recent_deals_for_address(
deal_type=deal_type, deal_type=deal_type,
) )
try: try:
# Get coordinates for search center
autocomplete_result = client.autocomplete_address(address)
search_coords = None
if autocomplete_result.results:
coords = autocomplete_result.results[0].coordinates
if coords:
search_coords = {"longitude": coords.longitude, "latitude": coords.latitude}
deals = client.find_recent_deals_for_address( deals = client.find_recent_deals_for_address(
address, years_back, radius_meters, max_deals, deal_type address, years_back, radius_meters, max_deals, deal_type
) )
@@ -347,21 +399,25 @@ def find_recent_deals_for_address(
} }
deal_type_desc = "first hand (new)" if deal_type == 1 else "second hand (used)" deal_type_desc = "first hand (new)" if deal_type == 1 else "second hand (used)"
return json.dumps( search_params = {
{
"search_parameters": {
"address": address, "address": address,
"years_back": years_back, "years_back": years_back,
"radius_meters": radius_meters, "radius_meters": radius_meters,
"max_deals": max_deals, "max_deals": max_deals,
"deal_type": deal_type, "deal_type": deal_type,
"deal_type_description": deal_type_desc, "deal_type_description": deal_type_desc,
}, }
if search_coords:
search_params["search_coordinates"] = search_coords
return json.dumps(
{
"search_parameters": search_params,
"market_statistics": stats, "market_statistics": stats,
"deals": strip_bloat_fields(deals), "deals": strip_bloat_fields(deals, lang=lang),
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
except Exception as e: except Exception as e:
@@ -370,13 +426,16 @@ def find_recent_deals_for_address(
@conditional_tool("tool_get_neighborhood_deals_enabled") @conditional_tool("tool_get_neighborhood_deals_enabled")
def get_neighborhood_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> str: def get_neighborhood_deals(
polygon_id: str, limit: int = 100, deal_type: int = 2, lang: str = "he"
) -> str:
"""Get real estate deals for a specific neighborhood polygon. """Get real estate deals for a specific neighborhood polygon.
Args: Args:
polygon_id: The polygon ID of the neighborhood polygon_id: The polygon ID of the neighborhood
limit: Maximum number of deals to return (default: 100) limit: Maximum number of deals to return (default: 100)
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2) deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
lang: Language for text values ("he" for Hebrew [default], "en" for English)
Returns: Returns:
JSON string containing recent real estate deals in the specified neighborhood JSON string containing recent real estate deals in the specified neighborhood
@@ -415,10 +474,10 @@ def get_neighborhood_deals(polygon_id: str, limit: int = 100, deal_type: int = 2
"deal_type": deal_type, "deal_type": deal_type,
"deal_type_description": deal_type_desc, "deal_type_description": deal_type_desc,
"market_statistics": stats, "market_statistics": stats,
"deals": strip_bloat_fields(deals), "deals": strip_bloat_fields(deals, lang=lang),
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
except Exception as e: except Exception as e:
@@ -457,6 +516,14 @@ def analyze_market_trends(
deal_type=deal_type, deal_type=deal_type,
) )
try: try:
# Get coordinates for search center
autocomplete_result = client.autocomplete_address(address)
search_coords = None
if autocomplete_result.results:
coords = autocomplete_result.results[0].coordinates
if coords:
search_coords = {"longitude": coords.longitude, "latitude": coords.latitude}
# Get deals for the address with larger radius for trend analysis # Get deals for the address with larger radius for trend analysis
deals = client.find_recent_deals_for_address( deals = client.find_recent_deals_for_address(
address, years_back, radius_meters, max_deals, deal_type address, years_back, radius_meters, max_deals, deal_type
@@ -600,16 +667,20 @@ def analyze_market_trends(
# Return summarized analysis (NO raw deals to save tokens) # Return summarized analysis (NO raw deals to save tokens)
# Normalize structure with standard market_statistics while keeping tool-specific analysis # Normalize structure with standard market_statistics while keeping tool-specific analysis
return json.dumps( analysis_params = {
{
"analysis_parameters": {
"address": address, "address": address,
"years_analyzed": years_back, "years_analyzed": years_back,
"radius_meters": radius_meters, "radius_meters": radius_meters,
"deals_analyzed": len(deals), "deals_analyzed": len(deals),
"deal_type": deal_type, "deal_type": deal_type,
"deal_type_description": deal_type_desc, "deal_type_description": deal_type_desc,
}, }
if search_coords:
analysis_params["search_coordinates"] = search_coords
return json.dumps(
{
"analysis_parameters": analysis_params,
"market_statistics": { "market_statistics": {
"deal_breakdown": { "deal_breakdown": {
"total_deals": len(deals), "total_deals": len(deals),
@@ -640,7 +711,7 @@ def analyze_market_trends(
"deals": [], # Trend analysis doesn't return raw deals to save tokens "deals": [], # Trend analysis doesn't return raw deals to save tokens
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
except Exception as e: except Exception as e:
@@ -666,6 +737,20 @@ def compare_addresses(addresses: List[str]) -> str:
for address in addresses: for address in addresses:
try: try:
# Get coordinates for this address
search_coords = None
try:
autocomplete_result = client.autocomplete_address(address)
if autocomplete_result.results:
coords = autocomplete_result.results[0].coordinates
if coords:
search_coords = {
"longitude": coords.longitude,
"latitude": coords.latitude,
}
except Exception:
pass # Continue without coordinates if autocomplete fails
deals = client.find_recent_deals_for_address(address, 2) deals = client.find_recent_deals_for_address(address, 2)
if deals: if deals:
@@ -690,6 +775,7 @@ def compare_addresses(addresses: List[str]) -> str:
comparison = { comparison = {
"address": address, "address": address,
"search_coordinates": search_coords,
"total_deals": len(deals), "total_deals": len(deals),
"same_building_deals": len(building_deals), "same_building_deals": len(building_deals),
"street_deals": len(street_deals), "street_deals": len(street_deals),
@@ -731,6 +817,7 @@ def compare_addresses(addresses: List[str]) -> str:
else: else:
comparison = { comparison = {
"address": address, "address": address,
"search_coordinates": search_coords,
"total_deals": 0, "total_deals": 0,
"same_building_deals": 0, "same_building_deals": 0,
"street_deals": 0, "street_deals": 0,
@@ -775,7 +862,7 @@ def compare_addresses(addresses: List[str]) -> str:
"all_results": comparisons, "all_results": comparisons,
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
except Exception as e: except Exception as e:
@@ -800,6 +887,7 @@ def get_valuation_comparables(
max_comparables: int = 50, max_comparables: int = 50,
iqr_multiplier: Optional[float] = None, iqr_multiplier: Optional[float] = None,
include_outlier_deals: bool = True, include_outlier_deals: bool = True,
lang: str = "he",
) -> str: ) -> str:
"""Get comparable properties for valuation analysis. """Get comparable properties for valuation analysis.
@@ -825,6 +913,7 @@ def get_valuation_comparables(
iqr_multiplier: Override IQR multiplier for outlier detection (default: 1.0). Lower = more aggressive filtering iqr_multiplier: Override IQR multiplier for outlier detection (default: 1.0). Lower = more aggressive filtering
include_outlier_deals: If True (default), include the outlier deals separately in the response include_outlier_deals: If True (default), include the outlier deals separately in the response
This allows you to see what was filtered out and answer questions about it This allows you to see what was filtered out and answer questions about it
lang: Language for text values ("he" for Hebrew [default], "en" for English)
Returns: Returns:
JSON string containing: JSON string containing:
@@ -855,20 +944,32 @@ def get_valuation_comparables(
iqr_multiplier=iqr_multiplier, iqr_multiplier=iqr_multiplier,
) )
try: try:
# Get coordinates for search center
autocomplete_result = client.autocomplete_address(address)
search_coords = None
if autocomplete_result.results:
coords = autocomplete_result.results[0].coordinates
if coords:
search_coords = {"longitude": coords.longitude, "latitude": coords.latitude}
# Get all deals for the address with higher limits for valuation # Get all deals for the address with higher limits for valuation
deals = client.find_recent_deals_for_address( deals = client.find_recent_deals_for_address(
address, years_back, radius=radius_meters, max_deals=max_comparables address, years_back, radius=radius_meters, max_deals=max_comparables
) )
if not deals: search_params_base = {
return json.dumps(
{
"search_parameters": {
"address": address, "address": address,
"years_back": years_back, "years_back": years_back,
"radius_meters": radius_meters, "radius_meters": radius_meters,
"max_comparables": max_comparables, "max_comparables": max_comparables,
}, }
if search_coords:
search_params_base["search_coordinates"] = search_coords
if not deals:
return json.dumps(
{
"search_parameters": search_params_base,
"market_statistics": { "market_statistics": {
"deal_breakdown": { "deal_breakdown": {
"total_deals": 0, "total_deals": 0,
@@ -878,7 +979,7 @@ def get_valuation_comparables(
"message": "No deals found for this address", "message": "No deals found for this address",
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
# Apply filters # Apply filters
@@ -945,20 +1046,16 @@ def get_valuation_comparables(
deal_breakdown["iqr_multiplier"] = outlier_report["parameters"]["iqr_multiplier"] deal_breakdown["iqr_multiplier"] = outlier_report["parameters"]["iqr_multiplier"]
# Build response with filtered deals # Build response with filtered deals
response_data = { search_params_base["filters_applied"] = {
"search_parameters": {
"address": address,
"years_back": years_back,
"radius_meters": radius_meters,
"max_comparables": max_comparables,
"filters_applied": {
"property_type": property_type, "property_type": property_type,
"rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None, "rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None,
"price": f"{min_price}-{max_price}" if min_price or max_price else None, "price": f"{min_price}-{max_price}" if min_price or max_price else None,
"area": f"{min_area}-{max_area}" if min_area or max_area else None, "area": f"{min_area}-{max_area}" if min_area or max_area else None,
"floor": f"{min_floor}-{max_floor}" if min_floor or max_floor else None, "floor": f"{min_floor}-{max_floor}" if min_floor or max_floor else None,
}, }
},
response_data = {
"search_parameters": search_params_base,
"market_statistics": { "market_statistics": {
"deal_breakdown": deal_breakdown, "deal_breakdown": deal_breakdown,
"price_statistics": stats.price_statistics, "price_statistics": stats.price_statistics,
@@ -967,14 +1064,16 @@ def get_valuation_comparables(
"property_type_distribution": stats.property_type_distribution, "property_type_distribution": stats.property_type_distribution,
"date_range": stats.date_range, "date_range": stats.date_range,
}, },
"deals": strip_bloat_fields(filtered_deals), "deals": strip_bloat_fields(filtered_deals, lang=lang),
} }
# Add outlier deals if present and requested # Add outlier deals if present and requested
if include_outlier_deals and outlier_report and "outlier_deals" in outlier_report: if include_outlier_deals and outlier_report and "outlier_deals" in outlier_report:
response_data["outlier_deals"] = strip_bloat_fields(outlier_report["outlier_deals"]) response_data["outlier_deals"] = strip_bloat_fields(
outlier_report["outlier_deals"], lang=lang
)
return json.dumps(response_data, ensure_ascii=False, indent=2) return json.dumps(response_data, ensure_ascii=False, indent=None)
except Exception as e: except Exception as e:
logger.error(f"Error in get_valuation_comparables: {e}", exc_info=True) logger.error(f"Error in get_valuation_comparables: {e}", exc_info=True)
@@ -989,12 +1088,11 @@ def get_deal_statistics(
min_rooms: Optional[float] = None, min_rooms: Optional[float] = None,
max_rooms: Optional[float] = None, max_rooms: Optional[float] = None,
iqr_multiplier: Optional[float] = None, iqr_multiplier: Optional[float] = None,
include_outlier_deals: bool = True,
) -> str: ) -> str:
"""Calculate statistical aggregations on deal data for an address. """Calculate statistical aggregations on deal data for an address.
This tool provides quick statistical summaries without returning all raw deals. This tool provides quick statistical summaries WITHOUT returning any deals.
Useful when LLM needs calculations on large datasets without full details. Useful when LLM needs calculations on large datasets without full deal details.
Args: Args:
address: The address to analyze (in Hebrew or English) address: The address to analyze (in Hebrew or English)
@@ -1003,13 +1101,9 @@ def get_deal_statistics(
min_rooms: Minimum number of rooms min_rooms: Minimum number of rooms
max_rooms: Maximum number of rooms max_rooms: Maximum number of rooms
iqr_multiplier: Override IQR multiplier for outlier detection (default: 1.0). Lower = more aggressive filtering iqr_multiplier: Override IQR multiplier for outlier detection (default: 1.0). Lower = more aggressive filtering
include_outlier_deals: If True (default), include the outlier deals in the response
This allows you to see what was filtered out
Returns: Returns:
JSON string containing: JSON string containing market_statistics only (no deals returned)
- market_statistics: Statistical metrics (mean, median, percentiles, etc.)
- outlier_deals: Deals that were removed as outliers (if include_outlier_deals=True)
""" """
log_mcp_call( log_mcp_call(
"get_deal_statistics", "get_deal_statistics",
@@ -1021,16 +1115,28 @@ def get_deal_statistics(
iqr_multiplier=iqr_multiplier, iqr_multiplier=iqr_multiplier,
) )
try: try:
# Get coordinates for search center
autocomplete_result = client.autocomplete_address(address)
search_coords = None
if autocomplete_result.results:
coords = autocomplete_result.results[0].coordinates
if coords:
search_coords = {"longitude": coords.longitude, "latitude": coords.latitude}
# Get all deals for the address # Get all deals for the address
deals = client.find_recent_deals_for_address(address, years_back) deals = client.find_recent_deals_for_address(address, years_back)
search_params_base = {
"address": address,
"years_back": years_back,
}
if search_coords:
search_params_base["search_coordinates"] = search_coords
if not deals: if not deals:
return json.dumps( return json.dumps(
{ {
"search_parameters": { "search_parameters": search_params_base,
"address": address,
"years_back": years_back,
},
"market_statistics": { "market_statistics": {
"deal_breakdown": {"total_deals": 0}, "deal_breakdown": {"total_deals": 0},
"message": "No deals found for this address", "message": "No deals found for this address",
@@ -1038,7 +1144,7 @@ def get_deal_statistics(
"deals": [], "deals": [],
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
# Apply filters if provided # Apply filters if provided
@@ -1047,21 +1153,19 @@ def get_deal_statistics(
deals, property_type=property_type, min_rooms=min_rooms, max_rooms=max_rooms deals, property_type=property_type, min_rooms=min_rooms, max_rooms=max_rooms
) )
# Calculate statistics # Calculate statistics (don't need outlier deals for statistics-only tool)
stats = client.calculate_deal_statistics( stats = client.calculate_deal_statistics(
deals, iqr_multiplier=iqr_multiplier, include_outlier_deals=include_outlier_deals deals, iqr_multiplier=iqr_multiplier, include_outlier_deals=False
) )
# Build response # Build response - statistics only, NO deals
response_data = { search_params_base["filters_applied"] = {
"search_parameters": {
"address": address,
"years_back": years_back,
"filters_applied": {
"property_type": property_type, "property_type": property_type,
"rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None, "rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None,
}, }
},
response_data = {
"search_parameters": search_params_base,
"market_statistics": { "market_statistics": {
"deal_breakdown": { "deal_breakdown": {
"total_deals": stats.total_deals, "total_deals": stats.total_deals,
@@ -1072,14 +1176,9 @@ def get_deal_statistics(
"property_type_distribution": stats.property_type_distribution, "property_type_distribution": stats.property_type_distribution,
"date_range": stats.date_range, "date_range": stats.date_range,
}, },
"deals": [], # Statistics-only query, no full deals returned
} }
# Add outlier deals if present and requested return json.dumps(response_data, ensure_ascii=False, indent=None)
if include_outlier_deals and stats.outlier_report and stats.outlier_report.outlier_deals:
response_data["outlier_deals"] = strip_bloat_fields(stats.outlier_report.outlier_deals)
return json.dumps(response_data, ensure_ascii=False, indent=2)
except Exception as e: except Exception as e:
logger.error(f"Error in get_deal_statistics: {e}", exc_info=True) logger.error(f"Error in get_deal_statistics: {e}", exc_info=True)
@@ -1139,17 +1238,29 @@ def get_market_activity_metrics(address: str, years_back: int = 2, radius_meters
radius_meters=radius_meters, radius_meters=radius_meters,
) )
try: try:
# Get coordinates for search center
autocomplete_result = client.autocomplete_address(address)
search_coords = None
if autocomplete_result.results:
coords = autocomplete_result.results[0].coordinates
if coords:
search_coords = {"longitude": coords.longitude, "latitude": coords.latitude}
# Get deals for the address # Get deals for the address
deals = client.find_recent_deals_for_address(address, years_back, radius_meters) deals = client.find_recent_deals_for_address(address, years_back, radius_meters)
analysis_params_base = {
"address": address,
"years_back": years_back,
"radius_meters": radius_meters,
}
if search_coords:
analysis_params_base["search_coordinates"] = search_coords
if not deals: if not deals:
return json.dumps( return json.dumps(
{ {
"analysis_parameters": { "analysis_parameters": analysis_params_base,
"address": address,
"years_back": years_back,
"radius_meters": radius_meters,
},
"market_statistics": { "market_statistics": {
"deal_breakdown": { "deal_breakdown": {
"total_deals": 0, "total_deals": 0,
@@ -1159,7 +1270,7 @@ def get_market_activity_metrics(address: str, years_back: int = 2, radius_meters
"error": "No deals found for analysis", "error": "No deals found for analysis",
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
# Calculate market metrics using helper to reduce duplication # Calculate market metrics using helper to reduce duplication
@@ -1170,11 +1281,7 @@ def get_market_activity_metrics(address: str, years_back: int = 2, radius_meters
# Combine all metrics with normalized structure # Combine all metrics with normalized structure
return json.dumps( return json.dumps(
{ {
"analysis_parameters": { "analysis_parameters": analysis_params_base,
"address": address,
"years_back": years_back,
"radius_meters": radius_meters,
},
"market_statistics": { "market_statistics": {
"deal_breakdown": { "deal_breakdown": {
"total_deals": len(deals), "total_deals": len(deals),
@@ -1195,7 +1302,7 @@ def get_market_activity_metrics(address: str, years_back: int = 2, radius_meters
"deals": [], # Activity metrics don't return raw deals "deals": [], # Activity metrics don't return raw deals
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=None,
) )
except Exception as e: except Exception as e:
+35 -7
View File
@@ -518,7 +518,7 @@ class GovmapClient:
self, self,
address: str, address: str,
years_back: int = 2, years_back: int = 2,
radius: int = 30, radius: int = 50,
max_deals: int = 100, max_deals: int = 100,
deal_type: int = 2, deal_type: int = 2,
) -> List[Deal]: ) -> List[Deal]:
@@ -531,14 +531,15 @@ class GovmapClient:
Args: Args:
address: The address to search for address: The address to search for
years_back: How many years back to search (default: 2) years_back: How many years back to search (default: 2)
radius: Search radius in meters for initial coordinate search (default: 30) radius: Search radius in meters (default: 50). Deals beyond this radius are filtered out,
Small radius since street deals cover the entire street anyway EXCEPT same-building deals which are always included regardless of distance.
max_deals: Maximum number of deals to return (default: 100) max_deals: Maximum number of deals to return (default: 100)
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2) deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
Returns: Returns:
List of Deal models found for the address area, with same building deals prioritized first, List of Deal models found within radius meters of the address, with same building deals
then street deals, then neighborhood deals prioritized first, then street deals, then neighborhood deals. Same-building deals are
included regardless of distance.
Raises: Raises:
ValueError: If address cannot be found or processed, or input is invalid ValueError: If address cannot be found or processed, or input is invalid
@@ -722,7 +723,11 @@ class GovmapClient:
seen_deals.add(deal_id) seen_deals.add(deal_id)
# Calculate distance from search point to deal # Calculate distance from search point to deal
# Most deals don't have individual coordinates, so use polygon distance # Try to extract centroid from shape, fall back to polygon distance
deal_centroid = utils.extract_shape_centroid(deal.shape)
if deal_centroid:
deal_distance = utils.calculate_distance(point, deal_centroid)
else:
deal_distance = polygon_distance deal_distance = polygon_distance
# Store metadata using dynamic attributes (allowed by extra='allow') # Store metadata using dynamic attributes (allowed by extra='allow')
@@ -765,7 +770,12 @@ class GovmapClient:
if deal_id not in seen_deals: if deal_id not in seen_deals:
seen_deals.add(deal_id) seen_deals.add(deal_id)
# Calculate distance from search point (use polygon distance) # Calculate distance from search point to deal
# Try to extract centroid from shape, fall back to polygon distance
deal_centroid = utils.extract_shape_centroid(deal.shape)
if deal_centroid:
deal_distance = utils.calculate_distance(point, deal_centroid)
else:
deal_distance = polygon_distance deal_distance = polygon_distance
# Apply distance filter for neighborhood deals # Apply distance filter for neighborhood deals
@@ -789,6 +799,24 @@ class GovmapClient:
# Step 5: Combine and prioritize: building deals first, then street, then neighborhood # Step 5: Combine and prioritize: building deals first, then street, then neighborhood
all_deals = building_deals + street_deals + neighborhood_deals all_deals = building_deals + street_deals + neighborhood_deals
# Filter by user-specified radius (deals beyond radius_meters are excluded)
# Same-building deals are ALWAYS included regardless of distance
filtered_deals = []
for deal in all_deals:
deal_distance = getattr(deal, "distance_meters", 0)
is_same_building = getattr(deal, "deal_source", None) == "same_building"
if is_same_building or deal_distance <= radius:
filtered_deals.append(deal)
else:
logger.debug(f"Filtered deal at {deal_distance:.0f}m (radius limit: {radius}m)")
all_deals = filtered_deals
logger.info(
f"After radius filtering ({radius}m): {len(all_deals)} deals "
f"(removed {len(building_deals) + len(street_deals) + len(neighborhood_deals) - len(all_deals)} deals)"
)
# Multi-level sort with stable sorting: # Multi-level sort with stable sorting:
# 1. Date (newest first) - applied first to maintain recency within same priority/distance # 1. Date (newest first) - applied first to maintain recency within same priority/distance
# 2. Distance (closest first) - applied second to prefer closer deals within same priority # 2. Distance (closest first) - applied second to prefer closer deals within same priority
+38 -1
View File
@@ -6,7 +6,7 @@ This module provides shared helper functions with no external dependencies
""" """
import re import re
from typing import Tuple from typing import Optional, Tuple
def calculate_distance(point1: Tuple[float, float], point2: Tuple[float, float]) -> float: def calculate_distance(point1: Tuple[float, float], point2: Tuple[float, float]) -> float:
@@ -28,6 +28,43 @@ def calculate_distance(point1: Tuple[float, float], point2: Tuple[float, float])
return (dx * dx + dy * dy) ** 0.5 return (dx * dx + dy * dy) ** 0.5
def extract_shape_centroid(shape_wkt: Optional[str]) -> Optional[Tuple[float, float]]:
"""
Extract centroid coordinates from WKT geometry (MULTIPOLYGON/POLYGON).
Parses WKT string and calculates centroid as average of all coordinate points.
Args:
shape_wkt: WKT geometry string (e.g., "MULTIPOLYGON(...)")
Returns:
(longitude, latitude) tuple in ITM coordinates, or None if parsing fails
"""
if not shape_wkt or not isinstance(shape_wkt, str):
return None
try:
# Extract all coordinate pairs using regex
# Matches: "number.number number.number" or "number number"
coord_pattern = r"([\d.]+)\s+([\d.]+)"
matches = re.findall(coord_pattern, shape_wkt)
if not matches:
return None
# Calculate average (centroid)
lons = [float(m[0]) for m in matches]
lats = [float(m[1]) for m in matches]
centroid_lon = sum(lons) / len(lons)
centroid_lat = sum(lats) / len(lats)
return (centroid_lon, centroid_lat)
except (ValueError, ZeroDivisionError):
return None
def is_same_building(search_address: str, deal_address: str) -> bool: def is_same_building(search_address: str, deal_address: str) -> bool:
""" """
Check if a deal is from the same building as the search address. Check if a deal is from the same building as the search address.
+79
View File
@@ -203,9 +203,28 @@ class TestGetDealsByRadius:
class TestFindRecentDealsForAddress: class TestFindRecentDealsForAddress:
"""Test find_recent_deals_for_address MCP tool.""" """Test find_recent_deals_for_address MCP tool."""
@staticmethod
def _mock_autocomplete():
"""Helper to create mock autocomplete response."""
return AutocompleteResponse(
resultsCount=1,
results=[
AutocompleteResult(
id="test",
text="Test Address",
type="address",
score=100,
coordinates=CoordinatePoint(longitude=180000, latitude=665000),
)
],
)
@patch("nadlan_mcp.fastmcp_server.client") @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_find_deals(self, mock_client): def test_successful_find_deals(self, mock_client):
"""Test successful deal finding with statistics.""" """Test successful deal finding with statistics."""
# Mock autocomplete
mock_client.autocomplete_address.return_value = self._mock_autocomplete()
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [
Deal( Deal(
@@ -248,6 +267,9 @@ class TestFindRecentDealsForAddress:
@patch("nadlan_mcp.fastmcp_server.client") @patch("nadlan_mcp.fastmcp_server.client")
def test_find_deals_strips_bloat(self, mock_client): def test_find_deals_strips_bloat(self, mock_client):
"""Test that bloat fields are stripped.""" """Test that bloat fields are stripped."""
# Mock autocomplete
mock_client.autocomplete_address.return_value = self._mock_autocomplete()
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [
Deal( Deal(
@@ -270,9 +292,28 @@ class TestFindRecentDealsForAddress:
class TestAnalyzeMarketTrends: class TestAnalyzeMarketTrends:
"""Test analyze_market_trends MCP tool.""" """Test analyze_market_trends MCP tool."""
@staticmethod
def _mock_autocomplete():
"""Helper to create mock autocomplete response."""
return AutocompleteResponse(
resultsCount=1,
results=[
AutocompleteResult(
id="test",
text="Test Address",
type="address",
score=100,
coordinates=CoordinatePoint(longitude=180000, latitude=665000),
)
],
)
@patch("nadlan_mcp.fastmcp_server.client") @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_market_analysis(self, mock_client): def test_successful_market_analysis(self, mock_client):
"""Test successful market trend analysis.""" """Test successful market trend analysis."""
# Mock autocomplete
mock_client.autocomplete_address.return_value = self._mock_autocomplete()
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [
Deal( Deal(
@@ -341,9 +382,27 @@ class TestCompareAddresses:
class TestGetValuationComparables: class TestGetValuationComparables:
"""Test get_valuation_comparables MCP tool.""" """Test get_valuation_comparables MCP tool."""
@staticmethod
def _mock_autocomplete():
"""Helper to create mock autocomplete response."""
return AutocompleteResponse(
resultsCount=1,
results=[
AutocompleteResult(
id="test",
text="Test Address",
type="address",
score=100,
coordinates=CoordinatePoint(longitude=180000, latitude=665000),
)
],
)
@patch("nadlan_mcp.fastmcp_server.client") @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_get_comparables(self, mock_client): def test_successful_get_comparables(self, mock_client):
"""Test successful comparable retrieval with filtering.""" """Test successful comparable retrieval with filtering."""
# Mock autocomplete
mock_client.autocomplete_address.return_value = self._mock_autocomplete()
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [
@@ -382,6 +441,8 @@ class TestGetValuationComparables:
@patch("nadlan_mcp.fastmcp_server.client") @patch("nadlan_mcp.fastmcp_server.client")
def test_comparables_strips_bloat(self, mock_client): def test_comparables_strips_bloat(self, mock_client):
"""Test that bloat fields are stripped from comparables.""" """Test that bloat fields are stripped from comparables."""
# Mock autocomplete
mock_client.autocomplete_address.return_value = self._mock_autocomplete()
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [
@@ -412,9 +473,27 @@ class TestGetValuationComparables:
class TestGetDealStatistics: class TestGetDealStatistics:
"""Test get_deal_statistics MCP tool.""" """Test get_deal_statistics MCP tool."""
@staticmethod
def _mock_autocomplete():
"""Helper to create mock autocomplete response."""
return AutocompleteResponse(
resultsCount=1,
results=[
AutocompleteResult(
id="test",
text="Test Address",
type="address",
score=100,
coordinates=CoordinatePoint(longitude=180000, latitude=665000),
)
],
)
@patch("nadlan_mcp.fastmcp_server.client") @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_statistics_calculation(self, mock_client): def test_successful_statistics_calculation(self, mock_client):
"""Test successful statistics calculation.""" """Test successful statistics calculation."""
# Mock autocomplete
mock_client.autocomplete_address.return_value = self._mock_autocomplete()
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [