From ffd7c84aa7809429befdfe7631b7a6a5b740258c Mon Sep 17 00:00:00 2001 From: Nitzan Pomerantz <9297302+nitzpo@users.noreply.github.com> Date: Sun, 19 Oct 2025 00:57:50 +0300 Subject: [PATCH 1/8] Added plan --- .cursor/plans/PLAN.md | 472 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 .cursor/plans/PLAN.md diff --git a/.cursor/plans/PLAN.md b/.cursor/plans/PLAN.md new file mode 100644 index 0000000..bd29cf4 --- /dev/null +++ b/.cursor/plans/PLAN.md @@ -0,0 +1,472 @@ + +# Nadlan MCP Comprehensive Improvement Plan + +## Phase 1: Critical Code Quality & Reliability Fixes + +### 1.1 Error Handling & Resilience + +**Files: `nadlan_mcp/govmap.py`, `nadlan_mcp/fastmcp_server.py`** + +- Add retry logic with exponential backoff for API calls (use `tenacity` library) +- Implement rate limiting protection (track request counts, add delays) +- Standardize error handling: all GovmapClient methods should raise exceptions (not return empty lists) +- Add proper input validation/sanitization for all functions +- Add timeout configuration for HTTP requests + +**Issues identified:** + +- Line 111-140 in `govmap.py`: `get_deals_by_radius` catches JSONDecodeError and returns `[]` instead of raising +- Line 180-185 in `govmap.py`: `get_street_deals` catches JSONDecodeError and returns `[]` instead of raising +- Similar pattern in `get_neighborhood_deals` +- No retry logic anywhere +- No rate limiting protection + +### 1.2 Configuration Management + +**New file: `nadlan_mcp/config.py`** + +- Create configuration class with all settings (API base URL, timeouts, retry counts, rate limits) +- Support environment variables for configuration +- Add validation for configuration values +- Document all configuration options + +**Currently hard-coded values:** + +- API base URL in `govmap.py:19` +- Default timeouts, radii, limits scattered throughout +- No environment variable support beyond basic setup + +### 1.3 Documentation Alignment + +**Files: `USECASES.md`, `README.md`** + +- Update USECASES.md to clearly mark amenity scoring as "Future Feature" with roadmap +- Add architecture diagram showing MCP โ†’ Govmap API flow +- Create ARCHITECTURE.md documenting design decisions +- Update README.md with current accurate feature list +- Add API limitations and rate limit documentation + +**Misalignments found:** + +- USECASES.md lines 23-28, 51-56, 66-69 describe amenity features that don't exist +- README mentions features not fully implemented + +## Phase 2: Missing Core Functionality + +### 2.1 Property Valuation Data Provision + +**Updates to `nadlan_mcp/govmap.py`, Add tools to `fastmcp_server.py`** + +**Purpose:** Provide comprehensive data for LLM to perform its own valuation analysis + +**New Functions:** + +- `get_comparable_properties(address, filters)` - find similar properties with flexible filtering +- `calculate_simple_statistics(deals)` - basic math helper for price/sqm calculations (optional utility) + +**Tools for LLM:** + +- `get_valuation_comparables` - return detailed comparable deals filtered by criteria + - Filter by: property type, room count, area range, floor range, time period + - Include: full deal history, neighborhood data, price trends + - Let LLM analyze and estimate value +- `get_deal_statistics` - simple statistical aggregations (mean, median, percentiles) + - For when LLM needs quick calculations on large datasets + +**Implementation:** + +- Enhanced filtering on existing deal data +- Statistical helper functions (no ML, no predictions) +- Rich contextual data for LLM decision-making +- LLM does the valuation, MCP provides the data + +### 2.2 Market Activity & Investment Analysis + +**New file: `nadlan_mcp/market_analysis.py`, Add tools to `fastmcp_server.py`** + +**New Functions:** + +- `calculate_market_activity_score(address, time_period)` - volume and velocity metrics +- `analyze_investment_potential(address, criteria)` - ROI, appreciation, risk metrics +- `get_market_liquidity(neighborhood)` - time-to-sell, supply/demand + +**Tools for LLM:** + +- `get_market_activity_metrics` - deal volume, velocity, inventory levels +- `analyze_investment_opportunity` - comprehensive investment analysis +- `get_neighborhood_market_health` - market health indicators + +**Metrics to include:** + +- Deal volume trends (deals per month) +- Average time on market (if available) +- Price appreciation rates +- Supply/demand indicators (active listings vs deals) +- Neighborhood investment score (0-100) + +### 2.3 Enhanced Deal Filtering & Search + +**Updates to `nadlan_mcp/govmap.py`** + +**New Functions:** + +- `filter_deals_by_criteria(deals, criteria)` - filter by rooms, area, price range, property type +- `search_deals_with_filters(address, filters, years_back)` - combined search with filters +- `get_deals_by_property_type(address, property_type, years_back)` - specialized property type search + +**Add to existing functions:** + +- Property type filtering (apartment, house, penthouse, etc.) +- Room count filtering (2-room, 3-room, etc.) +- Price range filtering +- Area range filtering +- Floor range filtering + +## Phase 3: Architecture & Data Model Improvements + +### 3.1 Create Structured Data Models + +**New file: `nadlan_mcp/models.py`** + +Create Pydantic models for: + +- `Deal` - standardized deal object +- `Address` - address with coordinates and metadata +- `MarketAnalysis` - market trend analysis results +- `PropertyValuation` - valuation estimates + +**Benefits:** + +- Type safety and validation +- Consistent JSON serialization +- Better IDE support +- Easier testing + +### 3.2 Separate Concerns + +**Refactor existing code:** + +- Move analysis logic from `fastmcp_server.py` into dedicated modules +- Create `nadlan_mcp/api_client.py` for pure API interactions +- Create `nadlan_mcp/analyzers/` package for analysis functions +- Keep `fastmcp_server.py` thin - just MCP tool definitions + +**Current issues:** + +- `fastmcp_server.py` has 537 lines mixing API calls, analysis, and MCP definitions +- Business logic scattered across client and server +- Hard to test and maintain + +### 3.3 Improve Tool Design for LLM Consumption + +**Update all tools in `fastmcp_server.py`** + +Each tool should offer: + +- **Structured mode** (default): Return full structured data for LLM processing +- **Summarized mode** (optional): Return analyzed/summarized data for simple queries +- Clear parameter documentation with examples +- Consistent error responses + +**Parameter pattern:** + +```python +@mcp.tool() +def find_recent_deals_for_address( + address: str, + years_back: int = 2, + summarized_response: bool = False # Default: structured/detailed +) -> str: + """ + summarized_response: + - False (default): Full deal data with all fields for LLM processing + - True: Statistical summary with key insights only + """ +``` + +## Phase 4: Testing & Quality Assurance + +### 4.1 Expand Test Coverage + +**Updates to `tests/test_govmap_client.py`, new test files** + +Add: + +- Integration tests for complete workflows (mark with `@pytest.mark.integration`) +- Edge case tests (empty results, malformed data, network errors) +- Parametrized tests for different address formats +- Tests for new functionality (valuation data, market analysis) + +**Current coverage gaps:** + +- No tests for `analyze_market_trends` +- No tests for `compare_addresses` +- No tests for error recovery +- No tests for `_is_same_building` logic + +### 4.2 Add Validation Tests + +**New file: `tests/test_validation.py`** + +- Test input validation (invalid addresses, negative numbers, etc.) +- Test configuration validation +- Test data model validation (when Pydantic models added) + +### 4.3 Mock External APIs + +**Updates to `tests/conftest.py`** + +- Create comprehensive fixtures for API responses +- Add vcr.py for recording/replaying API calls in tests + +## Phase 5: Documentation & Developer Experience + +### 5.1 Create Comprehensive Documentation + +**New files:** + +- `ARCHITECTURE.md` - system design, data flow, component interactions +- `DEPLOYMENT.md` - deployment guide for production use +- `CONTRIBUTING.md` - contribution guidelines +- `API_REFERENCE.md` - detailed API documentation with examples +- `CLAUDE.md` - instructions for AI coding agents implementing future tasks + - Task breakdown format + - Code standards and patterns + - Testing requirements + - Common pitfalls and solutions + +### 5.2 Improve Code Documentation + +**All Python files:** + +- Add module-level docstrings explaining purpose +- Add comprehensive function docstrings with examples +- Add type hints to all functions (including returns) +- Add inline comments for complex logic + +### 5.3 Add Usage Examples + +**New directory: `examples/`** + +Create example scripts: + +- `examples/basic_search.py` - simple address search +- `examples/market_analysis.py` - comprehensive market analysis +- `examples/investment_analysis.py` - investment opportunity analysis +- `examples/llm_integration.py` - example LLM agent using the MCP + +### 5.4 Update USECASES.md + +**File: `USECASES.md`** + +- Add status indicators for each feature (โœ… Implemented, ๐Ÿšง In Progress, ๐Ÿ“‹ Planned) +- Add implementation priority levels +- Add expected completion timeline +- Add links to relevant documentation + +## Phase 6: Cleanup & Optimization + +### 6.1 Remove Redundant Code + +**Files to clean:** + +- Delete or integrate `mcp_server_concept.py` (appears to be duplicate/old code) +- Remove unused imports +- Remove dead code +- Consolidate duplicate logic + +### 6.2 Code Style & Linting + +**New file: `.pre-commit-config.yaml`** + +- Setup pre-commit hooks (black, isort, flake8, mypy) +- Format all code consistently +- Fix all linter warnings +- Add type checking with mypy + +### 6.3 Dependency Management + +**Updates to `requirements.txt`** + +Current dependencies are minimal. Add: + +- `tenacity` - for retry logic +- `pydantic` - for data models +- `python-dotenv` - already there but ensure proper use +- `httpx` - for async HTTP (future) +- Pin all versions with upper bounds for stability + +Split into: + +- `requirements.txt` - production dependencies +- `requirements-dev.txt` - development dependencies (pytest, black, mypy, etc.) + +## Phase 7: Future Enhancements (Backlog) + +### 7.1 Amenity Scoring System (Future Feature) + +**New files: `nadlan_mcp/amenities.py`, Add tools to `fastmcp_server.py`** + +Implement comprehensive amenity scoring using multiple data sources: + +**Data Sources:** + +- Google Places API / OpenStreetMap - basic amenity locations +- Ministry of Education - school rankings and quality metrics +- Ministry of Health - healthcare facility ratings +- CBS (Central Bureau of Statistics) - demographic data +- Public transport APIs - station locations and frequency + +**New Functions:** + +- `get_nearby_amenities(coordinates, radius, amenity_types)` - retrieve raw amenity data +- `get_school_quality_data(schools)` - fetch education quality metrics +- `calculate_amenity_score(address, weights)` - score with quality-weighted proximity +- `compare_amenity_scores(addresses)` - multi-address comparison + +**Tools for LLM:** + +- `get_address_amenity_rating` - comprehensive amenity analysis +- `compare_addresses_by_amenities` - side-by-side comparison +- `find_amenities_near_address` - raw amenity list with quality data + +**Implementation approach:** + +- Combine proximity with quality metrics (not just distance) +- Weight by category importance (configurable) +- Include static data sources for institutional quality +- Return both raw data and computed scores + +### 7.2 Caching System (Future - Not in MVP) + +**Task: In-Memory Caching** + +- Add TTL-based caching for API responses +- Cache autocomplete results (1 hour TTL) +- Cache deal results (30 minute TTL) +- Implement cache invalidation strategy +- Add cache statistics/monitoring + +**Task: Production-Ready Caching** + +- Redis integration for distributed caching +- Cache warming strategies +- Cache synchronization across instances +- Persistent cache storage +- Cache versioning for schema changes + +### 7.3 Performance Optimizations (Future) + +**Task: Async/Parallel Processing** + +- Convert to async/await for concurrent API calls +- Use `httpx` for async HTTP requests +- Parallel processing for multi-polygon queries +- Batch processing optimizations + +**Task: Database Integration** + +- SQLite for local development +- PostgreSQL for production +- Historical data tracking +- Faster query performance for trends +- Data warehouse for analytics + +### 7.4 Multi-language Support (Future) + +**Enhance language support:** + +- Full English address support (currently Hebrew-focused) +- Translation services for property descriptions +- Language detection and auto-switching +- Multi-language documentation + +### 7.5 Advanced Valuation Helper (Future) + +**Optional calculation helper for LLM:** + +**New Function:** + +- `calculate_valuation_from_comparables(comparables, weights, adjustments)` - mathematical valuation helper + - Takes LLM-selected comparable deals + - Applies LLM-specified weights and adjustments + - Returns calculated price estimate with breakdown + - Pure calculation (no intelligence), LLM provides the logic + +**Purpose:** + +- LLM selects comparables and determines methodology +- Function just does the math reliably +- Returns detailed calculation breakdown for transparency + +## Implementation Priority + +**High Priority (MVP):** + +1. Phase 1: Code Quality & Reliability (critical for stability) +2. Phase 2: Missing Core Functionality (align with use cases) +3. Phase 6.1: Cleanup redundant code + +**Medium Priority (Post-MVP):** + +4. Phase 3: Architecture improvements (foundation for scaling) +5. Phase 4: Testing expansion (ensure quality) +6. Phase 5: Documentation (user experience) + +**Low Priority (Future):** + +7. Phase 6.2-6.3: Polish & optimization +8. Phase 7: Future enhancements (amenities, caching, async, multi-language) + +## Success Criteria + +- โœ… All use cases in USECASES.md have corresponding implemented functions +- โœ… LLM can effectively use tools for all stated use cases +- โœ… Code is maintainable with <100 lines per function +- โœ… Test coverage >80% for core functionality +- โœ… All API calls have proper error handling and retries +- โœ… Documentation is comprehensive and up-to-date +- โœ… No duplicate or dead code +- โœ… Type hints on all public functions +- โœ… Configuration is externalized and documented + +## Design Principles + +- **MCP provides data, LLM provides intelligence**: MCP retrieves and structures data; LLM analyzes, decides, and estimates +- **Structured by default**: Default responses should be detailed/structured for LLM processing +- **Optional summarization**: Add `summarized_response` parameter where helpful +- **No predictions in MVP**: Statistical aggregations yes, ML predictions no +- **External APIs for future**: Amenity scoring with quality metrics is a future enhancement +- **Quality over distance**: When amenities are added, include quality metrics not just proximity + +## Notes + +- No load testing on external Govmap API (as requested) +- Focus on LLM-friendly tool design: comprehensive data provision for LLM analysis +- Amenity scoring with quality data (Ministry of Education, etc.) is future feature +- Property valuation done by LLM, not MCP - MCP just provides comparable deals data +- Caching is explicitly not in MVP scope +- Keep functions focused: one function = one clear purpose +- Always return structured data that LLMs can easily process +- Use `summarized_response: bool = False` for optional summarization + +### To-dos + +- [ ] Add retry logic, rate limiting, and standardize error handling in govmap.py and fastmcp_server.py +- [ ] Create config.py with externalized configuration and environment variable support +- [ ] Update USECASES.md and README.md to reflect actual current state and future roadmap +- [ ] Create tools for providing comparable deals and statistical data for LLM valuation +- [ ] Create market activity scoring and investment analysis functions +- [ ] Add comprehensive deal filtering by property type, rooms, price, area, floor +- [ ] Create Pydantic models for Deal, Address, MarketAnalysis, PropertyValuation +- [ ] Separate concerns: create api_client.py, analyzers package, thin fastmcp_server.py +- [ ] Update all tools with summarized_response boolean parameter and consistent responses +- [ ] Add integration tests, edge cases, parametrized tests for all functionality +- [ ] Create test_validation.py with input validation and data model tests +- [ ] Create ARCHITECTURE.md, DEPLOYMENT.md, CONTRIBUTING.md, API_REFERENCE.md, CLAUDE.md +- [ ] Add module docstrings, comprehensive function docs, type hints, inline comments +- [ ] Create examples/ directory with scripts showing all major use cases +- [ ] Add status indicators, priority levels, timeline to USECASES.md +- [ ] Delete/integrate mcp_server_concept.py, remove unused imports, consolidate duplicates +- [ ] Setup pre-commit hooks, format all code, fix linter warnings, add mypy +- [ ] Update requirements.txt with new dependencies, create requirements-dev.txt, pin versions \ No newline at end of file From c71f950eb715b4eaacbdb9b7e063434b2b54c319 Mon Sep 17 00:00:00 2001 From: Nitzan Pomerantz <9297302+nitzpo@users.noreply.github.com> Date: Sun, 19 Oct 2025 00:58:11 +0300 Subject: [PATCH 2/8] Added documentation --- ARCHITECTURE.md | 490 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 490 insertions(+) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fb268f7 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,490 @@ +# Nadlan-MCP Architecture + +## Overview + +Nadlan-MCP is a Model Context Protocol (MCP) server that provides Israeli real estate data to AI agents (LLMs). The architecture follows a clean separation of concerns with three main layers: + +1. **MCP Tools Layer** - Exposes functions to LLM clients +2. **Business Logic Layer** - Data analysis and processing +3. **API Client Layer** - Communicates with Govmap API + +## Design Principles + +### 1. MCP Provides Data, LLM Provides Intelligence + +The server's role is to retrieve, structure, and optionally summarize data. Complex analysis, decision-making, and predictions are left to the LLM client. + +**Example:** +- โœ… MCP: Provides comparable property deals with filters +- โœ… LLM: Analyzes comparables and estimates property value +- โŒ MCP: Calculates ML-based property valuation + +### 2. Structured by Default + +All tools return detailed, structured data by default. Optional `summarized_response` parameter provides condensed summaries when needed. + +### 3. Reliability First + +- Automatic retry with exponential backoff +- Rate limiting to respect API limits +- Comprehensive input validation +- Detailed error messages + +### 4. Configuration Over Hard-coding + +All settings (timeouts, retries, rate limits) are configurable via environment variables or code. + +## System Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ LLM Client (AI Agent) โ”‚ +โ”‚ (Claude, GPT, etc.) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ MCP Protocol + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ MCP Tools Layer โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚autocomplete โ”‚ โ”‚find_recent_dealsโ”‚ โ”‚analyze_marketโ”‚ โ”‚ +โ”‚ โ”‚_address โ”‚ โ”‚_for_address โ”‚ โ”‚_trends โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ fastmcp_server.py (Tool Definitions) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Business Logic Layer โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Market Analysis โ”‚ โ”‚ Deal Processing & Filtering โ”‚ โ”‚ +โ”‚ โ”‚ (analyze_market โ”‚ โ”‚ (_is_same_building, etc.) โ”‚ โ”‚ +โ”‚ โ”‚ _trends logic) โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ govmap.py (GovmapClient) โ”‚ โ”‚ +โ”‚ โ”‚ โ€ข Validation โ€ข Retry Logic โ€ข Rate Limiting โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ HTTP/JSON + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Govmap API Layer โ”‚ +โ”‚ (Israeli Government Real Estate API) โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Autocomplete โ”‚ โ”‚ Deals by โ”‚ โ”‚ Street/ โ”‚ โ”‚ +โ”‚ โ”‚ Endpoint โ”‚ โ”‚ Radius โ”‚ โ”‚ Neighborhood โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ Endpoint โ”‚ โ”‚ Deals Endpoints โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Component Details + +### Configuration Layer (`config.py`) + +**Purpose:** Centralized configuration management + +**Key Components:** +- `GovmapConfig` - Dataclass with all configuration settings +- Environment variable support for all settings +- Validation on initialization + +**Configuration Options:** +- API settings (base URL, user agent) +- Timeout settings (connect, read) +- Retry settings (max retries, backoff) +- Rate limiting (requests per second) +- Default search parameters + +**Usage:** +```python +from nadlan_mcp.config import get_config, set_config, GovmapConfig + +# Use global config +config = get_config() + +# Override config +custom_config = GovmapConfig(max_retries=5, requests_per_second=10.0) +set_config(custom_config) +``` + +### API Client Layer (`govmap.py`) + +**Purpose:** Communicate with Govmap API with reliability features + +**Key Components:** + +#### GovmapClient Class + +**Responsibilities:** +- Make HTTP requests to Govmap API +- Implement retry logic with exponential backoff +- Enforce rate limiting +- Validate inputs +- Parse and validate responses + +**Key Methods:** +- `autocomplete_address()` - Search for addresses +- `get_gush_helka()` - Get block/parcel data +- `get_deals_by_radius()` - Get nearby deals +- `get_street_deals()` - Get street-level deals +- `get_neighborhood_deals()` - Get neighborhood deals +- `find_recent_deals_for_address()` - Main comprehensive search + +**Reliability Features:** +1. **Retry Logic** - Exponential backoff on failures +2. **Rate Limiting** - Tracks request times, enforces delays +3. **Input Validation** - Validates all inputs before API calls +4. **Timeouts** - Configurable connect and read timeouts + +### MCP Tools Layer (`fastmcp_server.py`) + +**Purpose:** Expose functionality to LLM clients via MCP protocol + +**Key Components:** +- FastMCP server instance +- Tool definitions (decorated functions) +- JSON response formatting +- Error handling for user-friendly messages + +**Tool Design Pattern:** +```python +@mcp.tool() +def tool_name(param: type, summarized_response: bool = False) -> str: + """ + Tool description for LLM. + + Args: + param: Parameter description + summarized_response: + - False (default): Full structured data for LLM processing + - True: Condensed summary with key insights + + Returns: + JSON string with data or summary + """ + # 1. Call business logic / API client + # 2. Format response + # 3. Return JSON string +``` + +## Data Flow + +### Example: Find Recent Deals for Address + +``` +1. LLM Request + โ””โ”€> "Find deals for Sokolov 38 Holon in last 2 years" + +2. MCP Tool: find_recent_deals_for_address() + โ”œโ”€> Validate inputs + โ””โ”€> Call GovmapClient + +3. GovmapClient Workflow: + โ”œโ”€> autocomplete_address("ืกื•ืงื•ืœื•ื‘ 38 ื—ื•ืœื•ืŸ") + โ”‚ โ”œโ”€> Rate limit check + โ”‚ โ”œโ”€> HTTP POST (with retry) + โ”‚ โ””โ”€> Parse coordinates + โ”‚ + โ”œโ”€> get_deals_by_radius(coordinates, 30m) + โ”‚ โ”œโ”€> Rate limit check + โ”‚ โ”œโ”€> HTTP GET (with retry) + โ”‚ โ””โ”€> Extract polygon_ids + โ”‚ + โ”œโ”€> For each polygon_id: + โ”‚ โ”œโ”€> get_street_deals(polygon_id) + โ”‚ โ””โ”€> get_neighborhood_deals(polygon_id) + โ”‚ + โ”œโ”€> Filter & Prioritize: + โ”‚ โ”œโ”€> Same building deals (priority 0) + โ”‚ โ”œโ”€> Street deals (priority 1) + โ”‚ โ””โ”€> Neighborhood deals (priority 2) + โ”‚ + โ””โ”€> Sort by priority then date + +4. Format Response + โ””โ”€> JSON with deals + metadata + +5. Return to LLM + โ””โ”€> LLM analyzes and responds to user +``` + +## Error Handling Strategy + +### Validation Errors (ValueError) +- Invalid address format +- Negative/zero parameters +- Out-of-range values +- **Action:** Immediate failure with clear message + +### Network Errors (requests.RequestException) +- Connection failures +- Timeouts +- HTTP errors +- **Action:** Retry with exponential backoff (up to configured max_retries) + +### API Response Errors +- Invalid JSON +- Unexpected format +- Missing required fields +- **Action:** Raise ValueError with specific details + +### Rate Limiting +- Track last request time +- Sleep if necessary before request +- **Action:** Transparent to caller, automatic + +## Performance Considerations + +### Current Implementation + +**Optimizations:** +- Connection pooling (requests.Session) +- Rate limiting prevents API throttling +- Early validation reduces unnecessary API calls +- Polygon deduplication in find_recent_deals_for_address() + +**Limitations:** +- Synchronous (blocking) API calls +- No caching (MVP decision) +- Sequential polygon queries + +### Future Optimizations (Not in MVP) + +**Phase 1: In-Memory Caching** +- Cache autocomplete results (1 hour TTL) +- Cache deal results (30 minute TTL) +- Reduce API load by 60-80% + +**Phase 2: Async/Parallel** +- Convert to async/await +- Parallel polygon queries +- 3-5x faster for multi-polygon searches + +**Phase 3: Production Caching** +- Redis for distributed caching +- Cache warming strategies +- Cross-instance consistency + +## Security & Rate Limiting + +### Rate Limiting + +**Current:** 5 requests/second (configurable) + +**Rationale:** +- Respects Govmap API (no published limits, being conservative) +- Prevents accidental DoS +- Balances responsiveness with safety + +**Implementation:** +- Tracks last request timestamp +- Sleeps if interval too short +- Per-instance (not distributed) + +### Input Validation + +All user inputs are validated before use: +- Address strings: length, type, non-empty +- Coordinates: numeric, reasonable bounds +- Integers: positive, max values +- Deal types: enum validation (1 or 2) + +**Prevents:** +- Injection attacks +- API errors from malformed requests +- Expensive/dangerous operations + +### API Key Management + +**Current:** No API keys required (public API) + +**Future:** If API keys added: +- Store in environment variables only +- Never log or expose in responses +- Rotate regularly + +## Testing Strategy + +### Unit Tests +- Mock API responses +- Test validation logic +- Test error handling +- Test retry logic + +### Integration Tests +- Real API calls (marked `@pytest.mark.integration`) +- VCR.py for recording/replaying +- Test complete workflows + +### Validation Tests +- Invalid inputs +- Edge cases +- Boundary conditions + +## Configuration Options + +### Environment Variables + +```bash +# API Settings +GOVMAP_BASE_URL=https://www.govmap.gov.il/api/ +GOVMAP_USER_AGENT=NadlanMCP/1.0.0 + +# Timeouts (seconds) +GOVMAP_CONNECT_TIMEOUT=10 +GOVMAP_READ_TIMEOUT=30 + +# Retry Settings +GOVMAP_MAX_RETRIES=3 +GOVMAP_RETRY_MIN_WAIT=1 +GOVMAP_RETRY_MAX_WAIT=10 + +# Rate Limiting +GOVMAP_REQUESTS_PER_SECOND=5.0 + +# Defaults +GOVMAP_DEFAULT_RADIUS=50 +GOVMAP_DEFAULT_YEARS_BACK=2 +GOVMAP_DEFAULT_DEAL_LIMIT=100 +``` + +### Tuning Guidelines + +**High Reliability (Production):** +- `MAX_RETRIES=5` +- `RETRY_MAX_WAIT=30` +- `REQUESTS_PER_SECOND=3.0` + +**High Performance (Development):** +- `MAX_RETRIES=2` +- `RETRY_MAX_WAIT=5` +- `REQUESTS_PER_SECOND=10.0` + +**Conservative (Shared API):** +- `MAX_RETRIES=3` +- `RETRY_MAX_WAIT=10` +- `REQUESTS_PER_SECOND=2.0` + +## Future Architecture Evolution + +### Phase 2: Data Models (Pydantic) + +Add structured models for type safety: +```python +class Deal(BaseModel): + deal_id: str + address: str + deal_amount: float + asset_area: float + price_per_sqm: float + deal_date: datetime + property_type: str + # ... +``` + +### Phase 3: Separation of Concerns + +``` +nadlan_mcp/ +โ”œโ”€โ”€ api_client.py # Pure API communication +โ”œโ”€โ”€ analyzers/ +โ”‚ โ”œโ”€โ”€ market.py # Market analysis logic +โ”‚ โ”œโ”€โ”€ valuation.py # Valuation helpers +โ”‚ โ””โ”€โ”€ filtering.py # Deal filtering +โ”œโ”€โ”€ models.py # Pydantic models +โ””โ”€โ”€ fastmcp_server.py # Thin MCP tool definitions +``` + +### Phase 4: Database Layer (Optional) + +For historical tracking and faster queries: +``` +โ”œโ”€โ”€ database/ +โ”‚ โ”œโ”€โ”€ models.py # SQLAlchemy models +โ”‚ โ”œโ”€โ”€ crud.py # CRUD operations +โ”‚ โ””โ”€โ”€ migrations/ # Alembic migrations +``` + +## Deployment + +### Development +```bash +python run_fastmcp_server.py +``` + +### Production (MCP Client) +```json +{ + "servers": { + "nadlan-mcp": { + "command": "python", + "args": ["/path/to/nadlan-mcp/run_fastmcp_server.py"], + "env": { + "GOVMAP_REQUESTS_PER_SECOND": "3.0", + "GOVMAP_MAX_RETRIES": "5" + } + } + } +} +``` + +## API Limitations + +### Govmap API Constraints + +**Known Limitations:** +- No published rate limits (being conservative with 5 req/sec) +- No authentication required (public API) +- Data freshness: Updated periodically (not real-time) +- Coverage: Official Israeli government records only + +**Best Practices:** +- Use appropriate time ranges (2-5 years recommended) +- Limit radius searches (under 1000m for performance) +- Cache results when possible (future feature) +- Respect rate limiting + +### MCP Protocol Constraints + +**Token Limits:** +- Large deal lists can exceed LLM context windows +- Use `summarized_response=True` for large datasets +- Default limits (50-100 deals) are token-optimized + +## Monitoring & Debugging + +### Logging Levels + +**INFO:** Normal operations +- Requests being made +- Successful responses + +**WARNING:** Recoverable issues +- Retrying after failures +- Rate limiting delays +- Validation warnings + +**ERROR:** Failures +- Exhausted retries +- Invalid responses +- Configuration errors + +### Debug Mode + +Enable debug logging: +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +## References + +- [Govmap API](https://www.govmap.gov.il/) +- [MCP Protocol](https://modelcontextprotocol.io/) +- [FastMCP Documentation](https://github.com/jlowin/fastmcp) +- [Israeli Real Estate Data](https://data.gov.il/) + From e0dacbc5b9dcdb1f2aef761ece5b6778e4cf6ab0 Mon Sep 17 00:00:00 2001 From: Nitzan Pomerantz <9297302+nitzpo@users.noreply.github.com> Date: Sun, 19 Oct 2025 00:58:20 +0300 Subject: [PATCH 3/8] Fix requirements --- requirements-dev.txt | 19 +++++++++++++++++++ requirements.txt | 13 +++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7edc20b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,19 @@ +# Development dependencies +-r requirements.txt + +# Testing +pytest>=7.0.0,<8.0.0 +pytest-cov>=4.0.0,<5.0.0 +pytest-mock>=3.10.0,<4.0.0 +vcrpy>=4.2.0,<5.0.0 + +# Code quality +black>=23.0.0,<24.0.0 +isort>=5.12.0,<6.0.0 +flake8>=6.0.0,<7.0.0 +mypy>=1.0.0,<2.0.0 +pre-commit>=3.0.0,<4.0.0 + +# Type stubs +types-requests>=2.31.0,<3.0.0 + diff --git a/requirements.txt b/requirements.txt index 8fc7013..c5e20ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -requests>=2.31.0 -python-dotenv>=1.0.0 -pytest>=7.0.0 -mcp>=1.0.0 -fastmcp>=0.1.0 -types-requests>=2.31.0 +requests>=2.31.0,<3.0.0 +python-dotenv>=1.0.0,<2.0.0 +pytest>=7.0.0,<8.0.0 +mcp>=1.0.0,<2.0.0 +fastmcp>=0.1.0,<1.0.0 +types-requests>=2.31.0,<3.0.0 +pydantic>=2.0.0,<3.0.0 From b844760147790991aa6da60889d118f7cbc24091 Mon Sep 17 00:00:00 2001 From: Nitzan Pomerantz <9297302+nitzpo@users.noreply.github.com> Date: Sun, 19 Oct 2025 00:58:27 +0300 Subject: [PATCH 4/8] Remove old file --- mcp_server_concept.py | 311 ------------------------------------------ 1 file changed, 311 deletions(-) delete mode 100644 mcp_server_concept.py diff --git a/mcp_server_concept.py b/mcp_server_concept.py deleted file mode 100644 index 1ec9f3a..0000000 --- a/mcp_server_concept.py +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple Israel Real Estate MCP Server - -A simplified MCP server that provides access to Israeli real estate data. -Focuses on the main use cases for AI agents. -""" - -import asyncio -import json -import logging -from typing import Any, Dict, List - -# Simple MCP server implementation -class SimpleMCPServer: - def __init__(self): - from nadlan_mcp.govmap import GovmapClient - self.client = GovmapClient() - self.tools = { - "find_recent_deals_for_address": { - "description": "๐Ÿ  Find all recent real estate deals for a given address. Main function for comprehensive market analysis.", - "parameters": { - "address": "Full address to search for (Hebrew or English)", - "years_back": "How many years back to search (default: 2)" - } - }, - "analyze_market_trends": { - "description": "๐Ÿ“Š Analyze market trends for a specific address with price analysis and insights", - "parameters": { - "address": "Address to analyze", - "years_back": "Years of data to analyze (default: 3)" - } - }, - "compare_neighborhoods": { - "description": "๐Ÿ˜๏ธ Compare real estate market data between multiple addresses", - "parameters": { - "addresses": "List of addresses to compare (2-5 addresses)", - "years_back": "Years of data to compare (default: 2)" - } - }, - "autocomplete_address": { - "description": "๐Ÿ” Search for Israeli addresses using autocomplete", - "parameters": { - "search_text": "Address to search for" - } - } - } - - def list_tools(self) -> str: - """List available tools.""" - tools_list = ["๐Ÿ“‹ Available Real Estate Tools:"] - for name, info in self.tools.items(): - tools_list.append(f"\n๐Ÿ”ง {name}") - tools_list.append(f" {info['description']}") - tools_list.append(" Parameters:") - for param, desc in info['parameters'].items(): - tools_list.append(f" โ€ข {param}: {desc}") - - return "\n".join(tools_list) - - def call_tool(self, tool_name: str, **kwargs) -> str: - """Call a specific tool.""" - try: - if tool_name == "find_recent_deals_for_address": - return self._find_recent_deals(**kwargs) - elif tool_name == "analyze_market_trends": - return self._analyze_trends(**kwargs) - elif tool_name == "compare_neighborhoods": - return self._compare_neighborhoods(**kwargs) - elif tool_name == "autocomplete_address": - return self._autocomplete_address(**kwargs) - else: - return f"โŒ Unknown tool: {tool_name}" - except Exception as e: - return f"โŒ Error in {tool_name}: {str(e)}" - - def _find_recent_deals(self, address: str, years_back: int = 2) -> str: - """Find recent deals for an address.""" - deals = self.client.find_recent_deals_for_address(address, years_back) - - if not deals: - return f"No recent deals found for: {address}" - - # Calculate statistics - amounts = [deal.get('dealAmount') for deal in deals if isinstance(deal.get('dealAmount'), (int, float))] - areas = [deal.get('assetArea') for deal in deals if isinstance(deal.get('assetArea'), (int, float))] - - result = [f"๐Ÿ  REAL ESTATE ANALYSIS FOR: {address}"] - result.append(f"๐Ÿ“Š Total deals found: {len(deals)}") - - if amounts: - avg_price = sum(amounts) / len(amounts) - result.append(f"๐Ÿ’ฐ Average price: {avg_price:,.0f} NIS") - result.append(f"๐Ÿ“ˆ Price range: {min(amounts):,} - {max(amounts):,} NIS") - - if areas: - avg_area = sum(areas) / len(areas) - result.append(f"๐Ÿ“ Average area: {avg_area:.0f} mยฒ") - - result.append(f"\n๐Ÿก Recent deals (last {years_back} years):") - - # Show first 10 deals - for i, deal in enumerate(deals[:10], 1): - price = deal.get('dealAmount', 'N/A') - area = deal.get('assetArea', 'N/A') - date = deal.get('dealDate', 'N/A')[:10] if deal.get('dealDate') else 'N/A' - prop_type = deal.get('propertyTypeDescription', 'N/A') - neighborhood = deal.get('neighborhood', 'N/A') - - if isinstance(price, (int, float)): - result.append(f"{i}. {date} | {price:,} NIS | {area} mยฒ | {prop_type} | {neighborhood}") - else: - result.append(f"{i}. {date} | {price} | {area} mยฒ | {prop_type} | {neighborhood}") - - if len(deals) > 10: - result.append(f"\n... and {len(deals) - 10} more deals") - - return "\n".join(result) - - def _analyze_trends(self, address: str, years_back: int = 3) -> str: - """Analyze market trends.""" - deals = self.client.find_recent_deals_for_address(address, years_back) - - if not deals: - return f"No market data found for {address}" - - # Analyze trends by year - yearly_data = {} - property_types = {} - neighborhoods = set() - - for deal in deals: - date_str = deal.get('dealDate', '') - if date_str: - year = date_str[:4] - price = deal.get('dealAmount') - area = deal.get('assetArea') - prop_type = deal.get('propertyTypeDescription', 'Unknown') - neighborhood = deal.get('neighborhood') - - if neighborhood: - neighborhoods.add(neighborhood) - - if isinstance(price, (int, float)) and isinstance(area, (int, float)) and area > 0: - if year not in yearly_data: - yearly_data[year] = [] - yearly_data[year].append({ - 'price': price, - 'area': area, - 'price_per_sqm': price / area - }) - - property_types[prop_type] = property_types.get(prop_type, 0) + 1 - - # Generate analysis - analysis = [f"๐Ÿ“Š MARKET TRENDS ANALYSIS: {address}"] - analysis.append(f"๐Ÿ“… Analysis period: Last {years_back} years") - analysis.append(f"๐Ÿ˜๏ธ Neighborhoods: {', '.join(neighborhoods) if neighborhoods else 'N/A'}") - analysis.append(f"๐Ÿ  Property types: {', '.join([f'{k} ({v})' for k, v in property_types.items()])}") - - if yearly_data: - analysis.append(f"\n๐Ÿ“ˆ YEARLY TRENDS:") - - for year in sorted(yearly_data.keys(), reverse=True): - year_deals = yearly_data[year] - avg_price = sum(d['price'] for d in year_deals) / len(year_deals) - avg_area = sum(d['area'] for d in year_deals) / len(year_deals) - avg_price_per_sqm = sum(d['price_per_sqm'] for d in year_deals) / len(year_deals) - - analysis.append(f" {year}: {len(year_deals)} deals | Avg: {avg_price:,.0f} NIS | {avg_area:.0f} mยฒ | {avg_price_per_sqm:,.0f} NIS/mยฒ") - - # Price trend - years_sorted = sorted(yearly_data.keys()) - if len(years_sorted) >= 2: - first_year_avg = sum(d['price_per_sqm'] for d in yearly_data[years_sorted[0]]) / len(yearly_data[years_sorted[0]]) - last_year_avg = sum(d['price_per_sqm'] for d in yearly_data[years_sorted[-1]]) / len(yearly_data[years_sorted[-1]]) - - trend = ((last_year_avg - first_year_avg) / first_year_avg) * 100 - trend_direction = "๐Ÿ“ˆ Rising" if trend > 0 else "๐Ÿ“‰ Declining" if trend < 0 else "โžก๏ธ Stable" - analysis.append(f"\n๐ŸŽฏ Price Trend: {trend_direction} ({trend:+.1f}% over period)") - - return "\n".join(analysis) - - def _compare_neighborhoods(self, addresses: List[str], years_back: int = 2) -> str: - """Compare neighborhoods.""" - if len(addresses) < 2: - return "โŒ At least 2 addresses are required for comparison" - - comparison = [f"๐Ÿ˜๏ธ NEIGHBORHOOD COMPARISON"] - comparison.append(f"๐Ÿ“… Comparing last {years_back} years of data\n") - - address_data = {} - - for address in addresses: - deals = self.client.find_recent_deals_for_address(address, years_back) - - if deals: - amounts = [deal.get('dealAmount') for deal in deals if isinstance(deal.get('dealAmount'), (int, float))] - areas = [deal.get('assetArea') for deal in deals if isinstance(deal.get('assetArea'), (int, float))] - neighborhoods = {deal.get('neighborhood') for deal in deals if deal.get('neighborhood')} - - if amounts and areas: - price_per_sqm = [amounts[i] / areas[i] for i in range(min(len(amounts), len(areas))) if areas[i] > 0] - - address_data[address] = { - 'deals_count': len(deals), - 'avg_price': sum(amounts) / len(amounts), - 'avg_area': sum(areas) / len(areas), - 'avg_price_per_sqm': sum(price_per_sqm) / len(price_per_sqm) if price_per_sqm else 0, - 'neighborhoods': neighborhoods - } - - # Generate comparison - for address, data in address_data.items(): - comparison.append(f"๐Ÿ“ {address}:") - comparison.append(f" โ€ข {data['deals_count']} deals found") - comparison.append(f" โ€ข Avg price: {data['avg_price']:,.0f} NIS") - comparison.append(f" โ€ข Avg area: {data['avg_area']:.0f} mยฒ") - comparison.append(f" โ€ข Price per mยฒ: {data['avg_price_per_sqm']:,.0f} NIS") - comparison.append(f" โ€ข Neighborhoods: {', '.join(data['neighborhoods'])}") - comparison.append("") - - # Ranking - if address_data: - comparison.append("๐Ÿ† RANKINGS:") - by_price_per_sqm = sorted(address_data.items(), key=lambda x: x[1]['avg_price_per_sqm'], reverse=True) - - comparison.append("๐Ÿ’ฐ Most expensive (NIS/mยฒ):") - for i, (addr, data) in enumerate(by_price_per_sqm, 1): - comparison.append(f" {i}. {addr}: {data['avg_price_per_sqm']:,.0f} NIS/mยฒ") - - return "\n".join(comparison) - - def _autocomplete_address(self, search_text: str) -> str: - """Autocomplete address search.""" - result = self.client.autocomplete_address(search_text) - - formatted_results = [] - for item in result.get("results", []): - formatted_results.append(f"โ€ข {item.get('text')} (type: {item.get('type')}, score: {item.get('score')})") - - return f"Found {result.get('resultsCount', 0)} address matches:\n" + "\n".join(formatted_results[:5]) - - -def main(): - """Interactive demo of the MCP server functionality.""" - print("๐Ÿ  Israel Real Estate MCP Server - Demo Mode") - print("=" * 50) - - server = SimpleMCPServer() - - while True: - print("\n" + "=" * 50) - print("๐Ÿ”ง Available commands:") - print("1. list - List all available tools") - print("2. find - Find recent deals for address") - print("3. trends - Analyze market trends") - print("4. compare - Compare neighborhoods") - print("5. search - Search for addresses") - print("6. quit - Exit") - - choice = input("\nSelect a command (1-6): ").strip() - - if choice == "1": - print(server.list_tools()) - - elif choice == "2": - address = input("Enter address: ").strip() - years = input("Years back (default 2): ").strip() - years_back = int(years) if years.isdigit() else 2 - - print("\n๐Ÿ” Searching for deals...") - result = server.call_tool("find_recent_deals_for_address", address=address, years_back=years_back) - print(result) - - elif choice == "3": - address = input("Enter address: ").strip() - years = input("Years back (default 3): ").strip() - years_back = int(years) if years.isdigit() else 3 - - print("\n๐Ÿ“Š Analyzing trends...") - result = server.call_tool("analyze_market_trends", address=address, years_back=years_back) - print(result) - - elif choice == "4": - addresses_input = input("Enter addresses (comma separated): ").strip() - addresses = [addr.strip() for addr in addresses_input.split(",")] - years = input("Years back (default 2): ").strip() - years_back = int(years) if years.isdigit() else 2 - - print("\n๐Ÿ˜๏ธ Comparing neighborhoods...") - result = server.call_tool("compare_neighborhoods", addresses=addresses, years_back=years_back) - print(result) - - elif choice == "5": - search_text = input("Enter search text: ").strip() - - print("\n๐Ÿ” Searching addresses...") - result = server.call_tool("autocomplete_address", search_text=search_text) - print(result) - - elif choice == "6": - print("๐Ÿ‘‹ Goodbye!") - break - - else: - print("โŒ Invalid choice. Please select 1-6.") - - -if __name__ == "__main__": - main() \ No newline at end of file From 85f52a81082032f3cfad66460f6c813fde42afd5 Mon Sep 17 00:00:00 2001 From: Nitzan Pomerantz <9297302+nitzpo@users.noreply.github.com> Date: Sun, 19 Oct 2025 00:58:46 +0300 Subject: [PATCH 5/8] Main updates of Phase 1 --- TASKS.md | 242 +++++++++++++ USECASES.md | 129 +++++-- nadlan_mcp/config.py | 131 ++++++++ nadlan_mcp/fastmcp_server.py | 148 +++++++- nadlan_mcp/govmap.py | 635 ++++++++++++++++++++++++++++++----- 5 files changed, 1169 insertions(+), 116 deletions(-) create mode 100644 TASKS.md create mode 100644 nadlan_mcp/config.py diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..d41121c --- /dev/null +++ b/TASKS.md @@ -0,0 +1,242 @@ +# Nadlan-MCP Implementation Tasks + +This document tracks the implementation progress of the Nadlan-MCP improvement plan. + +## โœ… Completed Tasks + +### Phase 1: Code Quality & Reliability +- โœ… Created configuration management system (`config.py`) +- โœ… Added retry logic with exponential backoff +- โœ… Implemented rate limiting protection +- โœ… Standardized error handling (raise exceptions, not return empty lists) +- โœ… Added comprehensive input validation +- โœ… Updated requirements.txt with pinned versions +- โœ… Created requirements-dev.txt for development dependencies + +### Documentation +- โœ… Updated USECASES.md with status indicators and roadmap +- โœ… Created ARCHITECTURE.md with system design documentation +- โœ… Marked amenity scoring as future feature with clear roadmap + +### Cleanup +- โœ… Deleted redundant mcp_server_concept.py file + +## ๐Ÿšง In Progress + +### Phase 2: Missing Core Functionality + +#### 2.1 Property Valuation Data Provision +- [ ] Create `get_comparable_properties()` function with flexible filtering +- [ ] Create `get_deal_statistics()` helper for statistical aggregations +- [ ] Add MCP tool: `get_valuation_comparables` +- [ ] Add MCP tool: `get_deal_statistics` +- [ ] Update documentation with valuation tool usage + +#### 2.2 Market Activity & Investment Analysis +- [ ] Create `market_analysis.py` module +- [ ] Implement `calculate_market_activity_score()` +- [ ] Implement `analyze_investment_potential()` +- [ ] Implement `get_market_liquidity()` +- [ ] Add MCP tools for market analysis +- [ ] Update documentation + +#### 2.3 Enhanced Deal Filtering & Search +- [ ] Implement `filter_deals_by_criteria()` in govmap.py +- [ ] Add property type filtering +- [ ] Add room count filtering +- [ ] Add price range filtering +- [ ] Add area range filtering +- [ ] Add floor range filtering +- [ ] Update existing functions to support new filters +- [ ] Add tests for filtering logic + +## ๐Ÿ“‹ To-Do (Next Priority) + +### Phase 3: Architecture Improvements + +#### 3.1 Data Models +- [ ] Create `models.py` with Pydantic models + - [ ] Deal model + - [ ] Address model + - [ ] MarketAnalysis model + - [ ] PropertyValuation model + - [ ] Filter models (DealFilters, etc.) +- [ ] Update functions to use models +- [ ] Add model validation tests + +#### 3.2 Separation of Concerns +- [ ] Refactor fastmcp_server.py: + - [ ] Move analysis logic to dedicated modules + - [ ] Keep only MCP tool definitions in fastmcp_server.py +- [ ] Create `api_client.py` for pure API interactions +- [ ] Create `analyzers/` package: + - [ ] `analyzers/market.py` - Market analysis functions + - [ ] `analyzers/filtering.py` - Deal filtering logic + - [ ] `analyzers/valuation.py` - Valuation helpers +- [ ] Update imports and dependencies +- [ ] Update tests + +#### 3.3 LLM-Friendly Tool Design +- [ ] Add `summarized_response: bool = False` parameter to all tools +- [ ] Implement summarization logic for each tool +- [ ] Update tool docstrings with parameter descriptions +- [ ] Test both modes (structured and summarized) +- [ ] Update documentation with examples + +### Phase 4: Testing & Quality + +#### 4.1 Expand Test Coverage +- [ ] Add integration tests (with @pytest.mark.integration) +- [ ] Add edge case tests for all functions +- [ ] Add parametrized tests for address formats +- [ ] Add tests for new valuation tools +- [ ] Add tests for market analysis tools +- [ ] Add tests for enhanced filtering +- [ ] Add tests for `analyze_market_trends` +- [ ] Add tests for `compare_addresses` +- [ ] Add tests for `_is_same_building` logic + +#### 4.2 Validation Tests +- [ ] Create `tests/test_validation.py` +- [ ] Test address validation +- [ ] Test coordinate validation +- [ ] Test integer validation +- [ ] Test configuration validation +- [ ] Test model validation (Pydantic) + +#### 4.3 Mock External APIs +- [ ] Update `tests/conftest.py` with comprehensive fixtures +- [ ] Add VCR.py for recording/replaying API calls +- [ ] Create fixture for deal responses +- [ ] Create fixture for autocomplete responses +- [ ] Create fixture for error scenarios + +### Phase 5: Documentation + +#### 5.1 Additional Documentation Files +- [ ] Create `DEPLOYMENT.md` - Deployment guide +- [ ] Create `CONTRIBUTING.md` - Contribution guidelines +- [ ] Create `API_REFERENCE.md` - Detailed API docs +- [ ] Create `CLAUDE.md` - Instructions for AI coding agents +- [ ] Create `docs/` directory for additional docs + +#### 5.2 Code Documentation +- [ ] Add module-level docstrings to all Python files +- [ ] Enhance function docstrings with examples +- [ ] Add type hints to remaining functions +- [ ] Add inline comments for complex logic +- [ ] Review and improve existing documentation + +#### 5.3 Usage Examples +- [ ] Create `examples/` directory +- [ ] Create `examples/basic_search.py` +- [ ] Create `examples/market_analysis.py` +- [ ] Create `examples/investment_analysis.py` +- [ ] Create `examples/llm_integration.py` +- [ ] Add README in examples/ directory + +#### 5.4 README Updates +- [ ] Update README.md with current feature list +- [ ] Add configuration documentation +- [ ] Add troubleshooting section +- [ ] Add API limitations section +- [ ] Add examples from examples/ directory + +### Phase 6: Code Quality & Polish + +#### 6.2 Code Style & Linting +- [ ] Create `.pre-commit-config.yaml` +- [ ] Setup black formatter +- [ ] Setup isort for imports +- [ ] Setup flake8 linter +- [ ] Setup mypy for type checking +- [ ] Format all code with black +- [ ] Sort all imports with isort +- [ ] Fix all flake8 warnings +- [ ] Fix all mypy errors +- [ ] Add pre-commit hooks to CI + +#### 6.3 Remaining Cleanup +- [ ] Remove any remaining unused imports +- [ ] Consolidate duplicate code +- [ ] Refactor long functions (>100 lines) +- [ ] Improve naming consistency + +## ๐Ÿ”ฎ Future Features (Backlog) + +### Phase 7.1: Amenity Scoring +- [ ] Research Google Places API integration +- [ ] Research OpenStreetMap integration +- [ ] Research Ministry of Education data sources +- [ ] Research Ministry of Health data sources +- [ ] Design amenity scoring algorithm +- [ ] Implement `amenities.py` module +- [ ] Add amenity MCP tools +- [ ] Add amenity tests +- [ ] Document amenity scoring methodology + +### Phase 7.2: Caching System +- [ ] Design caching strategy +- [ ] Implement in-memory cache with TTL +- [ ] Add cache configuration options +- [ ] Add cache statistics/monitoring +- [ ] Test cache invalidation +- [ ] Document caching behavior +- [ ] (Later) Implement Redis integration +- [ ] (Later) Add cache warming + +### Phase 7.3: Performance Optimizations +- [ ] Research async/await patterns +- [ ] Convert to async HTTP with httpx +- [ ] Implement parallel polygon queries +- [ ] Add performance benchmarks +- [ ] Optimize token usage in responses +- [ ] (Later) Database integration design +- [ ] (Later) SQLite implementation +- [ ] (Later) PostgreSQL migration path + +### Phase 7.4: Multi-language Support +- [ ] Add English address support +- [ ] Add translation service integration +- [ ] Implement language detection +- [ ] Update documentation for multiple languages +- [ ] Add language selection parameter + +### Phase 7.5: Advanced Valuation Helper +- [ ] Design calculation algorithm +- [ ] Implement `calculate_valuation_from_comparables()` +- [ ] Add detailed breakdown in response +- [ ] Test calculation accuracy +- [ ] Document methodology + +## ๐Ÿ“Š Progress Summary + +**Overall Progress:** ~15% complete + +### By Phase: +- Phase 1 (Code Quality): โœ… 100% complete +- Phase 2 (Core Features): ๐Ÿšง 0% complete +- Phase 3 (Architecture): ๐Ÿ“‹ 0% started +- Phase 4 (Testing): ๐Ÿ“‹ 0% started +- Phase 5 (Documentation): ๐Ÿšง 20% complete (USECASES, ARCHITECTURE done) +- Phase 6 (Polish): ๐Ÿšง 33% complete (cleanup done, linting pending) +- Phase 7 (Future): ๐Ÿ“‹ Backlog + +### High Priority (MVP) Status: +- โœ… Phase 1: Code Quality & Reliability - COMPLETE +- ๐Ÿšง Phase 2: Missing Core Functionality - IN PROGRESS (next focus) +- ๐Ÿ“‹ Phase 6.1: Cleanup - COMPLETE + +## ๐ŸŽฏ Current Sprint Focus + +1. **Implement valuation data provision tools** (Phase 2.1) +2. **Implement market analysis tools** (Phase 2.2) +3. **Implement enhanced filtering** (Phase 2.3) + +## Notes + +- All configuration is now externalized and documented +- Error handling is robust with retry logic +- Documentation is aligned with actual implementation +- Ready to add new features on solid foundation + diff --git a/USECASES.md b/USECASES.md index 2356dc2..b519257 100644 --- a/USECASES.md +++ b/USECASES.md @@ -2,54 +2,81 @@ The nadlan-mcp MCP server provides comprehensive Israeli real estate data analysis capabilities. Here's what you can do with it: -## ๐Ÿ  **Address & Location Services** +**Legend:** +- โœ… **Implemented**: Feature is fully available +- ๐Ÿšง **In Progress**: Feature is currently being developed +- ๐Ÿ“‹ **Planned**: Feature is planned for future release + +## ๐Ÿ  **Address & Location Services** โœ… + - **Address Autocomplete**: Search and autocomplete Israeli addresses (supports both Hebrew and English) - **Location-based Deals**: Find real estate deals within a specific radius of any coordinates -## ๐Ÿ“Š **Real Estate Deal Analysis** +## ๐Ÿ“Š **Real Estate Deal Analysis** โœ… + - **Recent Deals by Address**: Find recent real estate transactions for any specific address - **Street-level Analysis**: Get all recent deals for an entire street/area - **Neighborhood Analysis**: Analyze deals across entire neighborhoods +- **Deal Filtering**: Filter deals by property type, room count, price range, area, and floor (๐Ÿšง enhanced filtering in progress) + +## ๐Ÿ“ˆ **Market Intelligence** โœ… -## ๐Ÿ“ˆ **Market Intelligence** - **Market Trends Analysis**: Analyze price patterns and market trends over time for any area - **Price per Square Meter Trends**: Track how property values change over time -- **Market Activity Levels**: See how active the real estate market is in different areas +- **Market Activity Levels**: See how active the real estate market is in different areas (๐Ÿšง enhanced metrics in progress) + +## ๐Ÿ” **Comparative Analysis** โœ… -## ๐Ÿ” **Comparative Analysis** - **Multi-Address Comparison**: Compare real estate markets between multiple addresses side-by-side -- **Investment Analysis**: Compare different areas for investment potential +- **Investment Analysis**: Compare different areas for investment potential (๐Ÿšง advanced metrics in progress) -## ๐Ÿ† **Amenity Scoring & Quality of Life Analysis** -- **Address Amenity Rating**: Score individual addresses based on proximity to essential amenities +## ๐Ÿ† **Amenity Scoring & Quality of Life Analysis** ๐Ÿ“‹ + +**Status: Planned for Future Release** + +This comprehensive feature will provide amenity-based location scoring using multiple data sources: + +- **Address Amenity Rating**: Score individual addresses based on proximity AND quality of essential amenities - **Street-level Amenity Analysis**: Rate entire streets by their access to schools, parks, healthcare, and other services -- **Neighborhood Quality Scoring**: Comprehensive neighborhood ratings based on amenity density and accessibility +- **Neighborhood Quality Scoring**: Comprehensive neighborhood ratings based on amenity density, accessibility, and quality - **Lifestyle Compatibility**: Match addresses to lifestyle preferences based on nearby amenities +**Data Sources (Planned):** +- Google Places API / OpenStreetMap - amenity locations +- Ministry of Education - school rankings and quality metrics +- Ministry of Health - healthcare facility ratings +- CBS (Central Bureau of Statistics) - demographic data +- Public transport APIs - station locations and service frequency + +**Note:** This feature will combine proximity with quality metrics (not just distance) to provide meaningful amenity scores. + ## ๐Ÿ’ก **Practical Use Cases** -### Property Valuation +### Property Valuation โœ… - Research recent sales to estimate property values - Understand pricing trends in specific neighborhoods - Compare similar properties in the area +- **Note:** The MCP provides comprehensive data; the LLM performs the valuation analysis -### Investment Research +### Investment Research โœ… - Identify trending neighborhoods and price patterns -- Analyze market activity levels across different areas +- Analyze market activity levels across different areas (๐Ÿšง enhanced metrics in progress) - Track price per square meter trends over time -### Market Analysis +### Market Analysis โœ… - Understand local real estate dynamics - Monitor market trends and patterns - Identify emerging or declining areas -### Due Diligence +### Due Diligence โœ… - Research an area before buying/selling property - Understand recent transaction history - Compare multiple potential locations -### Amenity-Based Location Selection -- Score addresses based on proximity to schools, parks, healthcare facilities +### Amenity-Based Location Selection ๐Ÿ“‹ +**Planned for Future Release** + +- Score addresses based on proximity AND quality of schools, parks, healthcare facilities - Find family-friendly neighborhoods with high amenity scores - Identify areas with the best access to public transportation and services - Compare lifestyle compatibility across different locations @@ -63,13 +90,15 @@ You can ask questions like: - "Find all recent sales on [street name]" - "Analyze market trends around [specific address]" - "What's the average price per square meter in [area]?" -- "Rate this address based on nearby amenities like schools and parks" -- "Which street has better access to healthcare and education facilities?" -- "Score this neighborhood for family-friendliness based on amenities" -- "Compare amenity scores between these two addresses" +- "Rate this address based on nearby amenities like schools and parks" *(๐Ÿ“‹ Planned)* +- "Which street has better access to healthcare and education facilities?" *(๐Ÿ“‹ Planned)* +- "Score this neighborhood for family-friendliness based on amenities" *(๐Ÿ“‹ Planned)* +- "Compare amenity scores between these two addresses" *(๐Ÿ“‹ Planned)* ## ๐Ÿ“‹ **Available Functions** +### โœ… Implemented + - `autocomplete_address` - Search and autocomplete Israeli addresses - `get_deals_by_radius` - Get deals within a radius of coordinates - `get_street_deals` - Get deals for a specific street polygon @@ -78,16 +107,64 @@ You can ask questions like: - `analyze_market_trends` - Analyze market trends and price patterns - `compare_addresses` - Compare real estate markets between multiple addresses +### ๐Ÿšง In Progress + +- `get_valuation_comparables` - Get comparable properties for valuation analysis +- `get_deal_statistics` - Calculate statistical aggregations on deal data +- `get_market_activity_metrics` - Detailed market activity and velocity metrics +- Enhanced filtering for all deal functions (property type, rooms, price range, area, floor) + +### ๐Ÿ“‹ Planned (Future Release) + +- `get_address_amenity_rating` - Comprehensive amenity analysis with quality metrics +- `compare_addresses_by_amenities` - Side-by-side amenity comparison +- `find_amenities_near_address` - Raw amenity list with quality data + ## ๐ŸŽฏ **Data Coverage** The data covers recent Israeli real estate transactions and can help with: -- Property research and valuation -- Investment analysis and decision-making -- Market understanding and trends -- Comparative analysis across locations -- Due diligence for real estate decisions -- Amenity-based location scoring and quality of life analysis +- Property research and valuation โœ… +- Investment analysis and decision-making โœ… (๐Ÿšง advanced metrics in progress) +- Market understanding and trends โœ… +- Comparative analysis across locations โœ… +- Due diligence for real estate decisions โœ… +- Amenity-based location scoring and quality of life analysis ๐Ÿ“‹ (planned for future release) ## ๐Ÿš€ **Getting Started** Simply connect to the nadlan-mcp server through your MCP client (like Cursor) and start asking questions about Israeli real estate data. The server supports both Hebrew and English addresses and provides comprehensive market analysis capabilities. + +## ๐Ÿ”„ **Roadmap & Timeline** + +### Phase 1: Core Reliability & Quality (Current) +- โœ… Configuration management system +- โœ… Retry logic with exponential backoff +- โœ… Rate limiting protection +- โœ… Input validation and error handling +- ๐Ÿšง Enhanced deal filtering +- ๐Ÿšง Valuation data provision tools +- ๐Ÿšง Market activity metrics + +### Phase 2: Architecture Improvements (Next) +- Data models with Pydantic +- Separation of concerns (API client, analyzers, tools) +- LLM-friendly tool design with summarized_response parameter +- Comprehensive testing suite + +### Phase 3: Documentation & Developer Experience +- Architecture documentation +- API reference guide +- Usage examples +- Contributing guidelines + +### Phase 4: Future Features +- Amenity scoring with quality metrics +- In-memory caching +- Async/parallel processing +- Production-ready caching (Redis) +- Multi-language support +- Database integration + +## ๐Ÿ“ž **Support & Contributions** + +For issues, questions, or contributions, please create an issue in the repository. The project follows semantic versioning and maintains backward compatibility. diff --git a/nadlan_mcp/config.py b/nadlan_mcp/config.py new file mode 100644 index 0000000..ad0465f --- /dev/null +++ b/nadlan_mcp/config.py @@ -0,0 +1,131 @@ +""" +Configuration management for Nadlan MCP. + +This module provides centralized configuration for API clients, timeouts, +rate limiting, and other settings. Configuration can be set via environment +variables or code. +""" + +import os +from typing import Optional +from dataclasses import dataclass, field + + +@dataclass +class GovmapConfig: + """Configuration for Govmap API client.""" + + # API settings + base_url: str = field( + default_factory=lambda: os.getenv( + "GOVMAP_BASE_URL", + "https://www.govmap.gov.il/api/" + ) + ) + + # Timeout settings (in seconds) + connect_timeout: int = field( + default_factory=lambda: int(os.getenv("GOVMAP_CONNECT_TIMEOUT", "10")) + ) + read_timeout: int = field( + default_factory=lambda: int(os.getenv("GOVMAP_READ_TIMEOUT", "30")) + ) + + # Retry settings + max_retries: int = field( + default_factory=lambda: int(os.getenv("GOVMAP_MAX_RETRIES", "3")) + ) + retry_min_wait: int = field( + default_factory=lambda: int(os.getenv("GOVMAP_RETRY_MIN_WAIT", "1")) + ) + retry_max_wait: int = field( + default_factory=lambda: int(os.getenv("GOVMAP_RETRY_MAX_WAIT", "10")) + ) + + # Rate limiting + requests_per_second: float = field( + default_factory=lambda: float(os.getenv("GOVMAP_REQUESTS_PER_SECOND", "5.0")) + ) + + # Default search parameters + default_radius_meters: int = field( + default_factory=lambda: int(os.getenv("GOVMAP_DEFAULT_RADIUS", "50")) + ) + default_years_back: int = field( + default_factory=lambda: int(os.getenv("GOVMAP_DEFAULT_YEARS_BACK", "2")) + ) + default_deal_limit: int = field( + default_factory=lambda: int(os.getenv("GOVMAP_DEFAULT_DEAL_LIMIT", "100")) + ) + + # User agent + user_agent: str = field( + default_factory=lambda: os.getenv( + "GOVMAP_USER_AGENT", + "NadlanMCP/1.0.0" + ) + ) + + def __post_init__(self): + """Validate configuration after initialization.""" + self._validate() + + def _validate(self): + """Validate configuration values.""" + if self.connect_timeout <= 0: + raise ValueError("connect_timeout must be positive") + if self.read_timeout <= 0: + raise ValueError("read_timeout must be positive") + if self.max_retries < 0: + raise ValueError("max_retries must be non-negative") + if self.retry_min_wait <= 0: + raise ValueError("retry_min_wait must be positive") + if self.retry_max_wait < self.retry_min_wait: + raise ValueError("retry_max_wait must be >= retry_min_wait") + if self.requests_per_second <= 0: + raise ValueError("requests_per_second must be positive") + if self.default_radius_meters <= 0: + raise ValueError("default_radius_meters must be positive") + if self.default_years_back <= 0: + raise ValueError("default_years_back must be positive") + if self.default_deal_limit <= 0: + raise ValueError("default_deal_limit must be positive") + if not self.base_url: + raise ValueError("base_url cannot be empty") + if not self.user_agent: + raise ValueError("user_agent cannot be empty") + + +# Global configuration instance +_config: Optional[GovmapConfig] = None + + +def get_config() -> GovmapConfig: + """ + Get the global configuration instance. + + Returns: + GovmapConfig: The global configuration object + """ + global _config + if _config is None: + _config = GovmapConfig() + return _config + + +def set_config(config: GovmapConfig): + """ + Set the global configuration instance. + + Args: + config: The new configuration object + """ + global _config + _config = config + + +def reset_config(): + """Reset the global configuration to default values.""" + global _config + _config = None + diff --git a/nadlan_mcp/fastmcp_server.py b/nadlan_mcp/fastmcp_server.py index b809aea..ba1d9d1 100644 --- a/nadlan_mcp/fastmcp_server.py +++ b/nadlan_mcp/fastmcp_server.py @@ -8,7 +8,7 @@ using the FastMCP library with simplified, working functions. import json import logging -from typing import List, Dict +from typing import List, Dict, Optional from mcp.server.fastmcp import FastMCP from nadlan_mcp.govmap import GovmapClient @@ -532,6 +532,152 @@ def compare_addresses(addresses: List[str]) -> str: logger.error(f"Error in compare_addresses: {e}") return f"Error comparing addresses: {str(e)}" +@mcp.tool() +def get_valuation_comparables( + address: str, + years_back: int = 2, + 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 +) -> str: + """Get comparable properties for valuation analysis. + + This tool provides detailed comparable deals filtered by your criteria. + The LLM can then analyze these comparables and estimate property values. + + Args: + address: The address to find comparables for (in Hebrew or English) + years_back: How many years back to search (default: 2) + property_type: Filter by property type (e.g., "ื“ื™ืจื”", "ื‘ื™ืช", "ืคื ื˜ื”ืื•ื–") + min_rooms: Minimum number of rooms + max_rooms: Maximum number of rooms + min_price: Minimum deal amount (NIS) + max_price: Maximum deal amount (NIS) + 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: + JSON string containing filtered comparable deals with full details + """ + try: + # Get all deals for the address + deals = client.find_recent_deals_for_address(address, years_back) + + if not deals: + return json.dumps({ + "address": address, + "years_back": years_back, + "comparables": [], + "message": "No deals found for this address" + }, ensure_ascii=False, indent=2) + + # Apply filters + filtered_deals = client.filter_deals_by_criteria( + 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 + ) + + # Calculate statistics on filtered comparables + stats = client.calculate_deal_statistics(filtered_deals) + + return json.dumps({ + "address": address, + "years_back": years_back, + "filters_applied": { + "property_type": property_type, + "rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None, + "price": f"{min_price}-{max_price}" if min_price or max_price else None, + "area": f"{min_area}-{max_area}" if min_area or max_area else None, + "floor": f"{min_floor}-{max_floor}" if min_floor or max_floor else None, + }, + "total_comparables": len(filtered_deals), + "statistics": stats, + "comparables": filtered_deals + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in get_valuation_comparables: {e}") + return f"Error getting valuation comparables: {str(e)}" + +@mcp.tool() +def get_deal_statistics( + address: str, + years_back: int = 2, + property_type: Optional[str] = None, + min_rooms: Optional[float] = None, + max_rooms: Optional[float] = None +) -> str: + """Calculate statistical aggregations on deal data for an address. + + This tool provides quick statistical summaries without returning all raw deals. + Useful when LLM needs calculations on large datasets without full details. + + Args: + address: The address to analyze (in Hebrew or English) + years_back: How many years back to analyze (default: 2) + property_type: Filter by property type (e.g., "ื“ื™ืจื”", "ื‘ื™ืช") + min_rooms: Minimum number of rooms + max_rooms: Maximum number of rooms + + Returns: + JSON string containing statistical metrics (mean, median, percentiles, etc.) + """ + try: + # Get all deals for the address + deals = client.find_recent_deals_for_address(address, years_back) + + if not deals: + return json.dumps({ + "address": address, + "years_back": years_back, + "statistics": { + "count": 0, + "message": "No deals found for this address" + } + }, ensure_ascii=False, indent=2) + + # Apply filters if provided + if property_type or min_rooms or max_rooms: + deals = client.filter_deals_by_criteria( + deals, + property_type=property_type, + min_rooms=min_rooms, + max_rooms=max_rooms + ) + + # Calculate statistics + stats = client.calculate_deal_statistics(deals) + + return json.dumps({ + "address": address, + "years_back": years_back, + "filters_applied": { + "property_type": property_type, + "rooms": f"{min_rooms}-{max_rooms}" if min_rooms or max_rooms else None, + }, + "statistics": stats + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in get_deal_statistics: {e}") + return f"Error calculating deal statistics: {str(e)}" + # Run the server if __name__ == "__main__": mcp.run() \ No newline at end of file diff --git a/nadlan_mcp/govmap.py b/nadlan_mcp/govmap.py index 7ca2d04..37c1853 100644 --- a/nadlan_mcp/govmap.py +++ b/nadlan_mcp/govmap.py @@ -1,10 +1,12 @@ import requests import logging - import json +import time from datetime import datetime, timedelta from typing import Any, Dict, List, Tuple, Optional +from nadlan_mcp.config import get_config, GovmapConfig + logger = logging.getLogger(__name__) @@ -13,22 +15,114 @@ 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. + 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, base_url: str = "https://www.govmap.gov.il/api/"): + def __init__(self, config: Optional[GovmapConfig] = None): """ Initialize the GovmapClient. Args: - base_url: The base URL for the Govmap API + config: Optional configuration object. If None, uses global config. """ - self.base_url = base_url.rstrip('/') + 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': 'NadlanMCP/1.0.0' + '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() + + def _validate_address(self, 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(self, point: Tuple[float, float]) -> Tuple[float, float]: + """ + Validate coordinate input. + + Args: + point: Tuple of (longitude, latitude) + + 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(self, 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 autocomplete_address(self, search_text: str) -> Dict[str, Any]: """ @@ -41,9 +135,10 @@ class GovmapClient: Dict containing the JSON response from the API with coordinates Raises: - requests.RequestException: If the API request fails - ValueError: If the response is invalid + 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 = { @@ -53,23 +148,35 @@ class GovmapClient: "maxResults": 10 } - try: - logger.info(f"Searching for address: {search_text}") - response = self.session.post(url, json=payload) - response.raise_for_status() + # 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") + data = response.json() + if not data or 'results' not in data: + raise ValueError("Invalid response format from autocomplete API") - return data - - except requests.RequestException as e: - logger.error(f"Error calling autocomplete API: {e}") - raise - except json.JSONDecodeError as e: - logger.error(f"Error parsing JSON response: {e}") - raise ValueError("Invalid JSON response from 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]: """ @@ -82,9 +189,10 @@ class GovmapClient: Dict containing the JSON response with block and parcel data Raises: - requests.RequestException: If the API request fails - ValueError: If the response is invalid + 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 = { @@ -93,20 +201,32 @@ class GovmapClient: "tolerance": 0 } - try: - logger.info(f"Getting Gush/Helka for point: {point}") - response = self.session.post(url, json=payload) - response.raise_for_status() + # 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 as e: - logger.error(f"Error calling entitiesByPoint API: {e}") - raise - except json.JSONDecodeError as e: - logger.error(f"Error parsing JSON response: {e}") - raise ValueError("Invalid JSON response from API") + 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]]: """ @@ -120,24 +240,41 @@ class GovmapClient: List of deals found within the radius Raises: - requests.RequestException: If the API request fails + 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}" - try: - logger.info(f"Getting deals by radius for point: {point}, radius: {radius}m") - response = self.session.get(url) - response.raise_for_status() + # 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() - return data if isinstance(data, list) else [] - - except requests.RequestException as e: - logger.error(f"Error calling deals by radius API: {e}") - raise - except json.JSONDecodeError as e: - logger.error(f"Error parsing JSON response: {e}") - return [] + 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, @@ -156,8 +293,19 @@ class GovmapClient: List of detailed deal information for the street Raises: - requests.RequestException: If the API request fails + 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) + if deal_type not in (1, 2): + raise ValueError("deal_type must be 1 (first hand/new) or 2 (second hand/used)") + url = f"{self.base_url}/real-estate/street-deals/{polygon_id}" params: Dict[str, Any] = {"limit": limit, "dealType": deal_type} @@ -166,23 +314,40 @@ class GovmapClient: if end_date: params["endDate"] = end_date - try: - logger.info(f"Getting street deals for polygon: {polygon_id}, dealType: {deal_type}") - response = self.session.get(url, params=params) - response.raise_for_status() + # 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: - return data['data'] if isinstance(data['data'], list) else [] - return data if isinstance(data, list) else [] - - except requests.RequestException as e: - logger.error(f"Error calling street deals API: {e}") - raise - except json.JSONDecodeError as e: - logger.error(f"Error parsing JSON response: {e}") - return [] + 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, @@ -201,8 +366,19 @@ class GovmapClient: List of deals in the neighborhood Raises: - requests.RequestException: If the API request fails + 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) + if deal_type not in (1, 2): + raise ValueError("deal_type must be 1 (first hand/new) or 2 (second hand/used)") + url = f"{self.base_url}/real-estate/neighborhood-deals/{polygon_id}" params: Dict[str, Any] = {"limit": limit, "dealType": deal_type} @@ -211,23 +387,40 @@ class GovmapClient: if end_date: params["endDate"] = end_date - try: - logger.info(f"Getting neighborhood deals for polygon: {polygon_id}, dealType: {deal_type}") - response = self.session.get(url, params=params) - response.raise_for_status() + # 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: - return data['data'] if isinstance(data['data'], list) else [] - return data if isinstance(data, list) else [] - - except requests.RequestException as e: - logger.error(f"Error calling neighborhood deals API: {e}") - raise - except json.JSONDecodeError as e: - logger.error(f"Error parsing JSON response: {e}") - return [] + 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 = 50, @@ -251,9 +444,17 @@ class GovmapClient: then street deals, then neighborhood deals Raises: - ValueError: If address cannot be found or processed - requests.RequestException: If API requests fail + 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) + if deal_type not in (1, 2): + raise ValueError("deal_type must be 1 (first hand/new) or 2 (second hand/used)") + try: # Step 1: Get coordinates for the address logger.info(f"Starting search for address: {address}, dealType: {deal_type}") @@ -438,4 +639,260 @@ class GovmapClient: if search_address in deal_address or deal_address in search_address: return True - return False \ No newline at end of file + return False + + 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. + + 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', '')) + if property_type.lower() not in deal_type.lower(): + continue + + # Room count filter + rooms = deal.get('assetRoomNum') + if rooms is not None: + 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): + pass # Skip deals with invalid room data + + # Price filter + price = deal.get('dealAmount') + if price is not None: + 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): + pass # Skip deals with invalid price data + + # Area filter + area = deal.get('assetArea') + if area is not None: + 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): + pass # Skip deals with invalid area data + + # Floor filter + floor_str = deal.get('floorNo', '') + if floor_str and isinstance(floor_str, str): + # Try to extract floor number (handles Hebrew floor descriptions) + floor_num = self._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 + + def _extract_floor_number(self, floor_str: str) -> Optional[int]: + """ + 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 + import re + numbers = re.findall(r'\d+', floor_str) + if numbers: + try: + return int(numbers[0]) + except ValueError: + pass + + return None + + def calculate_deal_statistics( + self, + 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], + "min": min(prices), + "max": max(prices), + "p25": sorted_prices[len(sorted_prices) // 4], + "p75": sorted_prices[(3 * len(sorted_prices)) // 4], + "std_dev": round(self._calculate_std_dev(prices), 2) if len(prices) > 1 else 0, + "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: + from collections import Counter + room_counts = Counter(rooms) + stats["room_distribution"] = dict(sorted(room_counts.items())) + + return stats + + def _calculate_std_dev(self, values: List[float]) -> float: + """Calculate standard deviation of a list of values.""" + 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 \ No newline at end of file From 7780d89bb42062c6482147128435bded4d887594 Mon Sep 17 00:00:00 2001 From: Nitzan Pomerantz <9297302+nitzpo@users.noreply.github.com> Date: Sun, 19 Oct 2025 01:02:36 +0300 Subject: [PATCH 6/8] Update tasks and plan --- .cursor/plans/PLAN.md | 48 +++++++++++++++++++++++--------------- TASKS.md | 54 +++++++++++++++++++++++++------------------ 2 files changed, 61 insertions(+), 41 deletions(-) diff --git a/.cursor/plans/PLAN.md b/.cursor/plans/PLAN.md index bd29cf4..1487775 100644 --- a/.cursor/plans/PLAN.md +++ b/.cursor/plans/PLAN.md @@ -450,23 +450,33 @@ Implement comprehensive amenity scoring using multiple data sources: - Always return structured data that LLMs can easily process - Use `summarized_response: bool = False` for optional summarization -### To-dos +## Implementation Tracking -- [ ] Add retry logic, rate limiting, and standardize error handling in govmap.py and fastmcp_server.py -- [ ] Create config.py with externalized configuration and environment variable support -- [ ] Update USECASES.md and README.md to reflect actual current state and future roadmap -- [ ] Create tools for providing comparable deals and statistical data for LLM valuation -- [ ] Create market activity scoring and investment analysis functions -- [ ] Add comprehensive deal filtering by property type, rooms, price, area, floor -- [ ] Create Pydantic models for Deal, Address, MarketAnalysis, PropertyValuation -- [ ] Separate concerns: create api_client.py, analyzers package, thin fastmcp_server.py -- [ ] Update all tools with summarized_response boolean parameter and consistent responses -- [ ] Add integration tests, edge cases, parametrized tests for all functionality -- [ ] Create test_validation.py with input validation and data model tests -- [ ] Create ARCHITECTURE.md, DEPLOYMENT.md, CONTRIBUTING.md, API_REFERENCE.md, CLAUDE.md -- [ ] Add module docstrings, comprehensive function docs, type hints, inline comments -- [ ] Create examples/ directory with scripts showing all major use cases -- [ ] Add status indicators, priority levels, timeline to USECASES.md -- [ ] Delete/integrate mcp_server_concept.py, remove unused imports, consolidate duplicates -- [ ] Setup pre-commit hooks, format all code, fix linter warnings, add mypy -- [ ] Update requirements.txt with new dependencies, create requirements-dev.txt, pin versions \ No newline at end of file +**For detailed task tracking and current progress, see [TASKS.md](../../TASKS.md)** + +### Completed (Phase 1) +- โœ… Configuration management system with environment variable support +- โœ… Retry logic with exponential backoff (manual implementation) +- โœ… Rate limiting protection +- โœ… Standardized error handling (raise exceptions) +- โœ… Comprehensive input validation +- โœ… Updated dependencies with version pinning +- โœ… Created requirements-dev.txt +- โœ… Updated USECASES.md with status indicators +- โœ… Created ARCHITECTURE.md +- โœ… Deleted redundant code files + +### Completed (Phase 2.1) +- โœ… Implemented `filter_deals_by_criteria()` with comprehensive filtering +- โœ… Implemented `calculate_deal_statistics()` with statistical aggregations +- โœ… Added `get_valuation_comparables` MCP tool +- โœ… Added `get_deal_statistics` MCP tool + +### In Progress +- ๐Ÿšง Phase 2.2: Market Activity & Investment Analysis +- ๐Ÿšง Phase 2.3: Enhanced Deal Filtering (partially complete) + +### Next Up +- Phase 3: Architecture Improvements (data models, separation of concerns) +- Phase 4: Testing Expansion +- Phase 5: Complete Documentation \ No newline at end of file diff --git a/TASKS.md b/TASKS.md index d41121c..5c58aad 100644 --- a/TASKS.md +++ b/TASKS.md @@ -21,17 +21,18 @@ This document tracks the implementation progress of the Nadlan-MCP improvement p ### Cleanup - โœ… Deleted redundant mcp_server_concept.py file +### Phase 2.1: Property Valuation Data Provision +- โœ… Created `filter_deals_by_criteria()` function with comprehensive filtering +- โœ… Created `calculate_deal_statistics()` helper for statistical aggregations +- โœ… Created `_extract_floor_number()` helper for Hebrew floor parsing +- โœ… Created `_calculate_std_dev()` for standard deviation +- โœ… Added MCP tool: `get_valuation_comparables` +- โœ… Added MCP tool: `get_deal_statistics` + ## ๐Ÿšง In Progress ### Phase 2: Missing Core Functionality -#### 2.1 Property Valuation Data Provision -- [ ] Create `get_comparable_properties()` function with flexible filtering -- [ ] Create `get_deal_statistics()` helper for statistical aggregations -- [ ] Add MCP tool: `get_valuation_comparables` -- [ ] Add MCP tool: `get_deal_statistics` -- [ ] Update documentation with valuation tool usage - #### 2.2 Market Activity & Investment Analysis - [ ] Create `market_analysis.py` module - [ ] Implement `calculate_market_activity_score()` @@ -41,13 +42,13 @@ This document tracks the implementation progress of the Nadlan-MCP improvement p - [ ] Update documentation #### 2.3 Enhanced Deal Filtering & Search -- [ ] Implement `filter_deals_by_criteria()` in govmap.py -- [ ] Add property type filtering -- [ ] Add room count filtering -- [ ] Add price range filtering -- [ ] Add area range filtering -- [ ] Add floor range filtering -- [ ] Update existing functions to support new filters +- โœ… Implement `filter_deals_by_criteria()` in govmap.py +- โœ… Add property type filtering +- โœ… Add room count filtering +- โœ… Add price range filtering +- โœ… Add area range filtering +- โœ… Add floor range filtering (with Hebrew floor name parsing) +- [ ] Update existing functions to support new filters (integration) - [ ] Add tests for filtering logic ## ๐Ÿ“‹ To-Do (Next Priority) @@ -211,27 +212,36 @@ This document tracks the implementation progress of the Nadlan-MCP improvement p ## ๐Ÿ“Š Progress Summary -**Overall Progress:** ~15% complete +**Overall Progress:** ~40% complete ### By Phase: - Phase 1 (Code Quality): โœ… 100% complete -- Phase 2 (Core Features): ๐Ÿšง 0% complete +- Phase 2.1 (Valuation Data): โœ… 100% complete +- Phase 2.2 (Market Analysis): ๐Ÿ“‹ 0% started +- Phase 2.3 (Enhanced Filtering): โœ… 85% complete (integration & tests pending) - Phase 3 (Architecture): ๐Ÿ“‹ 0% started - Phase 4 (Testing): ๐Ÿ“‹ 0% started -- Phase 5 (Documentation): ๐Ÿšง 20% complete (USECASES, ARCHITECTURE done) +- Phase 5 (Documentation): ๐Ÿšง 30% complete (USECASES, ARCHITECTURE, TASKS done) - Phase 6 (Polish): ๐Ÿšง 33% complete (cleanup done, linting pending) - Phase 7 (Future): ๐Ÿ“‹ Backlog ### High Priority (MVP) Status: - โœ… Phase 1: Code Quality & Reliability - COMPLETE -- ๐Ÿšง Phase 2: Missing Core Functionality - IN PROGRESS (next focus) -- ๐Ÿ“‹ Phase 6.1: Cleanup - COMPLETE +- โœ… Phase 2.1: Property Valuation Data Provision - COMPLETE +- ๐Ÿšง Phase 2.2: Market Analysis - NEXT PRIORITY +- ๐Ÿšง Phase 2.3: Enhanced Filtering - MOSTLY COMPLETE (tests pending) ## ๐ŸŽฏ Current Sprint Focus -1. **Implement valuation data provision tools** (Phase 2.1) -2. **Implement market analysis tools** (Phase 2.2) -3. **Implement enhanced filtering** (Phase 2.3) +1. โœ… **Implement valuation data provision tools** (Phase 2.1) - COMPLETE +2. **Implement market analysis tools** (Phase 2.2) - NEXT +3. โœ… **Implement enhanced filtering** (Phase 2.3) - MOSTLY COMPLETE + +## ๐ŸŽฏ Next Sprint + +1. **Implement market activity metrics** (Phase 2.2) +2. **Add tests for new filtering and valuation features** (Phase 4.1) +3. **Create additional documentation files** (Phase 5.1) ## Notes From 49bfc7b940279858ade45aa1addcf932d1bd71ee Mon Sep 17 00:00:00 2001 From: Nitzan Pomerantz <9297302+nitzpo@users.noreply.github.com> Date: Wed, 22 Oct 2025 23:59:07 +0300 Subject: [PATCH 7/8] CR Fixes and update markdownlint --- .markdownlint.json | 3 +- nadlan_mcp/govmap.py | 559 ++++++++++++++++++++++++++----------------- requirements.txt | 2 - 3 files changed, 337 insertions(+), 227 deletions(-) diff --git a/.markdownlint.json b/.markdownlint.json index a395bcd..7d22d56 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,4 +1,5 @@ { "MD032": false, - "MD022": false + "MD022": false, + "MD013": false } \ No newline at end of file diff --git a/nadlan_mcp/govmap.py b/nadlan_mcp/govmap.py index 37c1853..c8ffc41 100644 --- a/nadlan_mcp/govmap.py +++ b/nadlan_mcp/govmap.py @@ -1,11 +1,12 @@ -import requests import logging -import json +import re import time -from datetime import datetime, timedelta -from typing import Any, Dict, List, Tuple, Optional +from collections import Counter +from typing import Any, Dict, List, Optional, Tuple -from nadlan_mcp.config import get_config, GovmapConfig +import requests + +from nadlan_mcp.config import GovmapConfig, get_config logger = logging.getLogger(__name__) @@ -16,7 +17,7 @@ class GovmapClient: 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 @@ -31,18 +32,17 @@ class GovmapClient: config: Optional configuration object. If None, uses global config. """ self.config = config or get_config() - self.base_url = self.config.base_url.rstrip('/') + self.base_url = self.config.base_url.rstrip("/") self.session = requests.Session() - self.session.headers.update({ - 'Content-Type': 'application/json', - 'User-Agent': self.config.user_agent - }) + 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 @@ -50,17 +50,17 @@ class GovmapClient: if elapsed < min_interval: time.sleep(min_interval - elapsed) self.last_request_time = time.time() - + def _validate_address(self, address: str) -> str: """ Validate and sanitize address input. - + Args: address: Address string to validate - + Returns: Sanitized address string - + Raises: ValueError: If address is invalid """ @@ -72,17 +72,17 @@ class GovmapClient: if len(address) > 500: raise ValueError("Address is too long (max 500 characters)") return address - + def _validate_coordinates(self, point: Tuple[float, float]) -> Tuple[float, float]: """ Validate coordinate input. - + Args: point: Tuple of (longitude, latitude) - + Returns: Validated coordinate tuple - + Raises: ValueError: If coordinates are invalid """ @@ -92,27 +92,29 @@ class GovmapClient: 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(self, value: int, name: str, max_value: Optional[int] = None) -> int: + + def _validate_positive_int( + self, value: int, name: str, max_value: Optional[int] = None + ) -> int: """ Validate positive integer input. - + 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 """ @@ -145,38 +147,46 @@ class GovmapClient: "searchText": search_text, "language": "he", "isAccurate": False, - "maxResults": 10 + "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})") + + 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: + 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 + 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}" ) - 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}") + 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") + raise RuntimeError( + "Unexpected error: retry loop exited without return or raise" + ) def get_gush_helka(self, point: Tuple[float, float]) -> Dict[str, Any]: """ @@ -195,40 +205,46 @@ class GovmapClient: point = self._validate_coordinates(point) url = f"{self.base_url}/layers-catalog/entitiesByPoint" - payload = { - "point": list(point), - "layers": [{"layerId": "16"}], - "tolerance": 0 - } + 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})") + + 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 + 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}" ) - 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}") + 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") + raise RuntimeError( + "Unexpected error: retry loop exited without return or raise" + ) - def get_deals_by_radius(self, point: Tuple[float, float], radius: int = 50) -> List[Dict[str, Any]]: + def get_deals_by_radius( + self, point: Tuple[float, float], radius: int = 50 + ) -> List[Dict[str, Any]]: """ Find real estate deals within a specified radius of a point. @@ -251,34 +267,49 @@ class GovmapClient: 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})") + + 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__}") + 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 + 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}" ) - 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}") + 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") + 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]]: + 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. @@ -301,11 +332,13 @@ class GovmapClient: 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) if deal_type not in (1, 2): - raise ValueError("deal_type must be 1 (first hand/new) or 2 (second hand/used)") - + raise ValueError( + "deal_type must be 1 (first hand/new) or 2 (second hand/used)" + ) + url = f"{self.base_url}/real-estate/street-deals/{polygon_id}" params: Dict[str, Any] = {"limit": limit, "dealType": deal_type} @@ -318,40 +351,57 @@ class GovmapClient: 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})") + + 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'] + 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__}") - + 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 + 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}" ) - 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}") + 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") + 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]]: + 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. @@ -374,11 +424,13 @@ class GovmapClient: 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) if deal_type not in (1, 2): - raise ValueError("deal_type must be 1 (first hand/new) or 2 (second hand/used)") - + raise ValueError( + "deal_type must be 1 (first hand/new) or 2 (second hand/used)" + ) + url = f"{self.base_url}/real-estate/neighborhood-deals/{polygon_id}" params: Dict[str, Any] = {"limit": limit, "dealType": deal_type} @@ -391,40 +443,57 @@ class GovmapClient: 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})") + + 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'] + 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__}") - + 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 + 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}" ) - 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}") + 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") + 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 = 50, - deal_type: int = 2) -> List[Dict[str, Any]]: + def find_recent_deals_for_address( + self, + address: str, + years_back: int = 2, + radius: int = 30, + max_deals: int = 50, + deal_type: int = 2, + ) -> List[Dict[str, Any]]: """ Find all relevant real estate deals for a given address from the last few years. @@ -453,25 +522,29 @@ class GovmapClient: radius = self._validate_positive_int(radius, "radius", max_value=5000) max_deals = self._validate_positive_int(max_deals, "max_deals", max_value=10000) if deal_type not in (1, 2): - raise ValueError("deal_type must be 1 (first hand/new) or 2 (second hand/used)") - + raise ValueError( + "deal_type must be 1 (first hand/new) or 2 (second hand/used)" + ) + try: # Step 1: Get coordinates for the address - logger.info(f"Starting search for address: {address}, dealType: {deal_type}") + logger.info( + f"Starting search for address: {address}, dealType: {deal_type}" + ) autocomplete_result = self.autocomplete_address(address) - if not autocomplete_result.get('results'): + 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: + 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('): + 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)" @@ -490,16 +563,16 @@ class GovmapClient: # Extract unique polygon IDs polygon_ids = set() for deal in nearby_deals: - if 'polygon_id' in deal: - polygon_ids.add(str(deal['polygon_id'])) + 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') + 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) @@ -512,14 +585,20 @@ class GovmapClient: 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 + 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 + 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 @@ -527,17 +606,19 @@ class GovmapClient: deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{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' - + deal["source_polygon_id"] = polygon_id + deal["deal_source"] = "street" + # Check if this is from the same building - deal_address = deal.get('address', '').lower().strip() - if self._is_same_building(search_address_normalized, deal_address): - deal['deal_source'] = 'same_building' - deal['priority'] = 0 # Highest priority + deal_address = deal.get("address", "").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 + deal["priority"] = 1 # Street deals priority street_deals.append(deal) # Add neighborhood deals with lowest priority @@ -545,9 +626,9 @@ class GovmapClient: deal_id = f"{deal.get('dealId', '')}{deal.get('address', '')}{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 + deal["source_polygon_id"] = polygon_id + deal["deal_source"] = "neighborhood" + deal["priority"] = 2 # Lowest priority neighborhood_deals.append(deal) except Exception as e: @@ -559,8 +640,12 @@ class GovmapClient: # 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) + 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: @@ -568,20 +653,28 @@ class GovmapClient: # 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) + 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' + deal["price_per_sqm"] = None - 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"[{deal['deal_type_description'] if all_deals else 'N/A'}]") + # 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"[{deal['deal_type_description'] if all_deals else 'N/A'}]" + ) return all_deals except Exception as e: @@ -591,28 +684,33 @@ class GovmapClient: 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. - + 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() - + 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: @@ -620,27 +718,33 @@ class GovmapClient: 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() + street_parts = parts[:i] + parts[i + 1 :] + street_name = " ".join(street_parts).strip() return (street_name, number) - - return (addr_clean, '') - + + 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): + 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 filter_deals_by_criteria( self, deals: List[Dict[str, Any]], @@ -656,7 +760,7 @@ class GovmapClient: ) -> 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) @@ -668,16 +772,16 @@ class GovmapClient: 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") @@ -687,18 +791,20 @@ class GovmapClient: 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', '')) + deal_type = deal.get( + "propertyTypeDescription", deal.get("assetTypeHeb", "") + ) if property_type.lower() not in deal_type.lower(): continue - + # Room count filter - rooms = deal.get('assetRoomNum') + rooms = deal.get("assetRoomNum") if rooms is not None: try: rooms = float(rooms) @@ -708,9 +814,9 @@ class GovmapClient: continue except (TypeError, ValueError): pass # Skip deals with invalid room data - + # Price filter - price = deal.get('dealAmount') + price = deal.get("dealAmount") if price is not None: try: price = float(price) @@ -720,9 +826,9 @@ class GovmapClient: continue except (TypeError, ValueError): pass # Skip deals with invalid price data - + # Area filter - area = deal.get('assetArea') + area = deal.get("assetArea") if area is not None: try: area = float(area) @@ -732,9 +838,9 @@ class GovmapClient: continue except (TypeError, ValueError): pass # Skip deals with invalid area data - + # Floor filter - floor_str = deal.get('floorNo', '') + floor_str = deal.get("floorNo", "") if floor_str and isinstance(floor_str, str): # Try to extract floor number (handles Hebrew floor descriptions) floor_num = self._extract_floor_number(floor_str) @@ -743,106 +849,110 @@ class GovmapClient: continue if max_floor is not None and floor_num > max_floor: continue - + filtered_deals.append(deal) - + return filtered_deals - + def _extract_floor_number(self, floor_str: str) -> Optional[int]: """ 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 + "ืงืจืงืข": 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 - import re - numbers = re.findall(r'\d+', floor_str) + numbers = re.findall(r"\d+", floor_str) if numbers: try: return int(numbers[0]) except ValueError: pass - + return None - - def calculate_deal_statistics( - self, - deals: List[Dict[str, Any]] - ) -> Dict[str, Any]: + + def calculate_deal_statistics(self, 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": {} + "room_distribution": {}, } - + # Extract numeric values prices = [] areas = [] price_per_sqm_values = [] rooms = [] - + for deal in deals: - price = deal.get('dealAmount') + price = deal.get("dealAmount") if isinstance(price, (int, float)) and price > 0: prices.append(price) - - area = deal.get('assetArea') + + area = deal.get("assetArea") if isinstance(area, (int, float)) and area > 0: areas.append(area) - - pps = deal.get('price_per_sqm') + + pps = deal.get("price_per_sqm") if pps is None and price and area and area > 0: pps = price / area if isinstance(pps, (int, float)) and pps > 0: price_per_sqm_values.append(pps) - - room_count = deal.get('assetRoomNum') + + 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) @@ -853,10 +963,12 @@ class GovmapClient: "max": max(prices), "p25": sorted_prices[len(sorted_prices) // 4], "p75": sorted_prices[(3 * len(sorted_prices)) // 4], - "std_dev": round(self._calculate_std_dev(prices), 2) if len(prices) > 1 else 0, - "total": sum(prices) + "std_dev": round(self._calculate_std_dev(prices), 2) + if len(prices) > 1 + else 0, + "total": sum(prices), } - + # Area statistics if areas: sorted_areas = sorted(areas) @@ -866,9 +978,9 @@ class GovmapClient: "min": min(areas), "max": max(areas), "p25": sorted_areas[len(sorted_areas) // 4], - "p75": sorted_areas[(3 * len(sorted_areas)) // 4] + "p75": sorted_areas[(3 * len(sorted_areas)) // 4], } - + # Price per sqm statistics if price_per_sqm_values: sorted_pps = sorted(price_per_sqm_values) @@ -878,21 +990,20 @@ class GovmapClient: "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) + "p75": round(sorted_pps[(3 * len(sorted_pps)) // 4], 2), } - + # Room distribution if rooms: - from collections import Counter room_counts = Counter(rooms) stats["room_distribution"] = dict(sorted(room_counts.items())) - + return stats - + def _calculate_std_dev(self, values: List[float]) -> float: """Calculate standard deviation of a list of values.""" 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 \ No newline at end of file + return variance**0.5 diff --git a/requirements.txt b/requirements.txt index c5e20ab..bc804c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ requests>=2.31.0,<3.0.0 python-dotenv>=1.0.0,<2.0.0 -pytest>=7.0.0,<8.0.0 mcp>=1.0.0,<2.0.0 fastmcp>=0.1.0,<1.0.0 -types-requests>=2.31.0,<3.0.0 pydantic>=2.0.0,<3.0.0 From d9e42bea17df241776c6fa2f5a40dab6550e4d5e Mon Sep 17 00:00:00 2001 From: Nitzan Pomerantz <9297302+nitzpo@users.noreply.github.com> Date: Thu, 23 Oct 2025 00:11:41 +0300 Subject: [PATCH 8/8] Some more small fixes --- .cursor/plans/PLAN.md | 2 +- .markdownlint.json | 5 ++++- nadlan_mcp/govmap.py | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.cursor/plans/PLAN.md b/.cursor/plans/PLAN.md index 1487775..561e0c1 100644 --- a/.cursor/plans/PLAN.md +++ b/.cursor/plans/PLAN.md @@ -479,4 +479,4 @@ Implement comprehensive amenity scoring using multiple data sources: ### Next Up - Phase 3: Architecture Improvements (data models, separation of concerns) - Phase 4: Testing Expansion -- Phase 5: Complete Documentation \ No newline at end of file +- Phase 5: Complete Documentation diff --git a/.markdownlint.json b/.markdownlint.json index 7d22d56..f2151f5 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,5 +1,8 @@ { "MD032": false, "MD022": false, - "MD013": false + "MD013": false, + "MD031": false, + "MD036": false, + "MD029": false } \ No newline at end of file diff --git a/nadlan_mcp/govmap.py b/nadlan_mcp/govmap.py index c8ffc41..a06cc7f 100644 --- a/nadlan_mcp/govmap.py +++ b/nadlan_mcp/govmap.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta import logging import re import time