Ruff fixes

This commit is contained in:
Nitzan Pomerantz
2025-10-30 22:24:40 +02:00
parent a80b38047c
commit e4aa6487ff
46 changed files with 1563 additions and 1062 deletions
-1
View File
@@ -54,4 +54,3 @@ jobs:
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
-1
View File
@@ -47,4 +47,3 @@ jobs:
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)' # claude_args: '--allowed-tools Bash(gh pr:*)'
+51
View File
@@ -0,0 +1,51 @@
name: Code Quality
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
code-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Run Ruff formatter check
run: ruff format --check .
- name: Run Ruff linter
run: ruff check .
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run tests
run: pytest tests/ -m "not api_health" -q
+6 -2
View File
@@ -1,6 +1,10 @@
# Pre-commit hooks for code quality # Pre-commit hooks for code quality
# Install: pre-commit install #
# Run manually: pre-commit run --all-files # NOTE: These hooks are NOT installed locally (by design)
# They run automatically in GitHub Actions as PR checks
# This allows commits without blocking, with checks shown in PRs
#
# To run manually: pre-commit run --all-files
# Update hooks: pre-commit autoupdate # Update hooks: pre-commit autoupdate
repos: repos:
-1
View File
@@ -625,4 +625,3 @@ logging.basicConfig(level=logging.DEBUG)
- [MCP Protocol](https://modelcontextprotocol.io/) - [MCP Protocol](https://modelcontextprotocol.io/)
- [FastMCP Documentation](https://github.com/jlowin/fastmcp) - [FastMCP Documentation](https://github.com/jlowin/fastmcp)
- [Israeli Real Estate Data](https://data.gov.il/) - [Israeli Real Estate Data](https://data.gov.il/)
+20 -16
View File
@@ -12,16 +12,16 @@ Nadlan-MCP is a Model Context Protocol (MCP) server that provides Israeli real e
**See USECASES.md for the complete feature roadmap and user-facing capabilities.** **See USECASES.md for the complete feature roadmap and user-facing capabilities.**
### Current Capabilities (✅ Implemented) ### Current Capabilities (✅ Implemented - All 10 MCP Tools)
- **Address & Location Services** - Address autocomplete, location-based deal search - **Address & Location Services** - Address autocomplete, location-based deal search
- **Real Estate Deal Analysis** - Recent deals, street/neighborhood analysis, filtering - **Real Estate Deal Analysis** - Recent deals, street/neighborhood analysis, enhanced filtering
- **Market Intelligence** - Trend analysis, price per sqm tracking - **Market Intelligence** - Trend analysis, price per sqm tracking, market activity metrics
- **Comparative Analysis** - Multi-address comparison, investment insights - **Comparative Analysis** - Multi-address comparison, investment insights
- **Valuation Data** - Comparable properties, deal statistics, filtered deal sets
- **Enhanced Filtering** - Property type, rooms, price range, area, floor (all deal functions)
### In Development (🚧 In Progress) ### In Development (🚧 In Progress)
- Enhanced deal filtering (property type, rooms, price range, area, floor) - Phase 6-7: Documentation improvements, code quality with Ruff/mypy, pre-commit hooks
- Valuation data provision tools (`get_valuation_comparables`, `get_deal_statistics`)
- Market activity metrics (detailed activity and velocity metrics)
### Future Features (📋 Planned) ### Future Features (📋 Planned)
- **Amenity Scoring** - Comprehensive quality-of-life analysis using: - **Amenity Scoring** - Comprehensive quality-of-life analysis using:
@@ -72,17 +72,23 @@ pytest -m integration
### Code Quality ### Code Quality
```bash ```bash
# Format code with black # Format code with Ruff (replaces black + isort)
black nadlan_mcp/ tests/ ruff format .
# Sort imports # Lint code with Ruff (replaces flake8 + many more)
isort nadlan_mcp/ tests/ ruff check .
# Type checking # Lint with auto-fix
ruff check . --fix
# Type checking with mypy (currently has type annotation issues - WIP)
mypy nadlan_mcp/ mypy nadlan_mcp/
# Linting # Run all pre-commit hooks manually (for CI, not installed locally)
flake8 nadlan_mcp/ pre-commit run --all-files
# Note: Pre-commit hooks run as PR checks in GitHub Actions, not locally
# This allows commits without blocking, with checks shown in PRs
``` ```
## Architecture ## Architecture
@@ -147,7 +153,7 @@ The codebase follows a four-layer architecture:
## Available MCP Tools ## Available MCP Tools
**Implemented (✅):** **Implemented (✅ All 10 Tools):**
- `autocomplete_address` - Search and autocomplete Israeli addresses - `autocomplete_address` - Search and autocomplete Israeli addresses
- `get_deals_by_radius` - Get deals within a radius of coordinates - `get_deals_by_radius` - Get deals within a radius of coordinates
- `get_street_deals` - Get deals for a specific street polygon - `get_street_deals` - Get deals for a specific street polygon
@@ -155,8 +161,6 @@ The codebase follows a four-layer architecture:
- `find_recent_deals_for_address` - Main comprehensive analysis tool - `find_recent_deals_for_address` - Main comprehensive analysis tool
- `analyze_market_trends` - Analyze market trends and price patterns - `analyze_market_trends` - Analyze market trends and price patterns
- `compare_addresses` - Compare real estate markets between multiple addresses - `compare_addresses` - Compare real estate markets between multiple addresses
**In Progress (🚧):**
- `get_valuation_comparables` - Get comparable properties for valuation analysis - `get_valuation_comparables` - Get comparable properties for valuation analysis
- `get_deal_statistics` - Calculate statistical aggregations on deal data - `get_deal_statistics` - Calculate statistical aggregations on deal data
- `get_market_activity_metrics` - Detailed market activity and velocity metrics - `get_market_activity_metrics` - Detailed market activity and velocity metrics
+30 -18
View File
@@ -167,9 +167,31 @@ This document tracks the implementation progress of the Nadlan-MCP improvement p
- ✅ VCR.py ready for recording API interactions - ✅ VCR.py ready for recording API interactions
- ✅ Weekly API health monitoring established - ✅ Weekly API health monitoring established
### Phase 7: Code Quality & Polish ✅ COMPLETE
#### 7.1 Code Style & Linting with Ruff ✅
- ✅ Created `pyproject.toml` with Ruff + mypy configuration
- ✅ Created `.pre-commit-config.yaml` with Ruff hooks
- ✅ Updated `requirements-dev.txt` (replaced black/isort/flake8 with Ruff)
- ✅ Formatted all code with `ruff format` (25 files reformatted)
- ✅ Fixed linting issues with `ruff check --fix` (41 auto-fixes)
- ✅ Removed unused variables (prices, deals_per_quarter, unique_quarters)
- ✅ Fixed missing trend_direction in LiquidityMetrics return
- ✅ Set up pre-commit hooks (run in GitHub Actions as PR checks, not locally)
- ✅ Created `.github/workflows/code-quality.yml` for automated PR checks
- ✅ All 302 tests still passing after formatting
- 📋 Mypy type checking (deferred - needs systematic type annotation fixes)
**Phase 7 Results:**
- ✅ Modern code quality with Ruff (10-100x faster than black+isort+flake8)
- ✅ GitHub Actions PR checks prevent quality regressions (non-blocking locally)
- ✅ Consistent formatting across entire codebase
- ✅ Only 7 minor style suggestions remaining (not errors)
- ✅ All tests passing (302 passed, 1 skipped)
## 🚧 In Progress ## 🚧 In Progress
None - Phase 5 complete! None - Phase 7 complete!
## 📋 To-Do (Next Priority) ## 📋 To-Do (Next Priority)
@@ -204,23 +226,13 @@ None - Phase 5 complete!
- [ ] Add API limitations section - [ ] Add API limitations section
- [ ] Add examples from examples/ directory - [ ] Add examples from examples/ directory
### Phase 7: Code Quality & Polish ### Phase 7.2: Additional Code Quality (Optional - Future)
#### 7.1 Code Style & Linting
- [ ] Create `.pre-commit-config.yaml`
- [ ] Setup black formatter
- [ ] Setup isort for imports
- [ ] Setup flake8 linter
- [ ] Setup mypy for type checking
- [ ] Format all code with black
- [ ] Sort all imports with isort
- [ ] Fix all flake8 warnings
- [ ] Fix all mypy errors
- [ ] Add pre-commit hooks to CI
#### 7.2 Remaining Cleanup #### 7.2 Remaining Cleanup
- [ ] Remove any remaining unused imports - [ ] Fix mypy type annotation errors (systematic refactor needed)
- [ ] Consolidate duplicate code - [ ] Address 7 remaining Ruff style suggestions (SIM102, C401, SIM117)
- [ ] Add Bandit security scanning with baseline
- [ ] Consolidate any remaining duplicate code
- [ ] Refactor long functions (>100 lines) - [ ] Refactor long functions (>100 lines)
- [ ] Improve naming consistency - [ ] Improve naming consistency
@@ -297,9 +309,9 @@ None - Phase 5 complete!
- Phase 3 (Architecture Refactoring): ✅ 100% complete - Phase 3 (Architecture Refactoring): ✅ 100% complete
- Phase 4.1 (Pydantic Models): ✅ 100% complete (v2.0.0 released) - Phase 4.1 (Pydantic Models): ✅ 100% complete (v2.0.0 released)
- Phase 4.2 (LLM Tool Design): 📋 Deferred to backlog (optional) - Phase 4.2 (LLM Tool Design): 📋 Deferred to backlog (optional)
- Phase 5 (Testing): 🚧 75% complete (195 tests including integration tests) - Phase 5 (Testing): ✅ 100% complete (304 tests, 84% coverage)
- Phase 6 (Documentation): ✅ 90% complete (all major docs updated for v2.0) - Phase 6 (Documentation): ✅ 90% complete (all major docs updated for v2.0)
- Phase 7 (Polish): 🚧 33% complete (cleanup done, linting pending) - Phase 7 (Code Quality): ✅ 100% complete (Ruff formatting/linting, pre-commit hooks)
- Phase 8 (Future): 📋 Backlog - Phase 8 (Future): 📋 Backlog
### High Priority (MVP) Status ### High Priority (MVP) Status
+21 -28
View File
@@ -17,18 +17,18 @@ The nadlan-mcp MCP server provides comprehensive Israeli real estate data analys
- **Recent Deals by Address**: Find recent real estate transactions for any specific address - **Recent Deals by Address**: Find recent real estate transactions for any specific address
- **Street-level Analysis**: Get all recent deals for an entire street/area - **Street-level Analysis**: Get all recent deals for an entire street/area
- **Neighborhood Analysis**: Analyze deals across entire neighborhoods - **Neighborhood Analysis**: Analyze deals across entire neighborhoods
- **Deal Filtering**: Filter deals by property type, room count, price range, area, and floor (🚧 enhanced filtering in progress) - **Deal Filtering**: Filter deals by property type, room count, price range, area, and floor
## 📈 **Market Intelligence** ✅ ## 📈 **Market Intelligence** ✅
- **Market Trends Analysis**: Analyze price patterns and market trends over time for any area - **Market Trends Analysis**: Analyze price patterns and market trends over time for any area
- **Price per Square Meter Trends**: Track how property values change over time - **Price per Square Meter Trends**: Track how property values change over time
- **Market Activity Levels**: See how active the real estate market is in different areas (🚧 enhanced metrics in progress) - **Market Activity Levels**: See how active the real estate market is in different areas
## 🔍 **Comparative Analysis** ✅ ## 🔍 **Comparative Analysis** ✅
- **Multi-Address Comparison**: Compare real estate markets between multiple addresses side-by-side - **Multi-Address Comparison**: Compare real estate markets between multiple addresses side-by-side
- **Investment Analysis**: Compare different areas for investment potential (🚧 advanced metrics in progress) - **Investment Analysis**: Compare different areas for investment potential
## 🏆 **Amenity Scoring & Quality of Life Analysis** 📋 ## 🏆 **Amenity Scoring & Quality of Life Analysis** 📋
@@ -60,7 +60,7 @@ This comprehensive feature will provide amenity-based location scoring using mul
### Investment Research ✅ ### Investment Research ✅
- Identify trending neighborhoods and price patterns - Identify trending neighborhoods and price patterns
- Analyze market activity levels across different areas (🚧 enhanced metrics in progress) - Analyze market activity levels across different areas
- Track price per square meter trends over time - Track price per square meter trends over time
### Market Analysis ✅ ### Market Analysis ✅
@@ -106,9 +106,6 @@ You can ask questions like:
- `get_neighborhood_deals` - Get deals for a neighborhood polygon - `get_neighborhood_deals` - Get deals for a neighborhood polygon
- `analyze_market_trends` - Analyze market trends and price patterns - `analyze_market_trends` - Analyze market trends and price patterns
- `compare_addresses` - Compare real estate markets between multiple addresses - `compare_addresses` - Compare real estate markets between multiple addresses
### 🚧 In Progress
- `get_valuation_comparables` - Get comparable properties for valuation analysis - `get_valuation_comparables` - Get comparable properties for valuation analysis
- `get_deal_statistics` - Calculate statistical aggregations on deal data - `get_deal_statistics` - Calculate statistical aggregations on deal data
- `get_market_activity_metrics` - Detailed market activity and velocity metrics - `get_market_activity_metrics` - Detailed market activity and velocity metrics
@@ -124,7 +121,7 @@ You can ask questions like:
The data covers recent Israeli real estate transactions and can help with: The data covers recent Israeli real estate transactions and can help with:
- Property research and valuation ✅ - Property research and valuation ✅
- Investment analysis and decision-making ✅ (🚧 advanced metrics in progress) - Investment analysis and decision-making ✅
- Market understanding and trends ✅ - Market understanding and trends ✅
- Comparative analysis across locations ✅ - Comparative analysis across locations ✅
- Due diligence for real estate decisions ✅ - Due diligence for real estate decisions ✅
@@ -136,34 +133,30 @@ Simply connect to the nadlan-mcp server through your MCP client (like Cursor) an
## 🔄 **Roadmap & Timeline** ## 🔄 **Roadmap & Timeline**
### Phase 1: Core Reliability & Quality (Current) ### Phase 1-5: Core Features & Quality ✅ COMPLETE
- ✅ Configuration management system - ✅ Configuration management system
- ✅ Retry logic with exponential backoff - ✅ Retry logic with exponential backoff
- ✅ Rate limiting protection - ✅ Rate limiting protection
- ✅ Input validation and error handling - ✅ Input validation and error handling
- 🚧 Enhanced deal filtering - Enhanced deal filtering
- 🚧 Valuation data provision tools - Valuation data provision tools
- 🚧 Market activity metrics - Market activity metrics
- ✅ Pydantic data models
- ✅ Comprehensive testing (304 tests, 84% coverage)
### Phase 2: Architecture Improvements (Next) ### Phase 6-7: Documentation & Code Quality (In Progress)
- Data models with Pydantic - Documentation improvements
- Separation of concerns (API client, analyzers, tools) - Code formatting and linting with Ruff
- LLM-friendly tool design with summarized_response parameter - Pre-commit hooks
- Comprehensive testing suite - Type checking with mypy
### Phase 3: Documentation & Developer Experience ### Phase 8: Future Features (Planned)
- Architecture documentation - Amenity scoring with quality metrics (Google Places, school rankings, healthcare ratings)
- API reference guide - In-memory caching with TTL
- Usage examples - Async/parallel processing with httpx
- Contributing guidelines
### Phase 4: Future Features
- Amenity scoring with quality metrics
- In-memory caching
- Async/parallel processing
- Production-ready caching (Redis) - Production-ready caching (Redis)
- Multi-language support - Multi-language support
- Database integration - Database integration (SQLite/PostgreSQL)
## 📞 **Support & Contributions** ## 📞 **Support & Contributions**
+4 -4
View File
@@ -7,16 +7,16 @@ public real estate data API (Govmap).
from .govmap import GovmapClient from .govmap import GovmapClient
from .govmap.models import ( from .govmap.models import (
CoordinatePoint,
Address, Address,
AutocompleteResult,
AutocompleteResponse, AutocompleteResponse,
AutocompleteResult,
CoordinatePoint,
Deal, Deal,
DealFilters,
DealStatistics, DealStatistics,
MarketActivityScore,
InvestmentAnalysis, InvestmentAnalysis,
LiquidityMetrics, LiquidityMetrics,
DealFilters, MarketActivityScore,
) )
__version__ = "2.0.0" # Breaking change: Pydantic models integration (Phase 4.1) __version__ = "2.0.0" # Breaking change: Pydantic models integration (Phase 4.1)
+5 -16
View File
@@ -6,9 +6,9 @@ rate limiting, and other settings. Configuration can be set via environment
variables or code. variables or code.
""" """
from dataclasses import dataclass, field
import os import os
from typing import Optional from typing import Optional
from dataclasses import dataclass, field
@dataclass @dataclass
@@ -17,24 +17,17 @@ class GovmapConfig:
# API settings # API settings
base_url: str = field( base_url: str = field(
default_factory=lambda: os.getenv( default_factory=lambda: os.getenv("GOVMAP_BASE_URL", "https://www.govmap.gov.il/api/")
"GOVMAP_BASE_URL",
"https://www.govmap.gov.il/api/"
)
) )
# Timeout settings (in seconds) # Timeout settings (in seconds)
connect_timeout: int = field( connect_timeout: int = field(
default_factory=lambda: int(os.getenv("GOVMAP_CONNECT_TIMEOUT", "10")) default_factory=lambda: int(os.getenv("GOVMAP_CONNECT_TIMEOUT", "10"))
) )
read_timeout: int = field( read_timeout: int = field(default_factory=lambda: int(os.getenv("GOVMAP_READ_TIMEOUT", "30")))
default_factory=lambda: int(os.getenv("GOVMAP_READ_TIMEOUT", "30"))
)
# Retry settings # Retry settings
max_retries: int = field( max_retries: int = field(default_factory=lambda: int(os.getenv("GOVMAP_MAX_RETRIES", "3")))
default_factory=lambda: int(os.getenv("GOVMAP_MAX_RETRIES", "3"))
)
retry_min_wait: int = field( retry_min_wait: int = field(
default_factory=lambda: int(os.getenv("GOVMAP_RETRY_MIN_WAIT", "1")) default_factory=lambda: int(os.getenv("GOVMAP_RETRY_MIN_WAIT", "1"))
) )
@@ -65,10 +58,7 @@ class GovmapConfig:
# User agent # User agent
user_agent: str = field( user_agent: str = field(
default_factory=lambda: os.getenv( default_factory=lambda: os.getenv("GOVMAP_USER_AGENT", "NadlanMCP/1.0.0")
"GOVMAP_USER_AGENT",
"NadlanMCP/1.0.0"
)
) )
def __post_init__(self): def __post_init__(self):
@@ -135,4 +125,3 @@ def reset_config():
"""Reset the global configuration to default values.""" """Reset the global configuration to default values."""
global _config global _config
_config = None _config = None
+365 -212
View File
@@ -8,10 +8,12 @@ using the FastMCP library with simplified, working functions.
import json import json
import logging import logging
from typing import List, Dict, Optional, Any from typing import Any, Dict, List, Optional
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from nadlan_mcp.govmap import GovmapClient from nadlan_mcp.govmap import GovmapClient
from nadlan_mcp.govmap.models import Deal, AutocompleteResponse from nadlan_mcp.govmap.models import Deal
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -23,6 +25,7 @@ mcp = FastMCP("nadlan-mcp")
# Initialize the Govmap client # Initialize the Govmap client
client = GovmapClient() client = GovmapClient()
def strip_bloat_fields(deals: List[Deal]) -> List[Dict[str, Any]]: def strip_bloat_fields(deals: List[Deal]) -> 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.
@@ -38,14 +41,14 @@ def strip_bloat_fields(deals: List[Deal]) -> List[Dict[str, Any]]:
Returns: Returns:
List of deal dictionaries with bloat fields removed List of deal dictionaries with bloat fields removed
""" """
bloat_fields = {'shape', 'sourceorder'} bloat_fields = {"shape", "sourceorder"}
# Note: We keep source_polygon_id if it was added by our processing logic # Note: We keep source_polygon_id if it was added by our processing logic
result = [] result = []
for deal in deals: for deal in deals:
# Convert Deal model to dict, excluding None values for cleaner output # Convert Deal model to dict, excluding None values for cleaner output
# Use mode='json' to serialize dates as ISO strings # 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}
@@ -53,6 +56,7 @@ def strip_bloat_fields(deals: List[Deal]) -> List[Dict[str, Any]]:
return result return result
@mcp.tool() @mcp.tool()
def autocomplete_address(search_text: str) -> str: def autocomplete_address(search_text: str) -> str:
"""Search and autocomplete Israeli addresses. """Search and autocomplete Israeli addresses.
@@ -83,7 +87,7 @@ def autocomplete_address(search_text: str) -> str:
if result.coordinates: if result.coordinates:
result_dict["coordinates"] = { result_dict["coordinates"] = {
"longitude": result.coordinates.longitude, "longitude": result.coordinates.longitude,
"latitude": result.coordinates.latitude "latitude": result.coordinates.latitude,
} }
formatted_results.append(result_dict) formatted_results.append(result_dict)
@@ -94,6 +98,7 @@ def autocomplete_address(search_text: str) -> str:
logger.error(f"Error in autocomplete_address: {e}") logger.error(f"Error in autocomplete_address: {e}")
return f"Error searching for address: {str(e)}" return f"Error searching for address: {str(e)}"
@mcp.tool() @mcp.tool()
def get_deals_by_radius(latitude: float, longitude: float, radius_meters: int = 500) -> str: def get_deals_by_radius(latitude: float, longitude: float, radius_meters: int = 500) -> str:
"""Get polygon metadata within a radius of coordinates. """Get polygon metadata within a radius of coordinates.
@@ -117,17 +122,22 @@ def get_deals_by_radius(latitude: float, longitude: float, radius_meters: int =
if not polygons: if not polygons:
return f"No polygons found within {radius_meters}m of coordinates ({latitude}, {longitude})" return f"No polygons found within {radius_meters}m of coordinates ({latitude}, {longitude})"
return json.dumps({ return json.dumps(
"total_polygons": len(polygons), {
"search_radius_meters": radius_meters, "total_polygons": len(polygons),
"center_coordinates": {"latitude": latitude, "longitude": longitude}, "search_radius_meters": radius_meters,
"polygons": polygons # Return dicts directly, no stripping needed "center_coordinates": {"latitude": latitude, "longitude": longitude},
}, ensure_ascii=False, indent=2) "polygons": polygons, # Return dicts directly, no stripping needed
},
ensure_ascii=False,
indent=2,
)
except Exception as e: except Exception as e:
logger.error(f"Error in get_deals_by_radius: {e}") logger.error(f"Error in get_deals_by_radius: {e}")
return f"Error fetching polygons by radius: {str(e)}" return f"Error fetching polygons by radius: {str(e)}"
@mcp.tool() @mcp.tool()
def get_street_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> str: def get_street_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> str:
"""Get real estate deals for a specific street polygon. """Get real estate deals for a specific street polygon.
@@ -150,36 +160,48 @@ def get_street_deals(polygon_id: str, limit: int = 100, deal_type: int = 2) -> s
# Add deal type metadata # Add deal type metadata
for deal in deals: for deal in deals:
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"
# Calculate basic statistics using computed fields from models # Calculate basic statistics using computed fields from models
prices = [deal.deal_amount for deal in deals if deal.deal_amount]
price_per_sqm_values = [deal.price_per_sqm for deal in deals if deal.price_per_sqm] price_per_sqm_values = [deal.price_per_sqm for deal in deals if deal.price_per_sqm]
stats = {} stats = {}
if price_per_sqm_values: if price_per_sqm_values:
stats["price_per_sqm_stats"] = { stats["price_per_sqm_stats"] = {
"average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0), "average_price_per_sqm": round(
sum(price_per_sqm_values) / len(price_per_sqm_values), 0
),
"min_price_per_sqm": round(min(price_per_sqm_values), 0), "min_price_per_sqm": round(min(price_per_sqm_values), 0),
"max_price_per_sqm": round(max(price_per_sqm_values), 0) "max_price_per_sqm": round(max(price_per_sqm_values), 0),
} }
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({ return json.dumps(
"total_deals": len(deals), {
"polygon_id": polygon_id, "total_deals": len(deals),
"deal_type": deal_type, "polygon_id": polygon_id,
"deal_type_description": deal_type_desc, "deal_type": deal_type,
"market_statistics": stats, "deal_type_description": deal_type_desc,
"deals": strip_bloat_fields(deals) "market_statistics": stats,
}, ensure_ascii=False, indent=2) "deals": strip_bloat_fields(deals),
},
ensure_ascii=False,
indent=2,
)
except Exception as e: except Exception as e:
logger.error(f"Error in get_street_deals: {e}") logger.error(f"Error in get_street_deals: {e}")
return f"Error fetching street deals: {str(e)}" return f"Error fetching street deals: {str(e)}"
@mcp.tool() @mcp.tool()
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: 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. """Find recent real estate deals for a specific address.
Args: Args:
@@ -194,7 +216,9 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
JSON string containing recent real estate deals for the address JSON string containing recent real estate deals for the address
""" """
try: try:
deals = client.find_recent_deals_for_address(address, years_back, radius_meters, max_deals, deal_type) deals = client.find_recent_deals_for_address(
address, years_back, radius_meters, max_deals, deal_type
)
if not deals: if not deals:
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)"
@@ -207,9 +231,13 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
# Separate building, street and neighborhood deals for analysis # Separate building, street and neighborhood deals for analysis
# deal_source is added dynamically in find_recent_deals_for_address # deal_source is added dynamically in find_recent_deals_for_address
building_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "same_building"] building_deals = [
deal for deal in deals if getattr(deal, "deal_source", None) == "same_building"
]
street_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "street"] street_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "street"]
neighborhood_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "neighborhood"] neighborhood_deals = [
deal for deal in deals if getattr(deal, "deal_source", None) == "neighborhood"
]
stats = { stats = {
"deal_breakdown": { "deal_breakdown": {
@@ -217,9 +245,15 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
"same_building_deals": len(building_deals), "same_building_deals": len(building_deals),
"street_deals": len(street_deals), "street_deals": len(street_deals),
"neighborhood_deals": len(neighborhood_deals), "neighborhood_deals": len(neighborhood_deals),
"same_building_percentage": round((len(building_deals) / len(deals)) * 100, 1) if deals else 0, "same_building_percentage": round((len(building_deals) / len(deals)) * 100, 1)
"street_emphasis_percentage": round((len(street_deals) / len(deals)) * 100, 1) if deals else 0, if deals
"neighborhood_percentage": round((len(neighborhood_deals) / len(deals)) * 100, 1) if deals else 0 else 0,
"street_emphasis_percentage": round((len(street_deals) / len(deals)) * 100, 1)
if deals
else 0,
"neighborhood_percentage": round((len(neighborhood_deals) / len(deals)) * 100, 1)
if deals
else 0,
} }
} }
@@ -228,8 +262,8 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
"average_price": round(sum(prices) / len(prices), 0), "average_price": round(sum(prices) / len(prices), 0),
"min_price": min(prices), "min_price": min(prices),
"max_price": max(prices), "max_price": max(prices),
"median_price": sorted(prices)[len(prices)//2] if prices else 0, "median_price": sorted(prices)[len(prices) // 2] if prices else 0,
"total_volume": sum(prices) "total_volume": sum(prices),
} }
if areas: if areas:
@@ -237,35 +271,46 @@ def find_recent_deals_for_address(address: str, years_back: int = 2, radius_mete
"average_area": round(sum(areas) / len(areas), 1), "average_area": round(sum(areas) / len(areas), 1),
"min_area": min(areas), "min_area": min(areas),
"max_area": max(areas), "max_area": max(areas),
"median_area": sorted(areas)[len(areas)//2] if areas else 0 "median_area": sorted(areas)[len(areas) // 2] if areas else 0,
} }
if price_per_sqm_values: if price_per_sqm_values:
stats["price_per_sqm_stats"] = { stats["price_per_sqm_stats"] = {
"average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0), "average_price_per_sqm": round(
sum(price_per_sqm_values) / len(price_per_sqm_values), 0
),
"min_price_per_sqm": round(min(price_per_sqm_values), 0), "min_price_per_sqm": round(min(price_per_sqm_values), 0),
"max_price_per_sqm": round(max(price_per_sqm_values), 0), "max_price_per_sqm": round(max(price_per_sqm_values), 0),
"median_price_per_sqm": round(sorted(price_per_sqm_values)[len(price_per_sqm_values)//2], 0) if price_per_sqm_values else 0 "median_price_per_sqm": round(
sorted(price_per_sqm_values)[len(price_per_sqm_values) // 2], 0
)
if price_per_sqm_values
else 0,
} }
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({ return json.dumps(
"search_parameters": { {
"address": address, "search_parameters": {
"years_back": years_back, "address": address,
"radius_meters": radius_meters, "years_back": years_back,
"max_deals": max_deals, "radius_meters": radius_meters,
"deal_type": deal_type, "max_deals": max_deals,
"deal_type_description": deal_type_desc "deal_type": deal_type,
"deal_type_description": deal_type_desc,
},
"market_statistics": stats,
"deals": strip_bloat_fields(deals),
}, },
"market_statistics": stats, ensure_ascii=False,
"deals": strip_bloat_fields(deals) indent=2,
}, ensure_ascii=False, indent=2) )
except Exception as e: except Exception as e:
logger.error(f"Error in find_recent_deals_for_address: {e}") logger.error(f"Error in find_recent_deals_for_address: {e}")
return f"Error analyzing address: {str(e)}" return f"Error analyzing address: {str(e)}"
@mcp.tool() @mcp.tool()
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) -> str:
"""Get real estate deals for a specific neighborhood polygon. """Get real estate deals for a specific neighborhood polygon.
@@ -288,36 +333,48 @@ def get_neighborhood_deals(polygon_id: str, limit: int = 100, deal_type: int = 2
# Add deal type metadata # Add deal type metadata
for deal in deals: for deal in deals:
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"
# Calculate basic statistics using computed fields from models # Calculate basic statistics using computed fields from models
prices = [deal.deal_amount for deal in deals if deal.deal_amount]
price_per_sqm_values = [deal.price_per_sqm for deal in deals if deal.price_per_sqm] price_per_sqm_values = [deal.price_per_sqm for deal in deals if deal.price_per_sqm]
stats = {} stats = {}
if price_per_sqm_values: if price_per_sqm_values:
stats["price_per_sqm_stats"] = { stats["price_per_sqm_stats"] = {
"average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0), "average_price_per_sqm": round(
sum(price_per_sqm_values) / len(price_per_sqm_values), 0
),
"min_price_per_sqm": round(min(price_per_sqm_values), 0), "min_price_per_sqm": round(min(price_per_sqm_values), 0),
"max_price_per_sqm": round(max(price_per_sqm_values), 0) "max_price_per_sqm": round(max(price_per_sqm_values), 0),
} }
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({ return json.dumps(
"total_deals": len(deals), {
"polygon_id": polygon_id, "total_deals": len(deals),
"deal_type": deal_type, "polygon_id": polygon_id,
"deal_type_description": deal_type_desc, "deal_type": deal_type,
"market_statistics": stats, "deal_type_description": deal_type_desc,
"deals": strip_bloat_fields(deals) "market_statistics": stats,
}, ensure_ascii=False, indent=2) "deals": strip_bloat_fields(deals),
},
ensure_ascii=False,
indent=2,
)
except Exception as e: except Exception as e:
logger.error(f"Error in get_neighborhood_deals: {e}") logger.error(f"Error in get_neighborhood_deals: {e}")
return f"Error fetching neighborhood deals: {str(e)}" return f"Error fetching neighborhood deals: {str(e)}"
@mcp.tool() @mcp.tool()
def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int = 100, max_deals: int = 100, deal_type: int = 2) -> str: def analyze_market_trends(
address: str,
years_back: int = 3,
radius_meters: int = 100,
max_deals: int = 100,
deal_type: int = 2,
) -> str:
"""Analyze market trends and price patterns for an area with comprehensive data. """Analyze market trends and price patterns for an area with comprehensive data.
Args: Args:
@@ -332,7 +389,9 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
""" """
try: try:
# 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(address, years_back, radius_meters, max_deals, deal_type) deals = client.find_recent_deals_for_address(
address, years_back, radius_meters, max_deals, deal_type
)
if not deals: if not deals:
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)"
@@ -342,7 +401,9 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
from collections import defaultdict from collections import defaultdict
yearly_data = defaultdict(list) yearly_data = defaultdict(list)
property_types: Dict[str, List[float]] = defaultdict(list) # Store only prices for efficiency property_types: Dict[str, List[float]] = defaultdict(
list
) # Store only prices for efficiency
neighborhoods = defaultdict(list) neighborhoods = defaultdict(list)
# Simplified processing - extract only essential data # Simplified processing - extract only essential data
@@ -352,19 +413,34 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
# Convert date to string for parsing # Convert date to string for parsing
from datetime import date as date_type from datetime import date as date_type
date_str = deal.deal_date.isoformat() if isinstance(deal.deal_date, date_type) else str(deal.deal_date)
date_str = (
deal.deal_date.isoformat()
if isinstance(deal.deal_date, date_type)
else str(deal.deal_date)
)
year = date_str[:4] year = date_str[:4]
price = deal.deal_amount price = deal.deal_amount
area = deal.asset_area area = deal.asset_area
price_per_sqm = deal.price_per_sqm price_per_sqm = deal.price_per_sqm
prop_type = deal.property_type_description or 'לא ידוע' prop_type = deal.property_type_description or "לא ידוע"
neighborhood = deal.settlement_name_heb or deal.neighborhood or 'לא ידוע' neighborhood = deal.settlement_name_heb or deal.neighborhood or "לא ידוע"
deal_source = getattr(deal, 'deal_source', 'unknown') deal_source = getattr(deal, "deal_source", "unknown")
if isinstance(price, (int, float)) and isinstance(area, (int, float)) and area > 0 and isinstance(price_per_sqm, (int, float)): if (
yearly_data[year].append({ isinstance(price, (int, float))
'price': price, 'area': area, 'price_per_sqm': price_per_sqm, 'deal_source': deal_source and isinstance(area, (int, float))
}) and area > 0
and isinstance(price_per_sqm, (int, float))
):
yearly_data[year].append(
{
"price": price,
"area": area,
"price_per_sqm": price_per_sqm,
"deal_source": deal_source,
}
)
property_types[prop_type].append(price_per_sqm) property_types[prop_type].append(price_per_sqm)
neighborhoods[neighborhood].append(price_per_sqm) neighborhoods[neighborhood].append(price_per_sqm)
@@ -372,20 +448,22 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
yearly_trends = {} yearly_trends = {}
for year, year_deals in yearly_data.items(): for year, year_deals in yearly_data.items():
if year_deals: if year_deals:
prices = [d['price'] for d in year_deals] prices = [d["price"] for d in year_deals]
price_per_sqm_vals = [d['price_per_sqm'] for d in year_deals] price_per_sqm_vals = [d["price_per_sqm"] for d in year_deals]
building_deals = [d for d in year_deals if d['deal_source'] == 'same_building'] building_deals = [d for d in year_deals if d["deal_source"] == "same_building"]
street_deals = [d for d in year_deals if d['deal_source'] == 'street'] street_deals = [d for d in year_deals if d["deal_source"] == "street"]
yearly_trends[year] = { yearly_trends[year] = {
"deal_count": len(year_deals), "deal_count": len(year_deals),
"same_building_deals": len(building_deals), "same_building_deals": len(building_deals),
"street_deals": len(street_deals), "street_deals": len(street_deals),
"avg_price": round(sum(prices) / len(prices), 0), "avg_price": round(sum(prices) / len(prices), 0),
"avg_price_per_sqm": round(sum(price_per_sqm_vals) / len(price_per_sqm_vals), 0), "avg_price_per_sqm": round(
sum(price_per_sqm_vals) / len(price_per_sqm_vals), 0
),
"min_price_per_sqm": round(min(price_per_sqm_vals), 0), "min_price_per_sqm": round(min(price_per_sqm_vals), 0),
"max_price_per_sqm": round(max(price_per_sqm_vals), 0), "max_price_per_sqm": round(max(price_per_sqm_vals), 0),
"total_volume": sum(prices) "total_volume": sum(prices),
} }
# Streamlined property type analysis (top 5 only) # Streamlined property type analysis (top 5 only)
@@ -394,12 +472,15 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
if len(prices_per_sqm) >= 2: # Only include types with multiple deals if len(prices_per_sqm) >= 2: # Only include types with multiple deals
property_type_analysis[prop_type] = { property_type_analysis[prop_type] = {
"deal_count": len(prices_per_sqm), "deal_count": len(prices_per_sqm),
"avg_price_per_sqm": round(sum(prices_per_sqm) / len(prices_per_sqm), 0) "avg_price_per_sqm": round(sum(prices_per_sqm) / len(prices_per_sqm), 0),
} }
# Keep only top 5 property types by deal count # Keep only top 5 property types by deal count
property_type_analysis = dict(sorted(property_type_analysis.items(), property_type_analysis = dict(
key=lambda x: x[1]['deal_count'], reverse=True)[:5]) sorted(property_type_analysis.items(), key=lambda x: x[1]["deal_count"], reverse=True)[
:5
]
)
# Streamlined neighborhood analysis (top 5 only) # Streamlined neighborhood analysis (top 5 only)
neighborhood_analysis = {} neighborhood_analysis = {}
@@ -407,12 +488,15 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
if len(prices_per_sqm) >= 3: # Minimum 3 deals for statistical significance if len(prices_per_sqm) >= 3: # Minimum 3 deals for statistical significance
neighborhood_analysis[neighborhood] = { neighborhood_analysis[neighborhood] = {
"deal_count": len(prices_per_sqm), "deal_count": len(prices_per_sqm),
"avg_price_per_sqm": round(sum(prices_per_sqm) / len(prices_per_sqm), 0) "avg_price_per_sqm": round(sum(prices_per_sqm) / len(prices_per_sqm), 0),
} }
# Keep only top 5 neighborhoods by deal count # Keep only top 5 neighborhoods by deal count
neighborhood_analysis = dict(sorted(neighborhood_analysis.items(), neighborhood_analysis = dict(
key=lambda x: x[1]['deal_count'], reverse=True)[:5]) sorted(neighborhood_analysis.items(), key=lambda x: x[1]["deal_count"], reverse=True)[
:5
]
)
# Simple trend analysis # Simple trend analysis
years_sorted = sorted(yearly_trends.keys()) years_sorted = sorted(yearly_trends.keys())
@@ -421,50 +505,74 @@ def analyze_market_trends(address: str, years_back: int = 3, radius_meters: int
first_year = yearly_trends[years_sorted[0]] first_year = yearly_trends[years_sorted[0]]
last_year = yearly_trends[years_sorted[-1]] last_year = yearly_trends[years_sorted[-1]]
if first_year['avg_price_per_sqm'] > 0: if first_year["avg_price_per_sqm"] > 0:
price_change = ((last_year['avg_price_per_sqm'] - first_year['avg_price_per_sqm']) / first_year['avg_price_per_sqm']) * 100 price_change = (
volume_change = ((last_year['deal_count'] - first_year['deal_count']) / first_year['deal_count']) * 100 if first_year['deal_count'] > 0 else 0 (last_year["avg_price_per_sqm"] - first_year["avg_price_per_sqm"])
/ first_year["avg_price_per_sqm"]
) * 100
volume_change = (
(
(last_year["deal_count"] - first_year["deal_count"])
/ first_year["deal_count"]
)
* 100
if first_year["deal_count"] > 0
else 0
)
trend_analysis = { trend_analysis = {
"price_trend_percentage": round(price_change, 1), "price_trend_percentage": round(price_change, 1),
"volume_trend_percentage": round(volume_change, 1), "volume_trend_percentage": round(volume_change, 1),
"first_year_avg_price_per_sqm": first_year['avg_price_per_sqm'], "first_year_avg_price_per_sqm": first_year["avg_price_per_sqm"],
"last_year_avg_price_per_sqm": last_year['avg_price_per_sqm'] "last_year_avg_price_per_sqm": last_year["avg_price_per_sqm"],
} }
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 summarized analysis (NO raw deals to save tokens) # Return summarized analysis (NO raw deals to save tokens)
return json.dumps({ return json.dumps(
"analysis_parameters": { {
"address": address, "analysis_parameters": {
"years_analyzed": years_back, "address": address,
"radius_meters": radius_meters, "years_analyzed": years_back,
"deals_analyzed": len(deals), "radius_meters": radius_meters,
"deal_type": deal_type, "deals_analyzed": len(deals),
"deal_type_description": deal_type_desc "deal_type": deal_type,
"deal_type_description": deal_type_desc,
},
"market_summary": {
"total_deals": len(deals),
"years_with_data": len(yearly_trends),
"unique_property_types": len(property_type_analysis),
"unique_neighborhoods": len(neighborhood_analysis),
},
"yearly_trends": yearly_trends,
"top_property_types": property_type_analysis,
"top_neighborhoods": neighborhood_analysis,
"trend_analysis": trend_analysis,
"key_insights": {
"most_active_year": max(
yearly_trends.keys(), key=lambda y: yearly_trends[y]["deal_count"]
)
if yearly_trends
else None,
"highest_avg_price_year": max(
yearly_trends.keys(), key=lambda y: yearly_trends[y]["avg_price_per_sqm"]
)
if yearly_trends
else None,
"deal_source_summary": f"Building: {len([d for d in deals if getattr(d, 'deal_source', None) == 'same_building'])}, Street: {len([d for d in deals if getattr(d, 'deal_source', None) == 'street'])}, Neighborhood: {len([d for d in deals if getattr(d, 'deal_source', None) == 'neighborhood'])}",
},
}, },
"market_summary": { ensure_ascii=False,
"total_deals": len(deals), indent=2,
"years_with_data": len(yearly_trends), )
"unique_property_types": len(property_type_analysis),
"unique_neighborhoods": len(neighborhood_analysis)
},
"yearly_trends": yearly_trends,
"top_property_types": property_type_analysis,
"top_neighborhoods": neighborhood_analysis,
"trend_analysis": trend_analysis,
"key_insights": {
"most_active_year": max(yearly_trends.keys(), key=lambda y: yearly_trends[y]['deal_count']) if yearly_trends else None,
"highest_avg_price_year": max(yearly_trends.keys(), key=lambda y: yearly_trends[y]['avg_price_per_sqm']) if yearly_trends else None,
"deal_source_summary": f"Building: {len([d for d in deals if getattr(d, 'deal_source', None) == 'same_building'])}, Street: {len([d for d in deals if getattr(d, 'deal_source', None) == 'street'])}, Neighborhood: {len([d for d in deals if getattr(d, 'deal_source', None) == 'neighborhood'])}"
}
}, ensure_ascii=False, indent=2)
except Exception as e: except Exception as e:
logger.error(f"Error in analyze_market_trends: {e}") logger.error(f"Error in analyze_market_trends: {e}")
return f"Error analyzing market trends: {str(e)}" return f"Error analyzing market trends: {str(e)}"
@mcp.tool() @mcp.tool()
def compare_addresses(addresses: List[str]) -> str: def compare_addresses(addresses: List[str]) -> str:
"""Compare real estate markets between multiple addresses. """Compare real estate markets between multiple addresses.
@@ -485,10 +593,22 @@ def compare_addresses(addresses: List[str]) -> str:
if deals: if deals:
prices = [deal.deal_amount for deal in deals if deal.deal_amount] prices = [deal.deal_amount for deal in deals if deal.deal_amount]
areas = [deal.asset_area for deal in deals if deal.asset_area] areas = [deal.asset_area for deal in deals if deal.asset_area]
price_per_sqm_values = [deal.price_per_sqm for deal in deals if deal.price_per_sqm] price_per_sqm_values = [
building_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "same_building"] deal.price_per_sqm for deal in deals if deal.price_per_sqm
street_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "street"] ]
neighborhood_deals = [deal for deal in deals if getattr(deal, "deal_source", None) == "neighborhood"] building_deals = [
deal
for deal in deals
if getattr(deal, "deal_source", None) == "same_building"
]
street_deals = [
deal for deal in deals if getattr(deal, "deal_source", None) == "street"
]
neighborhood_deals = [
deal
for deal in deals
if getattr(deal, "deal_source", None) == "neighborhood"
]
comparison = { comparison = {
"address": address, "address": address,
@@ -496,23 +616,39 @@ def compare_addresses(addresses: List[str]) -> str:
"same_building_deals": len(building_deals), "same_building_deals": len(building_deals),
"street_deals": len(street_deals), "street_deals": len(street_deals),
"neighborhood_deals": len(neighborhood_deals), "neighborhood_deals": len(neighborhood_deals),
"same_building_percentage": round((len(building_deals) / len(deals)) * 100, 1) if deals else 0, "same_building_percentage": round(
"street_emphasis_percentage": round((len(street_deals) / len(deals)) * 100, 1) if deals else 0, (len(building_deals) / len(deals)) * 100, 1
)
if deals
else 0,
"street_emphasis_percentage": round(
(len(street_deals) / len(deals)) * 100, 1
)
if deals
else 0,
"price_stats": { "price_stats": {
"average_price": round(sum(prices) / len(prices), 0) if prices else 0, "average_price": round(sum(prices) / len(prices), 0) if prices else 0,
"min_price": min(prices) if prices else 0, "min_price": min(prices) if prices else 0,
"max_price": max(prices) if prices else 0 "max_price": max(prices) if prices else 0,
}, },
"area_stats": { "area_stats": {
"average_area": round(sum(areas) / len(areas), 1) if areas else 0, "average_area": round(sum(areas) / len(areas), 1) if areas else 0,
"min_area": min(areas) if areas else 0, "min_area": min(areas) if areas else 0,
"max_area": max(areas) if areas else 0 "max_area": max(areas) if areas else 0,
}, },
"price_per_sqm_stats": { "price_per_sqm_stats": {
"average_price_per_sqm": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 0) if price_per_sqm_values else 0, "average_price_per_sqm": round(
"min_price_per_sqm": round(min(price_per_sqm_values), 0) if price_per_sqm_values else 0, sum(price_per_sqm_values) / len(price_per_sqm_values), 0
"max_price_per_sqm": round(max(price_per_sqm_values), 0) if price_per_sqm_values else 0 )
} if price_per_sqm_values
else 0,
"min_price_per_sqm": round(min(price_per_sqm_values), 0)
if price_per_sqm_values
else 0,
"max_price_per_sqm": round(max(price_per_sqm_values), 0)
if price_per_sqm_values
else 0,
},
} }
else: else:
comparison = { comparison = {
@@ -525,25 +661,24 @@ def compare_addresses(addresses: List[str]) -> str:
"street_emphasis_percentage": 0, "street_emphasis_percentage": 0,
"price_stats": {}, "price_stats": {},
"area_stats": {}, "area_stats": {},
"price_per_sqm_stats": {} "price_per_sqm_stats": {},
} }
comparisons.append(comparison) comparisons.append(comparison)
except Exception as e: except Exception as e:
logger.error(f"Error comparing {address}: {e}") logger.error(f"Error comparing {address}: {e}")
comparisons.append({ comparisons.append({"address": address, "error": str(e)})
"address": address,
"error": str(e)
})
# Rank addresses by average price per sqm # Rank addresses by average price per sqm
valid_comparisons = [] valid_comparisons = []
for comparison in comparisons: for comparison in comparisons:
if (isinstance(comparison, dict) and if (
"price_per_sqm_stats" in comparison and isinstance(comparison, dict)
isinstance(comparison["price_per_sqm_stats"], dict) and and "price_per_sqm_stats" in comparison
comparison["price_per_sqm_stats"].get("average_price_per_sqm", 0) > 0): and isinstance(comparison["price_per_sqm_stats"], dict)
and comparison["price_per_sqm_stats"].get("average_price_per_sqm", 0) > 0
):
valid_comparisons.append(comparison) valid_comparisons.append(comparison)
# Sort by price per sqm # Sort by price per sqm
@@ -555,16 +690,21 @@ def compare_addresses(addresses: List[str]) -> str:
valid_comparisons.sort(key=get_price_per_sqm, reverse=True) valid_comparisons.sort(key=get_price_per_sqm, reverse=True)
return json.dumps({ return json.dumps(
"addresses_compared": len(addresses), {
"ranking_by_average_price_per_sqm": valid_comparisons, "addresses_compared": len(addresses),
"all_results": comparisons "ranking_by_average_price_per_sqm": valid_comparisons,
}, ensure_ascii=False, indent=2) "all_results": comparisons,
},
ensure_ascii=False,
indent=2,
)
except Exception as e: except Exception as e:
logger.error(f"Error in compare_addresses: {e}") logger.error(f"Error in compare_addresses: {e}")
return f"Error comparing addresses: {str(e)}" return f"Error comparing addresses: {str(e)}"
@mcp.tool() @mcp.tool()
def get_valuation_comparables( def get_valuation_comparables(
address: str, address: str,
@@ -579,7 +719,7 @@ def get_valuation_comparables(
min_floor: Optional[int] = None, min_floor: Optional[int] = None,
max_floor: Optional[int] = None, max_floor: Optional[int] = None,
radius_meters: int = 100, radius_meters: int = 100,
max_comparables: int = 50 max_comparables: int = 50,
) -> str: ) -> str:
"""Get comparable properties for valuation analysis. """Get comparable properties for valuation analysis.
@@ -609,19 +749,20 @@ def get_valuation_comparables(
try: try:
# 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, address, years_back, radius=radius_meters, max_deals=max_comparables
years_back,
radius=radius_meters,
max_deals=max_comparables
) )
if not deals: if not deals:
return json.dumps({ return json.dumps(
"address": address, {
"years_back": years_back, "address": address,
"comparables": [], "years_back": years_back,
"message": "No deals found for this address" "comparables": [],
}, ensure_ascii=False, indent=2) "message": "No deals found for this address",
},
ensure_ascii=False,
indent=2,
)
# Apply filters # Apply filters
filtered_deals = client.filter_deals_by_criteria( filtered_deals = client.filter_deals_by_criteria(
@@ -634,38 +775,43 @@ def get_valuation_comparables(
min_area=min_area, min_area=min_area,
max_area=max_area, max_area=max_area,
min_floor=min_floor, min_floor=min_floor,
max_floor=max_floor max_floor=max_floor,
) )
# Calculate statistics on filtered comparables # Calculate statistics on filtered comparables
stats = client.calculate_deal_statistics(filtered_deals) stats = client.calculate_deal_statistics(filtered_deals)
return json.dumps({ return json.dumps(
"address": address, {
"years_back": years_back, "address": address,
"filters_applied": { "years_back": years_back,
"property_type": property_type, "filters_applied": {
"rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None, "property_type": property_type,
"price": f"{min_price}-{max_price}" if min_price or max_price else None, "rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None,
"area": f"{min_area}-{max_area}" if min_area or max_area else None, "price": f"{min_price}-{max_price}" if min_price or max_price else None,
"floor": f"{min_floor}-{max_floor}" if min_floor or max_floor 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,
},
"total_comparables": len(filtered_deals),
"statistics": stats.model_dump(exclude_none=True), # Serialize DealStatistics model
"comparables": strip_bloat_fields(filtered_deals),
}, },
"total_comparables": len(filtered_deals), ensure_ascii=False,
"statistics": stats.model_dump(exclude_none=True), # Serialize DealStatistics model indent=2,
"comparables": strip_bloat_fields(filtered_deals) )
}, ensure_ascii=False, indent=2)
except Exception as e: except Exception as e:
logger.error(f"Error in get_valuation_comparables: {e}") logger.error(f"Error in get_valuation_comparables: {e}")
return f"Error getting valuation comparables: {str(e)}" return f"Error getting valuation comparables: {str(e)}"
@mcp.tool() @mcp.tool()
def get_deal_statistics( def get_deal_statistics(
address: str, address: str,
years_back: int = 2, years_back: int = 2,
property_type: Optional[str] = None, property_type: Optional[str] = None,
min_rooms: Optional[float] = None, min_rooms: Optional[float] = None,
max_rooms: Optional[float] = None max_rooms: Optional[float] = None,
) -> str: ) -> str:
"""Calculate statistical aggregations on deal data for an address. """Calculate statistical aggregations on deal data for an address.
@@ -687,36 +833,38 @@ def get_deal_statistics(
deals = client.find_recent_deals_for_address(address, years_back) deals = client.find_recent_deals_for_address(address, years_back)
if not deals: if not deals:
return json.dumps({ return json.dumps(
"address": address, {
"years_back": years_back, "address": address,
"statistics": { "years_back": years_back,
"count": 0, "statistics": {"count": 0, "message": "No deals found for this address"},
"message": "No deals found for this address" },
} ensure_ascii=False,
}, ensure_ascii=False, indent=2) indent=2,
)
# Apply filters if provided # Apply filters if provided
if property_type or min_rooms or max_rooms: if property_type or min_rooms or max_rooms:
deals = client.filter_deals_by_criteria( deals = client.filter_deals_by_criteria(
deals, deals, property_type=property_type, min_rooms=min_rooms, max_rooms=max_rooms
property_type=property_type,
min_rooms=min_rooms,
max_rooms=max_rooms
) )
# Calculate statistics # Calculate statistics
stats = client.calculate_deal_statistics(deals) stats = client.calculate_deal_statistics(deals)
return json.dumps({ return json.dumps(
"address": address, {
"years_back": years_back, "address": address,
"filters_applied": { "years_back": years_back,
"property_type": property_type, "filters_applied": {
"rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None, "property_type": property_type,
"rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None,
},
"statistics": stats.model_dump(exclude_none=True), # Serialize DealStatistics model
}, },
"statistics": stats.model_dump(exclude_none=True) # Serialize DealStatistics model ensure_ascii=False,
}, ensure_ascii=False, indent=2) indent=2,
)
except Exception as e: except Exception as e:
logger.error(f"Error in get_deal_statistics: {e}") logger.error(f"Error in get_deal_statistics: {e}")
@@ -741,7 +889,7 @@ def _safe_calculate_metric(metric_func, deals):
try: try:
result = metric_func(deals) result = metric_func(deals)
# Serialize Pydantic model to dict # Serialize Pydantic model to dict
if hasattr(result, 'model_dump'): if hasattr(result, "model_dump"):
return result.model_dump(exclude_none=True) return result.model_dump(exclude_none=True)
return result return result
except ValueError as e: except ValueError as e:
@@ -749,11 +897,7 @@ def _safe_calculate_metric(metric_func, deals):
@mcp.tool() @mcp.tool()
def get_market_activity_metrics( def get_market_activity_metrics(address: str, years_back: int = 2, radius_meters: int = 100) -> str:
address: str,
years_back: int = 2,
radius_meters: int = 100
) -> str:
"""Get comprehensive market activity and investment potential analysis. """Get comprehensive market activity and investment potential analysis.
This tool provides detailed market liquidity, activity scores, and investment This tool provides detailed market liquidity, activity scores, and investment
@@ -777,12 +921,16 @@ def get_market_activity_metrics(
deals = client.find_recent_deals_for_address(address, years_back, radius_meters) deals = client.find_recent_deals_for_address(address, years_back, radius_meters)
if not deals: if not deals:
return json.dumps({ return json.dumps(
"address": address, {
"error": "No deals found for analysis", "address": address,
"years_back": years_back, "error": "No deals found for analysis",
"radius_meters": radius_meters "years_back": years_back,
}, ensure_ascii=False, indent=2) "radius_meters": radius_meters,
},
ensure_ascii=False,
indent=2,
)
# Calculate market metrics using helper to reduce duplication # Calculate market metrics using helper to reduce duplication
activity_metrics = _safe_calculate_metric(client.calculate_market_activity_score, deals) activity_metrics = _safe_calculate_metric(client.calculate_market_activity_score, deals)
@@ -790,29 +938,34 @@ def get_market_activity_metrics(
investment_metrics = _safe_calculate_metric(client.analyze_investment_potential, deals) investment_metrics = _safe_calculate_metric(client.analyze_investment_potential, deals)
# Combine all metrics # Combine all metrics
return json.dumps({ return json.dumps(
"address": address, {
"years_back": years_back, "address": address,
"radius_meters": radius_meters, "years_back": years_back,
"total_deals_analyzed": len(deals), "radius_meters": radius_meters,
"market_activity": activity_metrics, "total_deals_analyzed": len(deals),
"market_liquidity": liquidity_metrics, "market_activity": activity_metrics,
"investment_potential": investment_metrics, "market_liquidity": liquidity_metrics,
"summary": { "investment_potential": investment_metrics,
"activity_score": activity_metrics.get("activity_score"), "summary": {
"activity_trend": activity_metrics.get("trend"), "activity_score": activity_metrics.get("activity_score"),
"liquidity_score": liquidity_metrics.get("liquidity_score"), "activity_trend": activity_metrics.get("trend"),
"market_activity_level": liquidity_metrics.get("market_activity_level"), "liquidity_score": liquidity_metrics.get("liquidity_score"),
"investment_score": investment_metrics.get("investment_score"), "market_activity_level": liquidity_metrics.get("market_activity_level"),
"price_trend": investment_metrics.get("price_trend"), "investment_score": investment_metrics.get("investment_score"),
"market_stability": investment_metrics.get("market_stability") "price_trend": investment_metrics.get("price_trend"),
} "market_stability": investment_metrics.get("market_stability"),
}, ensure_ascii=False, indent=2) },
},
ensure_ascii=False,
indent=2,
)
except Exception as e: except Exception as e:
logger.error(f"Error in get_market_activity_metrics: {e}") logger.error(f"Error in get_market_activity_metrics: {e}")
return f"Error analyzing market activity: {str(e)}" return f"Error analyzing market activity: {str(e)}"
# Run the server # Run the server
if __name__ == "__main__": if __name__ == "__main__":
mcp.run() mcp.run()
+20 -21
View File
@@ -15,47 +15,46 @@ Public API:
""" """
# Pydantic models # Pydantic models
from .models import ( # Main API client
CoordinatePoint, from .client import GovmapClient
Address,
AutocompleteResult,
AutocompleteResponse,
Deal,
DealStatistics,
MarketActivityScore,
InvestmentAnalysis,
LiquidityMetrics,
DealFilters,
)
# Filter functions # Filter functions
from .filters import filter_deals_by_criteria from .filters import filter_deals_by_criteria
# Statistics functions
from .statistics import calculate_deal_statistics, calculate_std_dev
# Market analysis functions # Market analysis functions
from .market_analysis import ( from .market_analysis import (
calculate_market_activity_score,
analyze_investment_potential, analyze_investment_potential,
calculate_market_activity_score,
get_market_liquidity, get_market_liquidity,
parse_deal_dates, parse_deal_dates,
) )
from .models import (
Address,
AutocompleteResponse,
AutocompleteResult,
CoordinatePoint,
Deal,
DealFilters,
DealStatistics,
InvestmentAnalysis,
LiquidityMetrics,
MarketActivityScore,
)
# Statistics functions
from .statistics import calculate_deal_statistics, calculate_std_dev
# Utility functions # Utility functions
from .utils import calculate_distance, is_same_building, extract_floor_number from .utils import calculate_distance, extract_floor_number, is_same_building
# Validation functions # Validation functions
from .validators import ( from .validators import (
validate_address, validate_address,
validate_coordinates, validate_coordinates,
validate_positive_int,
validate_deal_type, validate_deal_type,
validate_positive_int,
) )
# Main API client
from .client import GovmapClient
__all__ = [ __all__ = [
# Main client class # Main client class
"GovmapClient", "GovmapClient",
+38 -70
View File
@@ -6,34 +6,30 @@ Israeli government's Govmap API to retrieve property deals, market trends,
and real estate information. and real estate information.
""" """
from datetime import datetime, timedelta
import logging import logging
import time import time
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime, timedelta
import requests import requests
from nadlan_mcp.config import GovmapConfig, get_config from nadlan_mcp.config import GovmapConfig, get_config
# Import functions from modular package
from . import filters, market_analysis, statistics, utils, validators
# Import models # Import models
from .models import ( from .models import (
Deal,
AutocompleteResponse, AutocompleteResponse,
AutocompleteResult, AutocompleteResult,
CoordinatePoint, CoordinatePoint,
Deal,
DealStatistics, DealStatistics,
MarketActivityScore,
InvestmentAnalysis, InvestmentAnalysis,
LiquidityMetrics, LiquidityMetrics,
MarketActivityScore,
) )
# Import functions from modular package
from . import validators
from . import utils
from . import filters
from . import statistics
from . import market_analysis
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -86,9 +82,7 @@ class GovmapClient:
"""Validate coordinate input.""" """Validate coordinate input."""
return validators.validate_coordinates(point) return validators.validate_coordinates(point)
def _validate_positive_int( def _validate_positive_int(self, value: int, name: str, max_value: Optional[int] = None) -> int:
self, value: int, name: str, max_value: Optional[int] = None
) -> int:
"""Validate positive integer input.""" """Validate positive integer input."""
return validators.validate_positive_int(value, name, max_value) return validators.validate_positive_int(value, name, max_value)
@@ -160,24 +154,26 @@ class GovmapClient:
coords = coords_str.split() coords = coords_str.split()
if len(coords) == 2: if len(coords) == 2:
coordinates = CoordinatePoint( coordinates = CoordinatePoint(
longitude=float(coords[0]), longitude=float(coords[0]), latitude=float(coords[1])
latitude=float(coords[1])
) )
except (ValueError, IndexError) as e: except (ValueError, IndexError) as e:
logger.warning(f"Failed to parse coordinates from shape: {shape_str}, error: {e}") logger.warning(
f"Failed to parse coordinates from shape: {shape_str}, error: {e}"
)
results.append(AutocompleteResult( results.append(
text=result.get("text", ""), AutocompleteResult(
id=result.get("id", ""), text=result.get("text", ""),
type=result.get("type", ""), id=result.get("id", ""),
score=result.get("score", 0), type=result.get("type", ""),
coordinates=coordinates, score=result.get("score", 0),
shape=shape_str if shape_str else None, coordinates=coordinates,
)) shape=shape_str if shape_str else None,
)
)
return AutocompleteResponse( return AutocompleteResponse(
resultsCount=data.get("resultsCount", len(results)), resultsCount=data.get("resultsCount", len(results)), results=results
results=results
) )
except (requests.RequestException, requests.Timeout) as e: except (requests.RequestException, requests.Timeout) as e:
@@ -196,9 +192,7 @@ class GovmapClient:
) )
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError( raise RuntimeError("Unexpected error: retry loop exited without return or raise")
"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]:
""" """
@@ -250,9 +244,7 @@ class GovmapClient:
) )
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError( raise RuntimeError("Unexpected error: retry loop exited without return or raise")
"Unexpected error: retry loop exited without return or raise"
)
def get_deals_by_radius( def get_deals_by_radius(
self, point: Tuple[float, float], radius: int = 50 self, point: Tuple[float, float], radius: int = 50
@@ -295,9 +287,7 @@ class GovmapClient:
data = response.json() data = response.json()
if not isinstance(data, list): if not isinstance(data, list):
raise ValueError( raise ValueError(f"Expected list response, got {type(data).__name__}")
f"Expected list response, got {type(data).__name__}"
)
# NOTE: This endpoint returns polygon metadata, not actual deals! # NOTE: This endpoint returns polygon metadata, not actual deals!
# The response contains: dealscount, polygon_id, settlementNameHeb, streetNameHeb, houseNum, objectid # The response contains: dealscount, polygon_id, settlementNameHeb, streetNameHeb, houseNum, objectid
@@ -326,9 +316,7 @@ class GovmapClient:
) )
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError( raise RuntimeError("Unexpected error: retry loop exited without return or raise")
"Unexpected error: retry loop exited without return or raise"
)
def get_street_deals( def get_street_deals(
self, self,
@@ -396,9 +384,7 @@ class GovmapClient:
elif isinstance(data, list): elif isinstance(data, list):
deal_dicts = data deal_dicts = data
else: else:
raise ValueError( raise ValueError(f"Unexpected response format: {type(data).__name__}")
f"Unexpected response format: {type(data).__name__}"
)
# Parse each deal dict into Deal model # Parse each deal dict into Deal model
deals = [] deals = []
@@ -428,9 +414,7 @@ class GovmapClient:
) )
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError( raise RuntimeError("Unexpected error: retry loop exited without return or raise")
"Unexpected error: retry loop exited without return or raise"
)
def get_neighborhood_deals( def get_neighborhood_deals(
self, self,
@@ -498,9 +482,7 @@ class GovmapClient:
elif isinstance(data, list): elif isinstance(data, list):
deal_dicts = data deal_dicts = data
else: else:
raise ValueError( raise ValueError(f"Unexpected response format: {type(data).__name__}")
f"Unexpected response format: {type(data).__name__}"
)
# Parse each deal dict into Deal model # Parse each deal dict into Deal model
deals = [] deals = []
@@ -530,9 +512,7 @@ class GovmapClient:
) )
raise raise
# This line should never be reached but satisfies type checker # This line should never be reached but satisfies type checker
raise RuntimeError( raise RuntimeError("Unexpected error: retry loop exited without return or raise")
"Unexpected error: retry loop exited without return or raise"
)
def find_recent_deals_for_address( def find_recent_deals_for_address(
self, self,
@@ -573,9 +553,7 @@ class GovmapClient:
try: try:
# Step 1: Get coordinates for the address # Step 1: Get coordinates for the address
logger.info( logger.info(f"Starting search for address: {address}, dealType: {deal_type}")
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.results: if not autocomplete_result.results:
@@ -598,7 +576,7 @@ class GovmapClient:
polygon_ids = set() polygon_ids = set()
for metadata in nearby_polygons: for metadata in nearby_polygons:
# Extract polygon_id from dict (these are polygon metadata, not deals) # Extract polygon_id from dict (these are polygon metadata, not deals)
polygon_id = metadata.get('polygon_id') polygon_id = metadata.get("polygon_id")
if polygon_id: if polygon_id:
polygon_ids.add(str(polygon_id)) polygon_ids.add(str(polygon_id))
@@ -667,9 +645,7 @@ class GovmapClient:
street = deal.street_name or "" street = deal.street_name or ""
house_num = str(deal.house_number or "") house_num = str(deal.house_number or "")
deal_address = f"{street} {house_num}".lower().strip() deal_address = f"{street} {house_num}".lower().strip()
if self._is_same_building( if self._is_same_building(search_address_normalized, deal_address):
search_address_normalized, deal_address
):
deal.deal_source = "same_building" deal.deal_source = "same_building"
deal.priority = 0 # Highest priority deal.priority = 0 # Highest priority
building_deals.append(deal) building_deals.append(deal)
@@ -698,11 +674,9 @@ 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.deal_date or "1900-01-01", reverse=True) # Newest first
all_deals.sort( all_deals.sort(
key=lambda x: x.deal_date or "1900-01-01", reverse=True key=lambda x: getattr(x, "priority", 3)
) # Newest first
all_deals.sort(
key=lambda x: getattr(x, 'priority', 3)
) # Priority first (0=building, 1=street, 2=neighborhood) ) # Priority first (0=building, 1=street, 2=neighborhood)
# Limit to max_deals # Limit to max_deals
@@ -799,9 +773,7 @@ class GovmapClient:
return statistics.calculate_std_dev(values) return statistics.calculate_std_dev(values)
# Market analysis methods (delegate to market_analysis module) # Market analysis methods (delegate to market_analysis module)
def _parse_deal_dates( def _parse_deal_dates(self, deals: List[Deal], time_period_months: Optional[int] = None):
self, deals: List[Deal], time_period_months: Optional[int] = None
):
""" """
Parse and filter deal dates from a list of deals. Parse and filter deal dates from a list of deals.
@@ -831,13 +803,9 @@ class GovmapClient:
Returns: Returns:
MarketActivityScore model with activity metrics MarketActivityScore model with activity metrics
""" """
return market_analysis.calculate_market_activity_score( return market_analysis.calculate_market_activity_score(deals, time_period_months)
deals, time_period_months
)
def analyze_investment_potential( def analyze_investment_potential(self, deals: List[Deal]) -> InvestmentAnalysis:
self, deals: List[Deal]
) -> InvestmentAnalysis:
""" """
Analyze investment potential based on price trends and market stability. Analyze investment potential based on price trends and market stability.
+6 -3
View File
@@ -94,9 +94,12 @@ def filter_deals_by_criteria(
# Handle Hebrew feminine ending variations (ה ↔ ת) # Handle Hebrew feminine ending variations (ה ↔ ת)
# If the filter term ends with ה, also check for the ת variant # If the filter term ends with ה, also check for the ת variant
# This allows "דירה" to match "דירת גג", "דירה בבניין", etc. # This allows "דירה" to match "דירת גג", "דירה בבניין", etc.
if property_type_normalized.endswith('ה'): if property_type_normalized.endswith("ה"):
property_type_variant = property_type_normalized[:-1] + 'ת' property_type_variant = property_type_normalized[:-1] + "ת"
if property_type_variant not in deal_type_normalized and property_type_normalized not in deal_type_normalized: if (
property_type_variant not in deal_type_normalized
and property_type_normalized not in deal_type_normalized
):
# No match found for either variant # No match found for either variant
continue continue
else: else:
+83 -16
View File
@@ -5,12 +5,12 @@ This module provides functions for analyzing market trends, activity, and invest
Focused on providing data metrics; the LLM interprets them for investment advice. Focused on providing data metrics; the LLM interprets them for investment advice.
""" """
import logging
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import logging
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from .models import Deal, MarketActivityScore, InvestmentAnalysis, LiquidityMetrics from .models import Deal, InvestmentAnalysis, LiquidityMetrics, MarketActivityScore
from .statistics import calculate_std_dev from .statistics import calculate_std_dev
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,7 +73,11 @@ def parse_deal_dates(
try: try:
# Convert date to string for comparison and parsing # Convert date to string for comparison and parsing
date_str = deal.deal_date.isoformat() if isinstance(deal.deal_date, date) else str(deal.deal_date) date_str = (
deal.deal_date.isoformat()
if isinstance(deal.deal_date, date)
else str(deal.deal_date)
)
# Filter by time period if specified # Filter by time period if specified
if cutoff_date is not None and date_str < cutoff_date_str: if cutoff_date is not None and date_str < cutoff_date_str:
@@ -141,11 +145,27 @@ def calculate_market_activity_score(
if deals_per_month >= ACTIVITY_VERY_HIGH_THRESHOLD: if deals_per_month >= ACTIVITY_VERY_HIGH_THRESHOLD:
activity_score = 100 activity_score = 100
elif deals_per_month >= ACTIVITY_HIGH_THRESHOLD: elif deals_per_month >= ACTIVITY_HIGH_THRESHOLD:
activity_score = 75 + ((deals_per_month - ACTIVITY_HIGH_THRESHOLD) / ACTIVITY_HIGH_THRESHOLD) * 25 activity_score = (
75 + ((deals_per_month - ACTIVITY_HIGH_THRESHOLD) / ACTIVITY_HIGH_THRESHOLD) * 25
)
elif deals_per_month >= ACTIVITY_MODERATE_THRESHOLD: elif deals_per_month >= ACTIVITY_MODERATE_THRESHOLD:
activity_score = 50 + ((deals_per_month - ACTIVITY_MODERATE_THRESHOLD) / (ACTIVITY_HIGH_THRESHOLD - ACTIVITY_MODERATE_THRESHOLD)) * 25 activity_score = (
50
+ (
(deals_per_month - ACTIVITY_MODERATE_THRESHOLD)
/ (ACTIVITY_HIGH_THRESHOLD - ACTIVITY_MODERATE_THRESHOLD)
)
* 25
)
elif deals_per_month >= ACTIVITY_LOW_THRESHOLD: elif deals_per_month >= ACTIVITY_LOW_THRESHOLD:
activity_score = 25 + ((deals_per_month - ACTIVITY_LOW_THRESHOLD) / (ACTIVITY_MODERATE_THRESHOLD - ACTIVITY_LOW_THRESHOLD)) * 25 activity_score = (
25
+ (
(deals_per_month - ACTIVITY_LOW_THRESHOLD)
/ (ACTIVITY_MODERATE_THRESHOLD - ACTIVITY_LOW_THRESHOLD)
)
* 25
)
else: else:
activity_score = deals_per_month * 25 activity_score = deals_per_month * 25
@@ -158,7 +178,9 @@ def calculate_market_activity_score(
len(sorted_months) - mid_point len(sorted_months) - mid_point
) )
change_ratio = (second_half_avg - first_half_avg) / first_half_avg if first_half_avg > 0 else 0 change_ratio = (
(second_half_avg - first_half_avg) / first_half_avg if first_half_avg > 0 else 0
)
if change_ratio > 0.15: if change_ratio > 0.15:
trend = "increasing" trend = "increasing"
@@ -215,7 +237,11 @@ def analyze_investment_potential(deals: List[Deal]) -> InvestmentAnalysis:
if price_per_sqm and price_per_sqm > 0 and deal.deal_date: if price_per_sqm and price_per_sqm > 0 and deal.deal_date:
try: try:
# Convert date to string for parsing # Convert date to string for parsing
date_str = deal.deal_date.isoformat() if isinstance(deal.deal_date, date) else str(deal.deal_date) date_str = (
deal.deal_date.isoformat()
if isinstance(deal.deal_date, date)
else str(deal.deal_date)
)
# Parse date for sorting # Parse date for sorting
year = int(date_str[:4]) year = int(date_str[:4])
@@ -279,13 +305,34 @@ def analyze_investment_potential(deals: List[Deal]) -> InvestmentAnalysis:
volatility_score = 100 volatility_score = 100
market_stability = "very_volatile" market_stability = "very_volatile"
elif coefficient_of_variation > VOLATILITY_VOLATILE_THRESHOLD: elif coefficient_of_variation > VOLATILITY_VOLATILE_THRESHOLD:
volatility_score = 75 + ((coefficient_of_variation - VOLATILITY_VOLATILE_THRESHOLD) / (VOLATILITY_VERY_VOLATILE_THRESHOLD - VOLATILITY_VOLATILE_THRESHOLD)) * 25 volatility_score = (
75
+ (
(coefficient_of_variation - VOLATILITY_VOLATILE_THRESHOLD)
/ (VOLATILITY_VERY_VOLATILE_THRESHOLD - VOLATILITY_VOLATILE_THRESHOLD)
)
* 25
)
market_stability = "volatile" market_stability = "volatile"
elif coefficient_of_variation > VOLATILITY_MODERATE_THRESHOLD: elif coefficient_of_variation > VOLATILITY_MODERATE_THRESHOLD:
volatility_score = 50 + ((coefficient_of_variation - VOLATILITY_MODERATE_THRESHOLD) / (VOLATILITY_VOLATILE_THRESHOLD - VOLATILITY_MODERATE_THRESHOLD)) * 25 volatility_score = (
50
+ (
(coefficient_of_variation - VOLATILITY_MODERATE_THRESHOLD)
/ (VOLATILITY_VOLATILE_THRESHOLD - VOLATILITY_MODERATE_THRESHOLD)
)
* 25
)
market_stability = "moderate" market_stability = "moderate"
elif coefficient_of_variation > VOLATILITY_STABLE_THRESHOLD: elif coefficient_of_variation > VOLATILITY_STABLE_THRESHOLD:
volatility_score = 25 + ((coefficient_of_variation - VOLATILITY_STABLE_THRESHOLD) / (VOLATILITY_MODERATE_THRESHOLD - VOLATILITY_STABLE_THRESHOLD)) * 25 volatility_score = (
25
+ (
(coefficient_of_variation - VOLATILITY_STABLE_THRESHOLD)
/ (VOLATILITY_MODERATE_THRESHOLD - VOLATILITY_STABLE_THRESHOLD)
)
* 25
)
market_stability = "stable" market_stability = "stable"
else: else:
volatility_score = (coefficient_of_variation / VOLATILITY_STABLE_THRESHOLD) * 25 volatility_score = (coefficient_of_variation / VOLATILITY_STABLE_THRESHOLD) * 25
@@ -358,10 +405,8 @@ def get_market_liquidity(
# Calculate metrics # Calculate metrics
total_deals = len(deal_dates) total_deals = len(deal_dates)
unique_months = len(monthly_deals) unique_months = len(monthly_deals)
unique_quarters = len(quarterly_deals)
deals_per_month = total_deals / unique_months if unique_months > 0 else 0 deals_per_month = total_deals / unique_months if unique_months > 0 else 0
deals_per_quarter = total_deals / unique_quarters if unique_quarters > 0 else 0
# Calculate velocity score (similar to activity score but focused on turnover) # Calculate velocity score (similar to activity score but focused on turnover)
# Based on monthly deal velocity using defined thresholds # Based on monthly deal velocity using defined thresholds
@@ -369,13 +414,34 @@ def get_market_liquidity(
velocity_score = 100 velocity_score = 100
liquidity_rating = "very_high" liquidity_rating = "very_high"
elif deals_per_month >= LIQUIDITY_HIGH_THRESHOLD: elif deals_per_month >= LIQUIDITY_HIGH_THRESHOLD:
velocity_score = 75 + ((deals_per_month - LIQUIDITY_HIGH_THRESHOLD) / (LIQUIDITY_VERY_HIGH_THRESHOLD - LIQUIDITY_HIGH_THRESHOLD)) * 25 velocity_score = (
75
+ (
(deals_per_month - LIQUIDITY_HIGH_THRESHOLD)
/ (LIQUIDITY_VERY_HIGH_THRESHOLD - LIQUIDITY_HIGH_THRESHOLD)
)
* 25
)
liquidity_rating = "high" liquidity_rating = "high"
elif deals_per_month >= LIQUIDITY_MODERATE_THRESHOLD: elif deals_per_month >= LIQUIDITY_MODERATE_THRESHOLD:
velocity_score = 50 + ((deals_per_month - LIQUIDITY_MODERATE_THRESHOLD) / (LIQUIDITY_HIGH_THRESHOLD - LIQUIDITY_MODERATE_THRESHOLD)) * 25 velocity_score = (
50
+ (
(deals_per_month - LIQUIDITY_MODERATE_THRESHOLD)
/ (LIQUIDITY_HIGH_THRESHOLD - LIQUIDITY_MODERATE_THRESHOLD)
)
* 25
)
liquidity_rating = "moderate" liquidity_rating = "moderate"
elif deals_per_month >= LIQUIDITY_LOW_THRESHOLD: elif deals_per_month >= LIQUIDITY_LOW_THRESHOLD:
velocity_score = 25 + ((deals_per_month - LIQUIDITY_LOW_THRESHOLD) / (LIQUIDITY_MODERATE_THRESHOLD - LIQUIDITY_LOW_THRESHOLD)) * 25 velocity_score = (
25
+ (
(deals_per_month - LIQUIDITY_LOW_THRESHOLD)
/ (LIQUIDITY_MODERATE_THRESHOLD - LIQUIDITY_LOW_THRESHOLD)
)
* 25
)
liquidity_rating = "low" liquidity_rating = "low"
else: else:
velocity_score = deals_per_month * 50 velocity_score = deals_per_month * 50
@@ -405,4 +471,5 @@ def get_market_liquidity(
avg_deals_per_month=round(deals_per_month, 2), avg_deals_per_month=round(deals_per_month, 2),
deal_velocity=round(deals_per_month, 2), deal_velocity=round(deals_per_month, 2),
market_activity_level=liquidity_rating, market_activity_level=liquidity_rating,
trend_direction=trend_direction,
) )
+61 -39
View File
@@ -8,7 +8,8 @@ and type safety throughout the codebase.
from datetime import date, datetime from datetime import date, datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator, computed_field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
class CoordinatePoint(BaseModel): class CoordinatePoint(BaseModel):
@@ -19,6 +20,7 @@ class CoordinatePoint(BaseModel):
longitude: X coordinate in ITM projection (meters) longitude: X coordinate in ITM projection (meters)
latitude: Y coordinate in ITM projection (meters) latitude: Y coordinate in ITM projection (meters)
""" """
longitude: float = Field(..., description="X coordinate in ITM projection (meters)") longitude: float = Field(..., description="X coordinate in ITM projection (meters)")
latitude: float = Field(..., description="Y coordinate in ITM projection (meters)") latitude: float = Field(..., description="Y coordinate in ITM projection (meters)")
@@ -36,6 +38,7 @@ class Address(BaseModel):
score: Relevance score from autocomplete score: Relevance score from autocomplete
coordinates: ITM coordinate point coordinates: ITM coordinate point
""" """
text: str = Field(..., description="Full address text") text: str = Field(..., description="Full address text")
id: str = Field(..., description="Unique address identifier") id: str = Field(..., description="Unique address identifier")
type: str = Field(..., description="Address type") type: str = Field(..., description="Address type")
@@ -55,6 +58,7 @@ class AutocompleteResult(BaseModel):
coordinates: Optional coordinate point coordinates: Optional coordinate point
shape: Original WKT shape string from API shape: Original WKT shape string from API
""" """
text: str text: str
id: str id: str
type: str type: str
@@ -71,6 +75,7 @@ class AutocompleteResponse(BaseModel):
results_count: Number of results returned results_count: Number of results returned
results: List of autocomplete results results: List of autocomplete results
""" """
results_count: int = Field(alias="resultsCount") results_count: int = Field(alias="resultsCount")
results: List[AutocompleteResult] = Field(default_factory=list) results: List[AutocompleteResult] = Field(default_factory=list)
@@ -102,6 +107,7 @@ class Deal(BaseModel):
source_polygon_id: Source polygon ID source_polygon_id: Source polygon ID
sourceorder: Source ordering sourceorder: Source ordering
""" """
# Required fields # Required fields
objectid: int = Field(..., description="Unique deal identifier") objectid: int = Field(..., description="Unique deal identifier")
deal_amount: float = Field(..., alias="dealAmount", description="Transaction amount in NIS") deal_amount: float = Field(..., alias="dealAmount", description="Transaction amount in NIS")
@@ -109,30 +115,40 @@ class Deal(BaseModel):
# Common optional fields # Common optional fields
asset_area: Optional[float] = Field(None, alias="assetArea", description="Property area in sqm") asset_area: Optional[float] = Field(None, alias="assetArea", description="Property area in sqm")
settlement_name_heb: Optional[str] = Field(None, alias="settlementNameHeb", description="City name in Hebrew") settlement_name_heb: Optional[str] = Field(
property_type_description: Optional[str] = Field(None, alias="propertyTypeDescription", description="Property type") None, alias="settlementNameHeb", description="City name in Hebrew"
)
property_type_description: Optional[str] = Field(
None, alias="propertyTypeDescription", description="Property type"
)
neighborhood: Optional[str] = Field(None, description="Neighborhood name") neighborhood: Optional[str] = Field(None, description="Neighborhood name")
street_name: Optional[str] = Field(None, alias="streetName", description="Street name") street_name: Optional[str] = Field(None, alias="streetName", description="Street name")
house_number: Optional[str] = Field(None, alias="houseNumber", description="House number") house_number: Optional[str] = Field(None, alias="houseNumber", description="House number")
# Floor information # Floor information
floor: Optional[str] = Field(None, description="Floor description (may be Hebrew)") floor: Optional[str] = Field(None, description="Floor description (may be Hebrew)")
floor_number: Optional[int] = Field(None, alias="floorNumber", description="Numeric floor number") floor_number: Optional[int] = Field(
None, alias="floorNumber", description="Numeric floor number"
)
# Additional details # Additional details
rooms: Optional[float] = Field(None, description="Number of rooms") rooms: Optional[float] = Field(None, description="Number of rooms")
# Priority and metadata (added by our system, not from API) # Priority and metadata (added by our system, not from API)
priority: Optional[int] = Field(None, description="Priority for sorting (0=same building, 1=street, 2=neighborhood)") priority: Optional[int] = Field(
None, description="Priority for sorting (0=same building, 1=street, 2=neighborhood)"
)
# Geometry and internal fields (often not useful for analysis) # Geometry and internal fields (often not useful for analysis)
shape: Optional[str] = Field(None, description="WKT geometry") shape: Optional[str] = Field(None, description="WKT geometry")
source_polygon_id: Optional[str] = Field(None, alias="sourcePolygonId", description="Source polygon ID") source_polygon_id: Optional[str] = Field(
None, alias="sourcePolygonId", description="Source polygon ID"
)
sourceorder: Optional[int] = Field(None, description="Source ordering") sourceorder: Optional[int] = Field(None, description="Source ordering")
model_config = ConfigDict( model_config = ConfigDict(
populate_by_name=True, # Allow both alias and field name populate_by_name=True, # Allow both alias and field name
extra='allow' # Allow extra fields from API that we don't model extra="allow", # Allow extra fields from API that we don't model
) )
@computed_field @computed_field
@@ -148,7 +164,7 @@ class Deal(BaseModel):
return round(self.deal_amount / self.asset_area, 2) return round(self.deal_amount / self.asset_area, 2)
return None return None
@field_validator('deal_date', mode='before') @field_validator("deal_date", mode="before")
@classmethod @classmethod
def parse_deal_date(cls, v: Any) -> date: def parse_deal_date(cls, v: Any) -> date:
"""Parse deal date string into a date object.""" """Parse deal date string into a date object."""
@@ -158,8 +174,8 @@ class Deal(BaseModel):
return v.date() return v.date()
if isinstance(v, str): if isinstance(v, str):
# Handle ISO format with optional time and timezone # Handle ISO format with optional time and timezone
if 'T' in v: if "T" in v:
v = v.split('T')[0] v = v.split("T")[0]
try: try:
return date.fromisoformat(v) return date.fromisoformat(v)
except ValueError: except ValueError:
@@ -179,37 +195,32 @@ class DealStatistics(BaseModel):
property_type_distribution: Count by property type property_type_distribution: Count by property type
date_range: Earliest and latest deal dates date_range: Earliest and latest deal dates
""" """
total_deals: int = Field(..., description="Total number of deals analyzed") total_deals: int = Field(..., description="Total number of deals analyzed")
# Price statistics # Price statistics
price_statistics: Dict[str, float] = Field( price_statistics: Dict[str, float] = Field(
default_factory=dict, default_factory=dict,
description="Price stats (mean, median, std_dev, min, max, percentiles)" description="Price stats (mean, median, std_dev, min, max, percentiles)",
) )
# Area statistics # Area statistics
area_statistics: Dict[str, float] = Field( area_statistics: Dict[str, float] = Field(
default_factory=dict, default_factory=dict, description="Area stats (mean, median, std_dev, min, max)"
description="Area stats (mean, median, std_dev, min, max)"
) )
# Price per sqm statistics # Price per sqm statistics
price_per_sqm_statistics: Dict[str, float] = Field( price_per_sqm_statistics: Dict[str, float] = Field(
default_factory=dict, default_factory=dict, description="Price/sqm stats (mean, median, std_dev, min, max)"
description="Price/sqm stats (mean, median, std_dev, min, max)"
) )
# Distribution by property type # Distribution by property type
property_type_distribution: Dict[str, int] = Field( property_type_distribution: Dict[str, int] = Field(
default_factory=dict, default_factory=dict, description="Count of deals by property type"
description="Count of deals by property type"
) )
# Date range # Date range
date_range: Optional[Dict[str, str]] = Field( date_range: Optional[Dict[str, str]] = Field(None, description="Earliest and latest deal dates")
None,
description="Earliest and latest deal dates"
)
class MarketActivityScore(BaseModel): class MarketActivityScore(BaseModel):
@@ -224,14 +235,16 @@ class MarketActivityScore(BaseModel):
time_period_months: Analysis period in months time_period_months: Analysis period in months
monthly_distribution: Deals per month breakdown monthly_distribution: Deals per month breakdown
""" """
activity_score: float = Field(..., description="Overall activity score (0-100)", ge=0, le=100) activity_score: float = Field(..., description="Overall activity score (0-100)", ge=0, le=100)
total_deals: int = Field(..., description="Total deals in period") total_deals: int = Field(..., description="Total deals in period")
deals_per_month: float = Field(..., description="Average deals per month") deals_per_month: float = Field(..., description="Average deals per month")
trend: str = Field(..., description="Market trend (increasing, stable, decreasing)") trend: str = Field(..., description="Market trend (increasing, stable, decreasing)")
time_period_months: Optional[int] = Field(None, description="Analysis period in months (None = all data)") time_period_months: Optional[int] = Field(
None, description="Analysis period in months (None = all data)"
)
monthly_distribution: Dict[str, int] = Field( monthly_distribution: Dict[str, int] = Field(
default_factory=dict, default_factory=dict, description="Deals per month (YYYY-MM: count)"
description="Deals per month (YYYY-MM: count)"
) )
@@ -250,7 +263,10 @@ class InvestmentAnalysis(BaseModel):
total_deals: Total deals analyzed (sample size) total_deals: Total deals analyzed (sample size)
data_quality: Data quality assessment data_quality: Data quality assessment
""" """
investment_score: float = Field(..., description="Overall investment score (0-100)", ge=0, le=100)
investment_score: float = Field(
..., description="Overall investment score (0-100)", ge=0, le=100
)
price_trend: str = Field(..., description="Price trend (increasing, stable, decreasing)") price_trend: str = Field(..., description="Price trend (increasing, stable, decreasing)")
price_appreciation_rate: float = Field(..., description="Annual price growth rate (%)") price_appreciation_rate: float = Field(..., description="Annual price growth rate (%)")
price_volatility: float = Field(..., description="Price volatility score (0-100)", ge=0, le=100) price_volatility: float = Field(..., description="Price volatility score (0-100)", ge=0, le=100)
@@ -273,12 +289,17 @@ class LiquidityMetrics(BaseModel):
liquidity_rating: Market liquidity rating liquidity_rating: Market liquidity rating
trend_direction: Liquidity trend direction trend_direction: Liquidity trend direction
""" """
liquidity_score: float = Field(..., description="Overall liquidity score (0-100)", ge=0, le=100) liquidity_score: float = Field(..., description="Overall liquidity score (0-100)", ge=0, le=100)
total_deals: int = Field(..., description="Total deals in period") total_deals: int = Field(..., description="Total deals in period")
time_period_months: Optional[int] = Field(None, description="Analysis period in months (None = all data)") time_period_months: Optional[int] = Field(
None, description="Analysis period in months (None = all data)"
)
avg_deals_per_month: float = Field(..., description="Average deals per month") avg_deals_per_month: float = Field(..., description="Average deals per month")
deal_velocity: float = Field(..., description="Deal velocity (deals per month)") deal_velocity: float = Field(..., description="Deal velocity (deals per month)")
market_activity_level: str = Field(..., description="Activity level (very_high, high, moderate, low, very_low)") market_activity_level: str = Field(
..., description="Activity level (very_high, high, moderate, low, very_low)"
)
class DealFilters(BaseModel): class DealFilters(BaseModel):
@@ -298,6 +319,7 @@ class DealFilters(BaseModel):
min_floor: Minimum floor number min_floor: Minimum floor number
max_floor: Maximum floor number max_floor: Maximum floor number
""" """
property_type: Optional[str] = Field(None, description="Property type filter") property_type: Optional[str] = Field(None, description="Property type filter")
min_rooms: Optional[float] = Field(None, description="Minimum rooms", ge=0) min_rooms: Optional[float] = Field(None, description="Minimum rooms", ge=0)
max_rooms: Optional[float] = Field(None, description="Maximum rooms", ge=0) max_rooms: Optional[float] = Field(None, description="Maximum rooms", ge=0)
@@ -308,38 +330,38 @@ class DealFilters(BaseModel):
min_floor: Optional[int] = Field(None, description="Minimum floor") min_floor: Optional[int] = Field(None, description="Minimum floor")
max_floor: Optional[int] = Field(None, description="Maximum floor") max_floor: Optional[int] = Field(None, description="Maximum floor")
@field_validator('max_rooms') @field_validator("max_rooms")
@classmethod @classmethod
def validate_max_rooms(cls, v: Optional[float], info) -> Optional[float]: def validate_max_rooms(cls, v: Optional[float], info) -> Optional[float]:
"""Ensure max_rooms >= min_rooms if both specified.""" """Ensure max_rooms >= min_rooms if both specified."""
if v is not None and info.data.get('min_rooms') is not None: if v is not None and info.data.get("min_rooms") is not None:
if v < info.data['min_rooms']: if v < info.data["min_rooms"]:
raise ValueError("max_rooms must be >= min_rooms") raise ValueError("max_rooms must be >= min_rooms")
return v return v
@field_validator('max_price') @field_validator("max_price")
@classmethod @classmethod
def validate_max_price(cls, v: Optional[float], info) -> Optional[float]: def validate_max_price(cls, v: Optional[float], info) -> Optional[float]:
"""Ensure max_price >= min_price if both specified.""" """Ensure max_price >= min_price if both specified."""
if v is not None and info.data.get('min_price') is not None: if v is not None and info.data.get("min_price") is not None:
if v < info.data['min_price']: if v < info.data["min_price"]:
raise ValueError("max_price must be >= min_price") raise ValueError("max_price must be >= min_price")
return v return v
@field_validator('max_area') @field_validator("max_area")
@classmethod @classmethod
def validate_max_area(cls, v: Optional[float], info) -> Optional[float]: def validate_max_area(cls, v: Optional[float], info) -> Optional[float]:
"""Ensure max_area >= min_area if both specified.""" """Ensure max_area >= min_area if both specified."""
if v is not None and info.data.get('min_area') is not None: if v is not None and info.data.get("min_area") is not None:
if v < info.data['min_area']: if v < info.data["min_area"]:
raise ValueError("max_area must be >= min_area") raise ValueError("max_area must be >= min_area")
return v return v
@field_validator('max_floor') @field_validator("max_floor")
@classmethod @classmethod
def validate_max_floor(cls, v: Optional[int], info) -> Optional[int]: def validate_max_floor(cls, v: Optional[int], info) -> Optional[int]:
"""Ensure max_floor >= min_floor if both specified.""" """Ensure max_floor >= min_floor if both specified."""
if v is not None and info.data.get('min_floor') is not None: if v is not None and info.data.get("min_floor") is not None:
if v < info.data['min_floor']: if v < info.data["min_floor"]:
raise ValueError("max_floor must be >= min_floor") raise ValueError("max_floor must be >= min_floor")
return v return v
+9 -5
View File
@@ -5,9 +5,8 @@ This module provides pure mathematical functions for analyzing real estate deal
""" """
from collections import Counter from collections import Counter
from typing import List
import logging import logging
from datetime import date from typing import List
from .models import Deal, DealStatistics from .models import Deal, DealStatistics
@@ -78,7 +77,11 @@ def calculate_deal_statistics(deals: List[Deal]) -> DealStatistics:
sorted_prices = sorted(prices) sorted_prices = sorted(prices)
price_stats = { price_stats = {
"mean": round(sum(prices) / len(prices), 2), "mean": round(sum(prices) / len(prices), 2),
"median": (sorted_prices[len(sorted_prices) // 2] + sorted_prices[(len(sorted_prices) - 1) // 2]) / 2, "median": (
sorted_prices[len(sorted_prices) // 2]
+ sorted_prices[(len(sorted_prices) - 1) // 2]
)
/ 2,
"min": min(prices), "min": min(prices),
"max": max(prices), "max": max(prices),
"p25": sorted_prices[len(sorted_prices) // 4], "p25": sorted_prices[len(sorted_prices) // 4],
@@ -123,6 +126,7 @@ def calculate_deal_statistics(deals: List[Deal]) -> DealStatistics:
try: try:
# Convert dates to ISO strings for consistent formatting # Convert dates to ISO strings for consistent formatting
from datetime import date as date_type from datetime import date as date_type
parsed_dates = [] parsed_dates = []
for d in deal_dates: for d in deal_dates:
try: try:
@@ -133,8 +137,8 @@ def calculate_deal_statistics(deals: List[Deal]) -> DealStatistics:
# Handle string dates # Handle string dates
date_str = str(d) date_str = str(d)
# Handle ISO format with timezone (e.g., "2025-01-01T00:00:00.000Z") # Handle ISO format with timezone (e.g., "2025-01-01T00:00:00.000Z")
if 'T' in date_str: if "T" in date_str:
date_str = date_str.split('T')[0] date_str = date_str.split("T")[0]
parsed_dates.append(date_str) parsed_dates.append(date_str)
except (ValueError, TypeError): except (ValueError, TypeError):
logger.warning(f"Invalid date format: {d}") logger.warning(f"Invalid date format: {d}")
+1 -4
View File
@@ -51,10 +51,7 @@ def is_same_building(search_address: str, deal_address: str) -> bool:
"""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_clean = (
addr.replace("רח'", "") addr.replace("רח'", "").replace("רחוב", "").replace("שד'", "").replace("שדרות", "")
.replace("רחוב", "")
.replace("שד'", "")
.replace("שדרות", "")
) )
addr_clean = addr_clean.replace(" ", " ").strip() addr_clean = addr_clean.replace(" ", " ").strip()
+7 -5
View File
@@ -57,16 +57,18 @@ def validate_coordinates(point: Tuple[float, float]) -> Tuple[float, float]:
# Basic validation for Israeli coordinates (ITM projection) # Basic validation for Israeli coordinates (ITM projection)
# ITM bounds for Israel: X (longitude) ~150,000-300,000, Y (latitude) ~3,500,000-4,000,000 # ITM bounds for Israel: X (longitude) ~150,000-300,000, Y (latitude) ~3,500,000-4,000,000
if not (150000 <= lon <= 300000): # ITM longitude bounds for Israel if not (150000 <= lon <= 300000): # ITM longitude bounds for Israel
logger.warning(f"Longitude {lon} appears to be outside Israeli ITM bounds (150,000-300,000)") logger.warning(
f"Longitude {lon} appears to be outside Israeli ITM bounds (150,000-300,000)"
)
if not (3500000 <= lat <= 4000000): # ITM latitude bounds for Israel if not (3500000 <= lat <= 4000000): # ITM latitude bounds for Israel
logger.warning(f"Latitude {lat} appears to be outside Israeli ITM bounds (3,500,000-4,000,000)") logger.warning(
f"Latitude {lat} appears to be outside Israeli ITM bounds (3,500,000-4,000,000)"
)
return (lon, lat) return (lon, lat)
def validate_positive_int( def validate_positive_int(value: int, name: str, max_value: Optional[int] = None) -> int:
value: int, name: str, max_value: Optional[int] = None
) -> int:
""" """
Validate positive integer input. Validate positive integer input.
+18 -10
View File
@@ -27,22 +27,30 @@ def main():
# Display first few deals # Display first few deals
for i, deal in enumerate(deals[:5]): for i, deal in enumerate(deals[:5]):
print(f"\nDeal {i+1}:") print(f"\nDeal {i + 1}:")
# Build address from available fields # Build address from available fields
address_parts = [] address_parts = []
if deal.get('streetNameHeb'): if deal.get("streetNameHeb"):
address_parts.append(deal.get('streetNameHeb')) address_parts.append(deal.get("streetNameHeb"))
if deal.get('houseNum'): if deal.get("houseNum"):
address_parts.append(str(deal.get('houseNum'))) address_parts.append(str(deal.get("houseNum")))
if deal.get('settlementNameHeb'): if deal.get("settlementNameHeb"):
address_parts.append(deal.get('settlementNameHeb')) address_parts.append(deal.get("settlementNameHeb"))
address = ' '.join(address_parts) if address_parts else 'N/A' address = " ".join(address_parts) if address_parts else "N/A"
print(f" Address: {address}") print(f" Address: {address}")
print(f" Date: {deal.get('dealDate', 'N/A')[:10] if deal.get('dealDate') else 'N/A'}") print(f" Date: {deal.get('dealDate', 'N/A')[:10] if deal.get('dealDate') else 'N/A'}")
print(f" Price: {deal.get('dealAmount', 'N/A'):,} NIS" if deal.get('dealAmount') else " Price: N/A") print(
print(f" Area: {deal.get('assetArea', 'N/A')}" if deal.get('assetArea') else " Area: N/A") f" Price: {deal.get('dealAmount', 'N/A'):,} NIS"
if deal.get("dealAmount")
else " Price: N/A"
)
print(
f" Area: {deal.get('assetArea', 'N/A')}"
if deal.get("assetArea")
else " Area: N/A"
)
print(f" Type: {deal.get('propertyTypeDescription', 'N/A')}") print(f" Type: {deal.get('propertyTypeDescription', 'N/A')}")
print(f" Neighborhood: {deal.get('neighborhood', 'N/A')}") print(f" Neighborhood: {deal.get('neighborhood', 'N/A')}")
+1
View File
@@ -7,4 +7,5 @@ This script runs the FastMCP server for accessing Israeli government real estate
if __name__ == "__main__": if __name__ == "__main__":
from nadlan_mcp.fastmcp_server import mcp from nadlan_mcp.fastmcp_server import mcp
mcp.run() mcp.run()
+20 -18
View File
@@ -8,8 +8,9 @@ IMPORTANT: These make real API calls and take longer to run.
""" """
import pytest import pytest
from nadlan_mcp.govmap import GovmapClient from nadlan_mcp.govmap import GovmapClient
from nadlan_mcp.govmap.models import Deal, AutocompleteResponse from nadlan_mcp.govmap.models import AutocompleteResponse, Deal
@pytest.fixture @pytest.fixture
@@ -37,16 +38,16 @@ class TestAutocompleteAPIHealth:
response = client.autocomplete_address("דיזנגוף תל אביב") response = client.autocomplete_address("דיזנגוף תל אביב")
# Check response model fields exist # Check response model fields exist
assert hasattr(response, 'results_count') assert hasattr(response, "results_count")
assert hasattr(response, 'results') assert hasattr(response, "results")
# Check result fields # Check result fields
if len(response.results) > 0: if len(response.results) > 0:
result = response.results[0] result = response.results[0]
assert hasattr(result, 'id') assert hasattr(result, "id")
assert hasattr(result, 'text') assert hasattr(result, "text")
assert hasattr(result, 'type') assert hasattr(result, "type")
assert hasattr(result, 'coordinates') assert hasattr(result, "coordinates")
@pytest.mark.api_health @pytest.mark.api_health
def test_autocomplete_coordinates_present(self, client): def test_autocomplete_coordinates_present(self, client):
@@ -128,18 +129,18 @@ class TestDealsAPIHealth:
deal = deals[0] deal = deals[0]
# Check required fields # Check required fields
assert hasattr(deal, 'objectid') assert hasattr(deal, "objectid")
assert hasattr(deal, 'deal_amount') assert hasattr(deal, "deal_amount")
assert hasattr(deal, 'deal_date') assert hasattr(deal, "deal_date")
# Check common optional fields # Check common optional fields
assert hasattr(deal, 'asset_area') assert hasattr(deal, "asset_area")
assert hasattr(deal, 'property_type_description') assert hasattr(deal, "property_type_description")
assert hasattr(deal, 'rooms') assert hasattr(deal, "rooms")
assert hasattr(deal, 'floor') assert hasattr(deal, "floor")
# Check computed field # Check computed field
assert hasattr(deal, 'price_per_sqm') assert hasattr(deal, "price_per_sqm")
class TestAPIDataQuality: class TestAPIDataQuality:
@@ -169,8 +170,9 @@ class TestAPIDataQuality:
# Check deal amounts are reasonable (10K to 100M NIS) # Check deal amounts are reasonable (10K to 100M NIS)
for deal in deals: for deal in deals:
if deal.deal_amount > 0: if deal.deal_amount > 0:
assert 10000 <= deal.deal_amount <= 100000000, \ assert (
f"Deal amount {deal.deal_amount} outside reasonable range" 10000 <= deal.deal_amount <= 100000000
), f"Deal amount {deal.deal_amount} outside reasonable range"
@pytest.mark.api_health @pytest.mark.api_health
def test_dates_are_recent(self, client): def test_dates_are_recent(self, client):
@@ -196,7 +198,7 @@ class TestAPIDataQuality:
pytest.skip("No deals") pytest.skip("No deals")
# At least some deals should be from last 5 years # At least some deals should be from last 5 years
cutoff = date.today() - timedelta(days=5*365) cutoff = date.today() - timedelta(days=5 * 365)
recent_deals = [d for d in deals if isinstance(d.deal_date, date) and d.deal_date >= cutoff] recent_deals = [d for d in deals if isinstance(d.deal_date, date) and d.deal_date >= cutoff]
assert len(recent_deals) > 0, "No recent deals found (within last 5 years)" assert len(recent_deals) > 0, "No recent deals found (within last 5 years)"
+9 -7
View File
@@ -2,8 +2,10 @@
Pytest configuration for nadlan_mcp tests. Pytest configuration for nadlan_mcp tests.
""" """
import pytest
from unittest.mock import Mock from unittest.mock import Mock
import pytest
from tests.vcr_config import my_vcr from tests.vcr_config import my_vcr
@@ -27,9 +29,9 @@ def sample_autocomplete_response():
"type": "address", "type": "address",
"score": 100, "score": 100,
"shape": "POINT(3870000.123 3770000.456)", "shape": "POINT(3870000.123 3770000.456)",
"data": {} "data": {},
} }
] ],
} }
@@ -46,7 +48,7 @@ def sample_deals_response():
"assetArea": 100, "assetArea": 100,
"settlementNameHeb": "תל אביב-יפו", "settlementNameHeb": "תל אביב-יפו",
"propertyTypeDescription": "דירה", "propertyTypeDescription": "דירה",
"neighborhood": "test neighborhood" "neighborhood": "test neighborhood",
}, },
{ {
"objectid": 456, "objectid": 456,
@@ -55,9 +57,9 @@ def sample_deals_response():
"assetArea": 120, "assetArea": 120,
"settlementNameHeb": "תל אביב-יפو", "settlementNameHeb": "תל אביב-יפو",
"propertyTypeDescription": "דירה", "propertyTypeDescription": "דירה",
"neighborhood": "test neighborhood" "neighborhood": "test neighborhood",
} },
] ],
} }
+5 -5
View File
@@ -6,13 +6,16 @@ For comprehensive E2E testing, see test_mcp_tools_comprehensive.py
Target: Complete in <30 seconds Target: Complete in <30 seconds
""" """
import json import json
import pytest import pytest
from nadlan_mcp.fastmcp_server import ( from nadlan_mcp.fastmcp_server import (
autocomplete_address, autocomplete_address,
find_recent_deals_for_address, find_recent_deals_for_address,
get_street_deals,
get_deals_by_radius, get_deals_by_radius,
get_street_deals,
) )
@@ -58,10 +61,7 @@ class TestMCPToolsSmokeTests:
"""Smoke test: Main tool works with minimal data.""" """Smoke test: Main tool works with minimal data."""
# Use very small limits to speed up # Use very small limits to speed up
result = find_recent_deals_for_address( result = find_recent_deals_for_address(
self.TEST_ADDRESS, self.TEST_ADDRESS, years_back=1, radius_meters=30, max_deals=10
years_back=1,
radius_meters=30,
max_deals=10
) )
data = json.loads(result) data = json.loads(result)
+12 -14
View File
@@ -6,19 +6,22 @@ of each MCP tool from end to end.
Mark as integration tests since they hit real APIs. Mark as integration tests since they hit real APIs.
""" """
import json import json
import pytest import pytest
from nadlan_mcp.fastmcp_server import ( from nadlan_mcp.fastmcp_server import (
autocomplete_address,
find_recent_deals_for_address,
analyze_market_trends, analyze_market_trends,
get_valuation_comparables, autocomplete_address,
get_deal_statistics,
get_market_activity_metrics,
compare_addresses, compare_addresses,
get_street_deals, find_recent_deals_for_address,
get_neighborhood_deals, get_deal_statistics,
get_deals_by_radius, get_deals_by_radius,
get_market_activity_metrics,
get_neighborhood_deals,
get_street_deals,
get_valuation_comparables,
) )
@@ -69,9 +72,7 @@ class TestMCPToolsE2E:
def test_analyze_market_trends(self): def test_analyze_market_trends(self):
"""Test market trend analysis.""" """Test market trend analysis."""
result = analyze_market_trends( result = analyze_market_trends(self.TEST_ADDRESS_1, years_back=3, radius_meters=100)
self.TEST_ADDRESS_1, years_back=3, radius_meters=100
)
data = json.loads(result) data = json.loads(result)
# Check response structure # Check response structure
@@ -83,10 +84,7 @@ class TestMCPToolsE2E:
def test_get_valuation_comparables(self): def test_get_valuation_comparables(self):
"""Test getting valuation comparables.""" """Test getting valuation comparables."""
result = get_valuation_comparables( result = get_valuation_comparables(
self.TEST_ADDRESS_1, self.TEST_ADDRESS_1, years_back=3, min_rooms=3.0, max_rooms=5.0
years_back=3,
min_rooms=3.0,
max_rooms=5.0
) )
data = json.loads(result) data = json.loads(result)
+43 -32
View File
@@ -5,6 +5,7 @@ Comprehensive tests for filter_deals_by_criteria function.
""" """
import pytest import pytest
from nadlan_mcp.govmap.filters import filter_deals_by_criteria from nadlan_mcp.govmap.filters import filter_deals_by_criteria
from nadlan_mcp.govmap.models import Deal, DealFilters from nadlan_mcp.govmap.models import Deal, DealFilters
@@ -80,83 +81,79 @@ class TestFilterDealsByCriteria:
result = filter_deals_by_criteria(sample_deals, property_type="דירה") result = filter_deals_by_criteria(sample_deals, property_type="דירה")
# Should match "דירה" and "דירת גג" via substring match. # Should match "דירה" and "דירת גג" via substring match.
assert len(result) == 3 assert len(result) == 3
assert set(d.objectid for d in result) == {1, 2, 4} assert {d.objectid for d in result} == {1, 2, 4}
def test_filter_by_min_rooms(self, sample_deals): def test_filter_by_min_rooms(self, sample_deals):
"""Test filtering by minimum rooms.""" """Test filtering by minimum rooms."""
result = filter_deals_by_criteria(sample_deals, min_rooms=4.0) result = filter_deals_by_criteria(sample_deals, min_rooms=4.0)
assert len(result) == 3 assert len(result) == 3
assert set(d.objectid for d in result) == {2, 3, 5} assert {d.objectid for d in result} == {2, 3, 5}
def test_filter_by_max_rooms(self, sample_deals): def test_filter_by_max_rooms(self, sample_deals):
"""Test filtering by maximum rooms.""" """Test filtering by maximum rooms."""
result = filter_deals_by_criteria(sample_deals, max_rooms=3.0) result = filter_deals_by_criteria(sample_deals, max_rooms=3.0)
assert len(result) == 2 assert len(result) == 2
assert set(d.objectid for d in result) == {1, 4} assert {d.objectid for d in result} == {1, 4}
def test_filter_by_room_range(self, sample_deals): def test_filter_by_room_range(self, sample_deals):
"""Test filtering by room range.""" """Test filtering by room range."""
result = filter_deals_by_criteria(sample_deals, min_rooms=3.0, max_rooms=4.0) result = filter_deals_by_criteria(sample_deals, min_rooms=3.0, max_rooms=4.0)
assert len(result) == 2 assert len(result) == 2
assert set(d.objectid for d in result) == {1, 2} assert {d.objectid for d in result} == {1, 2}
def test_filter_by_min_price(self, sample_deals): def test_filter_by_min_price(self, sample_deals):
"""Test filtering by minimum price.""" """Test filtering by minimum price."""
result = filter_deals_by_criteria(sample_deals, min_price=1200000.0) result = filter_deals_by_criteria(sample_deals, min_price=1200000.0)
assert len(result) == 3 assert len(result) == 3
assert set(d.objectid for d in result) == {2, 3, 5} assert {d.objectid for d in result} == {2, 3, 5}
def test_filter_by_max_price(self, sample_deals): def test_filter_by_max_price(self, sample_deals):
"""Test filtering by maximum price.""" """Test filtering by maximum price."""
result = filter_deals_by_criteria(sample_deals, max_price=1000000.0) result = filter_deals_by_criteria(sample_deals, max_price=1000000.0)
assert len(result) == 2 assert len(result) == 2
assert set(d.objectid for d in result) == {1, 4} assert {d.objectid for d in result} == {1, 4}
def test_filter_by_price_range(self, sample_deals): def test_filter_by_price_range(self, sample_deals):
"""Test filtering by price range.""" """Test filtering by price range."""
result = filter_deals_by_criteria( result = filter_deals_by_criteria(sample_deals, min_price=1000000.0, max_price=1500000.0)
sample_deals, min_price=1000000.0, max_price=1500000.0
)
assert len(result) == 3 assert len(result) == 3
assert set(d.objectid for d in result) == {1, 2, 5} assert {d.objectid for d in result} == {1, 2, 5}
def test_filter_by_min_area(self, sample_deals): def test_filter_by_min_area(self, sample_deals):
"""Test filtering by minimum area.""" """Test filtering by minimum area."""
result = filter_deals_by_criteria(sample_deals, min_area=100.0) result = filter_deals_by_criteria(sample_deals, min_area=100.0)
assert len(result) == 3 assert len(result) == 3
assert set(d.objectid for d in result) == {2, 3, 5} assert {d.objectid for d in result} == {2, 3, 5}
def test_filter_by_max_area(self, sample_deals): def test_filter_by_max_area(self, sample_deals):
"""Test filtering by maximum area.""" """Test filtering by maximum area."""
result = filter_deals_by_criteria(sample_deals, max_area=80.0) result = filter_deals_by_criteria(sample_deals, max_area=80.0)
assert len(result) == 2 assert len(result) == 2
assert set(d.objectid for d in result) == {1, 4} assert {d.objectid for d in result} == {1, 4}
def test_filter_by_area_range(self, sample_deals): def test_filter_by_area_range(self, sample_deals):
"""Test filtering by area range.""" """Test filtering by area range."""
result = filter_deals_by_criteria( result = filter_deals_by_criteria(sample_deals, min_area=80.0, max_area=120.0)
sample_deals, min_area=80.0, max_area=120.0
)
assert len(result) == 3 assert len(result) == 3
assert set(d.objectid for d in result) == {1, 2, 5} assert {d.objectid for d in result} == {1, 2, 5}
def test_filter_by_min_floor(self, sample_deals): def test_filter_by_min_floor(self, sample_deals):
"""Test filtering by minimum floor.""" """Test filtering by minimum floor."""
result = filter_deals_by_criteria(sample_deals, min_floor=2) result = filter_deals_by_criteria(sample_deals, min_floor=2)
assert len(result) == 3 assert len(result) == 3
assert set(d.objectid for d in result) == {1, 2, 5} assert {d.objectid for d in result} == {1, 2, 5}
def test_filter_by_max_floor(self, sample_deals): def test_filter_by_max_floor(self, sample_deals):
"""Test filtering by maximum floor.""" """Test filtering by maximum floor."""
result = filter_deals_by_criteria(sample_deals, max_floor=2) result = filter_deals_by_criteria(sample_deals, max_floor=2)
assert len(result) == 3 assert len(result) == 3
assert set(d.objectid for d in result) == {1, 3, 4} assert {d.objectid for d in result} == {1, 3, 4}
def test_filter_by_floor_range(self, sample_deals): def test_filter_by_floor_range(self, sample_deals):
"""Test filtering by floor range.""" """Test filtering by floor range."""
result = filter_deals_by_criteria(sample_deals, min_floor=1, max_floor=5) result = filter_deals_by_criteria(sample_deals, min_floor=1, max_floor=5)
assert len(result) == 3 assert len(result) == 3
assert set(d.objectid for d in result) == {1, 2, 4} assert {d.objectid for d in result} == {1, 2, 4}
def test_combined_filters(self, sample_deals): def test_combined_filters(self, sample_deals):
"""Test combining multiple filters.""" """Test combining multiple filters."""
@@ -171,19 +168,17 @@ class TestFilterDealsByCriteria:
# objectid=1: "דירה", 3 rooms, 1M price ✓ # objectid=1: "דירה", 3 rooms, 1M price ✓
# objectid=2: "דירת גג" (matches "דירה" variant), 4 rooms, 1.5M price ✓ # objectid=2: "דירת גג" (matches "דירה" variant), 4 rooms, 1.5M price ✓
assert len(result) == 2 assert len(result) == 2
assert set(d.objectid for d in result) == {1, 2} assert {d.objectid for d in result} == {1, 2}
def test_filter_using_dealfilters_model(self, sample_deals): def test_filter_using_dealfilters_model(self, sample_deals):
"""Test filtering using DealFilters model.""" """Test filtering using DealFilters model."""
filters = DealFilters( filters = DealFilters(property_type="דירה", min_rooms=3.0, max_rooms=4.0)
property_type="דירה", min_rooms=3.0, max_rooms=4.0
)
result = filter_deals_by_criteria(sample_deals, filters=filters) result = filter_deals_by_criteria(sample_deals, filters=filters)
# Should match objectid=1 and objectid=2 # Should match objectid=1 and objectid=2
# objectid=1: "דירה", 3 rooms ✓ # objectid=1: "דירה", 3 rooms ✓
# objectid=2: "דירת גג" (matches "דירה" variant), 4 rooms ✓ # objectid=2: "דירת גג" (matches "דירה" variant), 4 rooms ✓
assert len(result) == 2 assert len(result) == 2
assert set(d.objectid for d in result) == {1, 2} assert {d.objectid for d in result} == {1, 2}
def test_filter_using_dict(self, sample_deals): def test_filter_using_dict(self, sample_deals):
"""Test filtering using dict (converted to DealFilters).""" """Test filtering using dict (converted to DealFilters)."""
@@ -197,23 +192,35 @@ class TestFilterDealsByCriteria:
# objectid=1: "דירה", 3 rooms ✓ # objectid=1: "דירה", 3 rooms ✓
# objectid=2: "דירת גג" (matches "דירה" variant), 4 rooms ✓ # objectid=2: "דירת גג" (matches "דירה" variant), 4 rooms ✓
assert len(result) == 2 assert len(result) == 2
assert set(d.objectid for d in result) == {1, 2} assert {d.objectid for d in result} == {1, 2}
def test_individual_params_override_filters_model(self, sample_deals): def test_individual_params_override_filters_model(self, sample_deals):
"""Test that individual parameters override filters model.""" """Test that individual parameters override filters model."""
filters = DealFilters(min_rooms=5.0) # Would match only objectid=3 filters = DealFilters(min_rooms=5.0) # Would match only objectid=3
result = filter_deals_by_criteria( result = filter_deals_by_criteria(
sample_deals, filters=filters, min_rooms=3.0 # Override to 3.0 sample_deals,
filters=filters,
min_rooms=3.0, # Override to 3.0
) )
# Should use min_rooms=3.0, not 5.0 # Should use min_rooms=3.0, not 5.0
assert len(result) == 4 assert len(result) == 4
assert set(d.objectid for d in result) == {1, 2, 3, 5} assert {d.objectid for d in result} == {1, 2, 3, 5}
def test_filters_exclude_deals_with_missing_property_type(self): def test_filters_exclude_deals_with_missing_property_type(self):
"""Test that deals with missing property_type are excluded when filter active.""" """Test that deals with missing property_type are excluded when filter active."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date="2024-01-01", property_type_description="דירה"), Deal(
Deal(objectid=2, deal_amount=1000000, deal_date="2024-01-01", property_type_description=None), objectid=1,
deal_amount=1000000,
deal_date="2024-01-01",
property_type_description="דירה",
),
Deal(
objectid=2,
deal_amount=1000000,
deal_date="2024-01-01",
property_type_description=None,
),
Deal(objectid=3, deal_amount=1000000, deal_date="2024-01-01"), # No property_type Deal(objectid=3, deal_amount=1000000, deal_date="2024-01-01"), # No property_type
] ]
result = filter_deals_by_criteria(deals, property_type="דירה") result = filter_deals_by_criteria(deals, property_type="דירה")
@@ -281,13 +288,17 @@ class TestFilterDealsByCriteria:
"""Test floor filtering with Hebrew floor descriptions.""" """Test floor filtering with Hebrew floor descriptions."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date="2024-01-01", floor="קרקע"), # Ground=0 Deal(objectid=1, deal_amount=1000000, deal_date="2024-01-01", floor="קרקע"), # Ground=0
Deal(objectid=2, deal_amount=1000000, deal_date="2024-01-01", floor="קומה 3"), # Floor 3 Deal(
Deal(objectid=3, deal_amount=1000000, deal_date="2024-01-01", floor="מרתף"), # Basement=-1 objectid=2, deal_amount=1000000, deal_date="2024-01-01", floor="קומה 3"
), # Floor 3
Deal(
objectid=3, deal_amount=1000000, deal_date="2024-01-01", floor="מרתף"
), # Basement=-1
] ]
result = filter_deals_by_criteria(deals, min_floor=0) result = filter_deals_by_criteria(deals, min_floor=0)
# Should match ground (0) and floor 3, but not basement (-1) # Should match ground (0) and floor 3, but not basement (-1)
assert len(result) == 2 assert len(result) == 2
assert set(d.objectid for d in result) == {1, 2} assert {d.objectid for d in result} == {1, 2}
@pytest.mark.parametrize( @pytest.mark.parametrize(
"min_val,max_val,expected_count", "min_val,max_val,expected_count",
+179 -36
View File
@@ -4,15 +4,17 @@ Tests for nadlan_mcp.govmap.market_analysis module.
Comprehensive tests for market analysis functions. Comprehensive tests for market analysis functions.
""" """
from datetime import datetime, timedelta
import pytest import pytest
from datetime import date, datetime, timedelta
from nadlan_mcp.govmap.market_analysis import ( from nadlan_mcp.govmap.market_analysis import (
parse_deal_dates,
calculate_market_activity_score,
analyze_investment_potential, analyze_investment_potential,
calculate_market_activity_score,
get_market_liquidity, get_market_liquidity,
parse_deal_dates,
) )
from nadlan_mcp.govmap.models import Deal, MarketActivityScore, InvestmentAnalysis, LiquidityMetrics from nadlan_mcp.govmap.models import Deal, InvestmentAnalysis, LiquidityMetrics, MarketActivityScore
def get_recent_date(months_ago=0, days_ago=0): def get_recent_date(months_ago=0, days_ago=0):
@@ -27,11 +29,36 @@ class TestParseDealDates:
def sample_deals(self): def sample_deals(self):
"""Create sample deals spanning multiple months (recent dates).""" """Create sample deals spanning multiple months (recent dates)."""
return [ return [
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=4, days_ago=15), asset_area=80), Deal(
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=4, days_ago=10), asset_area=85), objectid=1,
Deal(objectid=3, deal_amount=1200000, deal_date=get_recent_date(months_ago=3, days_ago=20), asset_area=90), deal_amount=1000000,
Deal(objectid=4, deal_amount=1300000, deal_date=get_recent_date(months_ago=2, days_ago=25), asset_area=95), deal_date=get_recent_date(months_ago=4, days_ago=15),
Deal(objectid=5, deal_amount=1400000, deal_date=get_recent_date(months_ago=1, days_ago=18), asset_area=100), asset_area=80,
),
Deal(
objectid=2,
deal_amount=1100000,
deal_date=get_recent_date(months_ago=4, days_ago=10),
asset_area=85,
),
Deal(
objectid=3,
deal_amount=1200000,
deal_date=get_recent_date(months_ago=3, days_ago=20),
asset_area=90,
),
Deal(
objectid=4,
deal_amount=1300000,
deal_date=get_recent_date(months_ago=2, days_ago=25),
asset_area=95,
),
Deal(
objectid=5,
deal_amount=1400000,
deal_date=get_recent_date(months_ago=1, days_ago=18),
asset_area=100,
),
] ]
def test_parse_deal_dates_basic(self, sample_deals): def test_parse_deal_dates_basic(self, sample_deals):
@@ -60,8 +87,18 @@ class TestParseDealDates:
"""Test parsing with date objects instead of strings.""" """Test parsing with date objects instead of strings."""
recent = datetime.now().date() recent = datetime.now().date()
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date=recent - timedelta(days=30), asset_area=80), Deal(
Deal(objectid=2, deal_amount=1100000, deal_date=recent - timedelta(days=60), asset_area=85), objectid=1,
deal_amount=1000000,
deal_date=recent - timedelta(days=30),
asset_area=80,
),
Deal(
objectid=2,
deal_amount=1100000,
deal_date=recent - timedelta(days=60),
asset_area=85,
),
] ]
deal_dates, monthly, _ = parse_deal_dates(deals) deal_dates, monthly, _ = parse_deal_dates(deals)
@@ -71,8 +108,18 @@ class TestParseDealDates:
def test_parse_deal_dates_all_valid(self): def test_parse_deal_dates_all_valid(self):
"""Test that all valid dates are parsed.""" """Test that all valid dates are parsed."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=1), asset_area=80), Deal(
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=2), asset_area=85), objectid=1,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=1),
asset_area=80,
),
Deal(
objectid=2,
deal_amount=1100000,
deal_date=get_recent_date(months_ago=2),
asset_area=85,
),
] ]
deal_dates, _, _ = parse_deal_dates(deals) deal_dates, _, _ = parse_deal_dates(deals)
assert len(deal_dates) == 2 assert len(deal_dates) == 2
@@ -90,7 +137,12 @@ class TestCalculateMarketActivityScore:
"""Test basic market activity calculation.""" """Test basic market activity calculation."""
# Create 12 deals spread across last 12 months # Create 12 deals spread across last 12 months
deals = [ deals = [
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=i), asset_area=80) Deal(
objectid=i,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=i),
asset_area=80,
)
for i in range(12) for i in range(12)
] ]
score = calculate_market_activity_score(deals, time_period_months=12) score = calculate_market_activity_score(deals, time_period_months=12)
@@ -106,10 +158,15 @@ class TestCalculateMarketActivityScore:
# Create 120 deals spread across last 10 months = ~12 deals/month (very high) # Create 120 deals spread across last 10 months = ~12 deals/month (very high)
deals = [] deals = []
for i in range(120): for i in range(120):
month_ago = (i % 10) month_ago = i % 10
day = (i % 28) + 1 day = (i % 28) + 1
deals.append( deals.append(
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=month_ago, days_ago=day), asset_area=80) Deal(
objectid=i,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=month_ago, days_ago=day),
asset_area=80,
)
) )
score = calculate_market_activity_score(deals, time_period_months=12) score = calculate_market_activity_score(deals, time_period_months=12)
@@ -118,8 +175,18 @@ class TestCalculateMarketActivityScore:
def test_market_activity_low_volume(self): def test_market_activity_low_volume(self):
"""Test activity score with low volume.""" """Test activity score with low volume."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=1), asset_area=80), Deal(
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=6), asset_area=85), objectid=1,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=1),
asset_area=80,
),
Deal(
objectid=2,
deal_amount=1100000,
deal_date=get_recent_date(months_ago=6),
asset_area=85,
),
] ]
score = calculate_market_activity_score(deals, time_period_months=12) score = calculate_market_activity_score(deals, time_period_months=12)
@@ -135,7 +202,12 @@ class TestCalculateMarketActivityScore:
num_deals = i + 1 # Increasing: 1 deal earliest, 12 deals most recent num_deals = i + 1 # Increasing: 1 deal earliest, 12 deals most recent
for j in range(num_deals): for j in range(num_deals):
deals.append( deals.append(
Deal(objectid=len(deals), deal_amount=1000000, deal_date=get_recent_date(months_ago=months_ago, days_ago=j), asset_area=80) Deal(
objectid=len(deals),
deal_amount=1000000,
deal_date=get_recent_date(months_ago=months_ago, days_ago=j),
asset_area=80,
)
) )
score = calculate_market_activity_score(deals, time_period_months=12) score = calculate_market_activity_score(deals, time_period_months=12)
@@ -150,7 +222,12 @@ class TestCalculateMarketActivityScore:
num_deals = 12 - i # Decreasing: 12 deals earliest, 1 deal most recent num_deals = 12 - i # Decreasing: 12 deals earliest, 1 deal most recent
for j in range(num_deals): for j in range(num_deals):
deals.append( deals.append(
Deal(objectid=len(deals), deal_amount=1000000, deal_date=get_recent_date(months_ago=months_ago, days_ago=j), asset_area=80) Deal(
objectid=len(deals),
deal_amount=1000000,
deal_date=get_recent_date(months_ago=months_ago, days_ago=j),
asset_area=80,
)
) )
score = calculate_market_activity_score(deals, time_period_months=12) score = calculate_market_activity_score(deals, time_period_months=12)
@@ -160,7 +237,12 @@ class TestCalculateMarketActivityScore:
"""Test trend detection - stable activity.""" """Test trend detection - stable activity."""
# Same number of deals each month # Same number of deals each month
deals = [ deals = [
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=i), asset_area=80) Deal(
objectid=i,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=i),
asset_area=80,
)
for i in range(12) for i in range(12)
] ]
score = calculate_market_activity_score(deals, time_period_months=12) score = calculate_market_activity_score(deals, time_period_months=12)
@@ -171,8 +253,18 @@ class TestCalculateMarketActivityScore:
def test_market_activity_insufficient_data_for_trend(self): def test_market_activity_insufficient_data_for_trend(self):
"""Test trend with insufficient data.""" """Test trend with insufficient data."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=1), asset_area=80), Deal(
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=2), asset_area=85), objectid=1,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=1),
asset_area=80,
),
Deal(
objectid=2,
deal_amount=1100000,
deal_date=get_recent_date(months_ago=2),
asset_area=85,
),
] ]
score = calculate_market_activity_score(deals, time_period_months=12) score = calculate_market_activity_score(deals, time_period_months=12)
@@ -198,7 +290,12 @@ class TestCalculateMarketActivityScore:
month_ago = i % months month_ago = i % months
day = (i // months) % 28 day = (i // months) % 28
deals.append( deals.append(
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=month_ago, days_ago=day), asset_area=80) Deal(
objectid=i,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=month_ago, days_ago=day),
asset_area=80,
)
) )
score = calculate_market_activity_score(deals, time_period_months=12) score = calculate_market_activity_score(deals, time_period_months=12)
@@ -212,9 +309,15 @@ class TestAnalyzeInvestmentPotential:
"""Test basic investment analysis.""" """Test basic investment analysis."""
# Create deals with increasing prices # Create deals with increasing prices
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date="2024-01-01", asset_area=100), # 10000/sqm Deal(
Deal(objectid=2, deal_amount=1100000, deal_date="2024-02-01", asset_area=100), # 11000/sqm objectid=1, deal_amount=1000000, deal_date="2024-01-01", asset_area=100
Deal(objectid=3, deal_amount=1200000, deal_date="2024-03-01", asset_area=100), # 12000/sqm ), # 10000/sqm
Deal(
objectid=2, deal_amount=1100000, deal_date="2024-02-01", asset_area=100
), # 11000/sqm
Deal(
objectid=3, deal_amount=1200000, deal_date="2024-03-01", asset_area=100
), # 12000/sqm
] ]
analysis = analyze_investment_potential(deals) analysis = analyze_investment_potential(deals)
@@ -253,7 +356,12 @@ class TestAnalyzeInvestmentPotential:
"""Test volatility calculation with low volatility.""" """Test volatility calculation with low volatility."""
# Prices very similar # Prices very similar
deals = [ deals = [
Deal(objectid=i, deal_amount=1000000 + i*1000, deal_date=f"2024-{i+1:02d}-01", asset_area=100) Deal(
objectid=i,
deal_amount=1000000 + i * 1000,
deal_date=f"2024-{i + 1:02d}-01",
asset_area=100,
)
for i in range(5) for i in range(5)
] ]
analysis = analyze_investment_potential(deals) analysis = analyze_investment_potential(deals)
@@ -278,7 +386,12 @@ class TestAnalyzeInvestmentPotential:
def test_investment_analysis_data_quality_excellent(self): def test_investment_analysis_data_quality_excellent(self):
"""Test data quality assessment with excellent data.""" """Test data quality assessment with excellent data."""
deals = [ deals = [
Deal(objectid=i, deal_amount=1000000, deal_date=f"2024-{(i%12)+1:02d}-01", asset_area=100) Deal(
objectid=i,
deal_amount=1000000,
deal_date=f"2024-{(i % 12) + 1:02d}-01",
asset_area=100,
)
for i in range(25) for i in range(25)
] ]
analysis = analyze_investment_potential(deals) analysis = analyze_investment_potential(deals)
@@ -334,7 +447,7 @@ class TestAnalyzeInvestmentPotential:
def test_investment_analysis_price_trends(self, price_changes, expected_trend): def test_investment_analysis_price_trends(self, price_changes, expected_trend):
"""Parametrized test for price trend detection.""" """Parametrized test for price trend detection."""
deals = [ deals = [
Deal(objectid=i, deal_amount=price, deal_date=f"2024-{i+1:02d}-01", asset_area=100) Deal(objectid=i, deal_amount=price, deal_date=f"2024-{i + 1:02d}-01", asset_area=100)
for i, price in enumerate(price_changes) for i, price in enumerate(price_changes)
] ]
analysis = analyze_investment_potential(deals) analysis = analyze_investment_potential(deals)
@@ -348,7 +461,12 @@ class TestGetMarketLiquidity:
def test_market_liquidity_basic(self): def test_market_liquidity_basic(self):
"""Test basic liquidity calculation.""" """Test basic liquidity calculation."""
deals = [ deals = [
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=i), asset_area=80) Deal(
objectid=i,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=i),
asset_area=80,
)
for i in range(12) for i in range(12)
] ]
liquidity = get_market_liquidity(deals, time_period_months=12) liquidity = get_market_liquidity(deals, time_period_months=12)
@@ -367,7 +485,12 @@ class TestGetMarketLiquidity:
month_ago = i % 10 month_ago = i % 10
day = (i % 28) + 1 day = (i % 28) + 1
deals.append( deals.append(
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=month_ago, days_ago=day), asset_area=80) Deal(
objectid=i,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=month_ago, days_ago=day),
asset_area=80,
)
) )
liquidity = get_market_liquidity(deals, time_period_months=12) liquidity = get_market_liquidity(deals, time_period_months=12)
@@ -377,8 +500,18 @@ class TestGetMarketLiquidity:
def test_market_liquidity_low(self): def test_market_liquidity_low(self):
"""Test low liquidity.""" """Test low liquidity."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date=get_recent_date(months_ago=1), asset_area=80), Deal(
Deal(objectid=2, deal_amount=1100000, deal_date=get_recent_date(months_ago=6), asset_area=85), objectid=1,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=1),
asset_area=80,
),
Deal(
objectid=2,
deal_amount=1100000,
deal_date=get_recent_date(months_ago=6),
asset_area=85,
),
] ]
liquidity = get_market_liquidity(deals, time_period_months=12) liquidity = get_market_liquidity(deals, time_period_months=12)
@@ -389,7 +522,12 @@ class TestGetMarketLiquidity:
"""Test deal velocity calculation.""" """Test deal velocity calculation."""
# 12 deals spread evenly across 12 months # 12 deals spread evenly across 12 months
deals = [ deals = [
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=i), asset_area=80) Deal(
objectid=i,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=i),
asset_area=80,
)
for i in range(12) for i in range(12)
] ]
liquidity = get_market_liquidity(deals, time_period_months=12) liquidity = get_market_liquidity(deals, time_period_months=12)
@@ -420,7 +558,12 @@ class TestGetMarketLiquidity:
month_ago = i % months month_ago = i % months
day = (i // months) % 28 day = (i // months) % 28
deals.append( deals.append(
Deal(objectid=i, deal_amount=1000000, deal_date=get_recent_date(months_ago=month_ago, days_ago=day), asset_area=80) Deal(
objectid=i,
deal_amount=1000000,
deal_date=get_recent_date(months_ago=month_ago, days_ago=day),
asset_area=80,
)
) )
liquidity = get_market_liquidity(deals, time_period_months=12) liquidity = get_market_liquidity(deals, time_period_months=12)
+49 -102
View File
@@ -5,21 +5,20 @@ This module tests all Pydantic model validation, serialization,
and computed fields to ensure type safety and correctness. and computed fields to ensure type safety and correctness.
""" """
import pytest
from datetime import datetime
from pydantic import ValidationError from pydantic import ValidationError
import pytest
from nadlan_mcp.govmap.models import ( from nadlan_mcp.govmap.models import (
CoordinatePoint,
Address, Address,
AutocompleteResult,
AutocompleteResponse, AutocompleteResponse,
AutocompleteResult,
CoordinatePoint,
Deal, Deal,
DealFilters,
DealStatistics, DealStatistics,
MarketActivityScore,
InvestmentAnalysis, InvestmentAnalysis,
LiquidityMetrics, LiquidityMetrics,
DealFilters, MarketActivityScore,
) )
@@ -51,11 +50,7 @@ class TestAddress:
"""Test creating valid address.""" """Test creating valid address."""
coord = CoordinatePoint(longitude=180000.0, latitude=650000.0) coord = CoordinatePoint(longitude=180000.0, latitude=650000.0)
address = Address( address = Address(
text="סוקולוב 38 חולון", text="סוקולוב 38 חולון", id="addr123", type="address", score=95.5, coordinates=coord
id="addr123",
type="address",
score=95.5,
coordinates=coord
) )
assert address.text == "סוקולוב 38 חולון" assert address.text == "סוקולוב 38 חולון"
assert address.score == 95.5 assert address.score == 95.5
@@ -63,11 +58,7 @@ class TestAddress:
def test_address_without_coordinates(self): def test_address_without_coordinates(self):
"""Test creating address without coordinates.""" """Test creating address without coordinates."""
address = Address( address = Address(text="סוקולוב 38 חולון", id="addr123", type="address")
text="סוקולוב 38 חולון",
id="addr123",
type="address"
)
assert address.coordinates is None assert address.coordinates is None
assert address.score == 0 # Default value assert address.score == 0 # Default value
@@ -84,7 +75,7 @@ class TestAutocompleteResult:
type="city", type="city",
score=100.0, score=100.0,
coordinates=coord, coordinates=coord,
shape="POINT(180000.0 650000.0)" shape="POINT(180000.0 650000.0)",
) )
assert result.text == "חולון" assert result.text == "חולון"
assert result.coordinates.longitude == 180000.0 assert result.coordinates.longitude == 180000.0
@@ -92,11 +83,7 @@ class TestAutocompleteResult:
def test_result_without_shape(self): def test_result_without_shape(self):
"""Test result without shape data.""" """Test result without shape data."""
result = AutocompleteResult( result = AutocompleteResult(text="חולון", id="city123", type="city")
text="חולון",
id="city123",
type="city"
)
assert result.shape is None assert result.shape is None
assert result.coordinates is None assert result.coordinates is None
@@ -108,7 +95,7 @@ class TestAutocompleteResponse:
"""Test creating valid autocomplete response.""" """Test creating valid autocomplete response."""
results = [ results = [
AutocompleteResult(text="חולון", id="city1", type="city"), AutocompleteResult(text="חולון", id="city1", type="city"),
AutocompleteResult(text="חולון סוקולוב", id="street1", type="street") AutocompleteResult(text="חולון סוקולוב", id="street1", type="street"),
] ]
response = AutocompleteResponse(resultsCount=2, results=results) response = AutocompleteResponse(resultsCount=2, results=results)
assert response.results_count == 2 assert response.results_count == 2
@@ -116,10 +103,7 @@ class TestAutocompleteResponse:
def test_response_with_alias(self): def test_response_with_alias(self):
"""Test that camelCase alias works.""" """Test that camelCase alias works."""
response = AutocompleteResponse.model_validate({ response = AutocompleteResponse.model_validate({"resultsCount": 5, "results": []})
"resultsCount": 5,
"results": []
})
assert response.results_count == 5 assert response.results_count == 5
def test_empty_response(self): def test_empty_response(self):
@@ -143,7 +127,7 @@ class TestDeal:
property_type_description="דירה", property_type_description="דירה",
street_name="סוקולוב", street_name="סוקולוב",
house_number="38", house_number="38",
rooms=3.5 rooms=3.5,
) )
assert deal.objectid == 12345 assert deal.objectid == 12345
assert deal.deal_amount == 1500000.0 assert deal.deal_amount == 1500000.0
@@ -151,43 +135,31 @@ class TestDeal:
def test_deal_with_aliases(self): def test_deal_with_aliases(self):
"""Test creating deal using API camelCase field names.""" """Test creating deal using API camelCase field names."""
deal = Deal.model_validate({ deal = Deal.model_validate(
"objectid": 12345, {
"dealAmount": 1500000.0, "objectid": 12345,
"dealDate": "2024-01-15", "dealAmount": 1500000.0,
"assetArea": 85.0, "dealDate": "2024-01-15",
"propertyTypeDescription": "דירה" "assetArea": 85.0,
}) "propertyTypeDescription": "דירה",
}
)
assert deal.deal_amount == 1500000.0 assert deal.deal_amount == 1500000.0
assert deal.property_type_description == "דירה" assert deal.property_type_description == "דירה"
def test_deal_price_per_sqm_computed(self): def test_deal_price_per_sqm_computed(self):
"""Test price_per_sqm computed field.""" """Test price_per_sqm computed field."""
deal = Deal( deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15", asset_area=85.0)
objectid=12345,
deal_amount=1500000.0,
deal_date="2024-01-15",
asset_area=85.0
)
assert deal.price_per_sqm == round(1500000.0 / 85.0, 2) assert deal.price_per_sqm == round(1500000.0 / 85.0, 2)
def test_deal_price_per_sqm_no_area(self): def test_deal_price_per_sqm_no_area(self):
"""Test price_per_sqm returns None when area is missing.""" """Test price_per_sqm returns None when area is missing."""
deal = Deal( deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15")
objectid=12345,
deal_amount=1500000.0,
deal_date="2024-01-15"
)
assert deal.price_per_sqm is None assert deal.price_per_sqm is None
def test_deal_price_per_sqm_zero_area(self): def test_deal_price_per_sqm_zero_area(self):
"""Test price_per_sqm returns None when area is zero.""" """Test price_per_sqm returns None when area is zero."""
deal = Deal( deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15", asset_area=0.0)
objectid=12345,
deal_amount=1500000.0,
deal_date="2024-01-15",
asset_area=0.0
)
assert deal.price_per_sqm is None assert deal.price_per_sqm is None
def test_deal_extra_fields_allowed(self): def test_deal_extra_fields_allowed(self):
@@ -197,7 +169,7 @@ class TestDeal:
"dealAmount": 1500000.0, "dealAmount": 1500000.0,
"dealDate": "2024-01-15", "dealDate": "2024-01-15",
"extra_field": "extra_value", "extra_field": "extra_value",
"another_field": 123 "another_field": 123,
} }
deal = Deal.model_validate(deal_data) deal = Deal.model_validate(deal_data)
# Extra fields should be stored # Extra fields should be stored
@@ -205,12 +177,7 @@ class TestDeal:
def test_deal_serialization(self): def test_deal_serialization(self):
"""Test deal serialization to dict.""" """Test deal serialization to dict."""
deal = Deal( deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15", asset_area=85.0)
objectid=12345,
deal_amount=1500000.0,
deal_date="2024-01-15",
asset_area=85.0
)
deal_dict = deal.model_dump() deal_dict = deal.model_dump()
assert deal_dict["objectid"] == 12345 assert deal_dict["objectid"] == 12345
assert deal_dict["deal_amount"] == 1500000.0 assert deal_dict["deal_amount"] == 1500000.0
@@ -218,11 +185,7 @@ class TestDeal:
def test_deal_serialization_exclude_none(self): def test_deal_serialization_exclude_none(self):
"""Test deal serialization excluding None values.""" """Test deal serialization excluding None values."""
deal = Deal( deal = Deal(objectid=12345, deal_amount=1500000.0, deal_date="2024-01-15")
objectid=12345,
deal_amount=1500000.0,
deal_date="2024-01-15"
)
deal_dict = deal.model_dump(exclude_none=True) deal_dict = deal.model_dump(exclude_none=True)
assert "asset_area" not in deal_dict assert "asset_area" not in deal_dict
assert "rooms" not in deal_dict assert "rooms" not in deal_dict
@@ -240,28 +203,11 @@ class TestDealStatistics:
"""Test creating valid deal statistics.""" """Test creating valid deal statistics."""
stats = DealStatistics( stats = DealStatistics(
total_deals=100, total_deals=100,
price_statistics={ price_statistics={"mean": 1500000.0, "median": 1400000.0, "std_dev": 200000.0},
"mean": 1500000.0, area_statistics={"mean": 85.5, "median": 82.0},
"median": 1400000.0, price_per_sqm_statistics={"mean": 17500.0, "median": 17200.0},
"std_dev": 200000.0 property_type_distribution={"דירה": 80, "דירת גן": 15, "פנטהאוז": 5},
}, date_range={"earliest": "2022-01-01", "latest": "2024-12-31"},
area_statistics={
"mean": 85.5,
"median": 82.0
},
price_per_sqm_statistics={
"mean": 17500.0,
"median": 17200.0
},
property_type_distribution={
"דירה": 80,
"דירת גן": 15,
"פנטהאוז": 5
},
date_range={
"earliest": "2022-01-01",
"latest": "2024-12-31"
}
) )
assert stats.total_deals == 100 assert stats.total_deals == 100
assert stats.price_statistics["mean"] == 1500000.0 assert stats.price_statistics["mean"] == 1500000.0
@@ -286,7 +232,7 @@ class TestMarketActivityScore:
deals_per_month=10.0, deals_per_month=10.0,
trend="increasing", trend="increasing",
time_period_months=12, time_period_months=12,
monthly_distribution={"2024-01": 8, "2024-02": 12} monthly_distribution={"2024-01": 8, "2024-02": 12},
) )
assert score.activity_score == 75.5 assert score.activity_score == 75.5
assert score.trend == "increasing" assert score.trend == "increasing"
@@ -300,7 +246,7 @@ class TestMarketActivityScore:
total_deals=100, total_deals=100,
deals_per_month=8.0, deals_per_month=8.0,
trend="stable", trend="stable",
time_period_months=12 time_period_months=12,
) )
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
@@ -309,7 +255,7 @@ class TestMarketActivityScore:
total_deals=100, total_deals=100,
deals_per_month=8.0, deals_per_month=8.0,
trend="stable", trend="stable",
time_period_months=12 time_period_months=12,
) )
@@ -327,7 +273,7 @@ class TestInvestmentAnalysis:
avg_price_per_sqm=17500.0, avg_price_per_sqm=17500.0,
price_change_pct=12.5, price_change_pct=12.5,
total_deals=85, total_deals=85,
data_quality="good" data_quality="good",
) )
assert analysis.investment_score == 68.5 assert analysis.investment_score == 68.5
assert analysis.price_trend == "increasing" assert analysis.price_trend == "increasing"
@@ -345,7 +291,7 @@ class TestInvestmentAnalysis:
avg_price_per_sqm=17000.0, avg_price_per_sqm=17000.0,
price_change_pct=5.0, price_change_pct=5.0,
total_deals=100, total_deals=100,
data_quality="excellent" data_quality="excellent",
) )
@@ -360,7 +306,7 @@ class TestLiquidityMetrics:
time_period_months=12, time_period_months=12,
avg_deals_per_month=12.5, avg_deals_per_month=12.5,
deal_velocity=12.5, deal_velocity=12.5,
market_activity_level="high" market_activity_level="high",
) )
assert metrics.liquidity_score == 82.3 assert metrics.liquidity_score == 82.3
assert metrics.market_activity_level == "high" assert metrics.market_activity_level == "high"
@@ -381,7 +327,7 @@ class TestDealFilters:
min_area=60.0, min_area=60.0,
max_area=100.0, max_area=100.0,
min_floor=1, min_floor=1,
max_floor=5 max_floor=5,
) )
assert filters.property_type == "דירה" assert filters.property_type == "דירה"
assert filters.min_rooms == 2.0 assert filters.min_rooms == 2.0
@@ -436,14 +382,14 @@ class TestModelIntegration:
deal_amount=1000000.0, deal_amount=1000000.0,
deal_date="2024-01-01", deal_date="2024-01-01",
asset_area=100.0, asset_area=100.0,
property_type_description="דירה" property_type_description="דירה",
), ),
Deal( Deal(
objectid=2, objectid=2,
deal_amount=2000000.0, deal_amount=2000000.0,
deal_date="2024-01-02", deal_date="2024-01-02",
asset_area=100.0, asset_area=100.0,
property_type_description="דירה" property_type_description="דירה",
), ),
] ]
@@ -460,10 +406,7 @@ class TestModelIntegration:
# Simulate autocomplete response # Simulate autocomplete response
coord = CoordinatePoint(longitude=180000.0, latitude=650000.0) coord = CoordinatePoint(longitude=180000.0, latitude=650000.0)
result = AutocompleteResult( result = AutocompleteResult(
text="סוקולוב 38 חולון", text="סוקולוב 38 חולון", id="addr123", type="address", coordinates=coord
id="addr123",
type="address",
coordinates=coord
) )
response = AutocompleteResponse(resultsCount=1, results=[result]) response = AutocompleteResponse(resultsCount=1, results=[result])
@@ -478,14 +421,18 @@ class TestModelIntegration:
min_rooms=3.0, min_rooms=3.0,
max_rooms=4.0, max_rooms=4.0,
min_price=1000000.0, min_price=1000000.0,
max_price=2000000.0 max_price=2000000.0,
) )
# Create test deals # Create test deals
deals = [ deals = [
Deal(objectid=1, deal_amount=1200000.0, deal_date="2024-01-01", rooms=3.0), Deal(objectid=1, deal_amount=1200000.0, deal_date="2024-01-01", rooms=3.0),
Deal(objectid=2, deal_amount=2500000.0, deal_date="2024-01-02", rooms=4.0), # Price too high Deal(
Deal(objectid=3, deal_amount=1500000.0, deal_date="2024-01-03", rooms=2.0), # Too few rooms objectid=2, deal_amount=2500000.0, deal_date="2024-01-02", rooms=4.0
), # Price too high
Deal(
objectid=3, deal_amount=1500000.0, deal_date="2024-01-03", rooms=2.0
), # Too few rooms
] ]
# Manually check which deals would pass # Manually check which deals would pass
+60 -13
View File
@@ -4,10 +4,12 @@ Tests for nadlan_mcp.govmap.statistics module.
Comprehensive tests for statistical calculation functions. Comprehensive tests for statistical calculation functions.
""" """
import pytest
from datetime import date from datetime import date
from nadlan_mcp.govmap.statistics import calculate_deal_statistics, calculate_std_dev
import pytest
from nadlan_mcp.govmap.models import Deal, DealStatistics from nadlan_mcp.govmap.models import Deal, DealStatistics
from nadlan_mcp.govmap.statistics import calculate_deal_statistics, calculate_std_dev
class TestCalculateDealStatistics: class TestCalculateDealStatistics:
@@ -135,8 +137,12 @@ class TestCalculateDealStatistics:
"""Test statistics when some deals have zero prices.""" """Test statistics when some deals have zero prices."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-01", asset_area=80.0), Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-01", asset_area=80.0),
Deal(objectid=2, deal_amount=0.0, deal_date="2024-01-02", asset_area=100.0), # Zero price excluded Deal(
Deal(objectid=3, deal_amount=-1.0, deal_date="2024-01-03", asset_area=120.0), # Negative excluded objectid=2, deal_amount=0.0, deal_date="2024-01-02", asset_area=100.0
), # Zero price excluded
Deal(
objectid=3, deal_amount=-1.0, deal_date="2024-01-03", asset_area=120.0
), # Negative excluded
] ]
stats = calculate_deal_statistics(deals) stats = calculate_deal_statistics(deals)
@@ -161,8 +167,18 @@ class TestCalculateDealStatistics:
def test_deals_with_missing_property_types(self): def test_deals_with_missing_property_types(self):
"""Test statistics when some deals have missing property types.""" """Test statistics when some deals have missing property types."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-01", property_type_description="דירה"), Deal(
Deal(objectid=2, deal_amount=1500000.0, deal_date="2024-01-02", property_type_description=None), objectid=1,
deal_amount=1000000.0,
deal_date="2024-01-01",
property_type_description="דירה",
),
Deal(
objectid=2,
deal_amount=1500000.0,
deal_date="2024-01-02",
property_type_description=None,
),
Deal(objectid=3, deal_amount=2000000.0, deal_date="2024-01-03"), # No property_type Deal(objectid=3, deal_amount=2000000.0, deal_date="2024-01-03"), # No property_type
] ]
stats = calculate_deal_statistics(deals) stats = calculate_deal_statistics(deals)
@@ -196,7 +212,13 @@ class TestCalculateDealStatistics:
def test_single_deal(self): def test_single_deal(self):
"""Test statistics with single deal.""" """Test statistics with single deal."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-01", asset_area=80.0, property_type_description="דירה") Deal(
objectid=1,
deal_amount=1000000.0,
deal_date="2024-01-01",
asset_area=80.0,
property_type_description="דירה",
)
] ]
stats = calculate_deal_statistics(deals) stats = calculate_deal_statistics(deals)
@@ -238,8 +260,18 @@ class TestCalculateDealStatistics:
def test_date_handling_with_iso_strings(self): def test_date_handling_with_iso_strings(self):
"""Test date range calculation with ISO format strings.""" """Test date range calculation with ISO format strings."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-15T00:00:00.000Z", asset_area=80.0), Deal(
Deal(objectid=2, deal_amount=1500000.0, deal_date="2024-02-01T12:30:45.123Z", asset_area=100.0), objectid=1,
deal_amount=1000000.0,
deal_date="2024-01-15T00:00:00.000Z",
asset_area=80.0,
),
Deal(
objectid=2,
deal_amount=1500000.0,
deal_date="2024-02-01T12:30:45.123Z",
asset_area=100.0,
),
] ]
stats = calculate_deal_statistics(deals) stats = calculate_deal_statistics(deals)
@@ -250,9 +282,24 @@ class TestCalculateDealStatistics:
def test_multiple_same_property_types(self): def test_multiple_same_property_types(self):
"""Test property type distribution with duplicates.""" """Test property type distribution with duplicates."""
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000.0, deal_date="2024-01-01", property_type_description="דירה"), Deal(
Deal(objectid=2, deal_amount=1500000.0, deal_date="2024-01-02", property_type_description="דירה"), objectid=1,
Deal(objectid=3, deal_amount=2000000.0, deal_date="2024-01-03", property_type_description="דירה"), deal_amount=1000000.0,
deal_date="2024-01-01",
property_type_description="דירה",
),
Deal(
objectid=2,
deal_amount=1500000.0,
deal_date="2024-01-02",
property_type_description="דירה",
),
Deal(
objectid=3,
deal_amount=2000000.0,
deal_date="2024-01-03",
property_type_description="דירה",
),
] ]
stats = calculate_deal_statistics(deals) stats = calculate_deal_statistics(deals)
@@ -289,7 +336,7 @@ class TestCalculateDealStatistics:
Deal( Deal(
objectid=i, objectid=i,
deal_amount=1000000.0 if has_price else 0.0, # Use 0.0 instead of None deal_amount=1000000.0 if has_price else 0.0, # Use 0.0 instead of None
deal_date=f"2024-01-{i+1:02d}", deal_date=f"2024-01-{i + 1:02d}",
asset_area=80.0 if has_area else None, asset_area=80.0 if has_area else None,
) )
) )
+1 -2
View File
@@ -4,11 +4,10 @@ Unit tests for utils module.
Tests helper utilities including distance calculation, address matching, and floor parsing. Tests helper utilities including distance calculation, address matching, and floor parsing.
""" """
import pytest
from nadlan_mcp.govmap.utils import ( from nadlan_mcp.govmap.utils import (
calculate_distance, calculate_distance,
is_same_building,
extract_floor_number, extract_floor_number,
is_same_building,
) )
+2 -1
View File
@@ -5,11 +5,12 @@ Tests input validation functions for addresses, coordinates, integers, and deal
""" """
import pytest import pytest
from nadlan_mcp.govmap.validators import ( from nadlan_mcp.govmap.validators import (
validate_address, validate_address,
validate_coordinates, validate_coordinates,
validate_positive_int,
validate_deal_type, validate_deal_type,
validate_positive_int,
) )
+66 -91
View File
@@ -8,19 +8,22 @@ Updated for Phase 4.1 - Pydantic models integration.
""" """
import json import json
import pytest from unittest.mock import patch
from unittest.mock import Mock, patch
from nadlan_mcp import fastmcp_server from nadlan_mcp import fastmcp_server
from nadlan_mcp.govmap.models import ( from nadlan_mcp.govmap.models import (
Deal, AutocompleteResponse, AutocompleteResult, CoordinatePoint, AutocompleteResponse,
DealStatistics, MarketActivityScore, InvestmentAnalysis, LiquidityMetrics AutocompleteResult,
CoordinatePoint,
Deal,
DealStatistics,
) )
class TestAutocompleteAddress: class TestAutocompleteAddress:
"""Test autocomplete_address MCP tool.""" """Test autocomplete_address MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_autocomplete(self, mock_client): def test_successful_autocomplete(self, mock_client):
"""Test successful address autocomplete with correct field mapping.""" """Test successful address autocomplete with correct field mapping."""
# Now returns AutocompleteResponse model # Now returns AutocompleteResponse model
@@ -33,7 +36,7 @@ class TestAutocompleteAddress:
type="address", type="address",
score=100, score=100,
coordinates=CoordinatePoint(longitude=180000.5, latitude=650000.3), coordinates=CoordinatePoint(longitude=180000.5, latitude=650000.3),
shape="POINT(180000.5 650000.3)" shape="POINT(180000.5 650000.3)",
), ),
AutocompleteResult( AutocompleteResult(
id="address|ADDR|124", id="address|ADDR|124",
@@ -41,9 +44,9 @@ class TestAutocompleteAddress:
type="address", type="address",
score=95, score=95,
coordinates=CoordinatePoint(longitude=180010.2, latitude=650005.7), coordinates=CoordinatePoint(longitude=180010.2, latitude=650005.7),
shape="POINT(180010.2 650005.7)" shape="POINT(180010.2 650005.7)",
) ),
] ],
) )
result = fastmcp_server.autocomplete_address("דיזנגוף תל אביב") result = fastmcp_server.autocomplete_address("דיזנגוף תל אביב")
@@ -56,20 +59,19 @@ class TestAutocompleteAddress:
assert parsed[0]["coordinates"]["longitude"] == 180000.5 assert parsed[0]["coordinates"]["longitude"] == 180000.5
assert parsed[0]["coordinates"]["latitude"] == 650000.3 assert parsed[0]["coordinates"]["latitude"] == 650000.3
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_autocomplete_no_results(self, mock_client): def test_autocomplete_no_results(self, mock_client):
"""Test autocomplete with no results.""" """Test autocomplete with no results."""
# Now returns AutocompleteResponse model # Now returns AutocompleteResponse model
mock_client.autocomplete_address.return_value = AutocompleteResponse( mock_client.autocomplete_address.return_value = AutocompleteResponse(
resultsCount=0, resultsCount=0, results=[]
results=[]
) )
result = fastmcp_server.autocomplete_address("nonexistent address") result = fastmcp_server.autocomplete_address("nonexistent address")
# With empty results, returns a message string # With empty results, returns a message string
assert "No addresses found" in result assert "No addresses found" in result
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_autocomplete_invalid_coordinates(self, mock_client): def test_autocomplete_invalid_coordinates(self, mock_client):
"""Test autocomplete with invalid/missing coordinate format.""" """Test autocomplete with invalid/missing coordinate format."""
# Now returns AutocompleteResponse model with result that has no coordinates # Now returns AutocompleteResponse model with result that has no coordinates
@@ -82,9 +84,9 @@ class TestAutocompleteAddress:
type="address", type="address",
score=100, score=100,
coordinates=None, # No coordinates parsed coordinates=None, # No coordinates parsed
shape="INVALID_FORMAT" shape="INVALID_FORMAT",
) )
] ],
) )
result = fastmcp_server.autocomplete_address("test") result = fastmcp_server.autocomplete_address("test")
@@ -93,7 +95,7 @@ class TestAutocompleteAddress:
assert len(parsed) == 1 assert len(parsed) == 1
assert parsed[0]["text"] == "דיזנגוף 50" assert parsed[0]["text"] == "דיזנגוף 50"
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_autocomplete_missing_shape(self, mock_client): def test_autocomplete_missing_shape(self, mock_client):
"""Test autocomplete with missing shape field.""" """Test autocomplete with missing shape field."""
# Mock with AutocompleteResponse model # Mock with AutocompleteResponse model
@@ -105,9 +107,9 @@ class TestAutocompleteAddress:
text="דיזנגוף 50", text="דיזנגוף 50",
type="address", type="address",
score=100, score=100,
coordinates=None # No coordinates coordinates=None, # No coordinates
) )
] ],
) )
result = fastmcp_server.autocomplete_address("test") result = fastmcp_server.autocomplete_address("test")
@@ -115,7 +117,7 @@ class TestAutocompleteAddress:
# When coordinates are None, the field isn't included in the response # When coordinates are None, the field isn't included in the response
assert "coordinates" not in parsed[0] assert "coordinates" not in parsed[0]
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_autocomplete_error_handling(self, mock_client): def test_autocomplete_error_handling(self, mock_client):
"""Test autocomplete error handling.""" """Test autocomplete error handling."""
mock_client.autocomplete_address.side_effect = Exception("API Error") mock_client.autocomplete_address.side_effect = Exception("API Error")
@@ -128,7 +130,7 @@ class TestAutocompleteAddress:
class TestGetDealsByRadius: class TestGetDealsByRadius:
"""Test get_deals_by_radius MCP tool.""" """Test get_deals_by_radius MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_get_deals(self, mock_client): def test_successful_get_deals(self, mock_client):
"""Test successful polygon metadata retrieval.""" """Test successful polygon metadata retrieval."""
# Mock with polygon metadata dicts (not Deal objects) # Mock with polygon metadata dicts (not Deal objects)
@@ -139,7 +141,7 @@ class TestGetDealsByRadius:
"settlementNameHeb": "תל אביב-יפו", "settlementNameHeb": "תל אביב-יפו",
"streetNameHeb": "דיזנגוף", "streetNameHeb": "דיזנגוף",
"houseNum": 50, "houseNum": 50,
"polygon_id": "123-456" "polygon_id": "123-456",
} }
] ]
mock_client.get_deals_by_radius.return_value = mock_polygons mock_client.get_deals_by_radius.return_value = mock_polygons
@@ -152,7 +154,7 @@ class TestGetDealsByRadius:
assert parsed["total_polygons"] == 1 assert parsed["total_polygons"] == 1
mock_client.get_deals_by_radius.assert_called_once() mock_client.get_deals_by_radius.assert_called_once()
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_get_deals_no_results(self, mock_client): def test_get_deals_no_results(self, mock_client):
"""Test polygon metadata retrieval with no results.""" """Test polygon metadata retrieval with no results."""
mock_client.get_deals_by_radius.return_value = [] mock_client.get_deals_by_radius.return_value = []
@@ -160,7 +162,7 @@ class TestGetDealsByRadius:
result = fastmcp_server.get_deals_by_radius(650000.0, 180000.0, 500) result = fastmcp_server.get_deals_by_radius(650000.0, 180000.0, 500)
assert "No polygons found" in result assert "No polygons found" in result
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_get_deals_strips_bloat_fields(self, mock_client): def test_get_deals_strips_bloat_fields(self, mock_client):
"""Test that polygon metadata is returned as-is.""" """Test that polygon metadata is returned as-is."""
# Mock with polygon metadata dicts # Mock with polygon metadata dicts
@@ -169,7 +171,7 @@ class TestGetDealsByRadius:
"objectid": 123, "objectid": 123,
"dealscount": "10", "dealscount": "10",
"polygon_id": "abc123", "polygon_id": "abc123",
"settlementNameHeb": "Tel Aviv" "settlementNameHeb": "Tel Aviv",
} }
] ]
mock_client.get_deals_by_radius.return_value = mock_polygons mock_client.get_deals_by_radius.return_value = mock_polygons
@@ -182,7 +184,7 @@ class TestGetDealsByRadius:
assert polygon["polygon_id"] == "abc123" assert polygon["polygon_id"] == "abc123"
assert polygon["dealscount"] == "10" assert polygon["dealscount"] == "10"
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_get_deals_error_handling(self, mock_client): def test_get_deals_error_handling(self, mock_client):
"""Test error handling for deal retrieval.""" """Test error handling for deal retrieval."""
mock_client.get_deals_by_radius.side_effect = ValueError("Invalid coordinates") mock_client.get_deals_by_radius.side_effect = ValueError("Invalid coordinates")
@@ -194,7 +196,7 @@ class TestGetDealsByRadius:
class TestFindRecentDealsForAddress: class TestFindRecentDealsForAddress:
"""Test find_recent_deals_for_address MCP tool.""" """Test find_recent_deals_for_address MCP tool."""
@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 with Deal models # Mock with Deal models
@@ -204,21 +206,19 @@ class TestFindRecentDealsForAddress:
deal_amount=2000000, deal_amount=2000000,
deal_date="2023-01-01", deal_date="2023-01-01",
asset_area=80.0, asset_area=80.0,
priority=0 priority=0,
), ),
Deal( Deal(
objectid=124, objectid=124,
deal_amount=1800000, deal_amount=1800000,
deal_date="2023-01-02", deal_date="2023-01-02",
asset_area=70.0, asset_area=70.0,
priority=1 priority=1,
) ),
] ]
mock_client.find_recent_deals_for_address.return_value = mock_deals mock_client.find_recent_deals_for_address.return_value = mock_deals
result = fastmcp_server.find_recent_deals_for_address( result = fastmcp_server.find_recent_deals_for_address("דיזנגוף 50 תל אביב", 2, 100, 100)
"דיזנגוף 50 תל אביב", 2, 100, 100
)
parsed = json.loads(result) parsed = json.loads(result)
assert "search_parameters" in parsed assert "search_parameters" in parsed
@@ -227,20 +227,18 @@ class TestFindRecentDealsForAddress:
assert len(parsed["deals"]) == 2 assert len(parsed["deals"]) == 2
assert parsed["market_statistics"]["deal_breakdown"]["total_deals"] == 2 assert parsed["market_statistics"]["deal_breakdown"]["total_deals"] == 2
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_find_deals_no_results(self, mock_client): def test_find_deals_no_results(self, mock_client):
"""Test deal finding with no results.""" """Test deal finding with no results."""
mock_client.find_recent_deals_for_address.return_value = [] mock_client.find_recent_deals_for_address.return_value = []
result = fastmcp_server.find_recent_deals_for_address( result = fastmcp_server.find_recent_deals_for_address("nonexistent address", 2, 100, 100)
"nonexistent address", 2, 100, 100
)
# When no deals, returns a text message, not JSON # When no deals, returns a text message, not JSON
assert "No second hand (used) deals found" in result assert "No second hand (used) deals found" in result
assert "nonexistent address" in result assert "nonexistent address" in result
@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 with Deal models # Mock with Deal models
@@ -250,14 +248,12 @@ class TestFindRecentDealsForAddress:
deal_amount=2000000, deal_amount=2000000,
deal_date="2023-01-01", deal_date="2023-01-01",
shape="MULTIPOLYGON(...)", shape="MULTIPOLYGON(...)",
sourceorder=1 sourceorder=1,
) )
] ]
mock_client.find_recent_deals_for_address.return_value = mock_deals mock_client.find_recent_deals_for_address.return_value = mock_deals
result = fastmcp_server.find_recent_deals_for_address( result = fastmcp_server.find_recent_deals_for_address("test address", 2, 100, 100)
"test address", 2, 100, 100
)
parsed = json.loads(result) parsed = json.loads(result)
assert "shape" not in parsed["deals"][0] assert "shape" not in parsed["deals"][0]
@@ -267,7 +263,7 @@ class TestFindRecentDealsForAddress:
class TestAnalyzeMarketTrends: class TestAnalyzeMarketTrends:
"""Test analyze_market_trends MCP tool.""" """Test analyze_market_trends MCP tool."""
@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 with Deal models # Mock with Deal models
@@ -279,7 +275,7 @@ class TestAnalyzeMarketTrends:
asset_area=80.0, asset_area=80.0,
property_type_description="דירה", property_type_description="דירה",
neighborhood="תל אביב", neighborhood="תל אביב",
priority=1 priority=1,
) )
] ]
mock_client.find_recent_deals_for_address.return_value = mock_deals mock_client.find_recent_deals_for_address.return_value = mock_deals
@@ -292,7 +288,7 @@ class TestAnalyzeMarketTrends:
assert "yearly_trends" in parsed assert "yearly_trends" in parsed
assert "top_property_types" in parsed assert "top_property_types" in parsed
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_market_analysis_no_data(self, mock_client): def test_market_analysis_no_data(self, mock_client):
"""Test market analysis with no data.""" """Test market analysis with no data."""
mock_client.find_recent_deals_for_address.return_value = [] mock_client.find_recent_deals_for_address.return_value = []
@@ -307,25 +303,23 @@ class TestAnalyzeMarketTrends:
class TestCompareAddresses: class TestCompareAddresses:
"""Test compare_addresses MCP tool.""" """Test compare_addresses MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_comparison(self, mock_client): def test_successful_comparison(self, mock_client):
"""Test successful address comparison.""" """Test successful address comparison."""
# Mock different deals for each address # Mock different deals for each address
mock_client.find_recent_deals_for_address.side_effect = [ mock_client.find_recent_deals_for_address.side_effect = [
[{"dealAmount": 2000000, "assetArea": 80, "price_per_sqm": 25000}], [{"dealAmount": 2000000, "assetArea": 80, "price_per_sqm": 25000}],
[{"dealAmount": 1500000, "assetArea": 60, "price_per_sqm": 25000}] [{"dealAmount": 1500000, "assetArea": 60, "price_per_sqm": 25000}],
] ]
result = fastmcp_server.compare_addresses( result = fastmcp_server.compare_addresses(["דיזנגוף 50 תל אביב", "הרצל 1 חולון"])
["דיזנגוף 50 תל אביב", "הרצל 1 חולון"]
)
parsed = json.loads(result) parsed = json.loads(result)
assert "addresses_compared" in parsed assert "addresses_compared" in parsed
assert parsed["addresses_compared"] == 2 assert parsed["addresses_compared"] == 2
assert "all_results" in parsed assert "all_results" in parsed
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_comparison_error_handling(self, mock_client): def test_comparison_error_handling(self, mock_client):
"""Test error handling in address comparison.""" """Test error handling in address comparison."""
mock_client.find_recent_deals_for_address.side_effect = ValueError("Invalid address") mock_client.find_recent_deals_for_address.side_effect = ValueError("Invalid address")
@@ -340,10 +334,10 @@ class TestCompareAddresses:
class TestGetValuationComparables: class TestGetValuationComparables:
"""Test get_valuation_comparables MCP tool.""" """Test get_valuation_comparables MCP tool."""
@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."""
from nadlan_mcp.govmap.models import DealStatistics
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [
Deal( Deal(
@@ -352,24 +346,21 @@ class TestGetValuationComparables:
deal_date="2023-01-01", deal_date="2023-01-01",
asset_area=80.0, asset_area=80.0,
rooms=3.0, rooms=3.0,
property_type_description="דירה" property_type_description="דירה",
) )
] ]
mock_stats = DealStatistics( mock_stats = DealStatistics(
total_deals=1, total_deals=1,
price_statistics={"mean": 2000000}, price_statistics={"mean": 2000000},
area_statistics={"mean": 80}, area_statistics={"mean": 80},
price_per_sqm_statistics={"mean": 25000} price_per_sqm_statistics={"mean": 25000},
) )
mock_client.find_recent_deals_for_address.return_value = mock_deals mock_client.find_recent_deals_for_address.return_value = mock_deals
mock_client.filter_deals_by_criteria.return_value = mock_deals mock_client.filter_deals_by_criteria.return_value = mock_deals
mock_client.calculate_deal_statistics.return_value = mock_stats mock_client.calculate_deal_statistics.return_value = mock_stats
result = fastmcp_server.get_valuation_comparables( result = fastmcp_server.get_valuation_comparables(
"דיזנגוף 50 תל אביב", "דיזנגוף 50 תל אביב", property_type="דירה", min_rooms=2, max_rooms=4
property_type="דירה",
min_rooms=2,
max_rooms=4
) )
parsed = json.loads(result) parsed = json.loads(result)
@@ -378,10 +369,10 @@ class TestGetValuationComparables:
assert "comparables" in parsed assert "comparables" in parsed
assert parsed["filters_applied"]["property_type"] == "דירה" assert parsed["filters_applied"]["property_type"] == "דירה"
@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."""
from nadlan_mcp.govmap.models import DealStatistics
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [
Deal( Deal(
@@ -390,13 +381,10 @@ class TestGetValuationComparables:
deal_date="2023-01-01", deal_date="2023-01-01",
shape="MULTIPOLYGON(...)", shape="MULTIPOLYGON(...)",
sourceorder=1, sourceorder=1,
source_polygon_id="abc" source_polygon_id="abc",
) )
] ]
mock_stats = DealStatistics( mock_stats = DealStatistics(total_deals=1, price_statistics={"mean": 2000000})
total_deals=1,
price_statistics={"mean": 2000000}
)
mock_client.find_recent_deals_for_address.return_value = mock_deals mock_client.find_recent_deals_for_address.return_value = mock_deals
mock_client.filter_deals_by_criteria.return_value = mock_deals mock_client.filter_deals_by_criteria.return_value = mock_deals
mock_client.calculate_deal_statistics.return_value = mock_stats mock_client.calculate_deal_statistics.return_value = mock_stats
@@ -413,23 +401,18 @@ class TestGetValuationComparables:
class TestGetDealStatistics: class TestGetDealStatistics:
"""Test get_deal_statistics MCP tool.""" """Test get_deal_statistics MCP tool."""
@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."""
from nadlan_mcp.govmap.models import DealStatistics
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [
Deal(objectid=1, deal_amount=2000000, deal_date="2023-01-01", asset_area=80.0), Deal(objectid=1, deal_amount=2000000, deal_date="2023-01-01", asset_area=80.0),
Deal(objectid=2, deal_amount=1800000, deal_date="2023-01-02", asset_area=70.0) Deal(objectid=2, deal_amount=1800000, deal_date="2023-01-02", asset_area=70.0),
] ]
mock_stats = DealStatistics( mock_stats = DealStatistics(
total_deals=2, total_deals=2,
price_statistics={ price_statistics={"mean": 1900000, "median": 1900000, "min": 1800000, "max": 2000000},
"mean": 1900000,
"median": 1900000,
"min": 1800000,
"max": 2000000
}
) )
mock_client.find_recent_deals_for_address.return_value = mock_deals mock_client.find_recent_deals_for_address.return_value = mock_deals
mock_client.filter_deals_by_criteria.return_value = mock_deals mock_client.filter_deals_by_criteria.return_value = mock_deals
@@ -446,28 +429,24 @@ class TestGetDealStatistics:
class TestGetMarketActivityMetrics: class TestGetMarketActivityMetrics:
"""Test get_market_activity_metrics MCP tool.""" """Test get_market_activity_metrics MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_activity_metrics(self, mock_client): def test_successful_activity_metrics(self, mock_client):
"""Test successful market activity calculation.""" """Test successful market activity calculation."""
mock_deals = [ mock_deals = [
{ {"dealDate": "2024-01-15T00:00:00.000Z", "dealAmount": 2000000, "assetArea": 80}
"dealDate": "2024-01-15T00:00:00.000Z",
"dealAmount": 2000000,
"assetArea": 80
}
] ]
mock_client.find_recent_deals_for_address.return_value = mock_deals mock_client.find_recent_deals_for_address.return_value = mock_deals
mock_client.calculate_market_activity_score.return_value = { mock_client.calculate_market_activity_score.return_value = {
"activity_score": 75, "activity_score": 75,
"activity_level": "high" "activity_level": "high",
} }
mock_client.get_market_liquidity.return_value = { mock_client.get_market_liquidity.return_value = {
"velocity_score": 8.5, "velocity_score": 8.5,
"liquidity_rating": "high" "liquidity_rating": "high",
} }
mock_client.analyze_investment_potential.return_value = { mock_client.analyze_investment_potential.return_value = {
"investment_score": 80, "investment_score": 80,
"recommendation": "positive" "recommendation": "positive",
} }
result = fastmcp_server.get_market_activity_metrics("test address") result = fastmcp_server.get_market_activity_metrics("test address")
@@ -481,13 +460,11 @@ class TestGetMarketActivityMetrics:
class TestGetStreetDeals: class TestGetStreetDeals:
"""Test get_street_deals MCP tool.""" """Test get_street_deals MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_street_deals(self, mock_client): def test_successful_street_deals(self, mock_client):
"""Test successful street deal retrieval.""" """Test successful street deal retrieval."""
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [Deal(objectid=123, deal_amount=2000000, deal_date="2023-01-01")]
Deal(objectid=123, deal_amount=2000000, deal_date="2023-01-01")
]
mock_client.get_street_deals.return_value = mock_deals mock_client.get_street_deals.return_value = mock_deals
result = fastmcp_server.get_street_deals("12345", 100) result = fastmcp_server.get_street_deals("12345", 100)
@@ -500,13 +477,11 @@ class TestGetStreetDeals:
class TestGetNeighborhoodDeals: class TestGetNeighborhoodDeals:
"""Test get_neighborhood_deals MCP tool.""" """Test get_neighborhood_deals MCP tool."""
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_successful_neighborhood_deals(self, mock_client): def test_successful_neighborhood_deals(self, mock_client):
"""Test successful neighborhood deal retrieval.""" """Test successful neighborhood deal retrieval."""
# Mock with Deal models # Mock with Deal models
mock_deals = [ mock_deals = [Deal(objectid=123, deal_amount=2000000, deal_date="2023-01-01")]
Deal(objectid=123, deal_amount=2000000, deal_date="2023-01-01")
]
mock_client.get_neighborhood_deals.return_value = mock_deals mock_client.get_neighborhood_deals.return_value = mock_deals
result = fastmcp_server.get_neighborhood_deals("12345", 100) result = fastmcp_server.get_neighborhood_deals("12345", 100)
+195 -93
View File
@@ -4,12 +4,13 @@ Tests for the GovmapClient class.
Updated for Phase 4.1 - Pydantic models integration. Updated for Phase 4.1 - Pydantic models integration.
""" """
import pytest
import requests
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from nadlan_mcp.govmap import GovmapClient
from nadlan_mcp.govmap.models import Deal, AutocompleteResponse, AutocompleteResult, CoordinatePoint import pytest
from nadlan_mcp.config import GovmapConfig from nadlan_mcp.config import GovmapConfig
from nadlan_mcp.govmap import GovmapClient
from nadlan_mcp.govmap.models import AutocompleteResponse, AutocompleteResult, CoordinatePoint, Deal
class TestGovmapClient: class TestGovmapClient:
@@ -20,8 +21,8 @@ class TestGovmapClient:
client = GovmapClient() client = GovmapClient()
assert client.base_url == "https://www.govmap.gov.il/api" assert client.base_url == "https://www.govmap.gov.il/api"
assert client.session is not None assert client.session is not None
assert client.session.headers['Content-Type'] == 'application/json' assert client.session.headers["Content-Type"] == "application/json"
assert client.session.headers['User-Agent'] == 'NadlanMCP/1.0.0' assert client.session.headers["User-Agent"] == "NadlanMCP/1.0.0"
def test_client_initialization_with_custom_url(self): def test_client_initialization_with_custom_url(self):
"""Test that GovmapClient can be initialized with custom URL.""" """Test that GovmapClient can be initialized with custom URL."""
@@ -30,7 +31,7 @@ class TestGovmapClient:
client = GovmapClient(custom_config) client = GovmapClient(custom_config)
assert client.base_url == "https://custom-api.example.com/api" assert client.base_url == "https://custom-api.example.com/api"
@patch('requests.Session') @patch("requests.Session")
def test_autocomplete_address_success(self, mock_session_class): def test_autocomplete_address_success(self, mock_session_class):
"""Test successful address autocomplete.""" """Test successful address autocomplete."""
# Mock response # Mock response
@@ -44,9 +45,9 @@ class TestGovmapClient:
"type": "address", "type": "address",
"score": 100, "score": 100,
"shape": "POINT(3870000.123 3770000.456)", "shape": "POINT(3870000.123 3770000.456)",
"data": {} "data": {},
} }
] ],
} }
mock_response.raise_for_status.return_value = None mock_response.raise_for_status.return_value = None
@@ -66,7 +67,7 @@ class TestGovmapClient:
assert result.results[0].coordinates.longitude == 3870000.123 assert result.results[0].coordinates.longitude == 3870000.123
mock_session.post.assert_called_once() mock_session.post.assert_called_once()
@patch('requests.Session') @patch("requests.Session")
def test_autocomplete_address_empty_results(self, mock_session_class): def test_autocomplete_address_empty_results(self, mock_session_class):
"""Test autocomplete with empty results - should return empty results, not raise error.""" """Test autocomplete with empty results - should return empty results, not raise error."""
mock_response = Mock() mock_response = Mock()
@@ -84,7 +85,7 @@ class TestGovmapClient:
assert result.results_count == 0 assert result.results_count == 0
assert len(result.results) == 0 assert len(result.results) == 0
@patch('requests.Session') @patch("requests.Session")
def test_autocomplete_address_invalid_response(self, mock_session_class): def test_autocomplete_address_invalid_response(self, mock_session_class):
"""Test autocomplete with truly invalid response format.""" """Test autocomplete with truly invalid response format."""
mock_response = Mock() mock_response = Mock()
@@ -113,20 +114,20 @@ class TestGovmapClient:
text="test address", text="test address",
type="address", type="address",
shape="POINT(3870000.123 3770000.456)", shape="POINT(3870000.123 3770000.456)",
coordinates=CoordinatePoint(longitude=3870000.123, latitude=3770000.456) coordinates=CoordinatePoint(longitude=3870000.123, latitude=3770000.456),
) )
] ],
) )
# We'll test the coordinate parsing logic by calling the method that uses it # We'll test the coordinate parsing logic by calling the method that uses it
with patch.object(client, 'autocomplete_address', return_value=mock_autocomplete_result): with patch.object(client, "autocomplete_address", return_value=mock_autocomplete_result):
with patch.object(client, 'get_deals_by_radius', return_value=[]): with patch.object(client, "get_deals_by_radius", return_value=[]):
with patch.object(client, 'get_street_deals', return_value=[]): with patch.object(client, "get_street_deals", return_value=[]):
with patch.object(client, 'get_neighborhood_deals', return_value=[]): with patch.object(client, "get_neighborhood_deals", return_value=[]):
result = client.find_recent_deals_for_address("test", years_back=1) result = client.find_recent_deals_for_address("test", years_back=1)
assert result == [] assert result == []
@patch('requests.Session') @patch("requests.Session")
def test_get_deals_by_radius_success(self, mock_session_class): def test_get_deals_by_radius_success(self, mock_session_class):
"""Test successful polygon metadata retrieval by radius.""" """Test successful polygon metadata retrieval by radius."""
mock_response = Mock() mock_response = Mock()
@@ -138,7 +139,7 @@ class TestGovmapClient:
"settlementNameHeb": "תל אביב-יפו", "settlementNameHeb": "תל אביב-יפו",
"streetNameHeb": "דיזנגוף", "streetNameHeb": "דיזנגוף",
"houseNum": 50, "houseNum": 50,
"polygon_id": "123-456" "polygon_id": "123-456",
} }
] ]
mock_response.raise_for_status.return_value = None mock_response.raise_for_status.return_value = None
@@ -157,7 +158,7 @@ class TestGovmapClient:
assert result[0]["polygon_id"] == "123-456" assert result[0]["polygon_id"] == "123-456"
mock_session.get.assert_called_once() mock_session.get.assert_called_once()
@patch('requests.Session') @patch("requests.Session")
def test_get_street_deals_success(self, mock_session_class): def test_get_street_deals_success(self, mock_session_class):
"""Test successful street deals query.""" """Test successful street deals query."""
mock_response = Mock() mock_response = Mock()
@@ -170,9 +171,9 @@ class TestGovmapClient:
"dealDate": "2025-01-01T00:00:00.000Z", "dealDate": "2025-01-01T00:00:00.000Z",
"assetArea": 100, "assetArea": 100,
"settlementNameHeb": "תל אביב-יפו", "settlementNameHeb": "תל אביב-יפו",
"propertyTypeDescription": "דירה" "propertyTypeDescription": "דירה",
} }
] ],
} }
mock_response.raise_for_status.return_value = None mock_response.raise_for_status.return_value = None
@@ -191,7 +192,7 @@ class TestGovmapClient:
assert result[0].price_per_sqm == 10000.0 # Computed field assert result[0].price_per_sqm == 10000.0 # Computed field
mock_session.get.assert_called_once() mock_session.get.assert_called_once()
@patch('requests.Session') @patch("requests.Session")
def test_get_neighborhood_deals_success(self, mock_session_class): def test_get_neighborhood_deals_success(self, mock_session_class):
"""Test successful neighborhood deals query.""" """Test successful neighborhood deals query."""
mock_response = Mock() mock_response = Mock()
@@ -204,9 +205,9 @@ class TestGovmapClient:
"dealDate": "2025-01-15T00:00:00.000Z", "dealDate": "2025-01-15T00:00:00.000Z",
"assetArea": 120, "assetArea": 120,
"settlementNameHeb": "תל אביב-יפו", "settlementNameHeb": "תל אביב-יפו",
"propertyTypeDescription": "דירה" "propertyTypeDescription": "דירה",
} }
] ],
} }
mock_response.raise_for_status.return_value = None mock_response.raise_for_status.return_value = None
@@ -225,13 +226,19 @@ class TestGovmapClient:
assert result[0].price_per_sqm == round(2000000 / 120, 2) assert result[0].price_per_sqm == round(2000000 / 120, 2)
mock_session.get.assert_called_once() mock_session.get.assert_called_once()
@patch('nadlan_mcp.govmap.client.GovmapClient.get_neighborhood_deals') @patch("nadlan_mcp.govmap.client.GovmapClient.get_neighborhood_deals")
@patch('nadlan_mcp.govmap.client.GovmapClient.get_street_deals') @patch("nadlan_mcp.govmap.client.GovmapClient.get_street_deals")
@patch('nadlan_mcp.govmap.client.GovmapClient.get_deals_by_radius') @patch("nadlan_mcp.govmap.client.GovmapClient.get_deals_by_radius")
@patch('nadlan_mcp.govmap.client.GovmapClient.autocomplete_address') @patch("nadlan_mcp.govmap.client.GovmapClient.autocomplete_address")
def test_find_recent_deals_for_address_integration(self, mock_autocomplete, mock_radius, mock_street, mock_neighborhood): def test_find_recent_deals_for_address_integration(
self, mock_autocomplete, mock_radius, mock_street, mock_neighborhood
):
"""Test the main integration function.""" """Test the main integration function."""
from nadlan_mcp.govmap.models import CoordinatePoint, AutocompleteResult, AutocompleteResponse from nadlan_mcp.govmap.models import (
AutocompleteResponse,
AutocompleteResult,
CoordinatePoint,
)
# Mock autocomplete response - now returns AutocompleteResponse model # Mock autocomplete response - now returns AutocompleteResponse model
mock_autocomplete.return_value = AutocompleteResponse( mock_autocomplete.return_value = AutocompleteResponse(
@@ -242,14 +249,19 @@ class TestGovmapClient:
id="addr123", id="addr123",
type="address", type="address",
coordinates=CoordinatePoint(longitude=3870000.123, latitude=3770000.456), coordinates=CoordinatePoint(longitude=3870000.123, latitude=3770000.456),
shape="POINT(3870000.123 3770000.456)" shape="POINT(3870000.123 3770000.456)",
) )
] ],
) )
# Mock radius response - now returns List[Dict] (polygon metadata) # Mock radius response - now returns List[Dict] (polygon metadata)
mock_radius.return_value = [ mock_radius.return_value = [
{"objectid": 1, "dealscount": "10", "polygon_id": "123-456", "settlementNameHeb": "Tel Aviv"} {
"objectid": 1,
"dealscount": "10",
"polygon_id": "123-456",
"settlementNameHeb": "Tel Aviv",
}
] ]
# Mock street deals response - now returns List[Deal] # Mock street deals response - now returns List[Deal]
@@ -259,7 +271,7 @@ class TestGovmapClient:
deal_amount=1000000, deal_amount=1000000,
deal_date="2025-01-01T00:00:00.000Z", deal_date="2025-01-01T00:00:00.000Z",
street_name="Test Street", street_name="Test Street",
house_number="1" house_number="1",
) )
] ]
@@ -270,7 +282,7 @@ class TestGovmapClient:
deal_amount=2000000, deal_amount=2000000,
deal_date="2025-01-15T00:00:00.000Z", deal_date="2025-01-15T00:00:00.000Z",
street_name="Test Street", street_name="Test Street",
house_number="2" house_number="2",
) )
] ]
@@ -284,11 +296,11 @@ class TestGovmapClient:
# Should be sorted by priority first (street=1 before neighborhood=2), then by date # Should be sorted by priority first (street=1 before neighborhood=2), then by date
# Priority is set dynamically by find_recent_deals_for_address # Priority is set dynamically by find_recent_deals_for_address
assert hasattr(result[0], 'priority') assert hasattr(result[0], "priority")
assert hasattr(result[1], 'priority') assert hasattr(result[1], "priority")
assert result[0].priority <= result[1].priority # Lower priority comes first assert result[0].priority <= result[1].priority # Lower priority comes first
@patch('requests.Session') @patch("requests.Session")
def test_http_error_handling(self, mock_session_class): def test_http_error_handling(self, mock_session_class):
"""Test that HTTP errors are properly handled.""" """Test that HTTP errors are properly handled."""
mock_response = Mock() mock_response = Mock()
@@ -316,12 +328,12 @@ class TestGovmapClient:
text="test address", text="test address",
type="address", type="address",
shape="INVALID_FORMAT", # Invalid format shape="INVALID_FORMAT", # Invalid format
coordinates=None # No coordinates coordinates=None, # No coordinates
) )
] ],
) )
with patch.object(client, 'autocomplete_address', return_value=mock_autocomplete_result): with patch.object(client, "autocomplete_address", return_value=mock_autocomplete_result):
with pytest.raises(ValueError, match="No coordinates found"): with pytest.raises(ValueError, match="No coordinates found"):
client.find_recent_deals_for_address("test", years_back=1) client.find_recent_deals_for_address("test", years_back=1)
@@ -332,18 +344,21 @@ class TestMarketAnalysisFunctions:
def test_calculate_market_activity_score_success(self): def test_calculate_market_activity_score_success(self):
"""Test successful market activity score calculation.""" """Test successful market activity score calculation."""
from nadlan_mcp.govmap.models import MarketActivityScore from nadlan_mcp.govmap.models import MarketActivityScore
client = GovmapClient() client = GovmapClient()
# Sample deals with dates - now using Deal models # Sample deals with dates - now using Deal models
deals = [ deals = [
Deal(objectid=i, deal_date=date, deal_amount=amount) Deal(objectid=i, deal_date=date, deal_amount=amount)
for i, (date, amount) in enumerate([ for i, (date, amount) in enumerate(
("2023-01-15", 1000000), [
("2023-01-20", 1100000), ("2023-01-15", 1000000),
("2023-02-10", 1200000), ("2023-01-20", 1100000),
("2023-03-05", 1150000), ("2023-02-10", 1200000),
("2023-04-12", 1250000), ("2023-03-05", 1150000),
]) ("2023-04-12", 1250000),
]
)
] ]
result = client.calculate_market_activity_score(deals, time_period_months=None) result = client.calculate_market_activity_score(deals, time_period_months=None)
@@ -360,14 +375,18 @@ class TestMarketAnalysisFunctions:
"""Test market activity score with empty deals list.""" """Test market activity score with empty deals list."""
client = GovmapClient() client = GovmapClient()
with pytest.raises(ValueError, match="Cannot calculate market activity from empty deals list"): with pytest.raises(
ValueError, match="Cannot calculate market activity from empty deals list"
):
client.calculate_market_activity_score([]) client.calculate_market_activity_score([])
def test_calculate_market_activity_score_with_time_filter(self): def test_calculate_market_activity_score_with_time_filter(self):
"""Test market activity score with time period filtering.""" """Test market activity score with time period filtering."""
# Note: With Pydantic models, deal_date is required and validated # Note: With Pydantic models, deal_date is required and validated
from datetime import datetime, timedelta from datetime import datetime, timedelta
from nadlan_mcp.govmap.models import MarketActivityScore from nadlan_mcp.govmap.models import MarketActivityScore
client = GovmapClient() client = GovmapClient()
# Create deals spanning several months using recent dates # Create deals spanning several months using recent dates
@@ -376,7 +395,7 @@ class TestMarketAnalysisFunctions:
Deal( Deal(
objectid=i, objectid=i,
deal_date=(today - timedelta(days=30 * month)).strftime("%Y-%m-%d"), deal_date=(today - timedelta(days=30 * month)).strftime("%Y-%m-%d"),
deal_amount=1000000 + i * 10000 deal_amount=1000000 + i * 10000,
) )
for i, month in enumerate([1, 1, 2, 3, 3, 3, 6, 11], 1) # All within last 12 months for i, month in enumerate([1, 1, 2, 3, 3, 3, 6, 11], 1) # All within last 12 months
] ]
@@ -392,7 +411,9 @@ class TestMarketAnalysisFunctions:
# Generate many deals across multiple months for trend analysis - now using Deal models # Generate many deals across multiple months for trend analysis - now using Deal models
deals = [ deals = [
Deal(objectid=i, deal_date=f"2023-{(i % 6) + 1:02d}-15", deal_amount=1000000 + i * 10000) Deal(
objectid=i, deal_date=f"2023-{(i % 6) + 1:02d}-15", deal_amount=1000000 + i * 10000
)
for i in range(1, 31) # 30 deals spread across 6 months for i in range(1, 31) # 30 deals spread across 6 months
] ]
@@ -405,31 +426,34 @@ class TestMarketAnalysisFunctions:
def test_analyze_investment_potential_success(self): def test_analyze_investment_potential_success(self):
"""Test successful investment potential analysis.""" """Test successful investment potential analysis."""
from nadlan_mcp.govmap.models import InvestmentAnalysis from nadlan_mcp.govmap.models import InvestmentAnalysis
client = GovmapClient() client = GovmapClient()
# Sample deals with price appreciation - now using Deal models # Sample deals with price appreciation - now using Deal models
# Note: price_per_sqm is computed automatically from deal_amount / asset_area # Note: price_per_sqm is computed automatically from deal_amount / asset_area
deals = [ deals = [
Deal(objectid=i, deal_date=date, deal_amount=amount, asset_area=80.0) Deal(objectid=i, deal_date=date, deal_amount=amount, asset_area=80.0)
for i, (date, amount) in enumerate([ for i, (date, amount) in enumerate(
("2022-01-15", 1000000), [
("2022-06-10", 1050000), ("2022-01-15", 1000000),
("2023-01-05", 1100000), ("2022-06-10", 1050000),
("2023-06-12", 1150000), ("2023-01-05", 1100000),
]) ("2023-06-12", 1150000),
]
)
] ]
result = client.analyze_investment_potential(deals) result = client.analyze_investment_potential(deals)
# Now returns InvestmentAnalysis model # Now returns InvestmentAnalysis model
assert isinstance(result, InvestmentAnalysis) assert isinstance(result, InvestmentAnalysis)
assert hasattr(result, 'price_appreciation_rate') assert hasattr(result, "price_appreciation_rate")
assert hasattr(result, 'price_volatility') assert hasattr(result, "price_volatility")
assert hasattr(result, 'market_stability') assert hasattr(result, "market_stability")
assert hasattr(result, 'price_trend') assert hasattr(result, "price_trend")
assert hasattr(result, 'avg_price_per_sqm') assert hasattr(result, "avg_price_per_sqm")
assert hasattr(result, 'investment_score') assert hasattr(result, "investment_score")
assert hasattr(result, 'data_quality') assert hasattr(result, "data_quality")
assert 0 <= result.investment_score <= 100 assert 0 <= result.investment_score <= 100
assert result.price_trend in ["increasing", "stable", "decreasing"] assert result.price_trend in ["increasing", "stable", "decreasing"]
@@ -437,7 +461,9 @@ class TestMarketAnalysisFunctions:
"""Test investment potential with empty deals list.""" """Test investment potential with empty deals list."""
client = GovmapClient() client = GovmapClient()
with pytest.raises(ValueError, match="Cannot analyze investment potential from empty deals list"): with pytest.raises(
ValueError, match="Cannot analyze investment potential from empty deals list"
):
client.analyze_investment_potential([]) client.analyze_investment_potential([])
def test_analyze_investment_potential_insufficient_data(self): def test_analyze_investment_potential_insufficient_data(self):
@@ -459,7 +485,12 @@ class TestMarketAnalysisFunctions:
# Deals with consistent prices (very stable) - now using Deal models # Deals with consistent prices (very stable) - now using Deal models
deals = [ deals = [
Deal(objectid=i, deal_date=f"2023-{i:02d}-15", deal_amount=1000000 + i * 1000, asset_area=80.0) Deal(
objectid=i,
deal_date=f"2023-{i:02d}-15",
deal_amount=1000000 + i * 1000,
asset_area=80.0,
)
for i in range(1, 13) # 12 months, slight increase for i in range(1, 13) # 12 months, slight increase
] ]
@@ -472,19 +503,22 @@ class TestMarketAnalysisFunctions:
def test_get_market_liquidity_success(self): def test_get_market_liquidity_success(self):
"""Test successful market liquidity calculation.""" """Test successful market liquidity calculation."""
from nadlan_mcp.govmap.models import LiquidityMetrics from nadlan_mcp.govmap.models import LiquidityMetrics
client = GovmapClient() client = GovmapClient()
# Sample deals across multiple quarters - now using Deal models # Sample deals across multiple quarters - now using Deal models
deals = [ deals = [
Deal(objectid=i, deal_date=date, deal_amount=amount) Deal(objectid=i, deal_date=date, deal_amount=amount)
for i, (date, amount) in enumerate([ for i, (date, amount) in enumerate(
("2023-01-15", 1000000), [
("2023-02-20", 1100000), ("2023-01-15", 1000000),
("2023-05-10", 1200000), ("2023-02-20", 1100000),
("2023-06-05", 1150000), ("2023-05-10", 1200000),
("2023-09-12", 1250000), ("2023-06-05", 1150000),
("2023-10-18", 1300000), ("2023-09-12", 1250000),
]) ("2023-10-18", 1300000),
]
)
] ]
result = client.get_market_liquidity(deals, time_period_months=None) result = client.get_market_liquidity(deals, time_period_months=None)
@@ -500,12 +534,15 @@ class TestMarketAnalysisFunctions:
"""Test market liquidity with empty deals list.""" """Test market liquidity with empty deals list."""
client = GovmapClient() client = GovmapClient()
with pytest.raises(ValueError, match="Cannot calculate market liquidity from empty deals list"): with pytest.raises(
ValueError, match="Cannot calculate market liquidity from empty deals list"
):
client.get_market_liquidity([]) client.get_market_liquidity([])
def test_get_market_liquidity_varied_periods(self): def test_get_market_liquidity_varied_periods(self):
"""Test market liquidity with varied time periods.""" """Test market liquidity with varied time periods."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
client = GovmapClient() client = GovmapClient()
# Deals spread across recent quarters - now using Deal models # Deals spread across recent quarters - now using Deal models
@@ -514,7 +551,7 @@ class TestMarketAnalysisFunctions:
Deal( Deal(
objectid=i, objectid=i,
deal_date=(today - timedelta(days=days)).strftime("%Y-%m-%d"), deal_date=(today - timedelta(days=days)).strftime("%Y-%m-%d"),
deal_amount=1000000 deal_amount=1000000,
) )
for i, days in enumerate([30, 60, 150, 240, 330]) # Spread across ~11 months for i, days in enumerate([30, 60, 150, 240, 330]) # Spread across ~11 months
] ]
@@ -532,9 +569,27 @@ class TestMarketAnalysisFunctions:
# Now using Deal models # Now using Deal models
deals = [ deals = [
Deal(objectid=1, property_type_description="דירה", rooms=3, deal_amount=1000000, deal_date="2023-01-01"), Deal(
Deal(objectid=2, property_type_description="בית", rooms=5, deal_amount=2000000, deal_date="2023-01-01"), objectid=1,
Deal(objectid=3, property_type_description="דירה", rooms=4, deal_amount=1500000, deal_date="2023-01-01"), property_type_description="דירה",
rooms=3,
deal_amount=1000000,
deal_date="2023-01-01",
),
Deal(
objectid=2,
property_type_description="בית",
rooms=5,
deal_amount=2000000,
deal_date="2023-01-01",
),
Deal(
objectid=3,
property_type_description="דירה",
rooms=4,
deal_amount=1500000,
deal_date="2023-01-01",
),
] ]
filtered = client.filter_deals_by_criteria(deals, property_type="דירה") filtered = client.filter_deals_by_criteria(deals, property_type="דירה")
@@ -551,7 +606,9 @@ class TestMarketAnalysisFunctions:
# Now using Deal models # Now using Deal models
deals = [ deals = [
Deal(objectid=i, rooms=rooms, deal_amount=amount, deal_date="2023-01-01") Deal(objectid=i, rooms=rooms, deal_amount=amount, deal_date="2023-01-01")
for i, (rooms, amount) in enumerate([(2, 800000), (3, 1000000), (4, 1500000), (5, 2000000)]) for i, (rooms, amount) in enumerate(
[(2, 800000), (3, 1000000), (4, 1500000), (5, 2000000)]
)
] ]
filtered = client.filter_deals_by_criteria(deals, min_rooms=3, max_rooms=4) filtered = client.filter_deals_by_criteria(deals, min_rooms=3, max_rooms=4)
@@ -579,6 +636,7 @@ class TestMarketAnalysisFunctions:
def test_calculate_deal_statistics_success(self): def test_calculate_deal_statistics_success(self):
"""Test successful deal statistics calculation.""" """Test successful deal statistics calculation."""
from nadlan_mcp.govmap.models import DealStatistics from nadlan_mcp.govmap.models import DealStatistics
client = GovmapClient() client = GovmapClient()
# Now using Deal models - price_per_sqm computed automatically # Now using Deal models - price_per_sqm computed automatically
@@ -620,10 +678,27 @@ class TestMarketAnalysisFunctions:
"""Test that deals with missing property type are excluded when filter is active.""" """Test that deals with missing property type are excluded when filter is active."""
client = GovmapClient() client = GovmapClient()
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date="2023-01-01", property_type_description="דירה"), Deal(
Deal(objectid=2, deal_amount=1000000, deal_date="2023-01-01", property_type_description=None), objectid=1,
Deal(objectid=3, deal_amount=1000000, deal_date="2023-01-01", property_type_description="בית"), deal_amount=1000000,
Deal(objectid=4, deal_amount=1000000, deal_date="2023-01-01"), # Missing property_type_description deal_date="2023-01-01",
property_type_description="דירה",
),
Deal(
objectid=2,
deal_amount=1000000,
deal_date="2023-01-01",
property_type_description=None,
),
Deal(
objectid=3,
deal_amount=1000000,
deal_date="2023-01-01",
property_type_description="בית",
),
Deal(
objectid=4, deal_amount=1000000, deal_date="2023-01-01"
), # Missing property_type_description
] ]
filtered = client.filter_deals_by_criteria(deals, property_type="דירה") filtered = client.filter_deals_by_criteria(deals, property_type="דירה")
@@ -684,10 +759,18 @@ class TestMarketAnalysisFunctions:
# This test now verifies filtering based on numeric ranges # This test now verifies filtering based on numeric ranges
client = GovmapClient() client = GovmapClient()
deals = [ deals = [
Deal(objectid=1, deal_amount=2000000, deal_date="2023-01-01", asset_area=65.0, rooms=3.0), Deal(
Deal(objectid=2, deal_amount=2000000, deal_date="2023-01-01", asset_area=80.0, rooms=3.0), # Area too high objectid=1, deal_amount=2000000, deal_date="2023-01-01", asset_area=65.0, rooms=3.0
Deal(objectid=3, deal_amount=2000000, deal_date="2023-01-01", asset_area=65.0, rooms=5.0), # Rooms too high ),
Deal(objectid=4, deal_amount=3000000, deal_date="2023-01-01", asset_area=65.0, rooms=3.0), # Price too high Deal(
objectid=2, deal_amount=2000000, deal_date="2023-01-01", asset_area=80.0, rooms=3.0
), # Area too high
Deal(
objectid=3, deal_amount=2000000, deal_date="2023-01-01", asset_area=65.0, rooms=5.0
), # Rooms too high
Deal(
objectid=4, deal_amount=3000000, deal_date="2023-01-01", asset_area=65.0, rooms=3.0
), # Price too high
] ]
# Area filter should exclude deal 2 # Area filter should exclude deal 2
@@ -701,7 +784,9 @@ class TestMarketAnalysisFunctions:
assert all(d.objectid in [1, 2, 4] for d in filtered_rooms) assert all(d.objectid in [1, 2, 4] for d in filtered_rooms)
# Price filter should exclude deal 4 # Price filter should exclude deal 4
filtered_price = client.filter_deals_by_criteria(deals, min_price=1500000, max_price=2500000) filtered_price = client.filter_deals_by_criteria(
deals, min_price=1500000, max_price=2500000
)
assert len(filtered_price) == 3 assert len(filtered_price) == 3
assert all(d.objectid in [1, 2, 3] for d in filtered_price) assert all(d.objectid in [1, 2, 3] for d in filtered_price)
@@ -709,9 +794,26 @@ class TestMarketAnalysisFunctions:
"""Test that deals with missing data pass through when no filter is active for that field.""" """Test that deals with missing data pass through when no filter is active for that field."""
client = GovmapClient() client = GovmapClient()
deals = [ deals = [
Deal(objectid=1, deal_amount=1000000, deal_date="2023-01-01", property_type_description="דירה", asset_area=65.0), Deal(
Deal(objectid=2, deal_amount=1000000, deal_date="2023-01-01", property_type_description="דירה", asset_area=None), objectid=1,
Deal(objectid=3, deal_amount=1000000, deal_date="2023-01-01", property_type_description="דירה"), # Missing asset_area deal_amount=1000000,
deal_date="2023-01-01",
property_type_description="דירה",
asset_area=65.0,
),
Deal(
objectid=2,
deal_amount=1000000,
deal_date="2023-01-01",
property_type_description="דירה",
asset_area=None,
),
Deal(
objectid=3,
deal_amount=1000000,
deal_date="2023-01-01",
property_type_description="דירה",
), # Missing asset_area
] ]
# Filter by property type only - missing area should pass through # Filter by property type only - missing area should pass through
+23 -17
View File
@@ -6,19 +6,20 @@ without hitting the real Govmap API.
For full E2E tests with real API calls, see tests/e2e/test_mcp_tools.py For full E2E tests with real API calls, see tests/e2e/test_mcp_tools.py
""" """
import json import json
import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch, Mock from unittest.mock import patch
from nadlan_mcp.govmap.models import Deal, AutocompleteResult, AutocompleteResponse, CoordinatePoint
import pytest
from nadlan_mcp.fastmcp_server import ( from nadlan_mcp.fastmcp_server import (
autocomplete_address, autocomplete_address,
find_recent_deals_for_address,
get_street_deals,
get_neighborhood_deals,
get_deals_by_radius, get_deals_by_radius,
get_neighborhood_deals,
get_street_deals,
) )
from nadlan_mcp.govmap.models import AutocompleteResponse, AutocompleteResult, Deal
# Load fixtures # Load fixtures
FIXTURES_DIR = Path(__file__).parent / "fixtures" FIXTURES_DIR = Path(__file__).parent / "fixtures"
@@ -61,7 +62,7 @@ def mock_polygon_metadata_data():
class TestMCPToolsFast: class TestMCPToolsFast:
"""Fast unit tests for MCP tools using mocked data.""" """Fast unit tests for MCP tools using mocked data."""
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_autocomplete_address_fast(self, mock_client, mock_autocomplete_data): def test_autocomplete_address_fast(self, mock_client, mock_autocomplete_data):
"""Test autocomplete with cached data.""" """Test autocomplete with cached data."""
mock_client.autocomplete_address.return_value = mock_autocomplete_data mock_client.autocomplete_address.return_value = mock_autocomplete_data
@@ -74,7 +75,7 @@ class TestMCPToolsFast:
assert "text" in data[0] assert "text" in data[0]
assert "coordinates" in data[0] assert "coordinates" in data[0]
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_get_street_deals_fast(self, mock_client, mock_street_deals_data): def test_get_street_deals_fast(self, mock_client, mock_street_deals_data):
"""Test street deals with cached data.""" """Test street deals with cached data."""
mock_client.get_street_deals.return_value = mock_street_deals_data mock_client.get_street_deals.return_value = mock_street_deals_data
@@ -92,7 +93,7 @@ class TestMCPToolsFast:
assert "deal_amount" in deal assert "deal_amount" in deal
assert "deal_date" in deal assert "deal_date" in deal
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_get_neighborhood_deals_fast(self, mock_client, mock_neighborhood_deals_data): def test_get_neighborhood_deals_fast(self, mock_client, mock_neighborhood_deals_data):
"""Test neighborhood deals with cached data.""" """Test neighborhood deals with cached data."""
mock_client.get_neighborhood_deals.return_value = mock_neighborhood_deals_data mock_client.get_neighborhood_deals.return_value = mock_neighborhood_deals_data
@@ -104,7 +105,7 @@ class TestMCPToolsFast:
assert data["total_deals"] == len(mock_neighborhood_deals_data) assert data["total_deals"] == len(mock_neighborhood_deals_data)
assert "deals" in data assert "deals" in data
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_get_deals_by_radius_fast(self, mock_client, mock_polygon_metadata_data): def test_get_deals_by_radius_fast(self, mock_client, mock_polygon_metadata_data):
"""Test deals by radius with cached data.""" """Test deals by radius with cached data."""
mock_client.get_deals_by_radius.return_value = mock_polygon_metadata_data mock_client.get_deals_by_radius.return_value = mock_polygon_metadata_data
@@ -117,10 +118,15 @@ class TestMCPToolsFast:
assert "polygons" in data assert "polygons" in data
@pytest.mark.skip(reason="Complex workflow with statistics - tested in E2E suite") @pytest.mark.skip(reason="Complex workflow with statistics - tested in E2E suite")
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_find_recent_deals_fast(self, mock_client, mock_street_deals_data, def test_find_recent_deals_fast(
mock_neighborhood_deals_data, mock_polygon_metadata_data, self,
mock_autocomplete_data): mock_client,
mock_street_deals_data,
mock_neighborhood_deals_data,
mock_polygon_metadata_data,
mock_autocomplete_data,
):
"""Test find_recent_deals with fully mocked workflow. """Test find_recent_deals with fully mocked workflow.
NOTE: This test is skipped because find_recent_deals_for_address has complex NOTE: This test is skipped because find_recent_deals_for_address has complex
@@ -129,7 +135,7 @@ class TestMCPToolsFast:
""" """
pass pass
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_no_deals_found(self, mock_client): def test_no_deals_found(self, mock_client):
"""Test handling when no deals are found.""" """Test handling when no deals are found."""
mock_client.get_street_deals.return_value = [] mock_client.get_street_deals.return_value = []
@@ -140,7 +146,7 @@ class TestMCPToolsFast:
assert isinstance(result, str) assert isinstance(result, str)
assert "No" in result or "found" in result.lower() assert "No" in result or "found" in result.lower()
@patch('nadlan_mcp.fastmcp_server.client') @patch("nadlan_mcp.fastmcp_server.client")
def test_deal_type_filtering(self, mock_client, mock_street_deals_data): def test_deal_type_filtering(self, mock_client, mock_street_deals_data):
"""Test that deal_type parameter is passed correctly.""" """Test that deal_type parameter is passed correctly."""
mock_client.get_street_deals.return_value = mock_street_deals_data mock_client.get_street_deals.return_value = mock_street_deals_data
+6 -12
View File
@@ -10,26 +10,20 @@ import vcr
# Configure VCR instance # Configure VCR instance
my_vcr = vcr.VCR( my_vcr = vcr.VCR(
# Store cassettes in tests/cassettes/ directory # Store cassettes in tests/cassettes/ directory
cassette_library_dir='tests/cassettes', cassette_library_dir="tests/cassettes",
# Record mode: once = record once, then replay # Record mode: once = record once, then replay
# Use 'new_episodes' to record new interactions but replay existing ones # Use 'new_episodes' to record new interactions but replay existing ones
record_mode='once', record_mode="once",
# Match requests by method and URI # Match requests by method and URI
match_on=['method', 'scheme', 'host', 'port', 'path', 'query'], match_on=["method", "scheme", "host", "port", "path", "query"],
# Filter out sensitive data from recordings # Filter out sensitive data from recordings
filter_headers=['authorization', 'x-api-key'], filter_headers=["authorization", "x-api-key"],
# Decode compressed responses for better diffs # Decode compressed responses for better diffs
decode_compressed_response=True, decode_compressed_response=True,
# Serialize as YAML for human-readable diffs # Serialize as YAML for human-readable diffs
serializer='yaml', serializer="yaml",
# Path transformer to organize cassettes # Path transformer to organize cassettes
path_transformer=vcr.VCR.ensure_suffix('.yaml'), path_transformer=vcr.VCR.ensure_suffix(".yaml"),
) )