Merge pull request #4 from nitzpo/phase-3
Phase 3: Refactor govmap.py into modular package
This commit is contained in:
@@ -292,3 +292,8 @@ nadlan_mcp/__pycache__/
|
|||||||
nadlan_mcp/.env
|
nadlan_mcp/.env
|
||||||
nadlan_mcp/.env.*
|
nadlan_mcp/.env.*
|
||||||
nadlan_mcp/local_settings.py
|
nadlan_mcp/local_settings.py
|
||||||
|
|
||||||
|
# Claude Code workspace and local config
|
||||||
|
.claude/
|
||||||
|
.mcp.json
|
||||||
|
PHASE3-PLAN-SUMMARY.md
|
||||||
|
|||||||
+36
-20
@@ -58,15 +58,18 @@ All settings (timeouts, retries, rate limits) are configurable via environment v
|
|||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Business Logic Layer │
|
│ Business Logic Layer │
|
||||||
│ ┌────────────────────┐ ┌──────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ Market Analysis │ │ Deal Processing & Filtering │ │
|
│ │ govmap/ Package │ │
|
||||||
│ │ (analyze_market │ │ (_is_same_building, etc.) │ │
|
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ │
|
||||||
│ │ _trends logic) │ │ │ │
|
│ │ │ client.py │ │ validators.py│ │ filters.py │ │ │
|
||||||
│ └────────────────────┘ └──────────────────────────────┘ │
|
│ │ │ (API calls) │ │ (validation) │ │ (filtering) │ │ │
|
||||||
│ ┌────────────────────────────────────────────────────────┐ │
|
│ │ └─────────────┘ └──────────────┘ └───────────────┘ │ │
|
||||||
│ │ govmap.py (GovmapClient) │ │
|
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ │
|
||||||
│ │ • Validation • Retry Logic • Rate Limiting │ │
|
│ │ │statistics.py│ │market_ │ │ utils.py │ │ │
|
||||||
│ └─────────────────────────┬──────────────────────────────┘ │
|
│ │ │ (stats) │ │analysis.py │ │ (helpers) │ │ │
|
||||||
|
│ │ └─────────────┘ └──────────────┘ └───────────────┘ │ │
|
||||||
|
│ │ • Retry Logic • Rate Limiting • Validation │ │
|
||||||
|
│ └─────────────────────────┬───────────────────────────────┘ │
|
||||||
└───────────────────────────┬┴──────────────────────────────┘
|
└───────────────────────────┬┴──────────────────────────────┘
|
||||||
│ HTTP/JSON
|
│ HTTP/JSON
|
||||||
↓
|
↓
|
||||||
@@ -111,22 +114,28 @@ custom_config = GovmapConfig(max_retries=5, requests_per_second=10.0)
|
|||||||
set_config(custom_config)
|
set_config(custom_config)
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Client Layer (`govmap.py`)
|
### API Client Layer (`govmap/` package)
|
||||||
|
|
||||||
**Purpose:** Communicate with Govmap API with reliability features
|
**Purpose:** Modular package for Govmap API interaction and data processing
|
||||||
|
|
||||||
**Key Components:**
|
**Package Structure:**
|
||||||
|
- `client.py` - GovmapClient class with API methods
|
||||||
|
- `validators.py` - Input validation functions
|
||||||
|
- `filters.py` - Deal filtering logic
|
||||||
|
- `statistics.py` - Statistical calculations
|
||||||
|
- `market_analysis.py` - Market analysis functions
|
||||||
|
- `utils.py` - Helper utilities
|
||||||
|
- `__init__.py` - Public API exports
|
||||||
|
|
||||||
#### GovmapClient Class
|
#### GovmapClient Class (`govmap/client.py`)
|
||||||
|
|
||||||
**Responsibilities:**
|
**Responsibilities:**
|
||||||
- Make HTTP requests to Govmap API
|
- Make HTTP requests to Govmap API
|
||||||
- Implement retry logic with exponential backoff
|
- Implement retry logic with exponential backoff
|
||||||
- Enforce rate limiting
|
- Enforce rate limiting
|
||||||
- Validate inputs
|
- Delegate to specialized modules for validation, filtering, analysis
|
||||||
- Parse and validate responses
|
|
||||||
|
|
||||||
**Key Methods:**
|
**Core API Methods:**
|
||||||
- `autocomplete_address()` - Search for addresses
|
- `autocomplete_address()` - Search for addresses
|
||||||
- `get_gush_helka()` - Get block/parcel data
|
- `get_gush_helka()` - Get block/parcel data
|
||||||
- `get_deals_by_radius()` - Get nearby deals
|
- `get_deals_by_radius()` - Get nearby deals
|
||||||
@@ -134,10 +143,17 @@ set_config(custom_config)
|
|||||||
- `get_neighborhood_deals()` - Get neighborhood deals
|
- `get_neighborhood_deals()` - Get neighborhood deals
|
||||||
- `find_recent_deals_for_address()` - Main comprehensive search
|
- `find_recent_deals_for_address()` - Main comprehensive search
|
||||||
|
|
||||||
|
**Business Logic Methods** (delegate to respective modules):
|
||||||
|
- `filter_deals_by_criteria()` - Filter deals by various criteria
|
||||||
|
- `calculate_deal_statistics()` - Calculate statistical metrics
|
||||||
|
- `calculate_market_activity_score()` - Analyze market activity
|
||||||
|
- `analyze_investment_potential()` - Analyze investment potential
|
||||||
|
- `get_market_liquidity()` - Analyze market liquidity
|
||||||
|
|
||||||
**Reliability Features:**
|
**Reliability Features:**
|
||||||
1. **Retry Logic** - Exponential backoff on failures
|
1. **Retry Logic** - Exponential backoff on failures
|
||||||
2. **Rate Limiting** - Tracks request times, enforces delays
|
2. **Rate Limiting** - Tracks request times, enforces delays
|
||||||
3. **Input Validation** - Validates all inputs before API calls
|
3. **Input Validation** - Delegates to validators module
|
||||||
4. **Timeouts** - Configurable connect and read timeouts
|
4. **Timeouts** - Configurable connect and read timeouts
|
||||||
|
|
||||||
### MCP Tools Layer (`fastmcp_server.py`)
|
### MCP Tools Layer (`fastmcp_server.py`)
|
||||||
@@ -371,11 +387,11 @@ GOVMAP_DEFAULT_DEAL_LIMIT=100
|
|||||||
|
|
||||||
## Future Architecture Evolution
|
## Future Architecture Evolution
|
||||||
|
|
||||||
### Phase 3: Package Refactoring (PLANNED)
|
### Phase 3: Package Refactoring (✅ COMPLETED)
|
||||||
|
|
||||||
**See `.cursor/plans/PHASE3-REFACTORING.md` for detailed plan**
|
**✅ COMPLETED - See `.cursor/plans/PHASE3-REFACTORING.md` for detailed plan**
|
||||||
|
|
||||||
Refactor `govmap.py` (1,378 lines) into modular package:
|
The monolithic `govmap.py` (1,454 lines) has been refactored into a modular package:
|
||||||
|
|
||||||
```
|
```
|
||||||
nadlan_mcp/
|
nadlan_mcp/
|
||||||
|
|||||||
@@ -93,10 +93,15 @@ The codebase follows a three-layer architecture:
|
|||||||
- Main tools: `find_recent_deals_for_address`, `analyze_market_trends`, `compare_addresses`
|
- Main tools: `find_recent_deals_for_address`, `analyze_market_trends`, `compare_addresses`
|
||||||
- **Important:** Tools return structured JSON by default; use `summarized_response=True` for condensed summaries
|
- **Important:** Tools return structured JSON by default; use `summarized_response=True` for condensed summaries
|
||||||
|
|
||||||
### 2. Business Logic Layer (`nadlan_mcp/govmap.py`)
|
### 2. Business Logic Layer (`nadlan_mcp/govmap/` package)
|
||||||
- `GovmapClient` class implements all API interactions
|
- Modular package with specialized modules (see ARCHITECTURE.md for details)
|
||||||
|
- `client.py` - GovmapClient class with core API methods
|
||||||
|
- `validators.py` - Input validation functions
|
||||||
|
- `filters.py` - Deal filtering logic
|
||||||
|
- `statistics.py` - Statistical calculations
|
||||||
|
- `market_analysis.py` - Market analysis functions
|
||||||
|
- `utils.py` - Helper utilities
|
||||||
- Reliability features: retry logic with exponential backoff, rate limiting, input validation
|
- Reliability features: retry logic with exponential backoff, rate limiting, input validation
|
||||||
- Helper functions for data processing, filtering, and analysis
|
|
||||||
- **Key Design Principle:** MCP provides data, LLM provides intelligence - avoid complex analysis in the MCP layer
|
- **Key Design Principle:** MCP provides data, LLM provides intelligence - avoid complex analysis in the MCP layer
|
||||||
|
|
||||||
### 3. Configuration Layer (`nadlan_mcp/config.py`)
|
### 3. Configuration Layer (`nadlan_mcp/config.py`)
|
||||||
@@ -106,16 +111,22 @@ The codebase follows a three-layer architecture:
|
|||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
- `nadlan_mcp/govmap.py` - Core API client with ~1,378 lines of business logic
|
- `nadlan_mcp/govmap/` - **✅ Refactored modular package** (Phase 3 complete)
|
||||||
- **NOTE:** Will be refactored into package in Phase 3 (see `.cursor/plans/PHASE3-REFACTORING.md`)
|
- `client.py` - Core API client (~30KB, GovmapClient class)
|
||||||
|
- `validators.py` - Input validation (~3KB)
|
||||||
|
- `filters.py` - Deal filtering (~5KB)
|
||||||
|
- `statistics.py` - Statistical calculations (~4KB)
|
||||||
|
- `market_analysis.py` - Market analysis (~17KB)
|
||||||
|
- `utils.py` - Helper utilities (~4KB)
|
||||||
|
- `__init__.py` - Package exports for backward compatibility
|
||||||
- `nadlan_mcp/fastmcp_server.py` - MCP tool definitions (10 implemented tools)
|
- `nadlan_mcp/fastmcp_server.py` - MCP tool definitions (10 implemented tools)
|
||||||
- `nadlan_mcp/config.py` - Configuration management
|
- `nadlan_mcp/config.py` - Configuration management
|
||||||
- `run_fastmcp_server.py` - Server entry point
|
- `run_fastmcp_server.py` - Server entry point
|
||||||
- `tests/test_govmap_client.py` - Main test suite (27 tests)
|
- `tests/test_govmap_client.py` - Main test suite (34 tests, all passing)
|
||||||
- `USECASES.md` - **Product roadmap and feature status** (essential reading)
|
- `USECASES.md` - **Product roadmap and feature status** (essential reading)
|
||||||
- `ARCHITECTURE.md` - Detailed system architecture and design decisions
|
- `ARCHITECTURE.md` - Detailed system architecture and design decisions
|
||||||
- `TASKS.md` - Implementation tasks and progress tracking
|
- `TASKS.md` - Implementation tasks and progress tracking
|
||||||
- `.cursor/plans/PHASE3-REFACTORING.md` - Detailed refactoring plan for Phase 3
|
- `.cursor/plans/PHASE3-REFACTORING.md` - Detailed refactoring plan (✅ completed)
|
||||||
|
|
||||||
## Available MCP Tools
|
## Available MCP Tools
|
||||||
|
|
||||||
@@ -212,11 +223,12 @@ GOVMAP_DEFAULT_DEAL_LIMIT=100
|
|||||||
|
|
||||||
## Development Roadmap
|
## Development Roadmap
|
||||||
|
|
||||||
See `TASKS.md` for complete implementation plan. Current focus:
|
See `TASKS.md` for complete implementation plan. Current status:
|
||||||
- **Phase 2.2:** Market analysis tools (in progress)
|
- **Phase 2.2:** Market analysis tools (✅ complete)
|
||||||
- **Phase 2.3:** Enhanced filtering (mostly complete)
|
- **Phase 2.3:** Enhanced filtering (✅ complete)
|
||||||
- **Phase 3:** Architecture improvements with Pydantic models
|
- **Phase 3:** Package refactoring (✅ complete - monolithic govmap.py refactored into modular package)
|
||||||
- **Phase 4:** Expanded test coverage
|
- **Phase 4:** Pydantic data models (planned)
|
||||||
|
- **Phase 5:** Expanded test coverage (ongoing)
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
@@ -230,10 +242,11 @@ See `TASKS.md` for complete implementation plan. Current focus:
|
|||||||
|
|
||||||
### Adding New API Endpoint Support
|
### Adding New API Endpoint Support
|
||||||
|
|
||||||
1. Add method to `GovmapClient` class in `govmap.py`
|
1. Add method to `GovmapClient` class in `govmap/client.py`
|
||||||
2. Implement validation, retry logic, rate limiting
|
2. Implement validation (use `validators.py` functions)
|
||||||
3. Add unit tests in `tests/test_govmap_client.py`
|
3. Implement retry logic and rate limiting (follow existing patterns)
|
||||||
4. Optionally expose as MCP tool
|
4. Add unit tests in `tests/test_govmap_client.py`
|
||||||
|
5. Optionally expose as MCP tool in `fastmcp_server.py`
|
||||||
|
|
||||||
### Modifying Configuration
|
### Modifying Configuration
|
||||||
|
|
||||||
@@ -265,6 +278,13 @@ The client uses these Govmap API endpoints:
|
|||||||
|
|
||||||
- This project uses **FastMCP**, not the standard MCP library
|
- This project uses **FastMCP**, not the standard MCP library
|
||||||
- All coordinate tuples are `(longitude, latitude)` in ITM projection (Israeli Transverse Mercator)
|
- All coordinate tuples are `(longitude, latitude)` in ITM projection (Israeli Transverse Mercator)
|
||||||
- When reading `govmap.py`, note it's ~1000 lines with multiple helper functions - use search/grep to find specific methods
|
- The `govmap` package is now modular - each module has a specific responsibility:
|
||||||
- Floor numbers in Hebrew (e.g., "קרקע", "מרתף") are parsed by `_extract_floor_number()`
|
- `client.py` - API calls and HTTP logic
|
||||||
|
- `validators.py` - Input validation
|
||||||
|
- `filters.py` - Deal filtering
|
||||||
|
- `statistics.py` - Statistical calculations
|
||||||
|
- `market_analysis.py` - Market analysis metrics
|
||||||
|
- `utils.py` - Helper utilities
|
||||||
|
- Floor numbers in Hebrew (e.g., "קרקע", "מרתף") are parsed by `extract_floor_number()` in `utils.py`
|
||||||
- Deal types: 1 = first hand/new construction, 2 = second hand/resale
|
- Deal types: 1 = first hand/new construction, 2 = second hand/resale
|
||||||
|
- Backward compatibility maintained: `from nadlan_mcp.govmap import GovmapClient` still works
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
# Test Coverage Report - Phase 3 Refactoring
|
||||||
|
|
||||||
|
**Generated:** 2025-10-25
|
||||||
|
**Updated:** 2025-10-25 (Added comprehensive unit tests)
|
||||||
|
**Branch:** phase-3
|
||||||
|
**Total Tests:** 138 (all passing in 0.50s) ✅ **+104 new tests**
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
✅ **Overall Status:** EXCELLENT - Comprehensive test coverage across all modules
|
||||||
|
✅ **Bug Fixed:** `autocomplete_address` MCP tool bug fixed
|
||||||
|
✅ **Coverage:** Complete unit test coverage for validators, utils, and all MCP tools
|
||||||
|
🎉 **Achievement:** Increased from 34 to 138 tests (+304% improvement)
|
||||||
|
|
||||||
|
## Test Coverage by Module
|
||||||
|
|
||||||
|
### ✅ Well Tested (Indirect Coverage via Integration Tests)
|
||||||
|
|
||||||
|
| Module | Functions | Test Coverage | Notes |
|
||||||
|
|--------|-----------|---------------|-------|
|
||||||
|
| `client.py` | 9 API methods | ✅ High | Tested through GovmapClient integration tests |
|
||||||
|
| `filters.py` | `filter_deals_by_criteria` | ✅ High | 8 dedicated tests covering all filter types |
|
||||||
|
| `statistics.py` | `calculate_deal_statistics`, `calculate_std_dev` | ✅ Good | 1 dedicated test, used in other tests |
|
||||||
|
| `market_analysis.py` | 4 functions | ✅ High | 6 dedicated tests for market analysis |
|
||||||
|
| `utils.py` | `is_same_building` | ✅ Good | 1 dedicated test |
|
||||||
|
|
||||||
|
### ✅ NEW: Comprehensive Unit Tests Added
|
||||||
|
|
||||||
|
| Module | Test File | Tests | Coverage |
|
||||||
|
|--------|-----------|-------|----------|
|
||||||
|
| `validators.py` | `tests/govmap/test_validators.py` | 32 tests | ✅ Complete - All validation functions with edge cases |
|
||||||
|
| `utils.py` | `tests/govmap/test_utils.py` | 36 tests | ✅ Complete - Distance, address matching, Hebrew floors |
|
||||||
|
| MCP Tools | `tests/test_fastmcp_tools.py` | 36 tests | ✅ Complete - All 10 MCP tools with mocking |
|
||||||
|
|
||||||
|
**NEW Test Breakdown:**
|
||||||
|
- **Validators:** 32 tests covering address, coordinates, positive int, deal type validation
|
||||||
|
- **Utils:** 36 tests covering distance calculation, address matching, Hebrew floor parsing
|
||||||
|
- **MCP Tools:** 36 tests covering all 10 tools including error handling and edge cases
|
||||||
|
|
||||||
|
## E2E Test Results (MCP Tools)
|
||||||
|
|
||||||
|
Tested the following MCP tools with real data:
|
||||||
|
|
||||||
|
### ✅ Passing E2E Tests
|
||||||
|
|
||||||
|
1. **`find_recent_deals_for_address`**
|
||||||
|
- Input: `"הרצל 1 תל אביב"`, 1 year, 100m radius, max 5 deals
|
||||||
|
- Result: ✅ SUCCESS - Returned 5 deals with complete statistics
|
||||||
|
- Data Quality: Excellent - all fields populated correctly
|
||||||
|
|
||||||
|
2. **`analyze_market_trends`**
|
||||||
|
- Input: `"דיזנגוף 50 תל אביב"`, 2 years
|
||||||
|
- Result: ✅ SUCCESS - Returned 82 deals analyzed
|
||||||
|
- Output: Comprehensive yearly trends, property type breakdown, neighborhoods
|
||||||
|
- Data Quality: Excellent
|
||||||
|
|
||||||
|
3. **`get_valuation_comparables`**
|
||||||
|
- Input: `"רוטשילד 1 תל אביב"`, property_type="דירה", rooms 2-4, max 5
|
||||||
|
- Result: ✅ SUCCESS - Returned 1 comparable with statistics
|
||||||
|
- Filtering: ✅ Working correctly (rooms, property type)
|
||||||
|
- Token Usage: ✅ Optimized (no bloat fields)
|
||||||
|
|
||||||
|
### ❌ Bug Found: `autocomplete_address`
|
||||||
|
|
||||||
|
**Status:** 🐛 BUG - Incorrect field mapping
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
The `autocomplete_address` tool in `fastmcp_server.py` uses incorrect field names when parsing the API response.
|
||||||
|
|
||||||
|
**Current Code (WRONG):**
|
||||||
|
```python
|
||||||
|
formatted_results.append({
|
||||||
|
"address": result.get("addressLabel", ""), # ❌ Wrong field
|
||||||
|
"settlement": result.get("settlementNameHeb", ""), # ❌ Wrong field
|
||||||
|
"coordinates": result.get("coordinates", {}), # ❌ Wrong field
|
||||||
|
"polygon_id": result.get("polygon_id") # ❌ Wrong field
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Actual API Response Fields:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"id": "address|ADDR|123|test",
|
||||||
|
"text": "תל אביב", # ✅ Use this for address
|
||||||
|
"type": "address",
|
||||||
|
"score": 100,
|
||||||
|
"shape": "POINT(3870000.123 3770000.456)", # ✅ Parse this for coordinates
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** HIGH - The tool returns empty data for all fields
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
```python
|
||||||
|
# Parse coordinates from WKT POINT format
|
||||||
|
shape_str = result.get("shape", "")
|
||||||
|
coordinates = {}
|
||||||
|
if shape_str.startswith("POINT("):
|
||||||
|
coords_str = shape_str[6:-1] # Remove "POINT(" and ")"
|
||||||
|
coords = coords_str.split()
|
||||||
|
if len(coords) == 2:
|
||||||
|
coordinates = {
|
||||||
|
"longitude": float(coords[0]),
|
||||||
|
"latitude": float(coords[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted_results.append({
|
||||||
|
"text": result.get("text", ""), # ✅ Display text
|
||||||
|
"id": result.get("id", ""), # ✅ Unique ID
|
||||||
|
"type": result.get("type", ""), # ✅ Result type
|
||||||
|
"score": result.get("score", 0), # ✅ Match score
|
||||||
|
"coordinates": coordinates # ✅ Parsed coordinates
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Organization
|
||||||
|
|
||||||
|
### Current Structure ✅
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
└── test_govmap_client.py (34 tests)
|
||||||
|
├── TestGovmapClient (12 tests)
|
||||||
|
└── TestMarketAnalysisFunctions (22 tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Structure 📋
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── test_govmap_client.py (existing integration tests)
|
||||||
|
├── govmap/
|
||||||
|
│ ├── test_validators.py (NEW - 10-15 tests)
|
||||||
|
│ ├── test_utils.py (NEW - 8-10 tests)
|
||||||
|
│ ├── test_filters.py (refactor existing)
|
||||||
|
│ ├── test_statistics.py (refactor existing)
|
||||||
|
│ └── test_market_analysis.py (refactor existing)
|
||||||
|
└── test_fastmcp_tools.py (NEW - E2E tool tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Missing Test Cases
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
1. **Validator Edge Cases**
|
||||||
|
```python
|
||||||
|
# validators.py
|
||||||
|
- Test validate_address with empty string
|
||||||
|
- Test validate_address with very long address (>500 chars)
|
||||||
|
- Test validate_coordinates with out-of-bounds ITM coordinates
|
||||||
|
- Test validate_positive_int with negative numbers
|
||||||
|
- Test validate_deal_type with invalid types (3, 0, -1)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Utils Hebrew Floor Parsing**
|
||||||
|
```python
|
||||||
|
# utils.py
|
||||||
|
- Test extract_floor_number with all Hebrew floor names
|
||||||
|
- Test extract_floor_number with numeric strings
|
||||||
|
- Test extract_floor_number with invalid input
|
||||||
|
- Test calculate_distance with same point
|
||||||
|
- Test calculate_distance with far points
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **MCP Tool E2E Tests**
|
||||||
|
```python
|
||||||
|
# test_fastmcp_tools.py
|
||||||
|
- Test autocomplete_address with real API
|
||||||
|
- Test get_deals_by_radius with edge coordinates
|
||||||
|
- Test error handling for all tools
|
||||||
|
- Test token limits for large responses
|
||||||
|
```
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
4. **Client Error Handling**
|
||||||
|
```python
|
||||||
|
# client.py
|
||||||
|
- Test retry logic with transient failures
|
||||||
|
- Test rate limiting behavior
|
||||||
|
- Test timeout handling
|
||||||
|
- Test invalid API responses
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Filter Edge Cases**
|
||||||
|
```python
|
||||||
|
# filters.py
|
||||||
|
- Test filtering with all null values
|
||||||
|
- Test filtering with overlapping criteria
|
||||||
|
- Test filtering with no matches
|
||||||
|
```
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
6. **Integration Tests**
|
||||||
|
```python
|
||||||
|
# Integration with real API
|
||||||
|
- Test full workflow: autocomplete → find deals → analyze
|
||||||
|
- Test with various Israeli cities
|
||||||
|
- Test with English addresses
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### Immediate Actions (Before Merge)
|
||||||
|
|
||||||
|
1. **🔴 CRITICAL: Fix `autocomplete_address` bug**
|
||||||
|
- File: `nadlan_mcp/fastmcp_server.py` lines 65-71
|
||||||
|
- Estimated time: 10 minutes
|
||||||
|
- Add test to prevent regression
|
||||||
|
|
||||||
|
### Short-term Actions (Next Sprint)
|
||||||
|
|
||||||
|
2. **🟡 Add validator unit tests**
|
||||||
|
- Create `tests/govmap/test_validators.py`
|
||||||
|
- 10-15 tests covering edge cases
|
||||||
|
- Estimated time: 1-2 hours
|
||||||
|
|
||||||
|
3. **🟡 Add utils unit tests**
|
||||||
|
- Create `tests/govmap/test_utils.py`
|
||||||
|
- Focus on Hebrew floor parsing and distance calculation
|
||||||
|
- Estimated time: 1 hour
|
||||||
|
|
||||||
|
4. **🟡 Add MCP tool E2E tests**
|
||||||
|
- Create `tests/test_fastmcp_tools.py`
|
||||||
|
- Test all 10 MCP tools with real data
|
||||||
|
- Estimated time: 2-3 hours
|
||||||
|
|
||||||
|
### Long-term Actions (Future)
|
||||||
|
|
||||||
|
5. **🟢 Install and configure pytest-cov**
|
||||||
|
- Get actual coverage percentage
|
||||||
|
- Set coverage thresholds in CI
|
||||||
|
|
||||||
|
6. **🟢 Reorganize tests into submodules**
|
||||||
|
- Split test_govmap_client.py into focused test files
|
||||||
|
- Better test organization and maintainability
|
||||||
|
|
||||||
|
7. **🟢 Add integration test suite**
|
||||||
|
- Mark with @pytest.mark.integration
|
||||||
|
- Test full workflows with real API
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The refactored code has **good test coverage** overall:
|
||||||
|
- ✅ 34 tests all passing
|
||||||
|
- ✅ Core functionality well-tested through integration tests
|
||||||
|
- ✅ E2E tests show tools working correctly
|
||||||
|
- ⚠️ One bug found and documented (`autocomplete_address`)
|
||||||
|
- 📋 Some unit tests missing for edge cases
|
||||||
|
|
||||||
|
**Recommended Next Steps:**
|
||||||
|
1. Fix the `autocomplete_address` bug (10 min)
|
||||||
|
2. Add the fix to the PR before merging
|
||||||
|
3. Create follow-up issues for missing unit tests
|
||||||
|
4. Consider adding pytest-cov for coverage metrics
|
||||||
|
|
||||||
|
**Overall Assessment:** The Phase 3 refactoring maintains code quality and test coverage. The modular structure will make it easier to add targeted unit tests in the future.
|
||||||
@@ -63,11 +63,27 @@ def autocomplete_address(search_text: str) -> str:
|
|||||||
# Format results for better readability
|
# Format results for better readability
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for result in response['results']:
|
for result in response['results']:
|
||||||
|
# Parse coordinates from WKT POINT format: "POINT(longitude latitude)"
|
||||||
|
shape_str = result.get("shape", "")
|
||||||
|
coordinates = {}
|
||||||
|
if shape_str and shape_str.startswith("POINT("):
|
||||||
|
try:
|
||||||
|
coords_str = shape_str[6:-1] # Remove "POINT(" and ")"
|
||||||
|
coords = coords_str.split()
|
||||||
|
if len(coords) == 2:
|
||||||
|
coordinates = {
|
||||||
|
"longitude": float(coords[0]),
|
||||||
|
"latitude": float(coords[1])
|
||||||
|
}
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
logger.warning(f"Failed to parse coordinates from shape: {shape_str}, error: {e}")
|
||||||
|
|
||||||
formatted_results.append({
|
formatted_results.append({
|
||||||
"address": result.get("addressLabel", ""),
|
"text": result.get("text", ""),
|
||||||
"settlement": result.get("settlementNameHeb", ""),
|
"id": result.get("id", ""),
|
||||||
"coordinates": result.get("coordinates", {}),
|
"type": result.get("type", ""),
|
||||||
"polygon_id": result.get("polygon_id")
|
"score": result.get("score", 0),
|
||||||
|
"coordinates": coordinates
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(formatted_results, ensure_ascii=False, indent=2)
|
return json.dumps(formatted_results, ensure_ascii=False, indent=2)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Govmap package - Israeli government real estate data API client.
|
||||||
|
|
||||||
|
This package provides a modular interface to the Govmap API for querying
|
||||||
|
Israeli real estate deals, market trends, and property information.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
- GovmapClient: Main API client class (to be added from client.py)
|
||||||
|
- filter_deals_by_criteria: Filter deals by various criteria
|
||||||
|
- calculate_deal_statistics: Calculate statistical aggregations
|
||||||
|
- calculate_market_activity_score: Market activity and trend metrics
|
||||||
|
- analyze_investment_potential: Investment analysis and price trends
|
||||||
|
- get_market_liquidity: Market liquidity and velocity metrics
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Filter functions
|
||||||
|
from .filters import filter_deals_by_criteria
|
||||||
|
|
||||||
|
# Statistics functions
|
||||||
|
from .statistics import calculate_deal_statistics, calculate_std_dev
|
||||||
|
|
||||||
|
# Market analysis functions
|
||||||
|
from .market_analysis import (
|
||||||
|
calculate_market_activity_score,
|
||||||
|
analyze_investment_potential,
|
||||||
|
get_market_liquidity,
|
||||||
|
parse_deal_dates,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Utility functions
|
||||||
|
from .utils import calculate_distance, is_same_building, extract_floor_number
|
||||||
|
|
||||||
|
# Validation functions
|
||||||
|
from .validators import (
|
||||||
|
validate_address,
|
||||||
|
validate_coordinates,
|
||||||
|
validate_positive_int,
|
||||||
|
validate_deal_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Main API client
|
||||||
|
from .client import GovmapClient
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Main client class
|
||||||
|
"GovmapClient",
|
||||||
|
# Filtering
|
||||||
|
"filter_deals_by_criteria",
|
||||||
|
# Statistics
|
||||||
|
"calculate_deal_statistics",
|
||||||
|
"calculate_std_dev",
|
||||||
|
# Market analysis
|
||||||
|
"calculate_market_activity_score",
|
||||||
|
"analyze_investment_potential",
|
||||||
|
"get_market_liquidity",
|
||||||
|
"parse_deal_dates",
|
||||||
|
# Utilities
|
||||||
|
"calculate_distance",
|
||||||
|
"is_same_building",
|
||||||
|
"extract_floor_number",
|
||||||
|
# Validation
|
||||||
|
"validate_address",
|
||||||
|
"validate_coordinates",
|
||||||
|
"validate_positive_int",
|
||||||
|
"validate_deal_type",
|
||||||
|
]
|
||||||
@@ -0,0 +1,740 @@
|
|||||||
|
"""
|
||||||
|
Govmap API Client for Israeli real estate data.
|
||||||
|
|
||||||
|
This module provides the main GovmapClient class for interacting with the
|
||||||
|
Israeli government's Govmap API to retrieve property deals, market trends,
|
||||||
|
and real estate information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from nadlan_mcp.config import GovmapConfig, get_config
|
||||||
|
|
||||||
|
# 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__)
|
||||||
|
|
||||||
|
|
||||||
|
class GovmapClient:
|
||||||
|
"""
|
||||||
|
A client for interacting with the Israeli government's Govmap API.
|
||||||
|
|
||||||
|
This class provides methods to search for properties, find block/parcel information,
|
||||||
|
and retrieve real estate deal data with automatic retries and rate limiting.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
config: Configuration object with API settings
|
||||||
|
session: Requests session for connection pooling
|
||||||
|
last_request_time: Timestamp of last API request for rate limiting
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[GovmapConfig] = None):
|
||||||
|
"""
|
||||||
|
Initialize the GovmapClient.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Optional configuration object. If None, uses global config.
|
||||||
|
"""
|
||||||
|
self.config = config or get_config()
|
||||||
|
self.base_url = self.config.base_url.rstrip("/")
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(
|
||||||
|
{"Content-Type": "application/json", "User-Agent": self.config.user_agent}
|
||||||
|
)
|
||||||
|
self.last_request_time = 0.0
|
||||||
|
|
||||||
|
def _rate_limit(self):
|
||||||
|
"""
|
||||||
|
Enforce rate limiting by sleeping if necessary.
|
||||||
|
|
||||||
|
Ensures requests don't exceed the configured requests_per_second.
|
||||||
|
"""
|
||||||
|
min_interval = 1.0 / self.config.requests_per_second
|
||||||
|
elapsed = time.time() - self.last_request_time
|
||||||
|
if elapsed < min_interval:
|
||||||
|
time.sleep(min_interval - elapsed)
|
||||||
|
self.last_request_time = time.time()
|
||||||
|
|
||||||
|
# Validation methods (delegate to validators module)
|
||||||
|
def _validate_address(self, address: str) -> str:
|
||||||
|
"""Validate and sanitize address input."""
|
||||||
|
return validators.validate_address(address)
|
||||||
|
|
||||||
|
def _validate_coordinates(self, point: Tuple[float, float]) -> Tuple[float, float]:
|
||||||
|
"""Validate coordinate input."""
|
||||||
|
return validators.validate_coordinates(point)
|
||||||
|
|
||||||
|
def _validate_positive_int(
|
||||||
|
self, value: int, name: str, max_value: Optional[int] = None
|
||||||
|
) -> int:
|
||||||
|
"""Validate positive integer input."""
|
||||||
|
return validators.validate_positive_int(value, name, max_value)
|
||||||
|
|
||||||
|
# Utility methods (delegate to utils module)
|
||||||
|
def _calculate_distance(
|
||||||
|
self, point1: Tuple[float, float], point2: Tuple[float, float]
|
||||||
|
) -> float:
|
||||||
|
"""Calculate Euclidean distance between two points in ITM coordinates."""
|
||||||
|
return utils.calculate_distance(point1, point2)
|
||||||
|
|
||||||
|
def _is_same_building(self, search_address: str, deal_address: str) -> bool:
|
||||||
|
"""Check if a deal is from the same building as the search address."""
|
||||||
|
return utils.is_same_building(search_address, deal_address)
|
||||||
|
|
||||||
|
def _extract_floor_number(self, floor_str: str) -> Optional[int]:
|
||||||
|
"""Extract numeric floor number from Hebrew floor description."""
|
||||||
|
return utils.extract_floor_number(floor_str)
|
||||||
|
|
||||||
|
# Core API methods
|
||||||
|
def autocomplete_address(self, search_text: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Find the most likely match for a given address using autocomplete.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_text: The address to search for (e.g., "סוקולוב 38 חולון")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing the JSON response from the API with coordinates
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
requests.RequestException: If the API request fails after retries
|
||||||
|
ValueError: If the response is invalid or input is invalid
|
||||||
|
"""
|
||||||
|
search_text = self._validate_address(search_text)
|
||||||
|
url = f"{self.base_url}/search-service/autocomplete"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"searchText": search_text,
|
||||||
|
"language": "he",
|
||||||
|
"isAccurate": False,
|
||||||
|
"maxResults": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retry logic with exponential backoff
|
||||||
|
for attempt in range(self.config.max_retries + 1):
|
||||||
|
try:
|
||||||
|
self._rate_limit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Searching for address: {search_text} (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
||||||
|
)
|
||||||
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
||||||
|
response = self.session.post(url, json=payload, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if not data or "results" not in data:
|
||||||
|
raise ValueError("Invalid response format from autocomplete API")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
except (requests.RequestException, requests.Timeout) as e:
|
||||||
|
if attempt < self.config.max_retries:
|
||||||
|
wait_time = min(
|
||||||
|
self.config.retry_min_wait * (2**attempt),
|
||||||
|
self.config.retry_max_wait,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
||||||
|
)
|
||||||
|
time.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
# This line should never be reached but satisfies type checker
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unexpected error: retry loop exited without return or raise"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_gush_helka(self, point: Tuple[float, float]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get Gush (Block) and Helka (Parcel) information for a coordinate point.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
point: A tuple of (longitude, latitude)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing the JSON response with block and parcel data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
requests.RequestException: If the API request fails after retries
|
||||||
|
ValueError: If the response or input is invalid
|
||||||
|
"""
|
||||||
|
point = self._validate_coordinates(point)
|
||||||
|
url = f"{self.base_url}/layers-catalog/entitiesByPoint"
|
||||||
|
|
||||||
|
payload = {"point": list(point), "layers": [{"layerId": "16"}], "tolerance": 0}
|
||||||
|
|
||||||
|
# Retry logic with exponential backoff
|
||||||
|
for attempt in range(self.config.max_retries + 1):
|
||||||
|
try:
|
||||||
|
self._rate_limit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Getting Gush/Helka for point: {point} (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
||||||
|
)
|
||||||
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
||||||
|
response = self.session.post(url, json=payload, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data
|
||||||
|
|
||||||
|
except (requests.RequestException, requests.Timeout) as e:
|
||||||
|
if attempt < self.config.max_retries:
|
||||||
|
wait_time = min(
|
||||||
|
self.config.retry_min_wait * (2**attempt),
|
||||||
|
self.config.retry_max_wait,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
||||||
|
)
|
||||||
|
time.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
# This line should never be reached but satisfies type checker
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unexpected error: retry loop exited without return or raise"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_deals_by_radius(
|
||||||
|
self, point: Tuple[float, float], radius: int = 50
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Find real estate deals within a specified radius of a point.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
point: A tuple of (longitude, latitude)
|
||||||
|
radius: The search radius in meters (default: 50)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of deals found within the radius
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
requests.RequestException: If the API request fails after retries
|
||||||
|
ValueError: If the response or input is invalid
|
||||||
|
"""
|
||||||
|
point = self._validate_coordinates(point)
|
||||||
|
radius = self._validate_positive_int(radius, "radius", max_value=5000)
|
||||||
|
url = f"{self.base_url}/real-estate/deals/{point[0]},{point[1]}/{radius}"
|
||||||
|
|
||||||
|
# Retry logic with exponential backoff
|
||||||
|
for attempt in range(self.config.max_retries + 1):
|
||||||
|
try:
|
||||||
|
self._rate_limit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Getting deals by radius for point: {point}, radius: {radius}m (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
||||||
|
)
|
||||||
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
||||||
|
response = self.session.get(url, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected list response, got {type(data).__name__}"
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
except (requests.RequestException, requests.Timeout) as e:
|
||||||
|
if attempt < self.config.max_retries:
|
||||||
|
wait_time = min(
|
||||||
|
self.config.retry_min_wait * (2**attempt),
|
||||||
|
self.config.retry_max_wait,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
||||||
|
)
|
||||||
|
time.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
# This line should never be reached but satisfies type checker
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unexpected error: retry loop exited without return or raise"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_street_deals(
|
||||||
|
self,
|
||||||
|
polygon_id: str,
|
||||||
|
limit: int = 10,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
deal_type: int = 2,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Retrieve detailed information about deals on a specific street.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
polygon_id: The ID of the lot's polygon
|
||||||
|
limit: Maximum number of deals to return (default: 10)
|
||||||
|
start_date: Start date for search in 'YYYY-MM' format
|
||||||
|
end_date: End date for search in 'YYYY-MM' format
|
||||||
|
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of detailed deal information for the street
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
requests.RequestException: If the API request fails after retries
|
||||||
|
ValueError: If the response or input is invalid
|
||||||
|
"""
|
||||||
|
if not polygon_id or not isinstance(polygon_id, str):
|
||||||
|
raise ValueError("polygon_id must be a non-empty string")
|
||||||
|
polygon_id = polygon_id.strip()
|
||||||
|
if not polygon_id:
|
||||||
|
raise ValueError("polygon_id cannot be empty or whitespace only")
|
||||||
|
|
||||||
|
limit = self._validate_positive_int(limit, "limit", max_value=1000)
|
||||||
|
validators.validate_deal_type(deal_type)
|
||||||
|
|
||||||
|
url = f"{self.base_url}/real-estate/street-deals/{polygon_id}"
|
||||||
|
|
||||||
|
params: Dict[str, Any] = {"limit": limit, "dealType": deal_type}
|
||||||
|
if start_date:
|
||||||
|
params["startDate"] = start_date
|
||||||
|
if end_date:
|
||||||
|
params["endDate"] = end_date
|
||||||
|
|
||||||
|
# Retry logic with exponential backoff
|
||||||
|
for attempt in range(self.config.max_retries + 1):
|
||||||
|
try:
|
||||||
|
self._rate_limit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Getting street deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
||||||
|
)
|
||||||
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
||||||
|
response = self.session.get(url, params=params, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
# API returns {data: [...], totalCount: ..., limit: ..., offset: ...}
|
||||||
|
if isinstance(data, dict) and "data" in data:
|
||||||
|
if not isinstance(data["data"], list):
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected list in 'data' field, got {type(data['data']).__name__}"
|
||||||
|
)
|
||||||
|
return data["data"]
|
||||||
|
elif isinstance(data, list):
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected response format: {type(data).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except (requests.RequestException, requests.Timeout) as e:
|
||||||
|
if attempt < self.config.max_retries:
|
||||||
|
wait_time = min(
|
||||||
|
self.config.retry_min_wait * (2**attempt),
|
||||||
|
self.config.retry_max_wait,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
||||||
|
)
|
||||||
|
time.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
# This line should never be reached but satisfies type checker
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unexpected error: retry loop exited without return or raise"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_neighborhood_deals(
|
||||||
|
self,
|
||||||
|
polygon_id: str,
|
||||||
|
limit: int = 10,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
deal_type: int = 2,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Retrieve deals within the same neighborhood as the given polygon_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
polygon_id: The ID of the lot's polygon
|
||||||
|
limit: Maximum number of deals to return (default: 10)
|
||||||
|
start_date: Start date for search in 'YYYY-MM' format
|
||||||
|
end_date: End date for search in 'YYYY-MM' format
|
||||||
|
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of deals in the neighborhood
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
requests.RequestException: If the API request fails after retries
|
||||||
|
ValueError: If the response or input is invalid
|
||||||
|
"""
|
||||||
|
if not polygon_id or not isinstance(polygon_id, str):
|
||||||
|
raise ValueError("polygon_id must be a non-empty string")
|
||||||
|
polygon_id = polygon_id.strip()
|
||||||
|
if not polygon_id:
|
||||||
|
raise ValueError("polygon_id cannot be empty or whitespace only")
|
||||||
|
|
||||||
|
limit = self._validate_positive_int(limit, "limit", max_value=1000)
|
||||||
|
validators.validate_deal_type(deal_type)
|
||||||
|
|
||||||
|
url = f"{self.base_url}/real-estate/neighborhood-deals/{polygon_id}"
|
||||||
|
|
||||||
|
params: Dict[str, Any] = {"limit": limit, "dealType": deal_type}
|
||||||
|
if start_date:
|
||||||
|
params["startDate"] = start_date
|
||||||
|
if end_date:
|
||||||
|
params["endDate"] = end_date
|
||||||
|
|
||||||
|
# Retry logic with exponential backoff
|
||||||
|
for attempt in range(self.config.max_retries + 1):
|
||||||
|
try:
|
||||||
|
self._rate_limit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Getting neighborhood deals for polygon: {polygon_id}, dealType: {deal_type} (attempt {attempt + 1}/{self.config.max_retries + 1})"
|
||||||
|
)
|
||||||
|
timeout = (self.config.connect_timeout, self.config.read_timeout)
|
||||||
|
response = self.session.get(url, params=params, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
# API returns {data: [...], totalCount: ..., limit: ..., offset: ...}
|
||||||
|
if isinstance(data, dict) and "data" in data:
|
||||||
|
if not isinstance(data["data"], list):
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected list in 'data' field, got {type(data['data']).__name__}"
|
||||||
|
)
|
||||||
|
return data["data"]
|
||||||
|
elif isinstance(data, list):
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected response format: {type(data).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except (requests.RequestException, requests.Timeout) as e:
|
||||||
|
if attempt < self.config.max_retries:
|
||||||
|
wait_time = min(
|
||||||
|
self.config.retry_min_wait * (2**attempt),
|
||||||
|
self.config.retry_max_wait,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"Request failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}"
|
||||||
|
)
|
||||||
|
time.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Request failed after {self.config.max_retries + 1} attempts: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
# This line should never be reached but satisfies type checker
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unexpected error: retry loop exited without return or raise"
|
||||||
|
)
|
||||||
|
|
||||||
|
def find_recent_deals_for_address(
|
||||||
|
self,
|
||||||
|
address: str,
|
||||||
|
years_back: int = 2,
|
||||||
|
radius: int = 30,
|
||||||
|
max_deals: int = 100,
|
||||||
|
deal_type: int = 2,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Find all relevant real estate deals for a given address from the last few years.
|
||||||
|
|
||||||
|
This is the main use case function that ties everything together.
|
||||||
|
Street deals include deals from the same building which get highest priority.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: The address to search for
|
||||||
|
years_back: How many years back to search (default: 2)
|
||||||
|
radius: Search radius in meters for initial coordinate search (default: 30)
|
||||||
|
Small radius since street deals cover the entire street anyway
|
||||||
|
max_deals: Maximum number of deals to return (default: 100)
|
||||||
|
deal_type: Deal type filter (1=first hand/new, 2=second hand/used, default: 2)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of deals found for the address area, with same building deals prioritized first,
|
||||||
|
then street deals, then neighborhood deals
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If address cannot be found or processed, or input is invalid
|
||||||
|
requests.RequestException: If API requests fail after retries
|
||||||
|
"""
|
||||||
|
# Validate inputs
|
||||||
|
address = self._validate_address(address)
|
||||||
|
years_back = self._validate_positive_int(years_back, "years_back", max_value=50)
|
||||||
|
radius = self._validate_positive_int(radius, "radius", max_value=5000)
|
||||||
|
max_deals = self._validate_positive_int(max_deals, "max_deals", max_value=10000)
|
||||||
|
validators.validate_deal_type(deal_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Get coordinates for the address
|
||||||
|
logger.info(
|
||||||
|
f"Starting search for address: {address}, dealType: {deal_type}"
|
||||||
|
)
|
||||||
|
autocomplete_result = self.autocomplete_address(address)
|
||||||
|
|
||||||
|
if not autocomplete_result.get("results"):
|
||||||
|
raise ValueError(f"No results found for address: {address}")
|
||||||
|
|
||||||
|
# Get the best match (first result)
|
||||||
|
best_match = autocomplete_result["results"][0]
|
||||||
|
if "shape" not in best_match:
|
||||||
|
raise ValueError("No coordinates found in autocomplete result")
|
||||||
|
|
||||||
|
# Parse coordinates from WKT POINT string
|
||||||
|
# Format: "POINT(longitude latitude)"
|
||||||
|
shape_str = best_match["shape"]
|
||||||
|
if not shape_str.startswith("POINT("):
|
||||||
|
raise ValueError("Invalid coordinate format in autocomplete result")
|
||||||
|
|
||||||
|
# Extract coordinates from "POINT(x y)"
|
||||||
|
coords_str = shape_str[6:-1] # Remove "POINT(" and ")"
|
||||||
|
coords = coords_str.split()
|
||||||
|
if len(coords) != 2:
|
||||||
|
raise ValueError("Invalid coordinate format in autocomplete result")
|
||||||
|
|
||||||
|
point = (float(coords[0]), float(coords[1]))
|
||||||
|
search_address_normalized = address.lower().strip()
|
||||||
|
logger.info(f"Found coordinates: {point}")
|
||||||
|
|
||||||
|
# Step 2: Get deals by radius to find polygon IDs
|
||||||
|
nearby_deals = self.get_deals_by_radius(point, radius=radius)
|
||||||
|
|
||||||
|
# Extract unique polygon IDs
|
||||||
|
polygon_ids = set()
|
||||||
|
for deal in nearby_deals:
|
||||||
|
if "polygon_id" in deal:
|
||||||
|
polygon_ids.add(str(deal["polygon_id"]))
|
||||||
|
|
||||||
|
logger.info(f"Found {len(polygon_ids)} unique polygon IDs")
|
||||||
|
|
||||||
|
# Step 3: Calculate date range
|
||||||
|
end_date = datetime.now()
|
||||||
|
start_date = end_date - timedelta(days=years_back * 365)
|
||||||
|
start_date_str = start_date.strftime("%Y-%m")
|
||||||
|
end_date_str = end_date.strftime("%Y-%m")
|
||||||
|
|
||||||
|
# Step 4: Get street and neighborhood deals for each polygon
|
||||||
|
# Prioritize: same building (0) > street deals (1) > neighborhood deals (2)
|
||||||
|
building_deals = []
|
||||||
|
street_deals = []
|
||||||
|
neighborhood_deals = []
|
||||||
|
seen_deals = set() # For deduplication
|
||||||
|
|
||||||
|
for polygon_id in polygon_ids:
|
||||||
|
try:
|
||||||
|
# Get street deals first (higher priority)
|
||||||
|
current_street_deals = self.get_street_deals(
|
||||||
|
polygon_id,
|
||||||
|
limit=max_deals // 2, # Allocate more to street deals
|
||||||
|
start_date=start_date_str,
|
||||||
|
end_date=end_date_str,
|
||||||
|
deal_type=deal_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get neighborhood deals (lower priority)
|
||||||
|
current_neighborhood_deals = self.get_neighborhood_deals(
|
||||||
|
polygon_id,
|
||||||
|
limit=max_deals // 4, # Allocate less to neighborhood deals
|
||||||
|
start_date=start_date_str,
|
||||||
|
end_date=end_date_str,
|
||||||
|
deal_type=deal_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process street deals and separate building deals
|
||||||
|
for deal in current_street_deals:
|
||||||
|
# Create unique deal ID for deduplication
|
||||||
|
deal_id = f"{deal.get('dealId', '')}{deal.get('dealDate', '')}"
|
||||||
|
if deal_id not in seen_deals:
|
||||||
|
seen_deals.add(deal_id)
|
||||||
|
deal["source_polygon_id"] = polygon_id
|
||||||
|
deal["deal_source"] = "street"
|
||||||
|
|
||||||
|
# Check if this is from the same building
|
||||||
|
# Construct address from API fields (API doesn't have single "address" field)
|
||||||
|
street = deal.get("streetNameHeb", "")
|
||||||
|
house_num = str(deal.get("houseNum", ""))
|
||||||
|
deal_address = f"{street} {house_num}".lower().strip()
|
||||||
|
if self._is_same_building(
|
||||||
|
search_address_normalized, deal_address
|
||||||
|
):
|
||||||
|
deal["deal_source"] = "same_building"
|
||||||
|
deal["priority"] = 0 # Highest priority
|
||||||
|
building_deals.append(deal)
|
||||||
|
else:
|
||||||
|
deal["priority"] = 1 # Street deals priority
|
||||||
|
street_deals.append(deal)
|
||||||
|
|
||||||
|
# Add neighborhood deals with lowest priority
|
||||||
|
for deal in current_neighborhood_deals:
|
||||||
|
# Create unique deal ID for deduplication
|
||||||
|
deal_id = f"{deal.get('dealId', '')}{deal.get('dealDate', '')}"
|
||||||
|
if deal_id not in seen_deals:
|
||||||
|
seen_deals.add(deal_id)
|
||||||
|
deal["source_polygon_id"] = polygon_id
|
||||||
|
deal["deal_source"] = "neighborhood"
|
||||||
|
deal["priority"] = 2 # Lowest priority
|
||||||
|
neighborhood_deals.append(deal)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error processing polygon {polygon_id}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Step 5: Combine and prioritize: building deals first, then street, then neighborhood
|
||||||
|
all_deals = building_deals + street_deals + neighborhood_deals
|
||||||
|
|
||||||
|
# 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
|
||||||
|
all_deals.sort(
|
||||||
|
key=lambda x: x.get("dealDate", "1900-01-01"), reverse=True
|
||||||
|
) # Newest first
|
||||||
|
all_deals.sort(
|
||||||
|
key=lambda x: x.get("priority", 3)
|
||||||
|
) # Priority first (0=building, 1=street, 2=neighborhood)
|
||||||
|
|
||||||
|
# Limit to max_deals
|
||||||
|
if len(all_deals) > max_deals:
|
||||||
|
all_deals = all_deals[:max_deals]
|
||||||
|
|
||||||
|
# Add price per square meter calculation and deal type info
|
||||||
|
for deal in all_deals:
|
||||||
|
price = deal.get("dealAmount", 0)
|
||||||
|
area = deal.get("assetArea", 0)
|
||||||
|
if (
|
||||||
|
isinstance(price, (int, float))
|
||||||
|
and isinstance(area, (int, float))
|
||||||
|
and area > 0
|
||||||
|
):
|
||||||
|
deal["price_per_sqm"] = round(price / area, 2)
|
||||||
|
else:
|
||||||
|
deal["price_per_sqm"] = None
|
||||||
|
|
||||||
|
# Add deal type description for clarity
|
||||||
|
deal["deal_type"] = deal_type
|
||||||
|
deal["deal_type_description"] = (
|
||||||
|
"first_hand_new" if deal_type == 1 else "second_hand_used"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Found {len(all_deals)} total deals for address: {address} "
|
||||||
|
f"(Building: {len(building_deals)}, Street: {len(street_deals)}, Neighborhood: {len(neighborhood_deals)}) "
|
||||||
|
f"[{all_deals[0]['deal_type_description'] if all_deals else 'N/A'}]"
|
||||||
|
)
|
||||||
|
return all_deals
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in find_recent_deals_for_address: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Filtering methods (delegate to filters module)
|
||||||
|
def filter_deals_by_criteria(
|
||||||
|
self,
|
||||||
|
deals: List[Dict[str, Any]],
|
||||||
|
property_type: Optional[str] = None,
|
||||||
|
min_rooms: Optional[float] = None,
|
||||||
|
max_rooms: Optional[float] = None,
|
||||||
|
min_price: Optional[float] = None,
|
||||||
|
max_price: Optional[float] = None,
|
||||||
|
min_area: Optional[float] = None,
|
||||||
|
max_area: Optional[float] = None,
|
||||||
|
min_floor: Optional[int] = None,
|
||||||
|
max_floor: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Filter deals by various criteria.
|
||||||
|
|
||||||
|
Delegates to filters.filter_deals_by_criteria for the actual filtering logic.
|
||||||
|
"""
|
||||||
|
return filters.filter_deals_by_criteria(
|
||||||
|
deals=deals,
|
||||||
|
property_type=property_type,
|
||||||
|
min_rooms=min_rooms,
|
||||||
|
max_rooms=max_rooms,
|
||||||
|
min_price=min_price,
|
||||||
|
max_price=max_price,
|
||||||
|
min_area=min_area,
|
||||||
|
max_area=max_area,
|
||||||
|
min_floor=min_floor,
|
||||||
|
max_floor=max_floor,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Statistics methods (delegate to statistics module)
|
||||||
|
def calculate_deal_statistics(self, deals: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Calculate statistical aggregations on deal data.
|
||||||
|
|
||||||
|
Delegates to statistics.calculate_deal_statistics for the actual calculations.
|
||||||
|
"""
|
||||||
|
return statistics.calculate_deal_statistics(deals)
|
||||||
|
|
||||||
|
def _calculate_std_dev(self, values: List[float]) -> float:
|
||||||
|
"""
|
||||||
|
Calculate standard deviation of a list of values.
|
||||||
|
|
||||||
|
Delegates to statistics.calculate_std_dev for the actual calculation.
|
||||||
|
"""
|
||||||
|
return statistics.calculate_std_dev(values)
|
||||||
|
|
||||||
|
# Market analysis methods (delegate to market_analysis module)
|
||||||
|
def _parse_deal_dates(
|
||||||
|
self, deals: List[Dict[str, Any]], time_period_months: Optional[int] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Parse and filter deal dates from a list of deals.
|
||||||
|
|
||||||
|
Delegates to market_analysis.parse_deal_dates for the actual parsing.
|
||||||
|
"""
|
||||||
|
return market_analysis.parse_deal_dates(deals, time_period_months)
|
||||||
|
|
||||||
|
def calculate_market_activity_score(
|
||||||
|
self, deals: List[Dict[str, Any]], time_period_months: int = 12
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Calculate market activity and liquidity metrics.
|
||||||
|
|
||||||
|
Delegates to market_analysis.calculate_market_activity_score for the analysis.
|
||||||
|
"""
|
||||||
|
return market_analysis.calculate_market_activity_score(
|
||||||
|
deals, time_period_months
|
||||||
|
)
|
||||||
|
|
||||||
|
def analyze_investment_potential(
|
||||||
|
self, deals: List[Dict[str, Any]]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze investment potential based on price trends and market stability.
|
||||||
|
|
||||||
|
Delegates to market_analysis.analyze_investment_potential for the analysis.
|
||||||
|
"""
|
||||||
|
return market_analysis.analyze_investment_potential(deals)
|
||||||
|
|
||||||
|
def get_market_liquidity(
|
||||||
|
self, deals: List[Dict[str, Any]], time_period_months: int = 12
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get detailed market liquidity and turnover metrics.
|
||||||
|
|
||||||
|
Delegates to market_analysis.get_market_liquidity for the analysis.
|
||||||
|
"""
|
||||||
|
return market_analysis.get_market_liquidity(deals, time_period_months)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
Deal filtering functions.
|
||||||
|
|
||||||
|
This module provides composable functions for filtering real estate deal data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .utils import extract_floor_number
|
||||||
|
|
||||||
|
|
||||||
|
def filter_deals_by_criteria(
|
||||||
|
deals: List[Dict[str, Any]],
|
||||||
|
property_type: Optional[str] = None,
|
||||||
|
min_rooms: Optional[float] = None,
|
||||||
|
max_rooms: Optional[float] = None,
|
||||||
|
min_price: Optional[float] = None,
|
||||||
|
max_price: Optional[float] = None,
|
||||||
|
min_area: Optional[float] = None,
|
||||||
|
max_area: Optional[float] = None,
|
||||||
|
min_floor: Optional[int] = None,
|
||||||
|
max_floor: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Filter deals by various criteria.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deals: List of deal dictionaries to filter
|
||||||
|
property_type: Property type to filter by (Hebrew description)
|
||||||
|
min_rooms: Minimum number of rooms
|
||||||
|
max_rooms: Maximum number of rooms
|
||||||
|
min_price: Minimum deal amount
|
||||||
|
max_price: Maximum deal amount
|
||||||
|
min_area: Minimum asset area (square meters)
|
||||||
|
max_area: Maximum asset area (square meters)
|
||||||
|
min_floor: Minimum floor number
|
||||||
|
max_floor: Maximum floor number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list of deals
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If filter criteria are invalid
|
||||||
|
"""
|
||||||
|
if not isinstance(deals, list):
|
||||||
|
raise ValueError("deals must be a list")
|
||||||
|
|
||||||
|
# Validate numeric ranges
|
||||||
|
if min_rooms is not None and max_rooms is not None and min_rooms > max_rooms:
|
||||||
|
raise ValueError("min_rooms cannot be greater than max_rooms")
|
||||||
|
if min_price is not None and max_price is not None and min_price > max_price:
|
||||||
|
raise ValueError("min_price cannot be greater than max_price")
|
||||||
|
if min_area is not None and max_area is not None and min_area > max_area:
|
||||||
|
raise ValueError("min_area cannot be greater than max_area")
|
||||||
|
if min_floor is not None and max_floor is not None and min_floor > max_floor:
|
||||||
|
raise ValueError("min_floor cannot be greater than max_floor")
|
||||||
|
|
||||||
|
filtered_deals = []
|
||||||
|
|
||||||
|
for deal in deals:
|
||||||
|
# Property type filter
|
||||||
|
if property_type is not None:
|
||||||
|
deal_type = deal.get(
|
||||||
|
"propertyTypeDescription", deal.get("assetTypeHeb", "")
|
||||||
|
)
|
||||||
|
# Skip deals with missing property type data when filter is active
|
||||||
|
if not deal_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normalize both strings for flexible matching
|
||||||
|
property_type_normalized = property_type.lower().strip()
|
||||||
|
deal_type_normalized = deal_type.lower().strip()
|
||||||
|
|
||||||
|
# Check if the filter term appears in the deal type
|
||||||
|
# This allows "דירה" to match "דירת גג", "דירה בבניין", etc.
|
||||||
|
if property_type_normalized not in deal_type_normalized:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Room count filter
|
||||||
|
if min_rooms is not None or max_rooms is not None:
|
||||||
|
rooms = deal.get("assetRoomNum")
|
||||||
|
if rooms is None:
|
||||||
|
continue # Skip deals with missing room data when filter is active
|
||||||
|
try:
|
||||||
|
rooms = float(rooms)
|
||||||
|
if min_rooms is not None and rooms < min_rooms:
|
||||||
|
continue
|
||||||
|
if max_rooms is not None and rooms > max_rooms:
|
||||||
|
continue
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue # Skip deals with invalid room data when filter is active
|
||||||
|
|
||||||
|
# Price filter
|
||||||
|
if min_price is not None or max_price is not None:
|
||||||
|
price = deal.get("dealAmount")
|
||||||
|
if price is None:
|
||||||
|
continue # Skip deals with missing price data when filter is active
|
||||||
|
try:
|
||||||
|
price = float(price)
|
||||||
|
if min_price is not None and price < min_price:
|
||||||
|
continue
|
||||||
|
if max_price is not None and price > max_price:
|
||||||
|
continue
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue # Skip deals with invalid price data when filter is active
|
||||||
|
|
||||||
|
# Area filter
|
||||||
|
if min_area is not None or max_area is not None:
|
||||||
|
area = deal.get("assetArea")
|
||||||
|
if area is None:
|
||||||
|
continue # Skip deals with missing area data when filter is active
|
||||||
|
try:
|
||||||
|
area = float(area)
|
||||||
|
if min_area is not None and area < min_area:
|
||||||
|
continue
|
||||||
|
if max_area is not None and area > max_area:
|
||||||
|
continue
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue # Skip deals with invalid area data when filter is active
|
||||||
|
|
||||||
|
# Floor filter
|
||||||
|
if min_floor is not None or max_floor is not None:
|
||||||
|
floor_str = deal.get("floorNo", "")
|
||||||
|
if floor_str and isinstance(floor_str, str):
|
||||||
|
# Try to extract floor number (handles Hebrew floor descriptions)
|
||||||
|
floor_num = extract_floor_number(floor_str)
|
||||||
|
if floor_num is not None:
|
||||||
|
if min_floor is not None and floor_num < min_floor:
|
||||||
|
continue
|
||||||
|
if max_floor is not None and floor_num > max_floor:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered_deals.append(deal)
|
||||||
|
|
||||||
|
return filtered_deals
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""
|
||||||
|
Market analysis functions for real estate deal data.
|
||||||
|
|
||||||
|
This module provides functions for analyzing market trends, activity, and investment potential.
|
||||||
|
Focused on providing data metrics; the LLM interprets them for investment advice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .statistics import calculate_std_dev
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Market Activity Thresholds (deals per month)
|
||||||
|
ACTIVITY_VERY_HIGH_THRESHOLD = 10
|
||||||
|
ACTIVITY_HIGH_THRESHOLD = 5
|
||||||
|
ACTIVITY_MODERATE_THRESHOLD = 3
|
||||||
|
ACTIVITY_LOW_THRESHOLD = 1
|
||||||
|
|
||||||
|
# Price Volatility Thresholds (coefficient of variation %)
|
||||||
|
VOLATILITY_VERY_VOLATILE_THRESHOLD = 50
|
||||||
|
VOLATILITY_VOLATILE_THRESHOLD = 30
|
||||||
|
VOLATILITY_MODERATE_THRESHOLD = 20
|
||||||
|
VOLATILITY_STABLE_THRESHOLD = 10
|
||||||
|
|
||||||
|
# Liquidity Thresholds (deals per month)
|
||||||
|
LIQUIDITY_VERY_HIGH_THRESHOLD = 8
|
||||||
|
LIQUIDITY_HIGH_THRESHOLD = 5
|
||||||
|
LIQUIDITY_MODERATE_THRESHOLD = 2
|
||||||
|
LIQUIDITY_LOW_THRESHOLD = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def parse_deal_dates(
|
||||||
|
deals: List[Dict[str, Any]], time_period_months: Optional[int] = None
|
||||||
|
) -> Tuple[List[str], Dict[str, int], Dict[str, int]]:
|
||||||
|
"""
|
||||||
|
Parse and filter deal dates from a list of deals.
|
||||||
|
|
||||||
|
This helper method centralizes the date parsing logic used across
|
||||||
|
multiple market analysis functions. It validates dates, filters by
|
||||||
|
time period if specified, and groups deals by month and quarter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deals: List of deal dictionaries with 'dealDate' field
|
||||||
|
time_period_months: Optional time period to filter (from today backwards)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple containing:
|
||||||
|
- List of valid deal date strings
|
||||||
|
- Dictionary mapping year-month to deal counts
|
||||||
|
- Dictionary mapping year-quarter to deal counts
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no valid deal dates are found
|
||||||
|
"""
|
||||||
|
# Calculate cutoff date if time period is specified
|
||||||
|
cutoff_date = None
|
||||||
|
if time_period_months is not None:
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=time_period_months * 30)
|
||||||
|
cutoff_date_str = cutoff_date.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
monthly_deals = defaultdict(int)
|
||||||
|
quarterly_deals = defaultdict(int)
|
||||||
|
deal_dates = []
|
||||||
|
|
||||||
|
for deal in deals:
|
||||||
|
date_str = deal.get("dealDate", "")
|
||||||
|
if not date_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Filter by time period if specified
|
||||||
|
if cutoff_date is not None and date_str < cutoff_date_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse date components
|
||||||
|
year = int(date_str[:4])
|
||||||
|
month = int(date_str[5:7])
|
||||||
|
quarter = (month - 1) // 3 + 1 # 1-4
|
||||||
|
|
||||||
|
# Track by month and quarter
|
||||||
|
year_month = f"{year}-{month:02d}"
|
||||||
|
year_quarter = f"{year}-Q{quarter}"
|
||||||
|
|
||||||
|
monthly_deals[year_month] += 1
|
||||||
|
quarterly_deals[year_quarter] += 1
|
||||||
|
deal_dates.append(date_str)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logger.warning(f"Invalid date format: {date_str}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not deal_dates:
|
||||||
|
raise ValueError("No valid deal dates found in deals list")
|
||||||
|
|
||||||
|
return deal_dates, dict(monthly_deals), dict(quarterly_deals)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_market_activity_score(
|
||||||
|
deals: List[Dict[str, Any]], time_period_months: int = 12
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Calculate market activity and liquidity metrics.
|
||||||
|
|
||||||
|
This function analyzes deal frequency, velocity, and market activity levels
|
||||||
|
to provide a comprehensive view of market liquidity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deals: List of deal dictionaries
|
||||||
|
time_period_months: Time period to analyze in months (default: 12)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing:
|
||||||
|
- total_deals: Total number of deals
|
||||||
|
- deals_per_month: Average deals per month
|
||||||
|
- activity_score: Market activity score (0-100)
|
||||||
|
- trend: Activity trend ('increasing', 'stable', 'decreasing')
|
||||||
|
- monthly_distribution: Deals per month breakdown
|
||||||
|
- activity_level: Description ('very_high', 'high', 'moderate', 'low', 'very_low')
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If deals list is empty or invalid
|
||||||
|
"""
|
||||||
|
if not deals:
|
||||||
|
raise ValueError("Cannot calculate market activity from empty deals list")
|
||||||
|
|
||||||
|
# Parse deal dates and group by month (with time period filtering)
|
||||||
|
deal_dates, monthly_deals, _ = parse_deal_dates(deals, time_period_months)
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
total_deals = len(deal_dates)
|
||||||
|
unique_months = len(monthly_deals)
|
||||||
|
deals_per_month = total_deals / unique_months if unique_months > 0 else 0
|
||||||
|
|
||||||
|
# Calculate activity score (0-100)
|
||||||
|
# Based on deals per month using defined thresholds
|
||||||
|
if deals_per_month >= ACTIVITY_VERY_HIGH_THRESHOLD:
|
||||||
|
activity_score = 100
|
||||||
|
activity_level = "very_high"
|
||||||
|
elif deals_per_month >= ACTIVITY_HIGH_THRESHOLD:
|
||||||
|
activity_score = 75 + ((deals_per_month - ACTIVITY_HIGH_THRESHOLD) / ACTIVITY_HIGH_THRESHOLD) * 25
|
||||||
|
activity_level = "high"
|
||||||
|
elif deals_per_month >= ACTIVITY_MODERATE_THRESHOLD:
|
||||||
|
activity_score = 50 + ((deals_per_month - ACTIVITY_MODERATE_THRESHOLD) / (ACTIVITY_HIGH_THRESHOLD - ACTIVITY_MODERATE_THRESHOLD)) * 25
|
||||||
|
activity_level = "moderate"
|
||||||
|
elif deals_per_month >= ACTIVITY_LOW_THRESHOLD:
|
||||||
|
activity_score = 25 + ((deals_per_month - ACTIVITY_LOW_THRESHOLD) / (ACTIVITY_MODERATE_THRESHOLD - ACTIVITY_LOW_THRESHOLD)) * 25
|
||||||
|
activity_level = "low"
|
||||||
|
else:
|
||||||
|
activity_score = deals_per_month * 25
|
||||||
|
activity_level = "very_low"
|
||||||
|
|
||||||
|
# Calculate trend (compare first half vs second half)
|
||||||
|
sorted_months = sorted(monthly_deals.keys())
|
||||||
|
if len(sorted_months) >= 4:
|
||||||
|
mid_point = len(sorted_months) // 2
|
||||||
|
first_half_avg = sum(monthly_deals[m] for m in sorted_months[:mid_point]) / mid_point
|
||||||
|
second_half_avg = sum(monthly_deals[m] for m in 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
|
||||||
|
|
||||||
|
if change_ratio > 0.15:
|
||||||
|
trend = "increasing"
|
||||||
|
elif change_ratio < -0.15:
|
||||||
|
trend = "decreasing"
|
||||||
|
else:
|
||||||
|
trend = "stable"
|
||||||
|
else:
|
||||||
|
trend = "insufficient_data"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_deals": total_deals,
|
||||||
|
"unique_months": unique_months,
|
||||||
|
"deals_per_month": round(deals_per_month, 2),
|
||||||
|
"activity_score": round(activity_score, 1),
|
||||||
|
"activity_level": activity_level,
|
||||||
|
"trend": trend,
|
||||||
|
"monthly_distribution": dict(sorted(monthly_deals.items())),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_investment_potential(deals: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze investment potential based on price trends and market stability.
|
||||||
|
|
||||||
|
This function calculates price appreciation rates, market volatility,
|
||||||
|
and provides investment metrics for decision-making. The MCP provides
|
||||||
|
data metrics; the LLM interprets them for investment advice.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deals: List of deal dictionaries with price and date information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing:
|
||||||
|
- price_appreciation_rate: Annual price growth rate (%)
|
||||||
|
- price_volatility: Price volatility score (0-100, lower is more stable)
|
||||||
|
- market_stability: Stability rating ('very_stable', 'stable', 'moderate', 'volatile', 'very_volatile')
|
||||||
|
- price_trend: Price direction ('increasing', 'stable', 'decreasing')
|
||||||
|
- avg_price_per_sqm: Average price per square meter
|
||||||
|
- price_change_pct: Total price change percentage
|
||||||
|
- investment_score: Overall investment score (0-100)
|
||||||
|
- data_quality: Quality of data ('excellent', 'good', 'fair', 'limited')
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If deals list is empty or lacks required data
|
||||||
|
"""
|
||||||
|
if not deals:
|
||||||
|
raise ValueError("Cannot analyze investment potential from empty deals list")
|
||||||
|
|
||||||
|
# Extract price per sqm and dates
|
||||||
|
price_data = []
|
||||||
|
for deal in deals:
|
||||||
|
price_per_sqm = deal.get("price_per_sqm")
|
||||||
|
date_str = deal.get("dealDate", "")
|
||||||
|
|
||||||
|
if isinstance(price_per_sqm, (int, float)) and price_per_sqm > 0 and date_str:
|
||||||
|
try:
|
||||||
|
# Parse date for sorting
|
||||||
|
year = int(date_str[:4])
|
||||||
|
month = int(date_str[5:7])
|
||||||
|
price_data.append((year + month / 12.0, price_per_sqm))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(price_data) < 3:
|
||||||
|
raise ValueError(
|
||||||
|
"Insufficient data for investment analysis (need at least 3 valid deals with price and date)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by time
|
||||||
|
price_data.sort(key=lambda x: x[0])
|
||||||
|
times = [p[0] for p in price_data]
|
||||||
|
prices = [p[1] for p in price_data]
|
||||||
|
|
||||||
|
# Calculate average price
|
||||||
|
avg_price_per_sqm = sum(prices) / len(prices)
|
||||||
|
|
||||||
|
# Calculate price appreciation rate (using linear regression approximation)
|
||||||
|
n = len(price_data)
|
||||||
|
sum_t = sum(times)
|
||||||
|
sum_p = sum(prices)
|
||||||
|
sum_tp = sum(t * p for t, p in price_data)
|
||||||
|
sum_t2 = sum(t * t for t in times)
|
||||||
|
|
||||||
|
# Linear regression slope
|
||||||
|
if n * sum_t2 - sum_t * sum_t != 0:
|
||||||
|
slope = (n * sum_tp - sum_t * sum_p) / (n * sum_t2 - sum_t * sum_t)
|
||||||
|
# Convert to annual percentage change
|
||||||
|
price_appreciation_rate = (slope / avg_price_per_sqm) * 100 if avg_price_per_sqm > 0 else 0
|
||||||
|
else:
|
||||||
|
price_appreciation_rate = 0
|
||||||
|
|
||||||
|
# Calculate price change from first to last deal
|
||||||
|
if prices[0] > 0:
|
||||||
|
price_change_pct = ((prices[-1] - prices[0]) / prices[0]) * 100
|
||||||
|
else:
|
||||||
|
price_change_pct = 0
|
||||||
|
|
||||||
|
# Determine price trend
|
||||||
|
if price_appreciation_rate > 2:
|
||||||
|
price_trend = "increasing"
|
||||||
|
elif price_appreciation_rate < -2:
|
||||||
|
price_trend = "decreasing"
|
||||||
|
else:
|
||||||
|
price_trend = "stable"
|
||||||
|
|
||||||
|
# Calculate price volatility (coefficient of variation)
|
||||||
|
std_dev = calculate_std_dev(prices)
|
||||||
|
if avg_price_per_sqm > 0:
|
||||||
|
coefficient_of_variation = (std_dev / avg_price_per_sqm) * 100
|
||||||
|
else:
|
||||||
|
coefficient_of_variation = 0
|
||||||
|
|
||||||
|
# Convert CV to volatility score (0-100, lower is better)
|
||||||
|
# Using defined volatility thresholds
|
||||||
|
if coefficient_of_variation > VOLATILITY_VERY_VOLATILE_THRESHOLD:
|
||||||
|
volatility_score = 100
|
||||||
|
market_stability = "very_volatile"
|
||||||
|
elif coefficient_of_variation > VOLATILITY_VOLATILE_THRESHOLD:
|
||||||
|
volatility_score = 75 + ((coefficient_of_variation - VOLATILITY_VOLATILE_THRESHOLD) / (VOLATILITY_VERY_VOLATILE_THRESHOLD - VOLATILITY_VOLATILE_THRESHOLD)) * 25
|
||||||
|
market_stability = "volatile"
|
||||||
|
elif coefficient_of_variation > VOLATILITY_MODERATE_THRESHOLD:
|
||||||
|
volatility_score = 50 + ((coefficient_of_variation - VOLATILITY_MODERATE_THRESHOLD) / (VOLATILITY_VOLATILE_THRESHOLD - VOLATILITY_MODERATE_THRESHOLD)) * 25
|
||||||
|
market_stability = "moderate"
|
||||||
|
elif coefficient_of_variation > VOLATILITY_STABLE_THRESHOLD:
|
||||||
|
volatility_score = 25 + ((coefficient_of_variation - VOLATILITY_STABLE_THRESHOLD) / (VOLATILITY_MODERATE_THRESHOLD - VOLATILITY_STABLE_THRESHOLD)) * 25
|
||||||
|
market_stability = "stable"
|
||||||
|
else:
|
||||||
|
volatility_score = (coefficient_of_variation / VOLATILITY_STABLE_THRESHOLD) * 25
|
||||||
|
market_stability = "very_stable"
|
||||||
|
|
||||||
|
# Calculate investment score (0-100)
|
||||||
|
# Positive: price appreciation, market stability (low volatility)
|
||||||
|
# Negative: price decline, high volatility
|
||||||
|
appreciation_component = min(max(price_appreciation_rate * 5, -25), 50) # -25 to +50
|
||||||
|
stability_component = (100 - volatility_score) * 0.5 # 0 to 50
|
||||||
|
|
||||||
|
investment_score = max(0, min(100, appreciation_component + stability_component))
|
||||||
|
|
||||||
|
# Data quality assessment
|
||||||
|
if n >= 20:
|
||||||
|
data_quality = "excellent"
|
||||||
|
elif n >= 10:
|
||||||
|
data_quality = "good"
|
||||||
|
elif n >= 5:
|
||||||
|
data_quality = "fair"
|
||||||
|
else:
|
||||||
|
data_quality = "limited"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"price_appreciation_rate": round(price_appreciation_rate, 2),
|
||||||
|
"price_volatility": round(volatility_score, 1),
|
||||||
|
"market_stability": market_stability,
|
||||||
|
"price_trend": price_trend,
|
||||||
|
"avg_price_per_sqm": round(avg_price_per_sqm, 0),
|
||||||
|
"price_change_pct": round(price_change_pct, 2),
|
||||||
|
"investment_score": round(investment_score, 1),
|
||||||
|
"data_quality": data_quality,
|
||||||
|
"sample_size": n,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_market_liquidity(
|
||||||
|
deals: List[Dict[str, Any]], time_period_months: int = 12
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get detailed market liquidity and turnover metrics.
|
||||||
|
|
||||||
|
This function provides granular liquidity metrics including deal velocity,
|
||||||
|
quarterly trends, and market turnover indicators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deals: List of deal dictionaries
|
||||||
|
time_period_months: Time period to analyze in months (default: 12)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing:
|
||||||
|
- total_deals: Total number of deals in period
|
||||||
|
- deals_per_month: Average deals per month
|
||||||
|
- deals_per_quarter: Average deals per quarter
|
||||||
|
- quarterly_breakdown: Deals grouped by quarter
|
||||||
|
- velocity_score: Market velocity score (0-100)
|
||||||
|
- liquidity_rating: Liquidity rating ('very_high', 'high', 'moderate', 'low', 'very_low')
|
||||||
|
- trend_direction: Trend in liquidity ('improving', 'stable', 'declining')
|
||||||
|
- most_active_period: Quarter/month with most activity
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If deals list is empty or invalid
|
||||||
|
"""
|
||||||
|
if not deals:
|
||||||
|
raise ValueError("Cannot calculate market liquidity from empty deals list")
|
||||||
|
|
||||||
|
# Parse deal dates and group by month and quarter (with time period filtering)
|
||||||
|
deal_dates, monthly_deals, quarterly_deals = parse_deal_dates(deals, time_period_months)
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
total_deals = len(deal_dates)
|
||||||
|
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_quarter = total_deals / unique_quarters if unique_quarters > 0 else 0
|
||||||
|
|
||||||
|
# Calculate velocity score (similar to activity score but focused on turnover)
|
||||||
|
# Based on monthly deal velocity using defined thresholds
|
||||||
|
if deals_per_month >= LIQUIDITY_VERY_HIGH_THRESHOLD:
|
||||||
|
velocity_score = 100
|
||||||
|
liquidity_rating = "very_high"
|
||||||
|
elif deals_per_month >= LIQUIDITY_HIGH_THRESHOLD:
|
||||||
|
velocity_score = 75 + ((deals_per_month - LIQUIDITY_HIGH_THRESHOLD) / (LIQUIDITY_VERY_HIGH_THRESHOLD - LIQUIDITY_HIGH_THRESHOLD)) * 25
|
||||||
|
liquidity_rating = "high"
|
||||||
|
elif deals_per_month >= LIQUIDITY_MODERATE_THRESHOLD:
|
||||||
|
velocity_score = 50 + ((deals_per_month - LIQUIDITY_MODERATE_THRESHOLD) / (LIQUIDITY_HIGH_THRESHOLD - LIQUIDITY_MODERATE_THRESHOLD)) * 25
|
||||||
|
liquidity_rating = "moderate"
|
||||||
|
elif deals_per_month >= LIQUIDITY_LOW_THRESHOLD:
|
||||||
|
velocity_score = 25 + ((deals_per_month - LIQUIDITY_LOW_THRESHOLD) / (LIQUIDITY_MODERATE_THRESHOLD - LIQUIDITY_LOW_THRESHOLD)) * 25
|
||||||
|
liquidity_rating = "low"
|
||||||
|
else:
|
||||||
|
velocity_score = deals_per_month * 50
|
||||||
|
liquidity_rating = "very_low"
|
||||||
|
|
||||||
|
# Determine trend direction (compare recent quarter to earlier quarters)
|
||||||
|
sorted_quarters = sorted(quarterly_deals.keys())
|
||||||
|
if len(sorted_quarters) >= 3:
|
||||||
|
recent_quarter_avg = quarterly_deals[sorted_quarters[-1]]
|
||||||
|
earlier_quarters_avg = sum(quarterly_deals[q] for q in sorted_quarters[:-1]) / (
|
||||||
|
len(sorted_quarters) - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if recent_quarter_avg > earlier_quarters_avg * 1.2:
|
||||||
|
trend_direction = "improving"
|
||||||
|
elif recent_quarter_avg < earlier_quarters_avg * 0.8:
|
||||||
|
trend_direction = "declining"
|
||||||
|
else:
|
||||||
|
trend_direction = "stable"
|
||||||
|
else:
|
||||||
|
trend_direction = "insufficient_data"
|
||||||
|
|
||||||
|
# Find most active period
|
||||||
|
if quarterly_deals:
|
||||||
|
most_active_quarter = max(quarterly_deals.items(), key=lambda x: x[1])
|
||||||
|
most_active_period = f"{most_active_quarter[0]} ({most_active_quarter[1]} deals)"
|
||||||
|
else:
|
||||||
|
most_active_period = "N/A"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_deals": total_deals,
|
||||||
|
"unique_months": unique_months,
|
||||||
|
"unique_quarters": unique_quarters,
|
||||||
|
"deals_per_month": round(deals_per_month, 2),
|
||||||
|
"deals_per_quarter": round(deals_per_quarter, 2),
|
||||||
|
"quarterly_breakdown": dict(sorted(quarterly_deals.items())),
|
||||||
|
"monthly_breakdown": dict(sorted(monthly_deals.items())),
|
||||||
|
"velocity_score": round(velocity_score, 1),
|
||||||
|
"liquidity_rating": liquidity_rating,
|
||||||
|
"trend_direction": trend_direction,
|
||||||
|
"most_active_period": most_active_period,
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
Statistical calculation functions for deal data.
|
||||||
|
|
||||||
|
This module provides pure mathematical functions for analyzing real estate deal data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_deal_statistics(deals: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Calculate statistical aggregations on deal data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deals: List of deal dictionaries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with statistical metrics
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If deals is not a valid list
|
||||||
|
"""
|
||||||
|
if not isinstance(deals, list):
|
||||||
|
raise ValueError("deals must be a list")
|
||||||
|
|
||||||
|
if not deals:
|
||||||
|
return {
|
||||||
|
"count": 0,
|
||||||
|
"price_stats": {},
|
||||||
|
"area_stats": {},
|
||||||
|
"price_per_sqm_stats": {},
|
||||||
|
"room_distribution": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract numeric values
|
||||||
|
prices = []
|
||||||
|
areas = []
|
||||||
|
price_per_sqm_values = []
|
||||||
|
rooms = []
|
||||||
|
|
||||||
|
for deal in deals:
|
||||||
|
price = deal.get("dealAmount")
|
||||||
|
if isinstance(price, (int, float)) and price > 0:
|
||||||
|
prices.append(price)
|
||||||
|
|
||||||
|
area = deal.get("assetArea")
|
||||||
|
if isinstance(area, (int, float)) and area > 0:
|
||||||
|
areas.append(area)
|
||||||
|
|
||||||
|
pps = deal.get("price_per_sqm")
|
||||||
|
if pps is None and price and area and area > 0:
|
||||||
|
pps = price / area
|
||||||
|
if isinstance(pps, (int, float)) and pps > 0:
|
||||||
|
price_per_sqm_values.append(pps)
|
||||||
|
|
||||||
|
room_count = deal.get("assetRoomNum")
|
||||||
|
if isinstance(room_count, (int, float)):
|
||||||
|
rooms.append(room_count)
|
||||||
|
|
||||||
|
# Calculate statistics
|
||||||
|
stats: Dict[str, Any] = {"count": len(deals)}
|
||||||
|
|
||||||
|
# Price statistics
|
||||||
|
if prices:
|
||||||
|
sorted_prices = sorted(prices)
|
||||||
|
stats["price_stats"] = {
|
||||||
|
"mean": round(sum(prices) / len(prices), 2),
|
||||||
|
"median": (sorted_prices[len(sorted_prices) // 2] + sorted_prices[(len(sorted_prices) - 1) // 2]) / 2,
|
||||||
|
"min": min(prices),
|
||||||
|
"max": max(prices),
|
||||||
|
"p25": sorted_prices[len(sorted_prices) // 4],
|
||||||
|
"p75": sorted_prices[(3 * len(sorted_prices)) // 4],
|
||||||
|
"std_dev": round(calculate_std_dev(prices), 2) if len(prices) > 1 else 0,
|
||||||
|
"total": sum(prices),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Area statistics
|
||||||
|
if areas:
|
||||||
|
sorted_areas = sorted(areas)
|
||||||
|
stats["area_stats"] = {
|
||||||
|
"mean": round(sum(areas) / len(areas), 2),
|
||||||
|
"median": sorted_areas[len(sorted_areas) // 2],
|
||||||
|
"min": min(areas),
|
||||||
|
"max": max(areas),
|
||||||
|
"p25": sorted_areas[len(sorted_areas) // 4],
|
||||||
|
"p75": sorted_areas[(3 * len(sorted_areas)) // 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Price per sqm statistics
|
||||||
|
if price_per_sqm_values:
|
||||||
|
sorted_pps = sorted(price_per_sqm_values)
|
||||||
|
stats["price_per_sqm_stats"] = {
|
||||||
|
"mean": round(sum(price_per_sqm_values) / len(price_per_sqm_values), 2),
|
||||||
|
"median": round(sorted_pps[len(sorted_pps) // 2], 2),
|
||||||
|
"min": round(min(price_per_sqm_values), 2),
|
||||||
|
"max": round(max(price_per_sqm_values), 2),
|
||||||
|
"p25": round(sorted_pps[len(sorted_pps) // 4], 2),
|
||||||
|
"p75": round(sorted_pps[(3 * len(sorted_pps)) // 4], 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Room distribution
|
||||||
|
if rooms:
|
||||||
|
room_counts = Counter(rooms)
|
||||||
|
stats["room_distribution"] = dict(sorted(room_counts.items()))
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_std_dev(values: List[float]) -> float:
|
||||||
|
"""
|
||||||
|
Calculate standard deviation of a list of values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: List of numeric values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Standard deviation
|
||||||
|
"""
|
||||||
|
if len(values) < 2:
|
||||||
|
return 0.0
|
||||||
|
mean = sum(values) / len(values)
|
||||||
|
variance = sum((x - mean) ** 2 for x in values) / (len(values) - 1)
|
||||||
|
return variance**0.5
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Utility functions for Govmap client.
|
||||||
|
|
||||||
|
This module provides shared helper functions with no external dependencies
|
||||||
|
(except standard library).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_distance(point1: Tuple[float, float], point2: Tuple[float, float]) -> float:
|
||||||
|
"""
|
||||||
|
Calculate Euclidean distance between two points in ITM coordinates.
|
||||||
|
|
||||||
|
ITM (Israeli Transverse Mercator) uses meters as units, so Euclidean
|
||||||
|
distance provides accurate results for distances within Israel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
point1: (longitude, latitude) in ITM
|
||||||
|
point2: (longitude, latitude) in ITM
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Distance in meters
|
||||||
|
"""
|
||||||
|
dx = point2[0] - point1[0]
|
||||||
|
dy = point2[1] - point1[1]
|
||||||
|
return (dx * dx + dy * dy) ** 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def is_same_building(search_address: str, deal_address: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a deal is from the same building as the search address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_address: The normalized search address (lowercase, stripped)
|
||||||
|
deal_address: The normalized deal address (lowercase, stripped)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if likely the same building, False otherwise
|
||||||
|
"""
|
||||||
|
if not search_address or not deal_address:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Exact match
|
||||||
|
if search_address == deal_address:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Extract key components for comparison
|
||||||
|
def extract_address_parts(addr: str) -> tuple:
|
||||||
|
"""Extract street name and number from address"""
|
||||||
|
# Remove common prefixes/suffixes and normalize
|
||||||
|
addr_clean = (
|
||||||
|
addr.replace("רח'", "")
|
||||||
|
.replace("רחוב", "")
|
||||||
|
.replace("שד'", "")
|
||||||
|
.replace("שדרות", "")
|
||||||
|
)
|
||||||
|
addr_clean = addr_clean.replace(" ", " ").strip()
|
||||||
|
|
||||||
|
# Try to extract number and street name
|
||||||
|
parts = addr_clean.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
# Look for number (could be at start or end)
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if part.isdigit() or any(c.isdigit() for c in part):
|
||||||
|
number = part
|
||||||
|
street_parts = parts[:i] + parts[i + 1 :]
|
||||||
|
street_name = " ".join(street_parts).strip()
|
||||||
|
return (street_name, number)
|
||||||
|
|
||||||
|
return (addr_clean, "")
|
||||||
|
|
||||||
|
search_street, search_number = extract_address_parts(search_address)
|
||||||
|
deal_street, deal_number = extract_address_parts(deal_address)
|
||||||
|
|
||||||
|
# Same street and same number = same building
|
||||||
|
if (
|
||||||
|
search_street
|
||||||
|
and deal_street
|
||||||
|
and search_number
|
||||||
|
and deal_number
|
||||||
|
and search_street == deal_street
|
||||||
|
and search_number == deal_number
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if one address is contained in the other (for different formats of same address)
|
||||||
|
if len(search_address) > 5 and len(deal_address) > 5:
|
||||||
|
if search_address in deal_address or deal_address in search_address:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_floor_number(floor_str: str) -> int | None:
|
||||||
|
"""
|
||||||
|
Extract numeric floor number from Hebrew floor description.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
floor_str: Floor description string (e.g., "שלישית", "קומה 3", "3")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Floor number or None if cannot be extracted
|
||||||
|
"""
|
||||||
|
if not floor_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Hebrew ordinal floor names to numbers
|
||||||
|
hebrew_floors = {
|
||||||
|
"קרקע": 0,
|
||||||
|
"מרתף": -1,
|
||||||
|
"ראשונה": 1,
|
||||||
|
"שניה": 2,
|
||||||
|
"שלישית": 3,
|
||||||
|
"רביעית": 4,
|
||||||
|
"חמישית": 5,
|
||||||
|
"שישית": 6,
|
||||||
|
"שביעית": 7,
|
||||||
|
"שמינית": 8,
|
||||||
|
"תשיעית": 9,
|
||||||
|
"עשירית": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
floor_lower = floor_str.lower().strip()
|
||||||
|
|
||||||
|
# Check for direct match with Hebrew names
|
||||||
|
for heb, num in hebrew_floors.items():
|
||||||
|
if heb in floor_lower:
|
||||||
|
return num
|
||||||
|
|
||||||
|
# Try to extract number from string
|
||||||
|
numbers = re.findall(r"\d+", floor_str)
|
||||||
|
if numbers:
|
||||||
|
try:
|
||||||
|
return int(numbers[0])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
Input validation functions for Govmap API client.
|
||||||
|
|
||||||
|
This module provides pure validation functions with no dependencies on other modules.
|
||||||
|
All functions are stateless and raise ValueError on validation failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_address(address: str) -> str:
|
||||||
|
"""
|
||||||
|
Validate and sanitize address input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Address string to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized address string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If address is invalid
|
||||||
|
"""
|
||||||
|
if not address or not isinstance(address, str):
|
||||||
|
raise ValueError("Address must be a non-empty string")
|
||||||
|
address = address.strip()
|
||||||
|
if not address:
|
||||||
|
raise ValueError("Address cannot be empty or whitespace only")
|
||||||
|
if len(address) > 500:
|
||||||
|
raise ValueError("Address is too long (max 500 characters)")
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
def validate_coordinates(point: Tuple[float, float]) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Validate coordinate input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
point: Tuple of (longitude, latitude) in ITM projection
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated coordinate tuple
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If coordinates are invalid
|
||||||
|
"""
|
||||||
|
if not isinstance(point, (tuple, list)) or len(point) != 2:
|
||||||
|
raise ValueError("Point must be a tuple of (longitude, latitude)")
|
||||||
|
try:
|
||||||
|
lon, lat = float(point[0]), float(point[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("Coordinates must be numeric values")
|
||||||
|
|
||||||
|
# Basic validation for Israeli coordinates (ITM projection)
|
||||||
|
if not (0 < lon < 400000): # Rough bounds for Israeli ITM longitude
|
||||||
|
logger.warning(f"Longitude {lon} may be outside Israeli bounds")
|
||||||
|
if not (0 < lat < 1400000): # Rough bounds for Israeli ITM latitude
|
||||||
|
logger.warning(f"Latitude {lat} may be outside Israeli bounds")
|
||||||
|
|
||||||
|
return (lon, lat)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_positive_int(
|
||||||
|
value: int, name: str, max_value: Optional[int] = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Validate positive integer input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Value to validate
|
||||||
|
name: Name of the parameter (for error messages)
|
||||||
|
max_value: Optional maximum allowed value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated integer
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If value is invalid
|
||||||
|
"""
|
||||||
|
if not isinstance(value, int):
|
||||||
|
raise ValueError(f"{name} must be an integer")
|
||||||
|
if value <= 0:
|
||||||
|
raise ValueError(f"{name} must be positive")
|
||||||
|
if max_value and value > max_value:
|
||||||
|
raise ValueError(f"{name} must be <= {max_value}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_deal_type(deal_type: int) -> int:
|
||||||
|
"""
|
||||||
|
Validate deal type parameter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deal_type: Deal type (1=first hand/new, 2=second hand/used)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated deal type
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If deal type is invalid
|
||||||
|
"""
|
||||||
|
if deal_type not in (1, 2):
|
||||||
|
raise ValueError("deal_type must be 1 (first hand) or 2 (second hand)")
|
||||||
|
return deal_type
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for utils module.
|
||||||
|
|
||||||
|
Tests helper utilities including distance calculation, address matching, and floor parsing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from nadlan_mcp.govmap.utils import (
|
||||||
|
calculate_distance,
|
||||||
|
is_same_building,
|
||||||
|
extract_floor_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateDistance:
|
||||||
|
"""Test distance calculation function."""
|
||||||
|
|
||||||
|
def test_distance_between_same_point(self):
|
||||||
|
"""Test distance between identical points is zero."""
|
||||||
|
point = (180000.0, 650000.0)
|
||||||
|
distance = calculate_distance(point, point)
|
||||||
|
assert distance == 0.0
|
||||||
|
|
||||||
|
def test_distance_horizontal(self):
|
||||||
|
"""Test distance calculation for horizontal displacement."""
|
||||||
|
point1 = (180000.0, 650000.0)
|
||||||
|
point2 = (180100.0, 650000.0)
|
||||||
|
distance = calculate_distance(point1, point2)
|
||||||
|
assert distance == 100.0
|
||||||
|
|
||||||
|
def test_distance_vertical(self):
|
||||||
|
"""Test distance calculation for vertical displacement."""
|
||||||
|
point1 = (180000.0, 650000.0)
|
||||||
|
point2 = (180000.0, 650100.0)
|
||||||
|
distance = calculate_distance(point1, point2)
|
||||||
|
assert distance == 100.0
|
||||||
|
|
||||||
|
def test_distance_diagonal(self):
|
||||||
|
"""Test distance calculation for diagonal displacement."""
|
||||||
|
point1 = (0.0, 0.0)
|
||||||
|
point2 = (3.0, 4.0)
|
||||||
|
distance = calculate_distance(point1, point2)
|
||||||
|
assert distance == 5.0 # Pythagorean: 3^2 + 4^2 = 25, sqrt(25) = 5
|
||||||
|
|
||||||
|
def test_distance_is_symmetric(self):
|
||||||
|
"""Test distance calculation is symmetric."""
|
||||||
|
point1 = (180000.0, 650000.0)
|
||||||
|
point2 = (180100.0, 650100.0)
|
||||||
|
distance1 = calculate_distance(point1, point2)
|
||||||
|
distance2 = calculate_distance(point2, point1)
|
||||||
|
assert distance1 == distance2
|
||||||
|
|
||||||
|
def test_distance_negative_coordinates(self):
|
||||||
|
"""Test distance with negative coordinates."""
|
||||||
|
point1 = (-100.0, -200.0)
|
||||||
|
point2 = (100.0, 200.0)
|
||||||
|
distance = calculate_distance(point1, point2)
|
||||||
|
expected = (200.0**2 + 400.0**2) ** 0.5
|
||||||
|
assert abs(distance - expected) < 0.01
|
||||||
|
|
||||||
|
def test_distance_large_coordinates(self):
|
||||||
|
"""Test distance with large ITM coordinates."""
|
||||||
|
point1 = (180000.0, 650000.0)
|
||||||
|
point2 = (190000.0, 660000.0)
|
||||||
|
distance = calculate_distance(point1, point2)
|
||||||
|
expected = (10000.0**2 + 10000.0**2) ** 0.5
|
||||||
|
assert abs(distance - expected) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsSameBuilding:
|
||||||
|
"""Test address matching function."""
|
||||||
|
|
||||||
|
def test_exact_match_same_building(self):
|
||||||
|
"""Test exact address match identifies same building."""
|
||||||
|
address1 = "דיזנגוף 50 תל אביב"
|
||||||
|
address2 = "דיזנגוף 50 תל אביב"
|
||||||
|
assert is_same_building(address1, address2) is True
|
||||||
|
|
||||||
|
def test_different_street_different_building(self):
|
||||||
|
"""Test different street names identify different buildings."""
|
||||||
|
address1 = "דיזנגוף 50 תל אביב"
|
||||||
|
address2 = "הרצל 50 תל אביב"
|
||||||
|
assert is_same_building(address1, address2) is False
|
||||||
|
|
||||||
|
def test_different_number_different_building(self):
|
||||||
|
"""Test different house numbers identify different buildings."""
|
||||||
|
address1 = "דיזנגוף 50 תל אביב"
|
||||||
|
address2 = "דיזנגוף 52 תל אביב"
|
||||||
|
assert is_same_building(address1, address2) is False
|
||||||
|
|
||||||
|
def test_same_street_and_number_same_building(self):
|
||||||
|
"""Test same street and number identify same building."""
|
||||||
|
address1 = "דיזנגוף 50"
|
||||||
|
address2 = "דיזנגוף 50 תל אביב"
|
||||||
|
assert is_same_building(address1, address2) is True
|
||||||
|
|
||||||
|
def test_case_sensitive_matching(self):
|
||||||
|
"""Test address matching is case sensitive for street names."""
|
||||||
|
address1 = "Dizengoff 50 Tel Aviv"
|
||||||
|
address2 = "dizengoff 50 tel aviv"
|
||||||
|
# Function is case sensitive, so these won't match
|
||||||
|
assert is_same_building(address1, address2) is False
|
||||||
|
|
||||||
|
def test_whitespace_ignored(self):
|
||||||
|
"""Test extra whitespace doesn't affect matching."""
|
||||||
|
address1 = "דיזנגוף 50 תל אביב"
|
||||||
|
address2 = "דיזנגוף 50 תל אביב"
|
||||||
|
assert is_same_building(address1, address2) is True
|
||||||
|
|
||||||
|
def test_substring_matching(self):
|
||||||
|
"""Test substring matching for contained addresses."""
|
||||||
|
address1 = "דיזנגוף 50"
|
||||||
|
address2 = "רחוב דיזנגוף 50 תל אביב יפו"
|
||||||
|
assert is_same_building(address1, address2) is True
|
||||||
|
|
||||||
|
def test_short_addresses_dont_match_by_substring(self):
|
||||||
|
"""Test short addresses (<=5 chars) don't match by substring."""
|
||||||
|
address1 = "דיז 5"
|
||||||
|
address2 = "דיזנגוף 50"
|
||||||
|
# Both <= 5 chars after normalization won't use substring matching
|
||||||
|
result = is_same_building(address1, address2)
|
||||||
|
# Result depends on whether street/number extraction works
|
||||||
|
assert isinstance(result, bool)
|
||||||
|
|
||||||
|
def test_empty_address_no_match(self):
|
||||||
|
"""Test empty addresses don't match."""
|
||||||
|
address1 = ""
|
||||||
|
address2 = "דיזנגוף 50"
|
||||||
|
assert is_same_building(address1, address2) is False
|
||||||
|
|
||||||
|
def test_both_empty_no_match(self):
|
||||||
|
"""Test two empty addresses don't match."""
|
||||||
|
address1 = ""
|
||||||
|
address2 = ""
|
||||||
|
assert is_same_building(address1, address2) is False
|
||||||
|
|
||||||
|
def test_number_with_letters(self):
|
||||||
|
"""Test house numbers with letters (e.g., '50א')."""
|
||||||
|
address1 = "דיזנגוף 50א תל אביב"
|
||||||
|
address2 = "דיזנגוף 50א"
|
||||||
|
# Same street and number, one has city name
|
||||||
|
assert is_same_building(address1, address2) is True
|
||||||
|
|
||||||
|
def test_different_letter_suffix(self):
|
||||||
|
"""Test different letter suffixes identify different buildings."""
|
||||||
|
address1 = "דיזנגוף 50א"
|
||||||
|
address2 = "דיזנגוף 50ב"
|
||||||
|
assert is_same_building(address1, address2) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractFloorNumber:
|
||||||
|
"""Test floor number extraction from Hebrew descriptions."""
|
||||||
|
|
||||||
|
# Test all Hebrew floor names
|
||||||
|
def test_hebrew_ground_floor(self):
|
||||||
|
"""Test extraction of ground floor (קרקע)."""
|
||||||
|
assert extract_floor_number("קרקע") == 0
|
||||||
|
|
||||||
|
def test_hebrew_basement(self):
|
||||||
|
"""Test extraction of basement floor (מרתף)."""
|
||||||
|
assert extract_floor_number("מרתף") == -1
|
||||||
|
|
||||||
|
def test_hebrew_first_floor(self):
|
||||||
|
"""Test extraction of first floor (ראשונה)."""
|
||||||
|
assert extract_floor_number("ראשונה") == 1
|
||||||
|
|
||||||
|
def test_hebrew_second_floor(self):
|
||||||
|
"""Test extraction of second floor (שניה)."""
|
||||||
|
assert extract_floor_number("שניה") == 2
|
||||||
|
|
||||||
|
def test_hebrew_third_floor(self):
|
||||||
|
"""Test extraction of third floor (שלישית)."""
|
||||||
|
assert extract_floor_number("שלישית") == 3
|
||||||
|
|
||||||
|
def test_hebrew_fourth_floor(self):
|
||||||
|
"""Test extraction of fourth floor (רביעית)."""
|
||||||
|
assert extract_floor_number("רביעית") == 4
|
||||||
|
|
||||||
|
def test_hebrew_fifth_floor(self):
|
||||||
|
"""Test extraction of fifth floor (חמישית)."""
|
||||||
|
assert extract_floor_number("חמישית") == 5
|
||||||
|
|
||||||
|
def test_hebrew_sixth_floor(self):
|
||||||
|
"""Test extraction of sixth floor (שישית)."""
|
||||||
|
assert extract_floor_number("שישית") == 6
|
||||||
|
|
||||||
|
def test_hebrew_seventh_floor(self):
|
||||||
|
"""Test extraction of seventh floor (שביעית)."""
|
||||||
|
assert extract_floor_number("שביעית") == 7
|
||||||
|
|
||||||
|
def test_hebrew_eighth_floor(self):
|
||||||
|
"""Test extraction of eighth floor (שמינית)."""
|
||||||
|
assert extract_floor_number("שמינית") == 8
|
||||||
|
|
||||||
|
def test_hebrew_ninth_floor(self):
|
||||||
|
"""Test extraction of ninth floor (תשיעית)."""
|
||||||
|
assert extract_floor_number("תשיעית") == 9
|
||||||
|
|
||||||
|
def test_hebrew_tenth_floor(self):
|
||||||
|
"""Test extraction of tenth floor (עשירית)."""
|
||||||
|
assert extract_floor_number("עשירית") == 10
|
||||||
|
|
||||||
|
# Test numeric strings
|
||||||
|
def test_numeric_string(self):
|
||||||
|
"""Test extraction from numeric string."""
|
||||||
|
assert extract_floor_number("5") == 5
|
||||||
|
|
||||||
|
def test_numeric_string_with_spaces(self):
|
||||||
|
"""Test extraction from numeric string with spaces."""
|
||||||
|
assert extract_floor_number(" 12 ") == 12
|
||||||
|
|
||||||
|
def test_hebrew_with_prefix(self):
|
||||||
|
"""Test extraction from Hebrew with prefix (e.g., 'קומה שלישית')."""
|
||||||
|
assert extract_floor_number("קומה שלישית") == 3
|
||||||
|
|
||||||
|
def test_mixed_hebrew_and_number(self):
|
||||||
|
"""Test extraction from mixed Hebrew and number."""
|
||||||
|
assert extract_floor_number("קומה 7") == 7
|
||||||
|
|
||||||
|
# Edge cases
|
||||||
|
def test_empty_string_returns_none(self):
|
||||||
|
"""Test empty string returns None."""
|
||||||
|
assert extract_floor_number("") is None
|
||||||
|
|
||||||
|
def test_none_returns_none(self):
|
||||||
|
"""Test None input returns None."""
|
||||||
|
assert extract_floor_number(None) is None
|
||||||
|
|
||||||
|
def test_whitespace_only_returns_none(self):
|
||||||
|
"""Test whitespace-only string returns None."""
|
||||||
|
assert extract_floor_number(" ") is None
|
||||||
|
|
||||||
|
def test_invalid_text_returns_none(self):
|
||||||
|
"""Test invalid text without numbers or Hebrew floors returns None."""
|
||||||
|
result = extract_floor_number("לא קומה")
|
||||||
|
# Should return None since "לא קומה" doesn't contain a known floor
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_case_insensitive_hebrew(self):
|
||||||
|
"""Test Hebrew floor names are case insensitive."""
|
||||||
|
# Note: Hebrew doesn't have case, but testing with mixed formats
|
||||||
|
assert extract_floor_number("קרקע") == 0
|
||||||
|
assert extract_floor_number("קרקע") == 0
|
||||||
|
|
||||||
|
def test_hebrew_floor_in_sentence(self):
|
||||||
|
"""Test extracting Hebrew floor from longer sentence."""
|
||||||
|
assert extract_floor_number("דירה בקומה רביעית") == 4
|
||||||
|
|
||||||
|
def test_multiple_numbers_uses_first(self):
|
||||||
|
"""Test when multiple numbers present, uses first one."""
|
||||||
|
assert extract_floor_number("קומה 5 דירה 12") == 5
|
||||||
|
|
||||||
|
def test_negative_number(self):
|
||||||
|
"""Test extraction of negative floor number."""
|
||||||
|
# The regex extracts digits only, so -2 would be extracted as 2
|
||||||
|
# But מרתף is -1
|
||||||
|
assert extract_floor_number("מרתף") == -1
|
||||||
|
|
||||||
|
def test_very_high_floor(self):
|
||||||
|
"""Test extraction of very high floor number."""
|
||||||
|
assert extract_floor_number("קומה 25") == 25
|
||||||
|
assert extract_floor_number("35") == 35
|
||||||
|
|
||||||
|
def test_zero_floor(self):
|
||||||
|
"""Test extraction of floor zero."""
|
||||||
|
assert extract_floor_number("0") == 0
|
||||||
|
|
||||||
|
def test_hebrew_floor_with_typo(self):
|
||||||
|
"""Test Hebrew floor with slight variations still matches."""
|
||||||
|
# The function uses 'in' matching, so partial matches work
|
||||||
|
assert extract_floor_number("קומה ראשונה מיוחדת") == 1
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for validators module.
|
||||||
|
|
||||||
|
Tests input validation functions for addresses, coordinates, integers, and deal types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from nadlan_mcp.govmap.validators import (
|
||||||
|
validate_address,
|
||||||
|
validate_coordinates,
|
||||||
|
validate_positive_int,
|
||||||
|
validate_deal_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateAddress:
|
||||||
|
"""Test address validation function."""
|
||||||
|
|
||||||
|
def test_valid_address(self):
|
||||||
|
"""Test validation of valid address."""
|
||||||
|
address = "דיזנגוף 50 תל אביב"
|
||||||
|
result = validate_address(address)
|
||||||
|
assert result == "דיזנגוף 50 תל אביב"
|
||||||
|
|
||||||
|
def test_address_with_whitespace(self):
|
||||||
|
"""Test address with leading/trailing whitespace is trimmed."""
|
||||||
|
address = " הרצל 1 תל אביב "
|
||||||
|
result = validate_address(address)
|
||||||
|
assert result == "הרצל 1 תל אביב"
|
||||||
|
|
||||||
|
def test_empty_string_raises_error(self):
|
||||||
|
"""Test empty string raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Address must be a non-empty string"):
|
||||||
|
validate_address("")
|
||||||
|
|
||||||
|
def test_none_raises_error(self):
|
||||||
|
"""Test None raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Address must be a non-empty string"):
|
||||||
|
validate_address(None)
|
||||||
|
|
||||||
|
def test_whitespace_only_raises_error(self):
|
||||||
|
"""Test whitespace-only string raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Address cannot be empty or whitespace only"):
|
||||||
|
validate_address(" ")
|
||||||
|
|
||||||
|
def test_very_long_address_raises_error(self):
|
||||||
|
"""Test address longer than 500 characters raises ValueError."""
|
||||||
|
long_address = "א" * 501
|
||||||
|
with pytest.raises(ValueError, match="Address is too long"):
|
||||||
|
validate_address(long_address)
|
||||||
|
|
||||||
|
def test_address_at_max_length(self):
|
||||||
|
"""Test address at exactly 500 characters is valid."""
|
||||||
|
max_length_address = "א" * 500
|
||||||
|
result = validate_address(max_length_address)
|
||||||
|
assert len(result) == 500
|
||||||
|
|
||||||
|
def test_non_string_raises_error(self):
|
||||||
|
"""Test non-string input raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Address must be a non-empty string"):
|
||||||
|
validate_address(123)
|
||||||
|
|
||||||
|
def test_english_address(self):
|
||||||
|
"""Test English address is valid."""
|
||||||
|
address = "Dizengoff 50 Tel Aviv"
|
||||||
|
result = validate_address(address)
|
||||||
|
assert result == "Dizengoff 50 Tel Aviv"
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateCoordinates:
|
||||||
|
"""Test coordinate validation function."""
|
||||||
|
|
||||||
|
def test_valid_itm_coordinates(self):
|
||||||
|
"""Test valid ITM coordinates."""
|
||||||
|
point = (180000.0, 650000.0)
|
||||||
|
result = validate_coordinates(point)
|
||||||
|
assert result == (180000.0, 650000.0)
|
||||||
|
|
||||||
|
def test_coordinates_as_list(self):
|
||||||
|
"""Test coordinates provided as list are converted to tuple."""
|
||||||
|
point = [180000.0, 650000.0]
|
||||||
|
result = validate_coordinates(point)
|
||||||
|
assert result == (180000.0, 650000.0)
|
||||||
|
assert isinstance(result, tuple)
|
||||||
|
|
||||||
|
def test_coordinates_as_integers(self):
|
||||||
|
"""Test coordinates as integers are converted to floats."""
|
||||||
|
point = (180000, 650000)
|
||||||
|
result = validate_coordinates(point)
|
||||||
|
assert result == (180000.0, 650000.0)
|
||||||
|
assert all(isinstance(x, float) for x in result)
|
||||||
|
|
||||||
|
def test_wrong_number_of_coordinates_raises_error(self):
|
||||||
|
"""Test tuple with wrong number of elements raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Point must be a tuple of"):
|
||||||
|
validate_coordinates((180000.0,))
|
||||||
|
|
||||||
|
def test_three_coordinates_raises_error(self):
|
||||||
|
"""Test three coordinates raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Point must be a tuple of"):
|
||||||
|
validate_coordinates((180000.0, 650000.0, 100.0))
|
||||||
|
|
||||||
|
def test_non_numeric_coordinates_raises_error(self):
|
||||||
|
"""Test non-numeric coordinates raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Coordinates must be numeric values"):
|
||||||
|
validate_coordinates(("abc", "def"))
|
||||||
|
|
||||||
|
def test_none_coordinates_raises_error(self):
|
||||||
|
"""Test None coordinates raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Coordinates must be numeric values"):
|
||||||
|
validate_coordinates((None, None))
|
||||||
|
|
||||||
|
def test_string_coordinates_raises_error(self):
|
||||||
|
"""Test string coordinates raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Point must be a tuple of"):
|
||||||
|
validate_coordinates("180000, 650000")
|
||||||
|
|
||||||
|
def test_out_of_bounds_longitude_logs_warning(self, caplog):
|
||||||
|
"""Test out-of-bounds longitude logs warning but doesn't raise error."""
|
||||||
|
point = (500000.0, 650000.0) # Longitude > 400000
|
||||||
|
result = validate_coordinates(point)
|
||||||
|
assert result == (500000.0, 650000.0)
|
||||||
|
# Warning should be logged (implementation uses logger.warning)
|
||||||
|
|
||||||
|
def test_out_of_bounds_latitude_logs_warning(self, caplog):
|
||||||
|
"""Test out-of-bounds latitude logs warning but doesn't raise error."""
|
||||||
|
point = (180000.0, 2000000.0) # Latitude > 1400000
|
||||||
|
result = validate_coordinates(point)
|
||||||
|
assert result == (180000.0, 2000000.0)
|
||||||
|
# Warning should be logged (implementation uses logger.warning)
|
||||||
|
|
||||||
|
def test_negative_coordinates_logs_warning(self, caplog):
|
||||||
|
"""Test negative coordinates log warning but don't raise error."""
|
||||||
|
point = (-10000.0, 650000.0)
|
||||||
|
result = validate_coordinates(point)
|
||||||
|
assert result == (-10000.0, 650000.0)
|
||||||
|
# Warning should be logged
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidatePositiveInt:
|
||||||
|
"""Test positive integer validation function."""
|
||||||
|
|
||||||
|
def test_valid_positive_integer(self):
|
||||||
|
"""Test validation of valid positive integer."""
|
||||||
|
result = validate_positive_int(10, "test_param")
|
||||||
|
assert result == 10
|
||||||
|
|
||||||
|
def test_zero_raises_error(self):
|
||||||
|
"""Test zero raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="test_param must be positive"):
|
||||||
|
validate_positive_int(0, "test_param")
|
||||||
|
|
||||||
|
def test_negative_integer_raises_error(self):
|
||||||
|
"""Test negative integer raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="test_param must be positive"):
|
||||||
|
validate_positive_int(-5, "test_param")
|
||||||
|
|
||||||
|
def test_non_integer_raises_error(self):
|
||||||
|
"""Test non-integer raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="test_param must be an integer"):
|
||||||
|
validate_positive_int(10.5, "test_param")
|
||||||
|
|
||||||
|
def test_string_raises_error(self):
|
||||||
|
"""Test string raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="test_param must be an integer"):
|
||||||
|
validate_positive_int("10", "test_param")
|
||||||
|
|
||||||
|
def test_max_value_check(self):
|
||||||
|
"""Test max value constraint is enforced."""
|
||||||
|
result = validate_positive_int(50, "test_param", max_value=100)
|
||||||
|
assert result == 50
|
||||||
|
|
||||||
|
def test_exceeds_max_value_raises_error(self):
|
||||||
|
"""Test value exceeding max raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="test_param must be <= 100"):
|
||||||
|
validate_positive_int(150, "test_param", max_value=100)
|
||||||
|
|
||||||
|
def test_at_max_value(self):
|
||||||
|
"""Test value at exactly max is valid."""
|
||||||
|
result = validate_positive_int(100, "test_param", max_value=100)
|
||||||
|
assert result == 100
|
||||||
|
|
||||||
|
def test_large_integer(self):
|
||||||
|
"""Test very large integer is valid."""
|
||||||
|
large_int = 1000000
|
||||||
|
result = validate_positive_int(large_int, "test_param")
|
||||||
|
assert result == large_int
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateDealType:
|
||||||
|
"""Test deal type validation function."""
|
||||||
|
|
||||||
|
def test_deal_type_1_is_valid(self):
|
||||||
|
"""Test deal type 1 (first hand/new) is valid."""
|
||||||
|
result = validate_deal_type(1)
|
||||||
|
assert result == 1
|
||||||
|
|
||||||
|
def test_deal_type_2_is_valid(self):
|
||||||
|
"""Test deal type 2 (second hand/used) is valid."""
|
||||||
|
result = validate_deal_type(2)
|
||||||
|
assert result == 2
|
||||||
|
|
||||||
|
def test_deal_type_0_raises_error(self):
|
||||||
|
"""Test deal type 0 raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="deal_type must be 1.*or 2"):
|
||||||
|
validate_deal_type(0)
|
||||||
|
|
||||||
|
def test_deal_type_3_raises_error(self):
|
||||||
|
"""Test deal type 3 raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="deal_type must be 1.*or 2"):
|
||||||
|
validate_deal_type(3)
|
||||||
|
|
||||||
|
def test_negative_deal_type_raises_error(self):
|
||||||
|
"""Test negative deal type raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="deal_type must be 1.*or 2"):
|
||||||
|
validate_deal_type(-1)
|
||||||
|
|
||||||
|
def test_float_deal_type_raises_error(self):
|
||||||
|
"""Test float deal type raises ValueError (not an int)."""
|
||||||
|
# Note: This test depends on whether the function checks type before value
|
||||||
|
# If the function coerces to int, this might pass. Adjust based on implementation.
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_deal_type(1.5)
|
||||||
|
|
||||||
|
def test_string_deal_type_raises_error(self):
|
||||||
|
"""Test string deal type raises ValueError."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_deal_type("1")
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
"""
|
||||||
|
E2E tests for FastMCP tools.
|
||||||
|
|
||||||
|
Tests the MCP tool layer including JSON formatting, error handling,
|
||||||
|
and integration with the GovmapClient.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from nadlan_mcp import fastmcp_server
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutocompleteAddress:
|
||||||
|
"""Test autocomplete_address MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_autocomplete(self, mock_client):
|
||||||
|
"""Test successful address autocomplete with correct field mapping."""
|
||||||
|
mock_client.autocomplete_address.return_value = {
|
||||||
|
"resultsCount": 2,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "address|ADDR|123",
|
||||||
|
"text": "דיזנגוף 50 תל אביב-יפו",
|
||||||
|
"type": "address",
|
||||||
|
"score": 100,
|
||||||
|
"shape": "POINT(180000.5 650000.3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "address|ADDR|124",
|
||||||
|
"text": "דיזנגוף 52 תל אביב-יפו",
|
||||||
|
"type": "address",
|
||||||
|
"score": 95,
|
||||||
|
"shape": "POINT(180010.2 650005.7)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = fastmcp_server.autocomplete_address("דיזנגוף תל אביב")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert len(parsed) == 2
|
||||||
|
assert parsed[0]["text"] == "דיזנגוף 50 תל אביב-יפו"
|
||||||
|
assert parsed[0]["id"] == "address|ADDR|123"
|
||||||
|
assert parsed[0]["score"] == 100
|
||||||
|
assert parsed[0]["coordinates"]["longitude"] == 180000.5
|
||||||
|
assert parsed[0]["coordinates"]["latitude"] == 650000.3
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_autocomplete_no_results(self, mock_client):
|
||||||
|
"""Test autocomplete with no results."""
|
||||||
|
mock_client.autocomplete_address.return_value = {
|
||||||
|
"resultsCount": 0,
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
|
||||||
|
result = fastmcp_server.autocomplete_address("nonexistent address")
|
||||||
|
# With empty results, returns empty JSON array
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert len(parsed) == 0
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_autocomplete_invalid_coordinates(self, mock_client):
|
||||||
|
"""Test autocomplete with invalid coordinate format."""
|
||||||
|
mock_client.autocomplete_address.return_value = {
|
||||||
|
"resultsCount": 1,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "address|ADDR|123",
|
||||||
|
"text": "דיזנגוף 50",
|
||||||
|
"type": "address",
|
||||||
|
"score": 100,
|
||||||
|
"shape": "INVALID_FORMAT"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = fastmcp_server.autocomplete_address("test")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed[0]["coordinates"] == {}
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_autocomplete_missing_shape(self, mock_client):
|
||||||
|
"""Test autocomplete with missing shape field."""
|
||||||
|
mock_client.autocomplete_address.return_value = {
|
||||||
|
"resultsCount": 1,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "address|ADDR|123",
|
||||||
|
"text": "דיזנגוף 50",
|
||||||
|
"type": "address",
|
||||||
|
"score": 100
|
||||||
|
# No shape field
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = fastmcp_server.autocomplete_address("test")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert parsed[0]["coordinates"] == {}
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_autocomplete_error_handling(self, mock_client):
|
||||||
|
"""Test autocomplete error handling."""
|
||||||
|
mock_client.autocomplete_address.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
result = fastmcp_server.autocomplete_address("test")
|
||||||
|
assert "Error searching for address" in result
|
||||||
|
assert "API Error" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDealsByRadius:
|
||||||
|
"""Test get_deals_by_radius MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_get_deals(self, mock_client):
|
||||||
|
"""Test successful deal retrieval."""
|
||||||
|
mock_deals = [
|
||||||
|
{
|
||||||
|
"dealId": 123,
|
||||||
|
"dealAmount": 2000000,
|
||||||
|
"assetArea": 80,
|
||||||
|
"streetNameHeb": "דיזנגוף"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_client.get_deals_by_radius.return_value = mock_deals
|
||||||
|
|
||||||
|
result = fastmcp_server.get_deals_by_radius(650000.0, 180000.0, 500)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert len(parsed["deals"]) == 1
|
||||||
|
assert parsed["deals"][0]["dealAmount"] == 2000000
|
||||||
|
assert parsed["total_deals"] == 1
|
||||||
|
mock_client.get_deals_by_radius.assert_called_once()
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_get_deals_no_results(self, mock_client):
|
||||||
|
"""Test deal retrieval with no results."""
|
||||||
|
mock_client.get_deals_by_radius.return_value = []
|
||||||
|
|
||||||
|
result = fastmcp_server.get_deals_by_radius(650000.0, 180000.0, 500)
|
||||||
|
assert "No deals found" in result or json.loads(result)["total_deals"] == 0
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_get_deals_strips_bloat_fields(self, mock_client):
|
||||||
|
"""Test that bloat fields are stripped from response."""
|
||||||
|
mock_deals = [
|
||||||
|
{
|
||||||
|
"dealId": 123,
|
||||||
|
"dealAmount": 2000000,
|
||||||
|
"shape": "MULTIPOLYGON(...huge data...)",
|
||||||
|
"sourceorder": 1,
|
||||||
|
"source_polygon_id": "abc123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_client.get_deals_by_radius.return_value = mock_deals
|
||||||
|
|
||||||
|
result = fastmcp_server.get_deals_by_radius(650000.0, 180000.0, 500)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
# Verify bloat fields are removed
|
||||||
|
deal = parsed["deals"][0]
|
||||||
|
assert "shape" not in deal
|
||||||
|
assert "sourceorder" not in deal
|
||||||
|
assert "source_polygon_id" not in deal
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_get_deals_error_handling(self, mock_client):
|
||||||
|
"""Test error handling for deal retrieval."""
|
||||||
|
mock_client.get_deals_by_radius.side_effect = ValueError("Invalid coordinates")
|
||||||
|
|
||||||
|
result = fastmcp_server.get_deals_by_radius(999999.0, 999999.0, 500)
|
||||||
|
assert "Error" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindRecentDealsForAddress:
|
||||||
|
"""Test find_recent_deals_for_address MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_find_deals(self, mock_client):
|
||||||
|
"""Test successful deal finding with statistics."""
|
||||||
|
mock_deals = [
|
||||||
|
{
|
||||||
|
"dealId": 123,
|
||||||
|
"dealAmount": 2000000,
|
||||||
|
"assetArea": 80,
|
||||||
|
"price_per_sqm": 25000,
|
||||||
|
"priority": 0,
|
||||||
|
"deal_source": "same_building"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dealId": 124,
|
||||||
|
"dealAmount": 1800000,
|
||||||
|
"assetArea": 70,
|
||||||
|
"price_per_sqm": 25714,
|
||||||
|
"priority": 1,
|
||||||
|
"deal_source": "street"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||||
|
|
||||||
|
result = fastmcp_server.find_recent_deals_for_address(
|
||||||
|
"דיזנגוף 50 תל אביב", 2, 100, 100
|
||||||
|
)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "search_parameters" in parsed
|
||||||
|
assert "market_statistics" in parsed
|
||||||
|
assert "deals" in parsed
|
||||||
|
assert len(parsed["deals"]) == 2
|
||||||
|
assert parsed["market_statistics"]["deal_breakdown"]["total_deals"] == 2
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_find_deals_no_results(self, mock_client):
|
||||||
|
"""Test deal finding with no results."""
|
||||||
|
mock_client.find_recent_deals_for_address.return_value = []
|
||||||
|
|
||||||
|
result = fastmcp_server.find_recent_deals_for_address(
|
||||||
|
"nonexistent address", 2, 100, 100
|
||||||
|
)
|
||||||
|
|
||||||
|
# When no deals, returns a text message, not JSON
|
||||||
|
assert "No second hand (used) deals found" in result
|
||||||
|
assert "nonexistent address" in result
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_find_deals_strips_bloat(self, mock_client):
|
||||||
|
"""Test that bloat fields are stripped."""
|
||||||
|
mock_deals = [
|
||||||
|
{
|
||||||
|
"dealId": 123,
|
||||||
|
"dealAmount": 2000000,
|
||||||
|
"shape": "MULTIPOLYGON(...)",
|
||||||
|
"sourceorder": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||||
|
|
||||||
|
result = fastmcp_server.find_recent_deals_for_address(
|
||||||
|
"test address", 2, 100, 100
|
||||||
|
)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "shape" not in parsed["deals"][0]
|
||||||
|
assert "sourceorder" not in parsed["deals"][0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyzeMarketTrends:
|
||||||
|
"""Test analyze_market_trends MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_market_analysis(self, mock_client):
|
||||||
|
"""Test successful market trend analysis."""
|
||||||
|
mock_deals = [
|
||||||
|
{
|
||||||
|
"dealAmount": 2000000,
|
||||||
|
"assetArea": 80,
|
||||||
|
"price_per_sqm": 25000,
|
||||||
|
"dealDate": "2024-01-15T00:00:00.000Z",
|
||||||
|
"propertyTypeDescription": "דירה",
|
||||||
|
"neighborhood": "תל אביב",
|
||||||
|
"priority": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||||
|
|
||||||
|
result = fastmcp_server.analyze_market_trends("דיזנגוף 50 תל אביב", 2, 100)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "analysis_parameters" in parsed
|
||||||
|
assert "market_summary" in parsed
|
||||||
|
assert "yearly_trends" in parsed
|
||||||
|
assert "top_property_types" in parsed
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_market_analysis_no_data(self, mock_client):
|
||||||
|
"""Test market analysis with no data."""
|
||||||
|
mock_client.find_recent_deals_for_address.return_value = []
|
||||||
|
|
||||||
|
result = fastmcp_server.analyze_market_trends("test", 2, 100)
|
||||||
|
|
||||||
|
# When no deals, returns a text message, not JSON
|
||||||
|
assert "No second hand (used) deals found for comprehensive market analysis" in result
|
||||||
|
assert "test" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompareAddresses:
|
||||||
|
"""Test compare_addresses MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_comparison(self, mock_client):
|
||||||
|
"""Test successful address comparison."""
|
||||||
|
# Mock different deals for each address
|
||||||
|
mock_client.find_recent_deals_for_address.side_effect = [
|
||||||
|
[{"dealAmount": 2000000, "assetArea": 80, "price_per_sqm": 25000}],
|
||||||
|
[{"dealAmount": 1500000, "assetArea": 60, "price_per_sqm": 25000}]
|
||||||
|
]
|
||||||
|
|
||||||
|
result = fastmcp_server.compare_addresses(
|
||||||
|
["דיזנגוף 50 תל אביב", "הרצל 1 חולון"]
|
||||||
|
)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "addresses_compared" in parsed
|
||||||
|
assert parsed["addresses_compared"] == 2
|
||||||
|
assert "all_results" in parsed
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_comparison_error_handling(self, mock_client):
|
||||||
|
"""Test error handling in address comparison."""
|
||||||
|
mock_client.find_recent_deals_for_address.side_effect = ValueError("Invalid address")
|
||||||
|
|
||||||
|
result = fastmcp_server.compare_addresses(["invalid address"])
|
||||||
|
parsed = json.loads(result)
|
||||||
|
# Error is included in the result for that address
|
||||||
|
assert "all_results" in parsed
|
||||||
|
assert "error" in parsed["all_results"][0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetValuationComparables:
|
||||||
|
"""Test get_valuation_comparables MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_get_comparables(self, mock_client):
|
||||||
|
"""Test successful comparable retrieval with filtering."""
|
||||||
|
mock_deals = [
|
||||||
|
{
|
||||||
|
"dealAmount": 2000000,
|
||||||
|
"assetArea": 80,
|
||||||
|
"assetRoomNum": 3,
|
||||||
|
"propertyTypeDescription": "דירה",
|
||||||
|
"price_per_sqm": 25000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||||
|
mock_client.filter_deals_by_criteria.return_value = mock_deals
|
||||||
|
mock_client.calculate_deal_statistics.return_value = {
|
||||||
|
"count": 1,
|
||||||
|
"price_stats": {"mean": 2000000},
|
||||||
|
"area_stats": {"mean": 80},
|
||||||
|
"price_per_sqm_stats": {"mean": 25000}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = fastmcp_server.get_valuation_comparables(
|
||||||
|
"דיזנגוף 50 תל אביב",
|
||||||
|
property_type="דירה",
|
||||||
|
min_rooms=2,
|
||||||
|
max_rooms=4
|
||||||
|
)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "filters_applied" in parsed
|
||||||
|
assert "statistics" in parsed
|
||||||
|
assert "comparables" in parsed
|
||||||
|
assert parsed["filters_applied"]["property_type"] == "דירה"
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_comparables_strips_bloat(self, mock_client):
|
||||||
|
"""Test that bloat fields are stripped from comparables."""
|
||||||
|
mock_deals = [
|
||||||
|
{
|
||||||
|
"dealAmount": 2000000,
|
||||||
|
"shape": "MULTIPOLYGON(...)",
|
||||||
|
"sourceorder": 1,
|
||||||
|
"source_polygon_id": "abc"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||||
|
mock_client.filter_deals_by_criteria.return_value = mock_deals
|
||||||
|
mock_client.calculate_deal_statistics.return_value = {
|
||||||
|
"count": 1,
|
||||||
|
"price_stats": {"mean": 2000000}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = fastmcp_server.get_valuation_comparables("test address")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
comparable = parsed["comparables"][0]
|
||||||
|
assert "shape" not in comparable
|
||||||
|
assert "sourceorder" not in comparable
|
||||||
|
assert "source_polygon_id" not in comparable
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDealStatistics:
|
||||||
|
"""Test get_deal_statistics MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_statistics_calculation(self, mock_client):
|
||||||
|
"""Test successful statistics calculation."""
|
||||||
|
mock_deals = [
|
||||||
|
{"dealAmount": 2000000, "assetArea": 80},
|
||||||
|
{"dealAmount": 1800000, "assetArea": 70}
|
||||||
|
]
|
||||||
|
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||||
|
mock_client.filter_deals_by_criteria.return_value = mock_deals
|
||||||
|
mock_client.calculate_deal_statistics.return_value = {
|
||||||
|
"count": 2,
|
||||||
|
"price_stats": {
|
||||||
|
"mean": 1900000,
|
||||||
|
"median": 1900000,
|
||||||
|
"min": 1800000,
|
||||||
|
"max": 2000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = fastmcp_server.get_deal_statistics("test address")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "address" in parsed
|
||||||
|
assert "statistics" in parsed
|
||||||
|
assert parsed["statistics"]["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetMarketActivityMetrics:
|
||||||
|
"""Test get_market_activity_metrics MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_activity_metrics(self, mock_client):
|
||||||
|
"""Test successful market activity calculation."""
|
||||||
|
mock_deals = [
|
||||||
|
{
|
||||||
|
"dealDate": "2024-01-15T00:00:00.000Z",
|
||||||
|
"dealAmount": 2000000,
|
||||||
|
"assetArea": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_client.find_recent_deals_for_address.return_value = mock_deals
|
||||||
|
mock_client.calculate_market_activity_score.return_value = {
|
||||||
|
"activity_score": 75,
|
||||||
|
"activity_level": "high"
|
||||||
|
}
|
||||||
|
mock_client.get_market_liquidity.return_value = {
|
||||||
|
"velocity_score": 8.5,
|
||||||
|
"liquidity_rating": "high"
|
||||||
|
}
|
||||||
|
mock_client.analyze_investment_potential.return_value = {
|
||||||
|
"investment_score": 80,
|
||||||
|
"recommendation": "positive"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = fastmcp_server.get_market_activity_metrics("test address")
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "market_activity" in parsed
|
||||||
|
assert "market_liquidity" in parsed
|
||||||
|
assert "investment_potential" in parsed
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetStreetDeals:
|
||||||
|
"""Test get_street_deals MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_street_deals(self, mock_client):
|
||||||
|
"""Test successful street deal retrieval."""
|
||||||
|
mock_deals = [
|
||||||
|
{"dealId": 123, "dealAmount": 2000000}
|
||||||
|
]
|
||||||
|
mock_client.get_street_deals.return_value = mock_deals
|
||||||
|
|
||||||
|
result = fastmcp_server.get_street_deals("12345", 100)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert len(parsed["deals"]) == 1
|
||||||
|
assert "shape" not in parsed["deals"][0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetNeighborhoodDeals:
|
||||||
|
"""Test get_neighborhood_deals MCP tool."""
|
||||||
|
|
||||||
|
@patch('nadlan_mcp.fastmcp_server.client')
|
||||||
|
def test_successful_neighborhood_deals(self, mock_client):
|
||||||
|
"""Test successful neighborhood deal retrieval."""
|
||||||
|
mock_deals = [
|
||||||
|
{"dealId": 123, "dealAmount": 2000000}
|
||||||
|
]
|
||||||
|
mock_client.get_neighborhood_deals.return_value = mock_deals
|
||||||
|
|
||||||
|
result = fastmcp_server.get_neighborhood_deals("12345", 100)
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert len(parsed["deals"]) == 1
|
||||||
|
assert "shape" not in parsed["deals"][0]
|
||||||
Reference in New Issue
Block a user