Merge pull request #3 from nitzpo/e2e-run

Fix: E2E bugs from real-world MCP testing
This commit is contained in:
Nitzan Pomerantz
2025-10-25 00:44:35 +03:00
committed by GitHub
3 changed files with 237 additions and 40 deletions
+45 -12
View File
@@ -22,6 +22,28 @@ mcp = FastMCP("nadlan-mcp")
# Initialize the Govmap client
client = GovmapClient()
def strip_bloat_fields(deals: List[Dict]) -> List[Dict]:
"""
Remove bloat fields from deal objects to reduce token usage in MCP responses.
Removes:
- shape: Large MULTIPOLYGON coordinate data (~40-50% of tokens, not useful for LLM analysis)
- sourceorder: Internal ordering field
- source_polygon_id: Internal reference field
Args:
deals: List of deal dictionaries
Returns:
List of deals with bloat fields removed
"""
bloat_fields = {'shape', 'sourceorder', 'source_polygon_id'}
return [
{k: v for k, v in deal.items() if k not in bloat_fields}
for deal in deals
]
@mcp.tool()
def autocomplete_address(search_text: str) -> str:
"""Search and autocomplete Israeli addresses.
@@ -77,7 +99,7 @@ def get_deals_by_radius(latitude: float, longitude: float, radius_meters: int =
"total_deals": len(deals),
"search_radius_meters": radius_meters,
"center_coordinates": {"latitude": latitude, "longitude": longitude},
"deals": deals
"deals": strip_bloat_fields(deals)
}, ensure_ascii=False, indent=2)
except Exception as e:
@@ -134,7 +156,7 @@ def get_street_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> s
"deal_type": deal_type,
"deal_type_description": deal_type_desc,
"market_statistics": stats,
"deals": deals
"deals": strip_bloat_fields(deals)
}, ensure_ascii=False, indent=2)
except Exception as e:
@@ -142,7 +164,7 @@ def get_street_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> s
return f"Error fetching street deals: {str(e)}"
@mcp.tool()
def find_recent_deals_for_address(address: str, years_back: int = 2, radius_meters: int = 30, max_deals: int = 50, deal_type: int = 2) -> str:
def find_recent_deals_for_address(address: str, years_back: int = 2, radius_meters: int = 30, max_deals: int = 100, deal_type: int = 2) -> str:
"""Find recent real estate deals for a specific address.
Args:
@@ -150,7 +172,7 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
years_back: How many years back to search (default: 2)
radius_meters: Search radius in meters from the address (default: 30)
Small radius since street deals cover the entire street anyway
max_deals: Maximum number of deals to return (default: 50, optimized for LLM token limits)
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)
Returns:
@@ -221,7 +243,7 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
"deal_type_description": deal_type_desc
},
"market_statistics": stats,
"deals": deals
"deals": strip_bloat_fields(deals)
}, ensure_ascii=False, indent=2)
except Exception as e:
@@ -278,7 +300,7 @@ def get_neighborhood_deals(polygon_id: str, limit: int = 100, deal_type: int = 2
"deal_type": deal_type,
"deal_type_description": deal_type_desc,
"market_statistics": stats,
"deals": deals
"deals": strip_bloat_fields(deals)
}, ensure_ascii=False, indent=2)
except Exception as e:
@@ -544,12 +566,15 @@ def get_valuation_comparables(
min_area: Optional[float] = None,
max_area: Optional[float] = None,
min_floor: Optional[int] = None,
max_floor: Optional[int] = None
max_floor: Optional[int] = None,
radius_meters: int = 100,
max_comparables: int = 50
) -> str:
"""Get comparable properties for valuation analysis.
This tool provides detailed comparable deals filtered by your criteria.
The LLM can then analyze these comparables and estimate property values.
Returns a generous number of comparables by default - the LLM analyzing
the results can determine which are most similar based on the full details.
Args:
address: The address to find comparables for (in Hebrew or English)
@@ -563,13 +588,21 @@ def get_valuation_comparables(
max_area: Maximum asset area (square meters)
min_floor: Minimum floor number
max_floor: Maximum floor number
radius_meters: Search radius in meters (default: 100, larger than find_recent_deals to get more comparables)
max_comparables: Maximum number of deals to return (default: 50, optimized for MCP token limits)
Returns:
JSON string containing filtered comparable deals with full details
JSON string containing filtered comparable deals with full details.
Returns many comparables so LLM can assess similarity and relevance.
"""
try:
# Get all deals for the address
deals = client.find_recent_deals_for_address(address, years_back)
# Get all deals for the address with higher limits for valuation
deals = client.find_recent_deals_for_address(
address,
years_back,
radius=radius_meters,
max_deals=max_comparables
)
if not deals:
return json.dumps({
@@ -608,7 +641,7 @@ def get_valuation_comparables(
},
"total_comparables": len(filtered_deals),
"statistics": stats,
"comparables": filtered_deals
"comparables": strip_bloat_fields(filtered_deals)
}, ensure_ascii=False, indent=2)
except Exception as e:
+54 -15
View File
@@ -510,7 +510,7 @@ class GovmapClient:
address: str,
years_back: int = 2,
radius: int = 30,
max_deals: int = 50,
max_deals: int = 100,
deal_type: int = 2,
) -> List[Dict[str, Any]]:
"""
@@ -524,7 +524,7 @@ class GovmapClient:
years_back: How many years back to search (default: 2)
radius: Search radius in meters for initial coordinate search (default: 30)
Small radius since street deals cover the entire street anyway
max_deals: Maximum number of deals to return (default: 50)
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)
Returns:
@@ -622,14 +622,18 @@ class GovmapClient:
# Process street deals and separate building deals
for deal in current_street_deals:
deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{deal.get('dealDate', '')}"
# Create unique deal ID for deduplication
deal_id = f"{deal.get('dealId', '')}{deal.get('dealDate', '')}"
if deal_id not in seen_deals:
seen_deals.add(deal_id)
deal["source_polygon_id"] = polygon_id
deal["deal_source"] = "street"
# Check if this is from the same building
deal_address = deal.get("address", "").lower().strip()
# Construct address from API fields (API doesn't have single "address" field)
street = deal.get("streetNameHeb", "")
house_num = str(deal.get("houseNum", ""))
deal_address = f"{street} {house_num}".lower().strip()
if self._is_same_building(
search_address_normalized, deal_address
):
@@ -642,7 +646,8 @@ class GovmapClient:
# Add neighborhood deals with lowest priority
for deal in current_neighborhood_deals:
deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{deal.get('dealDate', '')}"
# Create unique deal ID for deduplication
deal_id = f"{deal.get('dealId', '')}{deal.get('dealDate', '')}"
if deal_id not in seen_deals:
seen_deals.add(deal_id)
deal["source_polygon_id"] = polygon_id
@@ -700,6 +705,24 @@ class GovmapClient:
logger.error(f"Error in find_recent_deals_for_address: {e}")
raise
def _calculate_distance(self, point1: Tuple[float, float], point2: Tuple[float, float]) -> float:
"""
Calculate Euclidean distance between two points in ITM coordinates.
ITM (Israeli Transverse Mercator) uses meters as units, so Euclidean
distance provides accurate results for distances within Israel.
Args:
point1: (longitude, latitude) in ITM
point2: (longitude, latitude) in ITM
Returns:
Distance in meters
"""
dx = point2[0] - point1[0]
dy = point2[1] - point1[1]
return (dx * dx + dy * dy) ** 0.5
def _is_same_building(self, search_address: str, deal_address: str) -> bool:
"""
Check if a deal is from the same building as the search address.
@@ -819,12 +842,24 @@ class GovmapClient:
deal_type = deal.get(
"propertyTypeDescription", deal.get("assetTypeHeb", "")
)
if property_type.lower() not in deal_type.lower():
# Skip deals with missing property type data when filter is active
if not deal_type:
continue
# Normalize both strings for flexible matching
property_type_normalized = property_type.lower().strip()
deal_type_normalized = deal_type.lower().strip()
# Check if the filter term appears in the deal type
# This allows "דירה" to match "דירת גג", "דירה בבניין", etc.
if property_type_normalized not in deal_type_normalized:
continue
# Room count filter
rooms = deal.get("assetRoomNum")
if rooms is not None:
if min_rooms is not None or max_rooms is not None:
rooms = deal.get("assetRoomNum")
if rooms is None:
continue # Skip deals with missing room data when filter is active
try:
rooms = float(rooms)
if min_rooms is not None and rooms < min_rooms:
@@ -832,11 +867,13 @@ class GovmapClient:
if max_rooms is not None and rooms > max_rooms:
continue
except (TypeError, ValueError):
pass # Skip deals with invalid room data
continue # Skip deals with invalid room data when filter is active
# Price filter
price = deal.get("dealAmount")
if price is not None:
if min_price is not None or max_price is not None:
price = deal.get("dealAmount")
if price is None:
continue # Skip deals with missing price data when filter is active
try:
price = float(price)
if min_price is not None and price < min_price:
@@ -844,11 +881,13 @@ class GovmapClient:
if max_price is not None and price > max_price:
continue
except (TypeError, ValueError):
pass # Skip deals with invalid price data
continue # Skip deals with invalid price data when filter is active
# Area filter
area = deal.get("assetArea")
if area is not None:
if min_area is not None or max_area is not None:
area = deal.get("assetArea")
if area is None:
continue # Skip deals with missing area data when filter is active
try:
area = float(area)
if min_area is not None and area < min_area:
@@ -856,7 +895,7 @@ class GovmapClient:
if max_area is not None and area > max_area:
continue
except (TypeError, ValueError):
pass # Skip deals with invalid area data
continue # Skip deals with invalid area data when filter is active
# Floor filter
floor_str = deal.get("floorNo", "")
+130 -5
View File
@@ -6,6 +6,7 @@ import pytest
import requests
from unittest.mock import Mock, patch
from nadlan_mcp.govmap import GovmapClient
from nadlan_mcp.config import GovmapConfig
class TestGovmapClient:
@@ -22,7 +23,8 @@ class TestGovmapClient:
def test_client_initialization_with_custom_url(self):
"""Test that GovmapClient can be initialized with custom URL."""
custom_url = "https://custom-api.example.com/api/"
client = GovmapClient(custom_url)
custom_config = GovmapConfig(base_url=custom_url)
client = GovmapClient(custom_config)
assert client.base_url == "https://custom-api.example.com/api"
@patch('requests.Session')
@@ -226,7 +228,8 @@ class TestGovmapClient:
"dealId": "deal1",
"dealAmount": 1000000,
"dealDate": "2025-01-01T00:00:00.000Z",
"address": "Test Street 1"
"address": "Test Street 1",
"priority": 1
}
]
@@ -236,7 +239,8 @@ class TestGovmapClient:
"dealId": "deal2",
"dealAmount": 2000000,
"dealDate": "2025-01-15T00:00:00.000Z",
"address": "Test Street 2"
"address": "Test Street 2",
"priority": 2
}
]
@@ -244,8 +248,11 @@ class TestGovmapClient:
result = client.find_recent_deals_for_address("test address", years_back=1)
assert len(result) == 2
assert result[0]["dealDate"] == "2025-01-15T00:00:00.000Z" # Should be sorted by date
assert result[1]["dealDate"] == "2025-01-01T00:00:00.000Z"
# Should be sorted by priority first (street=1 before neighborhood=2), then by date
assert result[0]["priority"] == 1 # Street deal comes first
assert result[0]["dealDate"] == "2025-01-01T00:00:00.000Z"
assert result[1]["priority"] == 2 # Neighborhood deal comes second
assert result[1]["dealDate"] == "2025-01-15T00:00:00.000Z"
@patch('requests.Session')
def test_http_error_handling(self, mock_session_class):
@@ -525,3 +532,121 @@ class TestMarketAnalysisFunctions:
assert stats["count"] == 3
assert stats["price_stats"]["mean"] > 0
assert stats["area_stats"]["mean"] == pytest.approx(80.0)
def test_is_same_building_comparisons(self):
"""Test `_is_same_building` correctly compares address strings."""
client = GovmapClient()
# Test that _is_same_building works with addresses constructed from API fields
search_address = "חנקין 62"
# Deal from same building (should match)
deal_address_same = "חנקין 62"
assert client._is_same_building(search_address, deal_address_same) is True
# Deal from different building on same street (should not match)
deal_address_different = "חנקין 50"
assert client._is_same_building(search_address, deal_address_different) is False
# Deal from different street (should not match)
deal_address_other_street = "בילינסון 6"
assert client._is_same_building(search_address, deal_address_other_street) is False
def test_filter_excludes_missing_property_type(self):
"""Test that deals with missing property type are excluded when filter is active."""
client = GovmapClient()
deals = [
{"dealId": "1", "propertyTypeDescription": "דירה"},
{"dealId": "2", "propertyTypeDescription": None},
{"dealId": "3", "propertyTypeDescription": "בית"},
{"dealId": "4"}, # Missing key entirely
]
filtered = client.filter_deals_by_criteria(deals, property_type="דירה")
assert len(filtered) == 1
assert filtered[0]["dealId"] == "1"
def test_filter_excludes_missing_area(self):
"""Test that deals with missing area are excluded when area filter is active."""
client = GovmapClient()
deals = [
{"dealId": "1", "assetArea": 65},
{"dealId": "2", "assetArea": None},
{"dealId": "3", "assetArea": 50},
{"dealId": "4"}, # Missing key entirely
]
filtered = client.filter_deals_by_criteria(deals, min_area=60, max_area=70)
assert len(filtered) == 1
assert filtered[0]["dealId"] == "1"
def test_filter_excludes_missing_rooms(self):
"""Test that deals with missing room count are excluded when room filter is active."""
client = GovmapClient()
deals = [
{"dealId": "1", "assetRoomNum": 3},
{"dealId": "2", "assetRoomNum": None},
{"dealId": "3", "assetRoomNum": 2},
{"dealId": "4"}, # Missing key entirely
]
filtered = client.filter_deals_by_criteria(deals, min_rooms=2.5, max_rooms=4)
assert len(filtered) == 1
assert filtered[0]["dealId"] == "1"
def test_filter_excludes_missing_price(self):
"""Test that deals with missing price are excluded when price filter is active."""
client = GovmapClient()
deals = [
{"dealId": "1", "dealAmount": 2000000},
{"dealId": "2", "dealAmount": None},
{"dealId": "3", "dealAmount": 1500000},
{"dealId": "4"}, # Missing key entirely
]
filtered = client.filter_deals_by_criteria(deals, min_price=1800000, max_price=2200000)
assert len(filtered) == 1
assert filtered[0]["dealId"] == "1"
def test_filter_excludes_invalid_numeric_data(self):
"""Test that deals with invalid numeric data are excluded when filter is active."""
client = GovmapClient()
deals = [
{"dealId": "1", "assetArea": 65, "assetRoomNum": 3, "dealAmount": 2000000},
{"dealId": "2", "assetArea": "invalid", "assetRoomNum": 3, "dealAmount": 2000000},
{"dealId": "3", "assetArea": 65, "assetRoomNum": "bad", "dealAmount": 2000000},
{"dealId": "4", "assetArea": 65, "assetRoomNum": 3, "dealAmount": "wrong"},
]
# Area filter should exclude deal 2
filtered_area = client.filter_deals_by_criteria(deals, min_area=60, max_area=70)
assert len(filtered_area) == 3
assert all(d["dealId"] in ["1", "3", "4"] for d in filtered_area)
# Room filter should exclude deal 3
filtered_rooms = client.filter_deals_by_criteria(deals, min_rooms=2, max_rooms=4)
assert len(filtered_rooms) == 3
assert all(d["dealId"] in ["1", "2", "4"] for d in filtered_rooms)
# Price filter should exclude deal 4
filtered_price = client.filter_deals_by_criteria(deals, min_price=1500000, max_price=2500000)
assert len(filtered_price) == 3
assert all(d["dealId"] in ["1", "2", "3"] for d in filtered_price)
def test_filter_allows_missing_data_when_no_filter(self):
"""Test that deals with missing data pass through when no filter is active for that field."""
client = GovmapClient()
deals = [
{"dealId": "1", "propertyTypeDescription": "דירה", "assetArea": 65},
{"dealId": "2", "propertyTypeDescription": "דירה", "assetArea": None},
{"dealId": "3", "propertyTypeDescription": "דירה"}, # Missing assetArea entirely
]
# Filter by property type only - missing area should pass through
filtered = client.filter_deals_by_criteria(deals, property_type="דירה")
assert len(filtered) == 3 # All should pass since we're not filtering by area