CR Fixes and update markdownlint

This commit is contained in:
Nitzan Pomerantz
2025-10-22 23:59:07 +03:00
parent 7780d89bb4
commit 49bfc7b940
3 changed files with 337 additions and 227 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
{ {
"MD032": false, "MD032": false,
"MD022": false "MD022": false,
"MD013": false
} }
+244 -133
View File
@@ -1,11 +1,12 @@
import requests
import logging import logging
import json import re
import time import time
from datetime import datetime, timedelta from collections import Counter
from typing import Any, Dict, List, Tuple, Optional from typing import Any, Dict, List, Optional, Tuple
from nadlan_mcp.config import get_config, GovmapConfig import requests
from nadlan_mcp.config import GovmapConfig, get_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,12 +32,11 @@ class GovmapClient:
config: Optional configuration object. If None, uses global config. config: Optional configuration object. If None, uses global config.
""" """
self.config = config or get_config() self.config = config or get_config()
self.base_url = self.config.base_url.rstrip('/') self.base_url = self.config.base_url.rstrip("/")
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ self.session.headers.update(
'Content-Type': 'application/json', {"Content-Type": "application/json", "User-Agent": self.config.user_agent}
'User-Agent': self.config.user_agent )
})
self.last_request_time = 0.0 self.last_request_time = 0.0
def _rate_limit(self): def _rate_limit(self):
@@ -101,7 +101,9 @@ class GovmapClient:
return (lon, lat) return (lon, lat)
def _validate_positive_int(self, value: int, name: str, max_value: Optional[int] = None) -> int: def _validate_positive_int(
self, value: int, name: str, max_value: Optional[int] = None
) -> int:
""" """
Validate positive integer input. Validate positive integer input.
@@ -145,7 +147,7 @@ class GovmapClient:
"searchText": search_text, "searchText": search_text,
"language": "he", "language": "he",
"isAccurate": False, "isAccurate": False,
"maxResults": 10 "maxResults": 10,
} }
# Retry logic with exponential backoff # Retry logic with exponential backoff
@@ -153,13 +155,15 @@ class GovmapClient:
try: try:
self._rate_limit() self._rate_limit()
logger.info(f"Searching for address: {search_text} (attempt {attempt + 1}/{self.config.max_retries + 1})") logger.info(
f"Searching for address: {search_text} (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout) timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.post(url, json=payload, timeout=timeout) response = self.session.post(url, json=payload, timeout=timeout)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if not data or 'results' not in data: if not data or "results" not in data:
raise ValueError("Invalid response format from autocomplete API") raise ValueError("Invalid response format from autocomplete API")
return data return data
@@ -168,15 +172,21 @@ class GovmapClient:
if attempt < self.config.max_retries: if attempt < self.config.max_retries:
wait_time = min( wait_time = min(
self.config.retry_min_wait * (2**attempt), self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
) )
logger.warning(f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}")
time.sleep(wait_time) time.sleep(wait_time)
else: else:
logger.error(f"Request failed after {self.config.max_retries + 1} attempts: {e}") logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError("Unexpected error: retry loop exited without return or raise") raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def get_gush_helka(self, point: Tuple[float, float]) -> Dict[str, Any]: def get_gush_helka(self, point: Tuple[float, float]) -> Dict[str, Any]:
""" """
@@ -195,18 +205,16 @@ class GovmapClient:
point = self._validate_coordinates(point) point = self._validate_coordinates(point)
url = f"{self.base_url}/layers-catalog/entitiesByPoint" url = f"{self.base_url}/layers-catalog/entitiesByPoint"
payload = { payload = {"point": list(point), "layers": [{"layerId": "16"}], "tolerance": 0}
"point": list(point),
"layers": [{"layerId": "16"}],
"tolerance": 0
}
# Retry logic with exponential backoff # Retry logic with exponential backoff
for attempt in range(self.config.max_retries + 1): for attempt in range(self.config.max_retries + 1):
try: try:
self._rate_limit() self._rate_limit()
logger.info(f"Getting Gush/Helka for point: {point} (attempt {attempt + 1}/{self.config.max_retries + 1})") logger.info(
f"Getting Gush/Helka for point: {point} (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout) timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.post(url, json=payload, timeout=timeout) response = self.session.post(url, json=payload, timeout=timeout)
response.raise_for_status() response.raise_for_status()
@@ -218,17 +226,25 @@ class GovmapClient:
if attempt < self.config.max_retries: if attempt < self.config.max_retries:
wait_time = min( wait_time = min(
self.config.retry_min_wait * (2**attempt), self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
) )
logger.warning(f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}")
time.sleep(wait_time) time.sleep(wait_time)
else: else:
logger.error(f"Request failed after {self.config.max_retries + 1} attempts: {e}") logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError("Unexpected error: retry loop exited without return or raise") raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def get_deals_by_radius(self, point: Tuple[float, float], radius: int = 50) -> List[Dict[str, Any]]: def get_deals_by_radius(
self, point: Tuple[float, float], radius: int = 50
) -> List[Dict[str, Any]]:
""" """
Find real estate deals within a specified radius of a point. Find real estate deals within a specified radius of a point.
@@ -252,33 +268,48 @@ class GovmapClient:
try: try:
self._rate_limit() self._rate_limit()
logger.info(f"Getting deals by radius for point: {point}, radius: {radius}m (attempt {attempt + 1}/{self.config.max_retries + 1})") logger.info(
f"Getting deals by radius for point: {point}, radius: {radius}m (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout) timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.get(url, timeout=timeout) response = self.session.get(url, timeout=timeout)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if not isinstance(data, list): if not isinstance(data, list):
raise ValueError(f"Expected list response, got {type(data).__name__}") raise ValueError(
f"Expected list response, got {type(data).__name__}"
)
return data return data
except (requests.RequestException, requests.Timeout) as e: except (requests.RequestException, requests.Timeout) as e:
if attempt < self.config.max_retries: if attempt < self.config.max_retries:
wait_time = min( wait_time = min(
self.config.retry_min_wait * (2**attempt), self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
) )
logger.warning(f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}")
time.sleep(wait_time) time.sleep(wait_time)
else: else:
logger.error(f"Request failed after {self.config.max_retries + 1} attempts: {e}") logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError("Unexpected error: retry loop exited without return or raise") raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def get_street_deals(self, polygon_id: str, limit: int = 10, def get_street_deals(
start_date: Optional[str] = None, end_date: Optional[str] = None, self,
deal_type: int = 2) -> List[Dict[str, Any]]: polygon_id: str,
limit: int = 10,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
deal_type: int = 2,
) -> List[Dict[str, Any]]:
""" """
Retrieve detailed information about deals on a specific street. Retrieve detailed information about deals on a specific street.
@@ -304,7 +335,9 @@ class GovmapClient:
limit = self._validate_positive_int(limit, "limit", max_value=1000) limit = self._validate_positive_int(limit, "limit", max_value=1000)
if deal_type not in (1, 2): if deal_type not in (1, 2):
raise ValueError("deal_type must be 1 (first hand/new) or 2 (second hand/used)") raise ValueError(
"deal_type must be 1 (first hand/new) or 2 (second hand/used)"
)
url = f"{self.base_url}/real-estate/street-deals/{polygon_id}" url = f"{self.base_url}/real-estate/street-deals/{polygon_id}"
@@ -319,39 +352,56 @@ class GovmapClient:
try: try:
self._rate_limit() self._rate_limit()
logger.info(f"Getting street deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})") logger.info(
f"Getting street deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout) timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.get(url, params=params, timeout=timeout) response = self.session.get(url, params=params, timeout=timeout)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# API returns {data: [...], totalCount: ..., limit: ..., offset: ...} # API returns {data: [...], totalCount: ..., limit: ..., offset: ...}
if isinstance(data, dict) and 'data' in data: if isinstance(data, dict) and "data" in data:
if not isinstance(data['data'], list): if not isinstance(data["data"], list):
raise ValueError(f"Expected list in 'data' field, got {type(data['data']).__name__}") raise ValueError(
return data['data'] f"Expected list in 'data' field, got {type(data['data']).__name__}"
)
return data["data"]
elif isinstance(data, list): elif isinstance(data, list):
return data return data
else: else:
raise ValueError(f"Unexpected response format: {type(data).__name__}") raise ValueError(
f"Unexpected response format: {type(data).__name__}"
)
except (requests.RequestException, requests.Timeout) as e: except (requests.RequestException, requests.Timeout) as e:
if attempt < self.config.max_retries: if attempt < self.config.max_retries:
wait_time = min( wait_time = min(
self.config.retry_min_wait * (2**attempt), self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
) )
logger.warning(f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}")
time.sleep(wait_time) time.sleep(wait_time)
else: else:
logger.error(f"Request failed after {self.config.max_retries + 1} attempts: {e}") logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError("Unexpected error: retry loop exited without return or raise") raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def get_neighborhood_deals(self, polygon_id: str, limit: int = 10, def get_neighborhood_deals(
start_date: Optional[str] = None, end_date: Optional[str] = None, self,
deal_type: int = 2) -> List[Dict[str, Any]]: polygon_id: str,
limit: int = 10,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
deal_type: int = 2,
) -> List[Dict[str, Any]]:
""" """
Retrieve deals within the same neighborhood as the given polygon_id. Retrieve deals within the same neighborhood as the given polygon_id.
@@ -377,7 +427,9 @@ class GovmapClient:
limit = self._validate_positive_int(limit, "limit", max_value=1000) limit = self._validate_positive_int(limit, "limit", max_value=1000)
if deal_type not in (1, 2): if deal_type not in (1, 2):
raise ValueError("deal_type must be 1 (first hand/new) or 2 (second hand/used)") raise ValueError(
"deal_type must be 1 (first hand/new) or 2 (second hand/used)"
)
url = f"{self.base_url}/real-estate/neighborhood-deals/{polygon_id}" url = f"{self.base_url}/real-estate/neighborhood-deals/{polygon_id}"
@@ -392,39 +444,56 @@ class GovmapClient:
try: try:
self._rate_limit() self._rate_limit()
logger.info(f"Getting neighborhood deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})") logger.info(
f"Getting neighborhood deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})"
)
timeout = (self.config.connect_timeout, self.config.read_timeout) timeout = (self.config.connect_timeout, self.config.read_timeout)
response = self.session.get(url, params=params, timeout=timeout) response = self.session.get(url, params=params, timeout=timeout)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# API returns {data: [...], totalCount: ..., limit: ..., offset: ...} # API returns {data: [...], totalCount: ..., limit: ..., offset: ...}
if isinstance(data, dict) and 'data' in data: if isinstance(data, dict) and "data" in data:
if not isinstance(data['data'], list): if not isinstance(data["data"], list):
raise ValueError(f"Expected list in 'data' field, got {type(data['data']).__name__}") raise ValueError(
return data['data'] f"Expected list in 'data' field, got {type(data['data']).__name__}"
)
return data["data"]
elif isinstance(data, list): elif isinstance(data, list):
return data return data
else: else:
raise ValueError(f"Unexpected response format: {type(data).__name__}") raise ValueError(
f"Unexpected response format: {type(data).__name__}"
)
except (requests.RequestException, requests.Timeout) as e: except (requests.RequestException, requests.Timeout) as e:
if attempt < self.config.max_retries: if attempt < self.config.max_retries:
wait_time = min( wait_time = min(
self.config.retry_min_wait * (2**attempt), self.config.retry_min_wait * (2**attempt),
self.config.retry_max_wait self.config.retry_max_wait,
)
logger.warning(
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
) )
logger.warning(f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}")
time.sleep(wait_time) time.sleep(wait_time)
else: else:
logger.error(f"Request failed after {self.config.max_retries + 1} attempts: {e}") logger.error(
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
)
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError("Unexpected error: retry loop exited without return or raise") raise RuntimeError(
"Unexpected error: retry loop exited without return or raise"
)
def find_recent_deals_for_address(self, address: str, years_back: int = 2, def find_recent_deals_for_address(
radius: int = 30, max_deals: int = 50, self,
deal_type: int = 2) -> List[Dict[str, Any]]: address: str,
years_back: int = 2,
radius: int = 30,
max_deals: int = 50,
deal_type: int = 2,
) -> List[Dict[str, Any]]:
""" """
Find all relevant real estate deals for a given address from the last few years. Find all relevant real estate deals for a given address from the last few years.
@@ -453,25 +522,29 @@ class GovmapClient:
radius = self._validate_positive_int(radius, "radius", max_value=5000) radius = self._validate_positive_int(radius, "radius", max_value=5000)
max_deals = self._validate_positive_int(max_deals, "max_deals", max_value=10000) max_deals = self._validate_positive_int(max_deals, "max_deals", max_value=10000)
if deal_type not in (1, 2): if deal_type not in (1, 2):
raise ValueError("deal_type must be 1 (first hand/new) or 2 (second hand/used)") raise ValueError(
"deal_type must be 1 (first hand/new) or 2 (second hand/used)"
)
try: try:
# Step 1: Get coordinates for the address # Step 1: Get coordinates for the address
logger.info(f"Starting search for address: {address}, dealType: {deal_type}") logger.info(
f"Starting search for address: {address}, dealType: {deal_type}"
)
autocomplete_result = self.autocomplete_address(address) autocomplete_result = self.autocomplete_address(address)
if not autocomplete_result.get('results'): if not autocomplete_result.get("results"):
raise ValueError(f"No results found for address: {address}") raise ValueError(f"No results found for address: {address}")
# Get the best match (first result) # Get the best match (first result)
best_match = autocomplete_result['results'][0] best_match = autocomplete_result["results"][0]
if 'shape' not in best_match: if "shape" not in best_match:
raise ValueError("No coordinates found in autocomplete result") raise ValueError("No coordinates found in autocomplete result")
# Parse coordinates from WKT POINT string # Parse coordinates from WKT POINT string
# Format: "POINT(longitude latitude)" # Format: "POINT(longitude latitude)"
shape_str = best_match['shape'] shape_str = best_match["shape"]
if not shape_str.startswith('POINT('): if not shape_str.startswith("POINT("):
raise ValueError("Invalid coordinate format in autocomplete result") raise ValueError("Invalid coordinate format in autocomplete result")
# Extract coordinates from "POINT(x y)" # Extract coordinates from "POINT(x y)"
@@ -490,16 +563,16 @@ class GovmapClient:
# Extract unique polygon IDs # Extract unique polygon IDs
polygon_ids = set() polygon_ids = set()
for deal in nearby_deals: for deal in nearby_deals:
if 'polygon_id' in deal: if "polygon_id" in deal:
polygon_ids.add(str(deal['polygon_id'])) polygon_ids.add(str(deal["polygon_id"]))
logger.info(f"Found {len(polygon_ids)} unique polygon IDs") logger.info(f"Found {len(polygon_ids)} unique polygon IDs")
# Step 3: Calculate date range # Step 3: Calculate date range
end_date = datetime.now() end_date = datetime.now()
start_date = end_date - timedelta(days=years_back * 365) start_date = end_date - timedelta(days=years_back * 365)
start_date_str = start_date.strftime('%Y-%m') start_date_str = start_date.strftime("%Y-%m")
end_date_str = end_date.strftime('%Y-%m') end_date_str = end_date.strftime("%Y-%m")
# Step 4: Get street and neighborhood deals for each polygon # Step 4: Get street and neighborhood deals for each polygon
# Prioritize: same building (0) > street deals (1) > neighborhood deals (2) # Prioritize: same building (0) > street deals (1) > neighborhood deals (2)
@@ -512,14 +585,20 @@ class GovmapClient:
try: try:
# Get street deals first (higher priority) # Get street deals first (higher priority)
current_street_deals = self.get_street_deals( current_street_deals = self.get_street_deals(
polygon_id, limit=max_deals // 2, # Allocate more to street deals polygon_id,
start_date=start_date_str, end_date=end_date_str, deal_type=deal_type limit=max_deals // 2, # Allocate more to street deals
start_date=start_date_str,
end_date=end_date_str,
deal_type=deal_type,
) )
# Get neighborhood deals (lower priority) # Get neighborhood deals (lower priority)
current_neighborhood_deals = self.get_neighborhood_deals( current_neighborhood_deals = self.get_neighborhood_deals(
polygon_id, limit=max_deals // 4, # Allocate less to neighborhood deals polygon_id,
start_date=start_date_str, end_date=end_date_str, deal_type=deal_type limit=max_deals // 4, # Allocate less to neighborhood deals
start_date=start_date_str,
end_date=end_date_str,
deal_type=deal_type,
) )
# Process street deals and separate building deals # Process street deals and separate building deals
@@ -527,17 +606,19 @@ class GovmapClient:
deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{deal.get('dealDate', '')}" deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{deal.get('dealDate', '')}"
if deal_id not in seen_deals: if deal_id not in seen_deals:
seen_deals.add(deal_id) seen_deals.add(deal_id)
deal['source_polygon_id'] = polygon_id deal["source_polygon_id"] = polygon_id
deal['deal_source'] = 'street' deal["deal_source"] = "street"
# Check if this is from the same building # Check if this is from the same building
deal_address = deal.get('address', '').lower().strip() deal_address = deal.get("address", "").lower().strip()
if self._is_same_building(search_address_normalized, deal_address): if self._is_same_building(
deal['deal_source'] = 'same_building' search_address_normalized, deal_address
deal['priority'] = 0 # Highest priority ):
deal["deal_source"] = "same_building"
deal["priority"] = 0 # Highest priority
building_deals.append(deal) building_deals.append(deal)
else: else:
deal['priority'] = 1 # Street deals priority deal["priority"] = 1 # Street deals priority
street_deals.append(deal) street_deals.append(deal)
# Add neighborhood deals with lowest priority # Add neighborhood deals with lowest priority
@@ -545,9 +626,9 @@ class GovmapClient:
deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{deal.get('dealDate', '')}" deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{deal.get('dealDate', '')}"
if deal_id not in seen_deals: if deal_id not in seen_deals:
seen_deals.add(deal_id) seen_deals.add(deal_id)
deal['source_polygon_id'] = polygon_id deal["source_polygon_id"] = polygon_id
deal['deal_source'] = 'neighborhood' deal["deal_source"] = "neighborhood"
deal['priority'] = 2 # Lowest priority deal["priority"] = 2 # Lowest priority
neighborhood_deals.append(deal) neighborhood_deals.append(deal)
except Exception as e: except Exception as e:
@@ -559,8 +640,12 @@ class GovmapClient:
# Use stable sort: first by date (newest first), then by priority # Use stable sort: first by date (newest first), then by priority
# Since Python's sort is stable, the second sort maintains date order within each priority # Since Python's sort is stable, the second sort maintains date order within each priority
all_deals.sort(key=lambda x: x.get('dealDate', '1900-01-01'), reverse=True) # Newest first all_deals.sort(
all_deals.sort(key=lambda x: x.get('priority', 3)) # Priority first (0=building, 1=street, 2=neighborhood) key=lambda x: x.get("dealDate", "1900-01-01"), reverse=True
) # Newest first
all_deals.sort(
key=lambda x: x.get("priority", 3)
) # Priority first (0=building, 1=street, 2=neighborhood)
# Limit to max_deals # Limit to max_deals
if len(all_deals) > max_deals: if len(all_deals) > max_deals:
@@ -568,20 +653,28 @@ class GovmapClient:
# Add price per square meter calculation and deal type info # Add price per square meter calculation and deal type info
for deal in all_deals: for deal in all_deals:
price = deal.get('dealAmount', 0) price = deal.get("dealAmount", 0)
area = deal.get('assetArea', 0) area = deal.get("assetArea", 0)
if isinstance(price, (int, float)) and isinstance(area, (int, float)) and area > 0: if (
deal['price_per_sqm'] = round(price / area, 2) isinstance(price, (int, float))
and isinstance(area, (int, float))
and area > 0
):
deal["price_per_sqm"] = round(price / area, 2)
else: else:
deal['price_per_sqm'] = None deal["price_per_sqm"] = None
# Add deal type description for clarity # Add deal type description for clarity
deal['deal_type'] = deal_type deal["deal_type"] = deal_type
deal['deal_type_description'] = 'first_hand_new' if deal_type == 1 else 'second_hand_used' deal["deal_type_description"] = (
"first_hand_new" if deal_type == 1 else "second_hand_used"
)
logger.info(f"Found {len(all_deals)} total deals for address: {address} " logger.info(
f"Found {len(all_deals)} total deals for address: {address} "
f"(Building: {len(building_deals)}, Street: {len(street_deals)}, Neighborhood: {len(neighborhood_deals)}) " f"(Building: {len(building_deals)}, Street: {len(street_deals)}, Neighborhood: {len(neighborhood_deals)}) "
f"[{deal['deal_type_description'] if all_deals else 'N/A'}]") f"[{deal['deal_type_description'] if all_deals else 'N/A'}]"
)
return all_deals return all_deals
except Exception as e: except Exception as e:
@@ -610,8 +703,13 @@ class GovmapClient:
def extract_address_parts(addr: str) -> tuple: def extract_address_parts(addr: str) -> tuple:
"""Extract street name and number from address""" """Extract street name and number from address"""
# Remove common prefixes/suffixes and normalize # Remove common prefixes/suffixes and normalize
addr_clean = addr.replace('רח\'', '').replace('רחוב', '').replace('שד\'', '').replace('שדרות', '') addr_clean = (
addr_clean = addr_clean.replace(' ', ' ').strip() addr.replace("רח'", "")
.replace("רחוב", "")
.replace("שד'", "")
.replace("שדרות", "")
)
addr_clean = addr_clean.replace(" ", " ").strip()
# Try to extract number and street name # Try to extract number and street name
parts = addr_clean.split() parts = addr_clean.split()
@@ -621,17 +719,23 @@ class GovmapClient:
if part.isdigit() or any(c.isdigit() for c in part): if part.isdigit() or any(c.isdigit() for c in part):
number = part number = part
street_parts = parts[:i] + parts[i + 1 :] street_parts = parts[:i] + parts[i + 1 :]
street_name = ' '.join(street_parts).strip() street_name = " ".join(street_parts).strip()
return (street_name, number) return (street_name, number)
return (addr_clean, '') return (addr_clean, "")
search_street, search_number = extract_address_parts(search_address) search_street, search_number = extract_address_parts(search_address)
deal_street, deal_number = extract_address_parts(deal_address) deal_street, deal_number = extract_address_parts(deal_address)
# Same street and same number = same building # Same street and same number = same building
if (search_street and deal_street and search_number and deal_number and if (
search_street == deal_street and search_number == deal_number): search_street
and deal_street
and search_number
and deal_number
and search_street == deal_street
and search_number == deal_number
):
return True return True
# Check if one address is contained in the other (for different formats of same address) # Check if one address is contained in the other (for different formats of same address)
@@ -693,12 +797,14 @@ class GovmapClient:
for deal in deals: for deal in deals:
# Property type filter # Property type filter
if property_type is not None: if property_type is not None:
deal_type = deal.get('propertyTypeDescription', deal.get('assetTypeHeb', '')) deal_type = deal.get(
"propertyTypeDescription", deal.get("assetTypeHeb", "")
)
if property_type.lower() not in deal_type.lower(): if property_type.lower() not in deal_type.lower():
continue continue
# Room count filter # Room count filter
rooms = deal.get('assetRoomNum') rooms = deal.get("assetRoomNum")
if rooms is not None: if rooms is not None:
try: try:
rooms = float(rooms) rooms = float(rooms)
@@ -710,7 +816,7 @@ class GovmapClient:
pass # Skip deals with invalid room data pass # Skip deals with invalid room data
# Price filter # Price filter
price = deal.get('dealAmount') price = deal.get("dealAmount")
if price is not None: if price is not None:
try: try:
price = float(price) price = float(price)
@@ -722,7 +828,7 @@ class GovmapClient:
pass # Skip deals with invalid price data pass # Skip deals with invalid price data
# Area filter # Area filter
area = deal.get('assetArea') area = deal.get("assetArea")
if area is not None: if area is not None:
try: try:
area = float(area) area = float(area)
@@ -734,7 +840,7 @@ class GovmapClient:
pass # Skip deals with invalid area data pass # Skip deals with invalid area data
# Floor filter # Floor filter
floor_str = deal.get('floorNo', '') floor_str = deal.get("floorNo", "")
if floor_str and isinstance(floor_str, str): if floor_str and isinstance(floor_str, str):
# Try to extract floor number (handles Hebrew floor descriptions) # Try to extract floor number (handles Hebrew floor descriptions)
floor_num = self._extract_floor_number(floor_str) floor_num = self._extract_floor_number(floor_str)
@@ -763,10 +869,18 @@ class GovmapClient:
# Hebrew ordinal floor names to numbers # Hebrew ordinal floor names to numbers
hebrew_floors = { hebrew_floors = {
'קרקע': 0, 'מרתף': -1, "קרקע": 0,
'ראשונה': 1, 'שניה': 2, 'שלישית': 3, 'רביעית': 4, "מרתף": -1,
'חמישית': 5, 'שישית': 6, 'שביעית': 7, 'שמינית': 8, "ראשונה": 1,
'תשיעית': 9, 'עשירית': 10 "שניה": 2,
"שלישית": 3,
"רביעית": 4,
"חמישית": 5,
"שישית": 6,
"שביעית": 7,
"שמינית": 8,
"תשיעית": 9,
"עשירית": 10,
} }
floor_lower = floor_str.lower().strip() floor_lower = floor_str.lower().strip()
@@ -777,8 +891,7 @@ class GovmapClient:
return num return num
# Try to extract number from string # Try to extract number from string
import re numbers = re.findall(r"\d+", floor_str)
numbers = re.findall(r'\d+', floor_str)
if numbers: if numbers:
try: try:
return int(numbers[0]) return int(numbers[0])
@@ -787,10 +900,7 @@ class GovmapClient:
return None return None
def calculate_deal_statistics( def calculate_deal_statistics(self, deals: List[Dict[str, Any]]) -> Dict[str, Any]:
self,
deals: List[Dict[str, Any]]
) -> Dict[str, Any]:
""" """
Calculate statistical aggregations on deal data. Calculate statistical aggregations on deal data.
@@ -812,7 +922,7 @@ class GovmapClient:
"price_stats": {}, "price_stats": {},
"area_stats": {}, "area_stats": {},
"price_per_sqm_stats": {}, "price_per_sqm_stats": {},
"room_distribution": {} "room_distribution": {},
} }
# Extract numeric values # Extract numeric values
@@ -822,21 +932,21 @@ class GovmapClient:
rooms = [] rooms = []
for deal in deals: for deal in deals:
price = deal.get('dealAmount') price = deal.get("dealAmount")
if isinstance(price, (int, float)) and price > 0: if isinstance(price, (int, float)) and price > 0:
prices.append(price) prices.append(price)
area = deal.get('assetArea') area = deal.get("assetArea")
if isinstance(area, (int, float)) and area > 0: if isinstance(area, (int, float)) and area > 0:
areas.append(area) areas.append(area)
pps = deal.get('price_per_sqm') pps = deal.get("price_per_sqm")
if pps is None and price and area and area > 0: if pps is None and price and area and area > 0:
pps = price / area pps = price / area
if isinstance(pps, (int, float)) and pps > 0: if isinstance(pps, (int, float)) and pps > 0:
price_per_sqm_values.append(pps) price_per_sqm_values.append(pps)
room_count = deal.get('assetRoomNum') room_count = deal.get("assetRoomNum")
if isinstance(room_count, (int, float)): if isinstance(room_count, (int, float)):
rooms.append(room_count) rooms.append(room_count)
@@ -853,8 +963,10 @@ class GovmapClient:
"max": max(prices), "max": max(prices),
"p25": sorted_prices[len(sorted_prices) // 4], "p25": sorted_prices[len(sorted_prices) // 4],
"p75": sorted_prices[(3 * len(sorted_prices)) // 4], "p75": sorted_prices[(3 * len(sorted_prices)) // 4],
"std_dev": round(self._calculate_std_dev(prices), 2) if len(prices) > 1 else 0, "std_dev": round(self._calculate_std_dev(prices), 2)
"total": sum(prices) if len(prices) > 1
else 0,
"total": sum(prices),
} }
# Area statistics # Area statistics
@@ -866,7 +978,7 @@ class GovmapClient:
"min": min(areas), "min": min(areas),
"max": max(areas), "max": max(areas),
"p25": sorted_areas[len(sorted_areas) // 4], "p25": sorted_areas[len(sorted_areas) // 4],
"p75": sorted_areas[(3 * len(sorted_areas)) // 4] "p75": sorted_areas[(3 * len(sorted_areas)) // 4],
} }
# Price per sqm statistics # Price per sqm statistics
@@ -878,12 +990,11 @@ class GovmapClient:
"min": round(min(price_per_sqm_values), 2), "min": round(min(price_per_sqm_values), 2),
"max": round(max(price_per_sqm_values), 2), "max": round(max(price_per_sqm_values), 2),
"p25": round(sorted_pps[len(sorted_pps) // 4], 2), "p25": round(sorted_pps[len(sorted_pps) // 4], 2),
"p75": round(sorted_pps[(3 * len(sorted_pps)) // 4], 2) "p75": round(sorted_pps[(3 * len(sorted_pps)) // 4], 2),
} }
# Room distribution # Room distribution
if rooms: if rooms:
from collections import Counter
room_counts = Counter(rooms) room_counts = Counter(rooms)
stats["room_distribution"] = dict(sorted(room_counts.items())) stats["room_distribution"] = dict(sorted(room_counts.items()))
-2
View File
@@ -1,7 +1,5 @@
requests>=2.31.0,<3.0.0 requests>=2.31.0,<3.0.0
python-dotenv>=1.0.0,<2.0.0 python-dotenv>=1.0.0,<2.0.0
pytest>=7.0.0,<8.0.0
mcp>=1.0.0,<2.0.0 mcp>=1.0.0,<2.0.0
fastmcp>=0.1.0,<1.0.0 fastmcp>=0.1.0,<1.0.0
types-requests>=2.31.0,<3.0.0
pydantic>=2.0.0,<3.0.0 pydantic>=2.0.0,<3.0.0