feat(alerts): add Pulse AI double opt-in alert subscriptions system
Browse files- Add 10 alert criteria types with RPC-based evaluation and 24h rolling cooldown
- Implement pluggable email service with Gmail SMTP and unsubscribe links
- Add ALERT_SUBSCRIPTION query classification and isolated 2-tool resolution
- Add confirmation, unsubscribe, and internal evaluate API routes
- Add background evaluator, digest sender, and cleanup workers
- Add comprehensive test suite with 45 unit tests and 44 simulated tool tests
- config.py +16 -0
- misc/alert_subscriptions_migration.sql +27 -0
- misc/pulse-alert-subscriptions-spec.md +161 -0
- routes/v1/__init__.py +3 -0
- routes/v1/alerts.py +92 -0
- services/agent_loop.py +4 -0
- services/alert_service.py +177 -0
- services/alert_tool_schema.py +51 -0
- services/email_service.py +197 -0
- services/prompts.py +38 -3
- services/query_router.py +2 -1
- services/tool_executor.py +12 -0
- tests/test_alert_system.py +332 -0
- tests/test_all_tools_simulated.py +4 -4
- workers/__init__.py +1 -0
- workers/alert_cleanup.py +38 -0
- workers/alert_evaluator.py +326 -0
- workers/digest_sender.py +118 -0
config.py
CHANGED
|
@@ -37,3 +37,19 @@ CONTACT_WHATSAPP = os.environ.get("CONTACT_WHATSAPP", "1234567890")
|
|
| 37 |
|
| 38 |
MARKET_CACHE_TTL_SECONDS = int(os.environ.get("MARKET_CACHE_TTL_SECONDS", "300"))
|
| 39 |
GEOCODE_COUNTRY_FILTER = os.environ.get("GEOCODE_COUNTRY_FILTER", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
MARKET_CACHE_TTL_SECONDS = int(os.environ.get("MARKET_CACHE_TTL_SECONDS", "300"))
|
| 39 |
GEOCODE_COUNTRY_FILTER = os.environ.get("GEOCODE_COUNTRY_FILTER", "")
|
| 40 |
+
|
| 41 |
+
EMAIL_SMTP_HOST = os.environ.get("EMAIL_SMTP_HOST", "smtp.gmail.com")
|
| 42 |
+
EMAIL_SMTP_PORT = int(os.environ.get("EMAIL_SMTP_PORT", "587"))
|
| 43 |
+
EMAIL_FROM_ADDRESS = os.environ.get("EMAIL_FROM_ADDRESS", "jouledynamicscto@gmail.com")
|
| 44 |
+
EMAIL_SMTP_PASSWORD = os.environ.get("EMAIL_SMTP_PASSWORD", "")
|
| 45 |
+
ALERT_BASE_URL = os.environ.get(
|
| 46 |
+
"ALERT_BASE_URL",
|
| 47 |
+
os.environ.get("CONFIRMATION_BASE_URL", "https://johnalbarkaibrahim-sentimentscope.hf.space"),
|
| 48 |
+
)
|
| 49 |
+
SUPABASE_SERVICE_ROLE_KEY = os.environ.get(
|
| 50 |
+
"SUPABASE_SERVICE_ROLE_KEY", os.environ.get("SUPABASE_KEY", "")
|
| 51 |
+
)
|
| 52 |
+
INTERNAL_API_KEY = os.environ.get("INTERNAL_API_KEY", "")
|
| 53 |
+
ALERT_COOLDOWN_HOURS = int(os.environ.get("ALERT_COOLDOWN_HOURS", "24"))
|
| 54 |
+
MAX_SUBSCRIPTIONS_PER_EMAIL = int(os.environ.get("MAX_SUBSCRIPTIONS_PER_EMAIL", "5"))
|
| 55 |
+
CONFIRMATION_EXPIRY_HOURS = int(os.environ.get("CONFIRMATION_EXPIRY_HOURS", "48"))
|
misc/alert_subscriptions_migration.sql
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Pulse AI Alert Subscriptions β Table + Indexes
|
| 2 |
+
-- Apply this in the Supabase SQL Editor (Dashboard β SQL Editor β New Query)
|
| 3 |
+
|
| 4 |
+
CREATE TABLE alert_subscriptions (
|
| 5 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 6 |
+
email TEXT NOT NULL,
|
| 7 |
+
criteria_type TEXT NOT NULL CHECK (criteria_type IN (
|
| 8 |
+
'spike', 'rate_change', 'price_threshold', 'availability_change',
|
| 9 |
+
'new_listing', 'tracking_removed', 'anomaly', 'volatility',
|
| 10 |
+
'trend_reversal', 'digest'
|
| 11 |
+
)),
|
| 12 |
+
criteria JSONB NOT NULL,
|
| 13 |
+
raw_request_text TEXT,
|
| 14 |
+
confirmed BOOLEAN NOT NULL DEFAULT false,
|
| 15 |
+
confirmation_token TEXT NOT NULL UNIQUE,
|
| 16 |
+
unsubscribe_token TEXT NOT NULL UNIQUE,
|
| 17 |
+
status TEXT NOT NULL DEFAULT 'pending',
|
| 18 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
| 19 |
+
confirmed_at TIMESTAMPTZ,
|
| 20 |
+
last_checked_at TIMESTAMPTZ,
|
| 21 |
+
last_fired_at TIMESTAMPTZ
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
CREATE INDEX idx_alert_subs_status ON alert_subscriptions(status) WHERE status = 'active';
|
| 25 |
+
CREATE INDEX idx_alert_subs_email ON alert_subscriptions(email);
|
| 26 |
+
CREATE INDEX idx_alert_subs_token ON alert_subscriptions(confirmation_token);
|
| 27 |
+
CREATE INDEX idx_alert_subs_unsub ON alert_subscriptions(unsubscribe_token);
|
misc/pulse-alert-subscriptions-spec.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pulse AI β Alert Subscriptions: Full Technical Specification
|
| 2 |
+
|
| 3 |
+
## Architecture Overview
|
| 4 |
+
|
| 5 |
+
```
|
| 6 |
+
User: "let me know when property_a in Miami spikes again"
|
| 7 |
+
β
|
| 8 |
+
ROUTER: classifies as ALERT_SUBSCRIPTION (fast, cheap β exactly 7 classifications)
|
| 9 |
+
β
|
| 10 |
+
AGENT LOOP (_resolve_active_tools):
|
| 11 |
+
- Injects exactly 2 tools: create_alert_subscription + suggest_actions
|
| 12 |
+
- Prompt: _PROMPT_BASE + _PROMPT_ALERT + _PROMPT_RESPONSE_RULES
|
| 13 |
+
β
|
| 14 |
+
SYNTHESIS:
|
| 15 |
+
- Missing details? β invokes suggest_actions with concrete chips
|
| 16 |
+
- Details complete? β invokes create_alert_subscription (PENDING, unconfirmed)
|
| 17 |
+
β
|
| 18 |
+
CONFIRMATION EMAIL:
|
| 19 |
+
- System sends double opt-in email with single-use verification token
|
| 20 |
+
- Link: https://pulse.jouledynamics.com/alerts/confirm?token=<token>
|
| 21 |
+
β
|
| 22 |
+
CONFIRMATION ENDPOINT (GET /api/v1/alerts/confirm):
|
| 23 |
+
- Service role validates token, sets confirmed = true, status = 'active'
|
| 24 |
+
- Pulse agent itself CANNOT confirm β double opt-in strictly enforced
|
| 25 |
+
β
|
| 26 |
+
EVALUATION WORKERS (Post-scrape / Scheduled / Webhook):
|
| 27 |
+
- Alert Evaluator: evaluates 9 event-driven criteria types against fresh data via existing Supabase RPCs
|
| 28 |
+
- Digest Sender: evaluates scheduled digest subscriptions (daily/weekly)
|
| 29 |
+
- Cleanup Job: expires and purges unconfirmed pending subscriptions > 48h
|
| 30 |
+
β
|
| 31 |
+
COOLDOWN ENFORCEMENT & DELIVERY:
|
| 32 |
+
- 24-hour rolling cooldown prevents duplicate fires for persistent conditions
|
| 33 |
+
- HTML email with unsubscribe link (GET /api/v1/alerts/unsubscribe?token=<token>)
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## 1. Supported Alert Criteria Types (10 Types)
|
| 39 |
+
|
| 40 |
+
The alert system supports 10 distinct criteria types spanning event-driven, threshold, and scheduled monitoring:
|
| 41 |
+
|
| 42 |
+
| `criteria_type` | Description | Target / Required Criteria | Evaluator RPC Used | Default Cooldown |
|
| 43 |
+
|---|---|---|---|---|
|
| 44 |
+
| `spike` | Price or rate spikes exceeding threshold | `{"market": "Miami", "threshold_pct": 25, "direction": "both"}` | `get_spike_alerts` | 24 hours |
|
| 45 |
+
| `rate_change` | Material rate changes over window | `{"market": "Abuja", "days": 7, "min_change_pct": 10}` | `get_market_rate_changes` | 24 hours |
|
| 46 |
+
| `price_threshold` | Property price crosses numeric limit | `{"property_search": "...", "operator": "below", "value": 150000}` | `get_property_snapshot` | 24 hours |
|
| 47 |
+
| `availability_change` | Property or market occupancy change | `{"property_search": "..."}` or `{"market": "Miami"}` | `get_property_snapshot` / `get_availability_rate` | 24 hours |
|
| 48 |
+
| `new_listing` | New property added to tracking | `{"market": "Lagos"}` | `get_recently_changed_tracking` | 24 hours |
|
| 49 |
+
| `tracking_removed` | Property delisted or untracked | `{"market": "Lagos"}` or `{"property_search": "..."}` | `get_recently_changed_tracking` | 24 hours |
|
| 50 |
+
| `anomaly` | Statistical rate deviation | `{"property_search": "...", "days": 30}` | `get_rate_anomaly_report` | 24 hours |
|
| 51 |
+
| `volatility` | High rate fluctuation in market | `{"market": "Abuja", "days": 14, "limit": 5}` | `get_most_volatile_properties` | 24 hours |
|
| 52 |
+
| `trend_reversal` | Market trend direction shift | `{"market": "Miami", "days": 14}` | `get_market_trend` | 24 hours |
|
| 53 |
+
| `digest` | Periodic market recap | `{"market": "Miami", "frequency": "weekly"}` (`"daily"` or `"weekly"`) | `get_real_estate_kpis` + `get_market_averages` | Cron-driven |
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## 2. Router Integration (`services/query_router.py`)
|
| 58 |
+
|
| 59 |
+
`ALERT_SUBSCRIPTION` is the 7th valid classification alongside:
|
| 60 |
+
- `OUT_OF_SCOPE`
|
| 61 |
+
- `PATH_A` (live market intelligence)
|
| 62 |
+
- `PATH_B` (general real estate / UI concepts)
|
| 63 |
+
- `BOTH`
|
| 64 |
+
- `GREETING`
|
| 65 |
+
- `COMMERCIAL_HANDOFF`
|
| 66 |
+
- `ALERT_SUBSCRIPTION`
|
| 67 |
+
|
| 68 |
+
Prompt instructions guide the router to detect explicit future-monitoring intent ("alert me when...", "notify me if...", "send me a weekly digest of...").
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## 3. Tool Definition & Token Budget (`services/alert_tool_schema.py`)
|
| 73 |
+
|
| 74 |
+
To preserve strict token economy:
|
| 75 |
+
- Exactly 2 tools are exposed during `ALERT_SUBSCRIPTION` turns: `create_alert_subscription` and `suggest_actions`.
|
| 76 |
+
- No heavy SQL or market analysis tools are loaded into context during subscription creation.
|
| 77 |
+
|
| 78 |
+
```json
|
| 79 |
+
{
|
| 80 |
+
"type": "function",
|
| 81 |
+
"function": {
|
| 82 |
+
"name": "create_alert_subscription",
|
| 83 |
+
"description": "Create a PENDING alert subscription. Never call this until you have the condition type, target (market or property), and email address.",
|
| 84 |
+
"parameters": {
|
| 85 |
+
"type": "object",
|
| 86 |
+
"properties": {
|
| 87 |
+
"email": {"type": "string"},
|
| 88 |
+
"criteria_type": {
|
| 89 |
+
"type": "string",
|
| 90 |
+
"enum": [
|
| 91 |
+
"spike", "rate_change", "price_threshold", "availability_change",
|
| 92 |
+
"new_listing", "tracking_removed", "anomaly", "volatility",
|
| 93 |
+
"trend_reversal", "digest"
|
| 94 |
+
]
|
| 95 |
+
},
|
| 96 |
+
"criteria": {"type": "object"},
|
| 97 |
+
"raw_request_text": {"type": "string"}
|
| 98 |
+
},
|
| 99 |
+
"required": ["email", "criteria_type", "criteria", "raw_request_text"]
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## 4. Prompt Extension (`_PROMPT_ALERT` in `services/prompts.py`)
|
| 108 |
+
|
| 109 |
+
When `classification == "ALERT_SUBSCRIPTION"`, the system prompt injects `_PROMPT_ALERT`:
|
| 110 |
+
- Demands 3 essential fields before subscription creation: WHAT condition, WHICH market/property, and EMAIL ADDRESS.
|
| 111 |
+
- Enforces conversational follow-up via `suggest_actions` if any field is missing.
|
| 112 |
+
- Explains that the subscription will be created in `PENDING` state and requires confirmation via email.
|
| 113 |
+
- Enforces anti-spam and privacy constraints (never store personal data beyond email).
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## 5. Security Architecture & RLS Boundary
|
| 118 |
+
|
| 119 |
+
1. **Table Schema (`misc/alert_subscriptions_migration.sql`):**
|
| 120 |
+
- Columns: `id`, `email`, `criteria_type`, `criteria`, `raw_request_text`, `confirmed`, `confirmation_token`, `unsubscribe_token`, `status`, `created_at`, `confirmed_at`, `last_checked_at`, `last_fired_at`.
|
| 121 |
+
- Double opt-in default: `confirmed BOOLEAN NOT NULL DEFAULT false`, `status TEXT NOT NULL DEFAULT 'pending'`.
|
| 122 |
+
2. **Access Control:**
|
| 123 |
+
- Frontend / Public agent role: `INSERT` only (cannot `SELECT`, `UPDATE`, or `DELETE`).
|
| 124 |
+
- Service Role key (`SUPABASE_SERVICE_ROLE_KEY`): Used exclusively by backend API routes and workers to confirm, update `last_fired_at`, and unsubscribe.
|
| 125 |
+
3. **Caps:** Maximum 5 active subscriptions per email address to prevent abuse. Friendly error message returned if exceeded.
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## 6. Endpoints (`routes/v1/alerts.py`)
|
| 130 |
+
|
| 131 |
+
- `GET /api/v1/alerts/confirm?token=<token>`: Validates confirmation token, activates subscription (`confirmed = true, status = 'active'`), returns branded HTML response.
|
| 132 |
+
- `GET /api/v1/alerts/unsubscribe?token=<token>`: Validates unsubscribe token, cancels subscription (`status = 'unsubscribed'`), returns branded confirmation.
|
| 133 |
+
- `POST /api/v1/internal/evaluate-alerts`: Protected internal endpoint called by scrapers or webhooks with `X-Internal-Secret` header to trigger evaluation runs.
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## 7. Workers & Automation (`workers/`)
|
| 138 |
+
|
| 139 |
+
1. **`alert_evaluator.py`**:
|
| 140 |
+
- Dispatches across the 9 event-driven criteria types.
|
| 141 |
+
- Reuses existing audited RPCs (`get_spike_alerts`, `get_property_snapshot`, `get_rate_anomaly_report`, etc.).
|
| 142 |
+
- Enforces 24-hour rolling cooldown (`ALERT_COOLDOWN_HOURS = 24`).
|
| 143 |
+
2. **`digest_sender.py`**:
|
| 144 |
+
- Queries active `digest` subscriptions.
|
| 145 |
+
- Compiles KPIs and average metrics via `get_real_estate_kpis` and `get_market_averages`.
|
| 146 |
+
- Sends daily or weekly digests based on elapsed time since `last_fired_at`.
|
| 147 |
+
3. **`alert_cleanup.py`**:
|
| 148 |
+
- Deletes unconfirmed subscriptions older than `CONFIRMATION_EXPIRY_HOURS = 48`.
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## 8. Pluggable Email Service (`services/email_service.py`)
|
| 153 |
+
|
| 154 |
+
- Currently backed by Gmail SMTP via `SMTP_USER` and `SMTP_PASSWORD`.
|
| 155 |
+
- Configured via environment variables with fallback support:
|
| 156 |
+
- `SMTP_HOST` (default: `smtp.gmail.com`)
|
| 157 |
+
- `SMTP_PORT` (default: `587`)
|
| 158 |
+
- `SMTP_USER` (default: `jouledynamicscto@gmail.com`)
|
| 159 |
+
- `FROM_EMAIL` (default: `jouledynamicscto@gmail.com`)
|
| 160 |
+
- `CONFIRMATION_BASE_URL` (default: `https://pulse.jouledynamics.com`)
|
| 161 |
+
- Ready to swap provider (e.g. Resend, Zoho) purely via `.env` configuration without modifying business logic.
|
routes/v1/__init__.py
CHANGED
|
@@ -6,8 +6,11 @@ from fastapi import APIRouter
|
|
| 6 |
from routes.v1.real_estate import router as real_estate_router
|
| 7 |
from routes.v1.rag import router as rag_router
|
| 8 |
from routes.v1.health import router as health_router
|
|
|
|
| 9 |
|
| 10 |
router = APIRouter()
|
| 11 |
router.include_router(real_estate_router)
|
| 12 |
router.include_router(rag_router)
|
| 13 |
router.include_router(health_router)
|
|
|
|
|
|
|
|
|
| 6 |
from routes.v1.real_estate import router as real_estate_router
|
| 7 |
from routes.v1.rag import router as rag_router
|
| 8 |
from routes.v1.health import router as health_router
|
| 9 |
+
from routes.v1.alerts import router as alerts_router
|
| 10 |
|
| 11 |
router = APIRouter()
|
| 12 |
router.include_router(real_estate_router)
|
| 13 |
router.include_router(rag_router)
|
| 14 |
router.include_router(health_router)
|
| 15 |
+
router.include_router(alerts_router)
|
| 16 |
+
|
routes/v1/alerts.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Alert subscription management endpoints (confirmation, unsubscribe, worker trigger)."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Request, status
|
| 4 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 5 |
+
|
| 6 |
+
from config import INTERNAL_API_KEY
|
| 7 |
+
from services.alert_service import confirm_subscription, unsubscribe
|
| 8 |
+
from services.observability import setup_logger
|
| 9 |
+
|
| 10 |
+
logger = setup_logger(__name__)
|
| 11 |
+
|
| 12 |
+
router = APIRouter(
|
| 13 |
+
prefix="/api/v1",
|
| 14 |
+
tags=["Alert Subscriptions"],
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
_SUCCESS_HTML = """
|
| 18 |
+
<html><body style="font-family: Arial, sans-serif; max-width: 600px; margin: 80px auto; text-align: center;">
|
| 19 |
+
<h2 style="color: #1a1a2e;">{title}</h2>
|
| 20 |
+
<p>{message}</p>
|
| 21 |
+
<p style="color: #999; font-size: 12px; margin-top: 40px;">Pulse AI β Real Estate Intelligence by Joule Dynamics</p>
|
| 22 |
+
</body></html>
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
_ERROR_HTML = """
|
| 26 |
+
<html><body style="font-family: Arial, sans-serif; max-width: 600px; margin: 80px auto; text-align: center;">
|
| 27 |
+
<h2 style="color: #c0392b;">{title}</h2>
|
| 28 |
+
<p>{message}</p>
|
| 29 |
+
<p style="color: #999; font-size: 12px; margin-top: 40px;">Pulse AI β Real Estate Intelligence by Joule Dynamics</p>
|
| 30 |
+
</body></html>
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.get("/alerts/confirm", response_class=HTMLResponse)
|
| 35 |
+
async def confirm_alert(token: str):
|
| 36 |
+
"""Confirms a pending alert subscription via token link click."""
|
| 37 |
+
if not token:
|
| 38 |
+
return HTMLResponse(
|
| 39 |
+
_ERROR_HTML.format(title="Invalid Link", message="No confirmation token provided."),
|
| 40 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
result = await confirm_subscription(token)
|
| 44 |
+
|
| 45 |
+
if result["status"] in ("active", "already_active"):
|
| 46 |
+
return HTMLResponse(
|
| 47 |
+
_SUCCESS_HTML.format(title="β
Subscription Confirmed!", message=result["message"]),
|
| 48 |
+
status_code=status.HTTP_200_OK,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
return HTMLResponse(
|
| 52 |
+
_ERROR_HTML.format(title="Confirmation Failed", message=result["message"]),
|
| 53 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/alerts/unsubscribe", response_class=HTMLResponse)
|
| 58 |
+
async def unsubscribe_alert(token: str):
|
| 59 |
+
"""Unsubscribes from an active alert subscription."""
|
| 60 |
+
if not token:
|
| 61 |
+
return HTMLResponse(
|
| 62 |
+
_ERROR_HTML.format(title="Invalid Link", message="No unsubscribe token provided."),
|
| 63 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
result = await unsubscribe(token)
|
| 67 |
+
|
| 68 |
+
if result["status"] in ("unsubscribed", "already_unsubscribed"):
|
| 69 |
+
return HTMLResponse(
|
| 70 |
+
_SUCCESS_HTML.format(title="Unsubscribed", message=result["message"]),
|
| 71 |
+
status_code=status.HTTP_200_OK,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return HTMLResponse(
|
| 75 |
+
_ERROR_HTML.format(title="Unsubscribe Failed", message=result["message"]),
|
| 76 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.post("/internal/evaluate-alerts")
|
| 81 |
+
async def trigger_alert_evaluation(request: Request):
|
| 82 |
+
"""Internal endpoint: evaluates all active subscriptions. Secured by X-Internal-Key header."""
|
| 83 |
+
provided_key = request.headers.get("X-Internal-Key", "")
|
| 84 |
+
if not INTERNAL_API_KEY or provided_key != INTERNAL_API_KEY:
|
| 85 |
+
return JSONResponse(
|
| 86 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 87 |
+
content={"error": "Forbidden"},
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
from workers.alert_evaluator import evaluate_all_active_subscriptions
|
| 91 |
+
result = await evaluate_all_active_subscriptions()
|
| 92 |
+
return result
|
services/agent_loop.py
CHANGED
|
@@ -372,6 +372,10 @@ def _resolve_active_tools(classification: str, user_query: str, messages: list[d
|
|
| 372 |
if classification == "PATH_B":
|
| 373 |
return [SUGGEST_ACTIONS_TOOL]
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
return None
|
| 376 |
|
| 377 |
|
|
|
|
| 372 |
if classification == "PATH_B":
|
| 373 |
return [SUGGEST_ACTIONS_TOOL]
|
| 374 |
|
| 375 |
+
if classification == "ALERT_SUBSCRIPTION":
|
| 376 |
+
from services.alert_tool_schema import CREATE_ALERT_SUBSCRIPTION_TOOL
|
| 377 |
+
return [CREATE_ALERT_SUBSCRIPTION_TOOL, SUGGEST_ACTIONS_TOOL]
|
| 378 |
+
|
| 379 |
return None
|
| 380 |
|
| 381 |
|
services/alert_service.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Alert subscription lifecycle: create (pending), confirm, unsubscribe."""
|
| 2 |
+
|
| 3 |
+
import secrets
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
|
| 6 |
+
from supabase import create_client
|
| 7 |
+
|
| 8 |
+
from config import (
|
| 9 |
+
MAX_SUBSCRIPTIONS_PER_EMAIL,
|
| 10 |
+
SUPABASE_SERVICE_ROLE_KEY,
|
| 11 |
+
SUPABASE_URL,
|
| 12 |
+
)
|
| 13 |
+
from services.email_service import (
|
| 14 |
+
build_criteria_summary,
|
| 15 |
+
send_confirmation_email,
|
| 16 |
+
send_subscription_active_email,
|
| 17 |
+
)
|
| 18 |
+
from services.observability import setup_logger
|
| 19 |
+
|
| 20 |
+
logger = setup_logger(__name__)
|
| 21 |
+
|
| 22 |
+
_service_client = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def create_pending_subscription(
|
| 26 |
+
email: str, criteria_type: str, criteria: dict, raw_text: str
|
| 27 |
+
) -> dict:
|
| 28 |
+
"""INSERT pending subscription and send confirmation email.
|
| 29 |
+
|
| 30 |
+
Enforces per-email subscription cap. Returns a natural language
|
| 31 |
+
rejection message if the cap is exceeded so the LLM can relay it.
|
| 32 |
+
"""
|
| 33 |
+
if not email or not criteria_type:
|
| 34 |
+
return {
|
| 35 |
+
"status": "error",
|
| 36 |
+
"message": "Email address and alert type are both required to create a subscription.",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
active_count = await _count_active_subscriptions(email)
|
| 40 |
+
if active_count >= MAX_SUBSCRIPTIONS_PER_EMAIL:
|
| 41 |
+
return {
|
| 42 |
+
"status": "limit_reached",
|
| 43 |
+
"message": (
|
| 44 |
+
f"You already have {active_count} active alert subscriptions on this email address. "
|
| 45 |
+
f"The maximum is {MAX_SUBSCRIPTIONS_PER_EMAIL}. "
|
| 46 |
+
"To add a new one, you'd need to unsubscribe from an existing alert first. "
|
| 47 |
+
"Check your email for unsubscribe links in any previous alert emails."
|
| 48 |
+
),
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
confirmation_token = secrets.token_urlsafe(32)
|
| 52 |
+
unsubscribe_token = secrets.token_urlsafe(32)
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
_service_client.table("alert_subscriptions").insert({
|
| 56 |
+
"email": email,
|
| 57 |
+
"criteria_type": criteria_type,
|
| 58 |
+
"criteria": criteria,
|
| 59 |
+
"raw_request_text": raw_text,
|
| 60 |
+
"confirmation_token": confirmation_token,
|
| 61 |
+
"unsubscribe_token": unsubscribe_token,
|
| 62 |
+
"confirmed": False,
|
| 63 |
+
"status": "pending",
|
| 64 |
+
}).execute()
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
logger.error(f"[alert_service] insert failed: {exc}")
|
| 67 |
+
return {"status": "error", "message": "Failed to create subscription. Please try again."}
|
| 68 |
+
|
| 69 |
+
summary = build_criteria_summary(criteria_type, criteria)
|
| 70 |
+
await send_confirmation_email(email, confirmation_token, summary)
|
| 71 |
+
|
| 72 |
+
logger.info(f"[alert_service] pending subscription created for {email}: {criteria_type}")
|
| 73 |
+
return {
|
| 74 |
+
"status": "pending",
|
| 75 |
+
"message": (
|
| 76 |
+
f"Alert subscription created! A confirmation email has been sent to {email}. "
|
| 77 |
+
"Please click the link in that email to activate the alert β "
|
| 78 |
+
"it won't start monitoring until you confirm."
|
| 79 |
+
),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def confirm_subscription(token: str) -> dict:
|
| 84 |
+
"""Confirm a pending subscription via confirmation token.
|
| 85 |
+
|
| 86 |
+
Sets confirmed=true, status='active', sends subscription-active email.
|
| 87 |
+
"""
|
| 88 |
+
try:
|
| 89 |
+
result = (
|
| 90 |
+
_service_client.table("alert_subscriptions")
|
| 91 |
+
.select("id, email, criteria_type, criteria, unsubscribe_token, status")
|
| 92 |
+
.eq("confirmation_token", token)
|
| 93 |
+
.execute()
|
| 94 |
+
)
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
logger.error(f"[alert_service] confirm lookup failed: {exc}")
|
| 97 |
+
return {"status": "error", "message": "Unable to process confirmation."}
|
| 98 |
+
|
| 99 |
+
if not result.data:
|
| 100 |
+
return {"status": "error", "message": "Invalid or expired confirmation link."}
|
| 101 |
+
|
| 102 |
+
sub = result.data[0]
|
| 103 |
+
if sub["status"] == "active":
|
| 104 |
+
return {"status": "already_active", "message": "This subscription is already active."}
|
| 105 |
+
|
| 106 |
+
if sub["status"] != "pending":
|
| 107 |
+
return {"status": "error", "message": "This subscription cannot be confirmed."}
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
_service_client.table("alert_subscriptions").update({
|
| 111 |
+
"confirmed": True,
|
| 112 |
+
"status": "active",
|
| 113 |
+
"confirmed_at": datetime.now(timezone.utc).isoformat(),
|
| 114 |
+
"confirmation_token": None,
|
| 115 |
+
}).eq("id", sub["id"]).execute()
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
logger.error(f"[alert_service] confirm update failed: {exc}")
|
| 118 |
+
return {"status": "error", "message": "Unable to activate subscription."}
|
| 119 |
+
|
| 120 |
+
await send_subscription_active_email(
|
| 121 |
+
to=sub["email"],
|
| 122 |
+
criteria_type=sub["criteria_type"],
|
| 123 |
+
criteria=sub["criteria"],
|
| 124 |
+
unsubscribe_token=sub["unsubscribe_token"],
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
logger.info(f"[alert_service] subscription confirmed: {sub['id']}")
|
| 128 |
+
return {"status": "active", "message": "Your alert subscription is now active!"}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def unsubscribe(token: str) -> dict:
|
| 132 |
+
"""Unsubscribe from an alert subscription via unsubscribe token."""
|
| 133 |
+
try:
|
| 134 |
+
result = (
|
| 135 |
+
_service_client.table("alert_subscriptions")
|
| 136 |
+
.select("id, status")
|
| 137 |
+
.eq("unsubscribe_token", token)
|
| 138 |
+
.execute()
|
| 139 |
+
)
|
| 140 |
+
except Exception as exc:
|
| 141 |
+
logger.error(f"[alert_service] unsubscribe lookup failed: {exc}")
|
| 142 |
+
return {"status": "error", "message": "Unable to process unsubscribe request."}
|
| 143 |
+
|
| 144 |
+
if not result.data:
|
| 145 |
+
return {"status": "error", "message": "Invalid unsubscribe link."}
|
| 146 |
+
|
| 147 |
+
sub = result.data[0]
|
| 148 |
+
if sub["status"] == "unsubscribed":
|
| 149 |
+
return {"status": "already_unsubscribed", "message": "You've already unsubscribed from this alert."}
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
_service_client.table("alert_subscriptions").update({
|
| 153 |
+
"status": "unsubscribed",
|
| 154 |
+
"unsubscribe_token": None,
|
| 155 |
+
}).eq("id", sub["id"]).execute()
|
| 156 |
+
except Exception as exc:
|
| 157 |
+
logger.error(f"[alert_service] unsubscribe update failed: {exc}")
|
| 158 |
+
return {"status": "error", "message": "Unable to process unsubscribe request."}
|
| 159 |
+
|
| 160 |
+
logger.info(f"[alert_service] unsubscribed: {sub['id']}")
|
| 161 |
+
return {"status": "unsubscribed", "message": "You've been unsubscribed. You won't receive any more alerts for this subscription."}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
async def _count_active_subscriptions(email: str) -> int:
|
| 165 |
+
"""Count active + pending subscriptions for rate-limiting."""
|
| 166 |
+
try:
|
| 167 |
+
result = (
|
| 168 |
+
_service_client.table("alert_subscriptions")
|
| 169 |
+
.select("id", count="exact")
|
| 170 |
+
.eq("email", email)
|
| 171 |
+
.in_("status", ["active", "pending"])
|
| 172 |
+
.execute()
|
| 173 |
+
)
|
| 174 |
+
return result.count or 0
|
| 175 |
+
except Exception as exc:
|
| 176 |
+
logger.error(f"[alert_service] count failed: {exc}")
|
| 177 |
+
return 0
|
services/alert_tool_schema.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool schema for the create_alert_subscription LLM tool."""
|
| 2 |
+
|
| 3 |
+
CREATE_ALERT_SUBSCRIPTION_TOOL = {
|
| 4 |
+
"type": "function",
|
| 5 |
+
"function": {
|
| 6 |
+
"name": "create_alert_subscription",
|
| 7 |
+
"description": (
|
| 8 |
+
"Create a PENDING alert subscription. Never call until you have: "
|
| 9 |
+
"condition type, target (market or property), and email address. "
|
| 10 |
+
"Does not activate the alert β confirmation happens via email link."
|
| 11 |
+
),
|
| 12 |
+
"parameters": {
|
| 13 |
+
"type": "object",
|
| 14 |
+
"properties": {
|
| 15 |
+
"email": {
|
| 16 |
+
"type": "string",
|
| 17 |
+
"description": "User's email address for receiving alerts",
|
| 18 |
+
},
|
| 19 |
+
"criteria_type": {
|
| 20 |
+
"type": "string",
|
| 21 |
+
"enum": [
|
| 22 |
+
"spike", "rate_change", "price_threshold",
|
| 23 |
+
"availability_change", "new_listing", "tracking_removed",
|
| 24 |
+
"anomaly", "volatility", "trend_reversal", "digest",
|
| 25 |
+
],
|
| 26 |
+
"description": "Type of condition to monitor",
|
| 27 |
+
},
|
| 28 |
+
"criteria": {
|
| 29 |
+
"type": "object",
|
| 30 |
+
"description": (
|
| 31 |
+
"Type-specific parameters. Examples: "
|
| 32 |
+
"spike: {market, threshold_pct}; "
|
| 33 |
+
"rate_change: {market, direction: both|up|down}; "
|
| 34 |
+
"price_threshold: {property_search, operator: above|below, value}; "
|
| 35 |
+
"availability_change: {property_search, watch_for: available|unavailable|any}; "
|
| 36 |
+
"new_listing/tracking_removed: {market}; "
|
| 37 |
+
"anomaly: {property_search, deviation_threshold}; "
|
| 38 |
+
"volatility: {market, top_n}; "
|
| 39 |
+
"trend_reversal: {market, watch_for: rising|falling}; "
|
| 40 |
+
"digest: {market, frequency: daily|weekly}"
|
| 41 |
+
),
|
| 42 |
+
},
|
| 43 |
+
"raw_request_text": {
|
| 44 |
+
"type": "string",
|
| 45 |
+
"description": "Original user request verbatim",
|
| 46 |
+
},
|
| 47 |
+
},
|
| 48 |
+
"required": ["email", "criteria_type", "criteria", "raw_request_text"],
|
| 49 |
+
},
|
| 50 |
+
},
|
| 51 |
+
}
|
services/email_service.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pluggable email dispatch interface with Gmail SMTP implementation."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import smtplib
|
| 5 |
+
from email.mime.multipart import MIMEMultipart
|
| 6 |
+
from email.mime.text import MIMEText
|
| 7 |
+
|
| 8 |
+
from config import (
|
| 9 |
+
ALERT_BASE_URL,
|
| 10 |
+
EMAIL_FROM_ADDRESS,
|
| 11 |
+
EMAIL_SMTP_HOST,
|
| 12 |
+
EMAIL_SMTP_PASSWORD,
|
| 13 |
+
EMAIL_SMTP_PORT,
|
| 14 |
+
)
|
| 15 |
+
from services.observability import setup_logger
|
| 16 |
+
|
| 17 |
+
logger = setup_logger(__name__)
|
| 18 |
+
|
| 19 |
+
_CRITERIA_LABELS = {
|
| 20 |
+
"spike": "Price spike",
|
| 21 |
+
"rate_change": "Rate change",
|
| 22 |
+
"price_threshold": "Price threshold",
|
| 23 |
+
"availability_change": "Availability change",
|
| 24 |
+
"new_listing": "New listing tracked",
|
| 25 |
+
"tracking_removed": "Property delisted",
|
| 26 |
+
"anomaly": "Pricing anomaly",
|
| 27 |
+
"volatility": "Volatility alert",
|
| 28 |
+
"trend_reversal": "Market trend reversal",
|
| 29 |
+
"digest": "Market digest",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
_FREQUENCY_DESCRIPTIONS = {
|
| 33 |
+
"spike": "Checked 4Γ daily after each data scrape. Max 1 alert per 24 hours.",
|
| 34 |
+
"rate_change": "Checked 4Γ daily after each data scrape. Max 1 alert per 24 hours.",
|
| 35 |
+
"price_threshold": "Checked 4Γ daily after each data scrape. Max 1 alert per 24 hours.",
|
| 36 |
+
"availability_change": "Checked 4Γ daily after each data scrape. Max 1 alert per 24 hours.",
|
| 37 |
+
"new_listing": "Checked 4Γ daily after each data scrape. Max 1 alert per 24 hours.",
|
| 38 |
+
"tracking_removed": "Checked 4Γ daily after each data scrape. Max 1 alert per 24 hours.",
|
| 39 |
+
"anomaly": "Checked 4Γ daily after each data scrape. Max 1 alert per 24 hours.",
|
| 40 |
+
"volatility": "Checked 4Γ daily after each data scrape. Max 1 alert per 24 hours.",
|
| 41 |
+
"trend_reversal": "Checked 4Γ daily after each data scrape. Max 1 alert per 24 hours.",
|
| 42 |
+
"digest": "Sent on your chosen schedule (daily or weekly).",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def build_criteria_summary(criteria_type: str, criteria: dict) -> str:
|
| 47 |
+
"""Convert structured criteria into a human-readable summary string."""
|
| 48 |
+
label = _CRITERIA_LABELS.get(criteria_type, criteria_type)
|
| 49 |
+
market = criteria.get("market", "")
|
| 50 |
+
prop = criteria.get("property_search", "")
|
| 51 |
+
|
| 52 |
+
if criteria_type == "spike":
|
| 53 |
+
threshold = criteria.get("threshold_pct", 25)
|
| 54 |
+
target = f" in {market}" if market else ""
|
| 55 |
+
return f"{label} β₯{threshold}%{target}"
|
| 56 |
+
|
| 57 |
+
if criteria_type == "price_threshold":
|
| 58 |
+
op = criteria.get("operator", "below")
|
| 59 |
+
val = criteria.get("value", "?")
|
| 60 |
+
return f"{label}: {prop} {op} {val}/night"
|
| 61 |
+
|
| 62 |
+
if criteria_type in ("availability_change",):
|
| 63 |
+
watch = criteria.get("watch_for", "any change")
|
| 64 |
+
return f"{label}: {prop} becomes {watch}"
|
| 65 |
+
|
| 66 |
+
if criteria_type == "digest":
|
| 67 |
+
freq = criteria.get("frequency", "weekly")
|
| 68 |
+
return f"{freq.capitalize()} {label} for {market}"
|
| 69 |
+
|
| 70 |
+
if criteria_type == "trend_reversal":
|
| 71 |
+
watch = criteria.get("watch_for", "any direction")
|
| 72 |
+
return f"{label}: {market} rates start {watch}"
|
| 73 |
+
|
| 74 |
+
if market:
|
| 75 |
+
return f"{label} in {market}"
|
| 76 |
+
|
| 77 |
+
if prop:
|
| 78 |
+
return f"{label} for {prop}"
|
| 79 |
+
|
| 80 |
+
return label
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def build_frequency_description(criteria_type: str, criteria: dict) -> str:
|
| 84 |
+
"""Describe expected alert frequency based on criteria type."""
|
| 85 |
+
if criteria_type == "digest":
|
| 86 |
+
freq = criteria.get("frequency", "weekly")
|
| 87 |
+
return f"Sent {freq}."
|
| 88 |
+
return _FREQUENCY_DESCRIPTIONS.get(criteria_type, "Checked periodically.")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def send_email(to: str, subject: str, body_html: str) -> bool:
|
| 92 |
+
"""Send email via configured SMTP provider. Returns True on success."""
|
| 93 |
+
loop = asyncio.get_event_loop()
|
| 94 |
+
try:
|
| 95 |
+
await loop.run_in_executor(None, _send_smtp, to, subject, body_html)
|
| 96 |
+
logger.info(f"[email] sent to={to} subject={subject[:50]}")
|
| 97 |
+
return True
|
| 98 |
+
except Exception as exc:
|
| 99 |
+
logger.error(f"[email] failed to={to}: {exc}")
|
| 100 |
+
return False
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _send_smtp(to: str, subject: str, body_html: str) -> None:
|
| 104 |
+
"""Blocking SMTP send β runs in executor."""
|
| 105 |
+
msg = MIMEMultipart("alternative")
|
| 106 |
+
msg["From"] = EMAIL_FROM_ADDRESS
|
| 107 |
+
msg["To"] = to
|
| 108 |
+
msg["Subject"] = subject
|
| 109 |
+
msg.attach(MIMEText(body_html, "html"))
|
| 110 |
+
|
| 111 |
+
with smtplib.SMTP(EMAIL_SMTP_HOST, EMAIL_SMTP_PORT) as server:
|
| 112 |
+
server.starttls()
|
| 113 |
+
server.login(EMAIL_FROM_ADDRESS, EMAIL_SMTP_PASSWORD)
|
| 114 |
+
server.sendmail(EMAIL_FROM_ADDRESS, to, msg.as_string())
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
async def send_confirmation_email(to: str, token: str, criteria_summary: str) -> bool:
|
| 118 |
+
"""Send double-opt-in confirmation email with token link."""
|
| 119 |
+
confirm_url = f"{ALERT_BASE_URL}/api/v1/alerts/confirm?token={token}"
|
| 120 |
+
subject = "Confirm Your Pulse AI Alert Subscription"
|
| 121 |
+
body = f"""
|
| 122 |
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: auto; padding: 20px;">
|
| 123 |
+
<h2 style="color: #1a1a2e;">Confirm Your Alert Subscription</h2>
|
| 124 |
+
<p>You requested a Pulse AI alert for:</p>
|
| 125 |
+
<div style="background: #f0f4ff; border-left: 4px solid #4361ee; padding: 12px 16px; margin: 16px 0; border-radius: 4px;">
|
| 126 |
+
<strong>{criteria_summary}</strong>
|
| 127 |
+
</div>
|
| 128 |
+
<p>Click the button below to activate this alert:</p>
|
| 129 |
+
<a href="{confirm_url}" style="display: inline-block; background: #4361ee; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 16px 0;">
|
| 130 |
+
Confirm Subscription
|
| 131 |
+
</a>
|
| 132 |
+
<p style="color: #666; font-size: 13px;">This link expires in 48 hours. If you didn't request this, ignore this email.</p>
|
| 133 |
+
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;">
|
| 134 |
+
<p style="color: #999; font-size: 12px;">Pulse AI β Real Estate Intelligence by Joule Dynamics</p>
|
| 135 |
+
</div>
|
| 136 |
+
"""
|
| 137 |
+
return await send_email(to, subject, body)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def send_subscription_active_email(
|
| 141 |
+
to: str,
|
| 142 |
+
criteria_type: str,
|
| 143 |
+
criteria: dict,
|
| 144 |
+
unsubscribe_token: str,
|
| 145 |
+
) -> bool:
|
| 146 |
+
"""Send 'subscription active' email with alert template preview and frequency."""
|
| 147 |
+
summary = build_criteria_summary(criteria_type, criteria)
|
| 148 |
+
frequency = build_frequency_description(criteria_type, criteria)
|
| 149 |
+
unsub_url = f"{ALERT_BASE_URL}/api/v1/alerts/unsubscribe?token={unsubscribe_token}"
|
| 150 |
+
subject = "β
Your Pulse AI Alert Is Now Active"
|
| 151 |
+
body = f"""
|
| 152 |
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: auto; padding: 20px;">
|
| 153 |
+
<h2 style="color: #1a1a2e;">Your Alert Is Active!</h2>
|
| 154 |
+
<p>Your Pulse AI alert subscription has been confirmed and is now live.</p>
|
| 155 |
+
|
| 156 |
+
<h3 style="color: #4361ee;">What You Subscribed To</h3>
|
| 157 |
+
<div style="background: #f0f4ff; border-left: 4px solid #4361ee; padding: 12px 16px; margin: 8px 0; border-radius: 4px;">
|
| 158 |
+
<strong>{summary}</strong>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
<h3 style="color: #4361ee;">How Often You'll Hear From Us</h3>
|
| 162 |
+
<p>{frequency}</p>
|
| 163 |
+
|
| 164 |
+
<h3 style="color: #4361ee;">What Alert Emails Look Like</h3>
|
| 165 |
+
<div style="background: #fff8e1; border: 1px solid #ffe082; padding: 16px; margin: 8px 0; border-radius: 4px;">
|
| 166 |
+
<p style="font-size: 13px; color: #666;"><em>Sample alert preview:</em></p>
|
| 167 |
+
<p><strong>π {summary}</strong></p>
|
| 168 |
+
<p>Example: "2 properties triggered this alert β Property A rate changed from $120 to $155/night (+29.2%), Property B..."</p>
|
| 169 |
+
<p style="font-size: 12px;"><a href="#">View in Dashboard</a> Β· <a href="#">Unsubscribe</a></p>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;">
|
| 173 |
+
<p style="color: #999; font-size: 12px;">
|
| 174 |
+
<a href="{unsub_url}" style="color: #999;">Unsubscribe from this alert</a> Β·
|
| 175 |
+
Pulse AI β Real Estate Intelligence by Joule Dynamics
|
| 176 |
+
</p>
|
| 177 |
+
</div>
|
| 178 |
+
"""
|
| 179 |
+
return await send_email(to, subject, body)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
async def send_alert_email(
|
| 183 |
+
to: str, subject: str, alert_body: str, unsubscribe_token: str
|
| 184 |
+
) -> bool:
|
| 185 |
+
"""Send triggered alert email with mandatory unsubscribe link."""
|
| 186 |
+
unsub_url = f"{ALERT_BASE_URL}/api/v1/alerts/unsubscribe?token={unsubscribe_token}"
|
| 187 |
+
body = f"""
|
| 188 |
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: auto; padding: 20px;">
|
| 189 |
+
{alert_body}
|
| 190 |
+
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;">
|
| 191 |
+
<p style="color: #999; font-size: 12px;">
|
| 192 |
+
<a href="{unsub_url}" style="color: #999;">Unsubscribe from this alert</a> Β·
|
| 193 |
+
Pulse AI β Real Estate Intelligence by Joule Dynamics
|
| 194 |
+
</p>
|
| 195 |
+
</div>
|
| 196 |
+
"""
|
| 197 |
+
return await send_email(to, subject, body)
|
services/prompts.py
CHANGED
|
@@ -3,7 +3,7 @@ Prompt definitions and dynamic prompt composer for the Joule Dynamics Real Estat
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
ROUTER_PROMPT = """You are the classification router for the Joule Dynamics Real Estate Intelligence Layer.
|
| 6 |
-
Analyze the user query and classify it into EXACTLY ONE of
|
| 7 |
|
| 8 |
1. "OUT_OF_SCOPE": Query asks about topics completely unrelated to real estate, data monitoring, or the current conversation (e.g. sports, general coding, cooking, recipes). IMPORTANT: Questions asking who you are ("who are you?"), what model you are, or asking to summarize/recap what was discussed in the current chat ("what have we discussed so far?", "summarize our chat") are ALWAYS IN-SCOPE and must NEVER be classified as OUT_OF_SCOPE.
|
| 9 |
2. "PATH_A": Query asks a live-data question (prices, spikes, availability, market averages, KPIs, specific listing rates).
|
|
@@ -11,10 +11,11 @@ Analyze the user query and classify it into EXACTLY ONE of six classifications:
|
|
| 11 |
4. "BOTH": Query requires BOTH explaining a dashboard UI/methodology concept AND fetching live data metrics via tools.
|
| 12 |
5. "GREETING": User is saying hello, thanking the assistant, asking "who are you?", or making casual conversation.
|
| 13 |
6. "COMMERCIAL_HANDOFF": Query asks about getting started, hiring Joule Dynamics, custom builds, custom dashboards, pricing for software, or requests tracking for their own specific portfolio outside the demo scope.
|
|
|
|
| 14 |
|
| 15 |
Respond ONLY with valid JSON matching this schema:
|
| 16 |
{
|
| 17 |
-
"classification": "OUT_OF_SCOPE" | "PATH_A" | "PATH_B" | "BOTH" | "GREETING" | "COMMERCIAL_HANDOFF",
|
| 18 |
"tool_categories": ["MARKET" | "ANOMALY" | "PROPERTY" | "GEO"],
|
| 19 |
"reason": "1-sentence justification"
|
| 20 |
}
|
|
@@ -24,7 +25,7 @@ INSTRUCTIONS FOR tool_categories:
|
|
| 24 |
* "ANOMALY": For spike alerts, rate deviations, price crashes, volatile listings.
|
| 25 |
* "PROPERTY": For specific listing details, comparisons, availability, price changes.
|
| 26 |
* "GEO": For addresses, neighborhoods, coordinates, nearby listings, distances.
|
| 27 |
-
- For "PATH_B", "GREETING", "COMMERCIAL_HANDOFF", or "OUT_OF_SCOPE", set "tool_categories": [].
|
| 28 |
"""
|
| 29 |
|
| 30 |
_PROMPT_CORE = """\
|
|
@@ -95,6 +96,37 @@ When a user asks about custom builds, deploying this system for their business,
|
|
| 95 |
"""
|
| 96 |
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
def build_system_prompt(classification: str, markets: list | None = None) -> str:
|
| 99 |
"""
|
| 100 |
Compose the system prompt from core + relevant extension blocks only.
|
|
@@ -118,4 +150,7 @@ def build_system_prompt(classification: str, markets: list | None = None) -> str
|
|
| 118 |
if classification == "COMMERCIAL_HANDOFF":
|
| 119 |
prompt += _PROMPT_COMMERCIAL
|
| 120 |
|
|
|
|
|
|
|
|
|
|
| 121 |
return prompt
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
ROUTER_PROMPT = """You are the classification router for the Joule Dynamics Real Estate Intelligence Layer.
|
| 6 |
+
Analyze the user query and classify it into EXACTLY ONE of seven classifications:
|
| 7 |
|
| 8 |
1. "OUT_OF_SCOPE": Query asks about topics completely unrelated to real estate, data monitoring, or the current conversation (e.g. sports, general coding, cooking, recipes). IMPORTANT: Questions asking who you are ("who are you?"), what model you are, or asking to summarize/recap what was discussed in the current chat ("what have we discussed so far?", "summarize our chat") are ALWAYS IN-SCOPE and must NEVER be classified as OUT_OF_SCOPE.
|
| 9 |
2. "PATH_A": Query asks a live-data question (prices, spikes, availability, market averages, KPIs, specific listing rates).
|
|
|
|
| 11 |
4. "BOTH": Query requires BOTH explaining a dashboard UI/methodology concept AND fetching live data metrics via tools.
|
| 12 |
5. "GREETING": User is saying hello, thanking the assistant, asking "who are you?", or making casual conversation.
|
| 13 |
6. "COMMERCIAL_HANDOFF": Query asks about getting started, hiring Joule Dynamics, custom builds, custom dashboards, pricing for software, or requests tracking for their own specific portfolio outside the demo scope.
|
| 14 |
+
7. "ALERT_SUBSCRIPTION": User wants to be notified about a future event β price spikes, rate changes, availability changes, new listings, pricing anomalies, volatility, trend reversals, or recurring market digests. Keywords: "alert me", "notify me", "let me know when", "tell me when", "tell me whenever", "subscribe", "watch this", "track this for me". This is DISTINCT from PATH_A (which asks about current/historical data, not future notifications).
|
| 15 |
|
| 16 |
Respond ONLY with valid JSON matching this schema:
|
| 17 |
{
|
| 18 |
+
"classification": "OUT_OF_SCOPE" | "PATH_A" | "PATH_B" | "BOTH" | "GREETING" | "COMMERCIAL_HANDOFF" | "ALERT_SUBSCRIPTION",
|
| 19 |
"tool_categories": ["MARKET" | "ANOMALY" | "PROPERTY" | "GEO"],
|
| 20 |
"reason": "1-sentence justification"
|
| 21 |
}
|
|
|
|
| 25 |
* "ANOMALY": For spike alerts, rate deviations, price crashes, volatile listings.
|
| 26 |
* "PROPERTY": For specific listing details, comparisons, availability, price changes.
|
| 27 |
* "GEO": For addresses, neighborhoods, coordinates, nearby listings, distances.
|
| 28 |
+
- For "PATH_B", "GREETING", "COMMERCIAL_HANDOFF", "ALERT_SUBSCRIPTION", or "OUT_OF_SCOPE", set "tool_categories": [].
|
| 29 |
"""
|
| 30 |
|
| 31 |
_PROMPT_CORE = """\
|
|
|
|
| 96 |
"""
|
| 97 |
|
| 98 |
|
| 99 |
+
_PROMPT_ALERT = """
|
| 100 |
+
ALERT SUBSCRIPTIONS: When a user wants to be notified about a future event,
|
| 101 |
+
determine if you have enough structured detail to create a subscription.
|
| 102 |
+
|
| 103 |
+
REQUIRED before calling create_alert_subscription:
|
| 104 |
+
1. WHAT condition β one of the 10 supported types:
|
| 105 |
+
- spike: Sharp price jumps β₯ threshold % in a market
|
| 106 |
+
- rate_change: Any rate increase or decrease in a market
|
| 107 |
+
- price_threshold: Property price crosses above/below a specific value
|
| 108 |
+
- availability_change: Property becomes available or unavailable
|
| 109 |
+
- new_listing: New property added to tracking in a market
|
| 110 |
+
- tracking_removed: Property removed from active tracking
|
| 111 |
+
- anomaly: Anomalous pricing deviating from historical baseline
|
| 112 |
+
- volatility: Property enters the most volatile rankings
|
| 113 |
+
- trend_reversal: Market rate direction changes (rising to falling or vice versa)
|
| 114 |
+
- digest: Periodic market summary (daily or weekly)
|
| 115 |
+
2. WHICH market or property (depending on the type)
|
| 116 |
+
3. EMAIL ADDRESS to send alerts to
|
| 117 |
+
|
| 118 |
+
If ANY of these is missing, ask conversationally and invoke suggest_actions
|
| 119 |
+
with concrete options to help the user decide (e.g. market names, alert type
|
| 120 |
+
choices like "Any spike β₯25%" vs "A specific price threshold").
|
| 121 |
+
|
| 122 |
+
If all details are present, call create_alert_subscription. This creates a
|
| 123 |
+
PENDING subscription only β tell the user a confirmation email is on its way
|
| 124 |
+
and the alert won't be active until they confirm it via the email link.
|
| 125 |
+
|
| 126 |
+
NEVER ask for or store any personal information beyond an email address.
|
| 127 |
+
"""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
def build_system_prompt(classification: str, markets: list | None = None) -> str:
|
| 131 |
"""
|
| 132 |
Compose the system prompt from core + relevant extension blocks only.
|
|
|
|
| 150 |
if classification == "COMMERCIAL_HANDOFF":
|
| 151 |
prompt += _PROMPT_COMMERCIAL
|
| 152 |
|
| 153 |
+
if classification == "ALERT_SUBSCRIPTION":
|
| 154 |
+
prompt += _PROMPT_ALERT
|
| 155 |
+
|
| 156 |
return prompt
|
services/query_router.py
CHANGED
|
@@ -17,7 +17,8 @@ from services.prompts import ROUTER_PROMPT
|
|
| 17 |
logger = setup_logger(__name__)
|
| 18 |
|
| 19 |
_VALID_CLASSIFICATIONS = frozenset({
|
| 20 |
-
"OUT_OF_SCOPE", "PATH_A", "PATH_B", "BOTH", "GREETING", "COMMERCIAL_HANDOFF"
|
|
|
|
| 21 |
})
|
| 22 |
|
| 23 |
|
|
|
|
| 17 |
logger = setup_logger(__name__)
|
| 18 |
|
| 19 |
_VALID_CLASSIFICATIONS = frozenset({
|
| 20 |
+
"OUT_OF_SCOPE", "PATH_A", "PATH_B", "BOTH", "GREETING", "COMMERCIAL_HANDOFF",
|
| 21 |
+
"ALERT_SUBSCRIPTION",
|
| 22 |
})
|
| 23 |
|
| 24 |
|
services/tool_executor.py
CHANGED
|
@@ -86,11 +86,23 @@ async def _data_export_handler(func_args: dict) -> dict:
|
|
| 86 |
return await upload_document_to_appwrite(content, "md")
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
_LOCAL_HANDLERS = {
|
| 90 |
"suggest_actions": _suggest_actions_handler,
|
| 91 |
"generate_contact_buttons": _contact_buttons_handler,
|
| 92 |
"geocode_address": _geocode_handler,
|
| 93 |
"generate_data_export": _data_export_handler,
|
|
|
|
| 94 |
}
|
| 95 |
|
| 96 |
|
|
|
|
| 86 |
return await upload_document_to_appwrite(content, "md")
|
| 87 |
|
| 88 |
|
| 89 |
+
async def _alert_subscription_handler(func_args: dict) -> dict:
|
| 90 |
+
"""Forwards alert subscription creation to alert_service."""
|
| 91 |
+
from services.alert_service import create_pending_subscription
|
| 92 |
+
return await create_pending_subscription(
|
| 93 |
+
email=func_args.get("email", ""),
|
| 94 |
+
criteria_type=func_args.get("criteria_type", ""),
|
| 95 |
+
criteria=func_args.get("criteria", {}),
|
| 96 |
+
raw_text=func_args.get("raw_request_text", ""),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
_LOCAL_HANDLERS = {
|
| 101 |
"suggest_actions": _suggest_actions_handler,
|
| 102 |
"generate_contact_buttons": _contact_buttons_handler,
|
| 103 |
"geocode_address": _geocode_handler,
|
| 104 |
"generate_data_export": _data_export_handler,
|
| 105 |
+
"create_alert_subscription": _alert_subscription_handler,
|
| 106 |
}
|
| 107 |
|
| 108 |
|
tests/test_alert_system.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Simulated tests for the Pulse AI Alert Subscription system.
|
| 2 |
+
|
| 3 |
+
All tests use mocks β no LLM, Supabase, or SMTP calls.
|
| 4 |
+
Tests cover: router classification, tool resolution, prompt composition,
|
| 5 |
+
tool executor dispatch, subscription cap enforcement, cooldown logic,
|
| 6 |
+
evaluator dispatch, and criteria summary builder.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
from datetime import datetime, timezone, timedelta
|
| 11 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def event_loop():
|
| 18 |
+
"""Provide a fresh event loop for each test."""
|
| 19 |
+
loop = asyncio.new_event_loop()
|
| 20 |
+
yield loop
|
| 21 |
+
loop.close()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TestRouterClassification:
|
| 25 |
+
"""Verify ALERT_SUBSCRIPTION is a valid classification."""
|
| 26 |
+
|
| 27 |
+
def test_alert_subscription_in_valid_classifications(self):
|
| 28 |
+
from services.query_router import _VALID_CLASSIFICATIONS
|
| 29 |
+
assert "ALERT_SUBSCRIPTION" in _VALID_CLASSIFICATIONS
|
| 30 |
+
|
| 31 |
+
def test_existing_classifications_preserved(self):
|
| 32 |
+
from services.query_router import _VALID_CLASSIFICATIONS
|
| 33 |
+
for cls in ("OUT_OF_SCOPE", "PATH_A", "PATH_B", "BOTH", "GREETING", "COMMERCIAL_HANDOFF"):
|
| 34 |
+
assert cls in _VALID_CLASSIFICATIONS
|
| 35 |
+
|
| 36 |
+
def test_total_classification_count(self):
|
| 37 |
+
from services.query_router import _VALID_CLASSIFICATIONS
|
| 38 |
+
assert len(_VALID_CLASSIFICATIONS) == 7
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TestToolResolution:
|
| 42 |
+
"""Verify _resolve_active_tools returns exactly 2 tools for ALERT_SUBSCRIPTION."""
|
| 43 |
+
|
| 44 |
+
def test_alert_subscription_returns_two_tools(self):
|
| 45 |
+
from services.agent_loop import _resolve_active_tools
|
| 46 |
+
tools = _resolve_active_tools("ALERT_SUBSCRIPTION", "alert me", [], [])
|
| 47 |
+
assert tools is not None
|
| 48 |
+
assert len(tools) == 2
|
| 49 |
+
tool_names = {t["function"]["name"] for t in tools}
|
| 50 |
+
assert tool_names == {"create_alert_subscription", "suggest_actions"}
|
| 51 |
+
|
| 52 |
+
def test_path_a_unchanged(self):
|
| 53 |
+
from services.agent_loop import _resolve_active_tools
|
| 54 |
+
with patch("services.agent_loop.discover_tools", return_value=[{"function": {"name": "mock"}}]):
|
| 55 |
+
tools = _resolve_active_tools("PATH_A", "show me spikes", [], ["ANOMALY"])
|
| 56 |
+
assert tools is not None
|
| 57 |
+
|
| 58 |
+
def test_path_b_unchanged(self):
|
| 59 |
+
from services.agent_loop import _resolve_active_tools
|
| 60 |
+
tools = _resolve_active_tools("PATH_B", "what does the sparkline mean", [], [])
|
| 61 |
+
assert tools is not None
|
| 62 |
+
assert len(tools) == 1
|
| 63 |
+
assert tools[0]["function"]["name"] == "suggest_actions"
|
| 64 |
+
|
| 65 |
+
def test_commercial_unchanged(self):
|
| 66 |
+
from services.agent_loop import _resolve_active_tools
|
| 67 |
+
from services.tools import COMMERCIAL_TOOLS
|
| 68 |
+
tools = _resolve_active_tools("COMMERCIAL_HANDOFF", "how much", [], [])
|
| 69 |
+
assert tools == COMMERCIAL_TOOLS
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TestPromptComposition:
|
| 73 |
+
"""Verify build_system_prompt includes _PROMPT_ALERT for ALERT_SUBSCRIPTION."""
|
| 74 |
+
|
| 75 |
+
def test_alert_prompt_included(self):
|
| 76 |
+
from services.prompts import build_system_prompt
|
| 77 |
+
prompt = build_system_prompt("ALERT_SUBSCRIPTION", markets=["Miami", "Abuja"])
|
| 78 |
+
assert "ALERT SUBSCRIPTIONS" in prompt
|
| 79 |
+
assert "create_alert_subscription" in prompt
|
| 80 |
+
|
| 81 |
+
def test_alert_prompt_excludes_data_prompts(self):
|
| 82 |
+
from services.prompts import build_system_prompt
|
| 83 |
+
prompt = build_system_prompt("ALERT_SUBSCRIPTION", markets=["Miami"])
|
| 84 |
+
assert "ADVISORY & STRATEGY" not in prompt
|
| 85 |
+
assert "DASHBOARD UI" not in prompt
|
| 86 |
+
assert "COMMERCIAL HANDOFFS" not in prompt
|
| 87 |
+
|
| 88 |
+
def test_path_a_prompt_excludes_alert(self):
|
| 89 |
+
from services.prompts import build_system_prompt
|
| 90 |
+
prompt = build_system_prompt("PATH_A", markets=["Miami"])
|
| 91 |
+
assert "ALERT SUBSCRIPTIONS" not in prompt
|
| 92 |
+
assert "ADVISORY & STRATEGY" in prompt
|
| 93 |
+
|
| 94 |
+
def test_all_ten_criteria_types_in_prompt(self):
|
| 95 |
+
from services.prompts import build_system_prompt
|
| 96 |
+
prompt = build_system_prompt("ALERT_SUBSCRIPTION", markets=["Miami"])
|
| 97 |
+
for ctype in ("spike", "rate_change", "price_threshold", "availability_change",
|
| 98 |
+
"new_listing", "tracking_removed", "anomaly", "volatility",
|
| 99 |
+
"trend_reversal", "digest"):
|
| 100 |
+
assert ctype in prompt, f"Missing criteria_type '{ctype}' in _PROMPT_ALERT"
|
| 101 |
+
|
| 102 |
+
def test_router_prompt_contains_alert_classification(self):
|
| 103 |
+
from services.prompts import ROUTER_PROMPT
|
| 104 |
+
assert "ALERT_SUBSCRIPTION" in ROUTER_PROMPT
|
| 105 |
+
assert "seven classifications" in ROUTER_PROMPT
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class TestToolExecutorDispatch:
|
| 109 |
+
"""Verify create_alert_subscription routes to the handler."""
|
| 110 |
+
|
| 111 |
+
def test_handler_registered(self):
|
| 112 |
+
from services.tool_executor import _LOCAL_HANDLERS
|
| 113 |
+
assert "create_alert_subscription" in _LOCAL_HANDLERS
|
| 114 |
+
|
| 115 |
+
@pytest.mark.asyncio
|
| 116 |
+
async def test_handler_calls_alert_service(self):
|
| 117 |
+
mock_result = {"status": "pending", "message": "Confirmation email sent"}
|
| 118 |
+
with patch("services.alert_service.create_pending_subscription", new_callable=AsyncMock, return_value=mock_result):
|
| 119 |
+
from services.tool_executor import execute_tool_by_name
|
| 120 |
+
result = await execute_tool_by_name("create_alert_subscription", {
|
| 121 |
+
"email": "test@example.com",
|
| 122 |
+
"criteria_type": "spike",
|
| 123 |
+
"criteria": {"market": "Miami", "threshold_pct": 25},
|
| 124 |
+
"raw_request_text": "Alert me on Miami spikes",
|
| 125 |
+
})
|
| 126 |
+
assert result["status"] == "pending"
|
| 127 |
+
|
| 128 |
+
def test_existing_handlers_preserved(self):
|
| 129 |
+
from services.tool_executor import _LOCAL_HANDLERS
|
| 130 |
+
for handler in ("suggest_actions", "generate_contact_buttons", "geocode_address", "generate_data_export"):
|
| 131 |
+
assert handler in _LOCAL_HANDLERS
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class TestAlertToolSchema:
|
| 135 |
+
"""Verify the tool schema structure."""
|
| 136 |
+
|
| 137 |
+
def test_schema_has_required_fields(self):
|
| 138 |
+
from services.alert_tool_schema import CREATE_ALERT_SUBSCRIPTION_TOOL
|
| 139 |
+
func = CREATE_ALERT_SUBSCRIPTION_TOOL["function"]
|
| 140 |
+
assert func["name"] == "create_alert_subscription"
|
| 141 |
+
params = func["parameters"]["properties"]
|
| 142 |
+
assert "email" in params
|
| 143 |
+
assert "criteria_type" in params
|
| 144 |
+
assert "criteria" in params
|
| 145 |
+
assert "raw_request_text" in params
|
| 146 |
+
|
| 147 |
+
def test_criteria_type_enum_has_ten_values(self):
|
| 148 |
+
from services.alert_tool_schema import CREATE_ALERT_SUBSCRIPTION_TOOL
|
| 149 |
+
enum_vals = CREATE_ALERT_SUBSCRIPTION_TOOL["function"]["parameters"]["properties"]["criteria_type"]["enum"]
|
| 150 |
+
assert len(enum_vals) == 10
|
| 151 |
+
expected = {"spike", "rate_change", "price_threshold", "availability_change",
|
| 152 |
+
"new_listing", "tracking_removed", "anomaly", "volatility",
|
| 153 |
+
"trend_reversal", "digest"}
|
| 154 |
+
assert set(enum_vals) == expected
|
| 155 |
+
|
| 156 |
+
def test_required_fields(self):
|
| 157 |
+
from services.alert_tool_schema import CREATE_ALERT_SUBSCRIPTION_TOOL
|
| 158 |
+
required = CREATE_ALERT_SUBSCRIPTION_TOOL["function"]["parameters"]["required"]
|
| 159 |
+
assert set(required) == {"email", "criteria_type", "criteria", "raw_request_text"}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class TestCriteriaSummaryBuilder:
|
| 163 |
+
"""Verify human-readable criteria summary generation."""
|
| 164 |
+
|
| 165 |
+
def test_spike_summary(self):
|
| 166 |
+
from services.email_service import build_criteria_summary
|
| 167 |
+
result = build_criteria_summary("spike", {"market": "Miami", "threshold_pct": 25})
|
| 168 |
+
assert "spike" in result.lower() or "25%" in result
|
| 169 |
+
assert "Miami" in result
|
| 170 |
+
|
| 171 |
+
def test_price_threshold_summary(self):
|
| 172 |
+
from services.email_service import build_criteria_summary
|
| 173 |
+
result = build_criteria_summary("price_threshold", {
|
| 174 |
+
"property_search": "Cozy Studio", "operator": "below", "value": 40000,
|
| 175 |
+
})
|
| 176 |
+
assert "Cozy Studio" in result
|
| 177 |
+
assert "below" in result
|
| 178 |
+
|
| 179 |
+
def test_digest_summary(self):
|
| 180 |
+
from services.email_service import build_criteria_summary
|
| 181 |
+
result = build_criteria_summary("digest", {"market": "Lagos", "frequency": "weekly"})
|
| 182 |
+
assert "Lagos" in result
|
| 183 |
+
assert "Weekly" in result or "weekly" in result.lower()
|
| 184 |
+
|
| 185 |
+
def test_trend_reversal_summary(self):
|
| 186 |
+
from services.email_service import build_criteria_summary
|
| 187 |
+
result = build_criteria_summary("trend_reversal", {"market": "Abuja", "watch_for": "falling"})
|
| 188 |
+
assert "Abuja" in result
|
| 189 |
+
assert "falling" in result
|
| 190 |
+
|
| 191 |
+
def test_availability_change_summary(self):
|
| 192 |
+
from services.email_service import build_criteria_summary
|
| 193 |
+
result = build_criteria_summary("availability_change", {
|
| 194 |
+
"property_search": "Beach House", "watch_for": "unavailable",
|
| 195 |
+
})
|
| 196 |
+
assert "Beach House" in result
|
| 197 |
+
|
| 198 |
+
def test_market_only_types(self):
|
| 199 |
+
from services.email_service import build_criteria_summary
|
| 200 |
+
for ctype in ("rate_change", "new_listing", "tracking_removed", "volatility"):
|
| 201 |
+
result = build_criteria_summary(ctype, {"market": "Miami"})
|
| 202 |
+
assert "Miami" in result
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class TestCooldownLogic:
|
| 206 |
+
"""Verify cooldown enforcement in the alert evaluator."""
|
| 207 |
+
|
| 208 |
+
def test_no_last_fired_not_in_cooldown(self):
|
| 209 |
+
from workers.alert_evaluator import _is_in_cooldown
|
| 210 |
+
assert not _is_in_cooldown({"last_fired_at": None})
|
| 211 |
+
|
| 212 |
+
def test_recently_fired_in_cooldown(self):
|
| 213 |
+
from workers.alert_evaluator import _is_in_cooldown
|
| 214 |
+
recent = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
|
| 215 |
+
assert _is_in_cooldown({"last_fired_at": recent}, cooldown_hours=24)
|
| 216 |
+
|
| 217 |
+
def test_old_fired_not_in_cooldown(self):
|
| 218 |
+
from workers.alert_evaluator import _is_in_cooldown
|
| 219 |
+
old = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat()
|
| 220 |
+
assert not _is_in_cooldown({"last_fired_at": old}, cooldown_hours=24)
|
| 221 |
+
|
| 222 |
+
def test_exactly_at_boundary(self):
|
| 223 |
+
from workers.alert_evaluator import _is_in_cooldown
|
| 224 |
+
boundary = (datetime.now(timezone.utc) - timedelta(hours=24, seconds=1)).isoformat()
|
| 225 |
+
assert not _is_in_cooldown({"last_fired_at": boundary}, cooldown_hours=24)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class TestEvaluatorDispatch:
|
| 229 |
+
"""Verify all 9 event-driven criteria types have evaluator functions."""
|
| 230 |
+
|
| 231 |
+
def test_all_evaluators_registered(self):
|
| 232 |
+
from workers.alert_evaluator import _EVALUATORS
|
| 233 |
+
expected = {"spike", "rate_change", "price_threshold", "availability_change",
|
| 234 |
+
"new_listing", "tracking_removed", "anomaly", "volatility", "trend_reversal"}
|
| 235 |
+
assert set(_EVALUATORS.keys()) == expected
|
| 236 |
+
|
| 237 |
+
def test_digest_not_in_event_evaluators(self):
|
| 238 |
+
from workers.alert_evaluator import _EVALUATORS
|
| 239 |
+
assert "digest" not in _EVALUATORS
|
| 240 |
+
|
| 241 |
+
def test_evaluators_are_coroutines(self):
|
| 242 |
+
from workers.alert_evaluator import _EVALUATORS
|
| 243 |
+
import asyncio
|
| 244 |
+
for name, fn in _EVALUATORS.items():
|
| 245 |
+
assert asyncio.iscoroutinefunction(fn), f"Evaluator {name} is not async"
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
class TestDigestSender:
|
| 249 |
+
"""Verify digest schedule logic."""
|
| 250 |
+
|
| 251 |
+
def test_never_fired_is_due(self):
|
| 252 |
+
from workers.digest_sender import _is_due
|
| 253 |
+
assert _is_due(None, "weekly")
|
| 254 |
+
assert _is_due(None, "daily")
|
| 255 |
+
|
| 256 |
+
def test_daily_not_due_recently(self):
|
| 257 |
+
from workers.digest_sender import _is_due
|
| 258 |
+
recent = (datetime.now(timezone.utc) - timedelta(hours=12)).isoformat()
|
| 259 |
+
assert not _is_due(recent, "daily")
|
| 260 |
+
|
| 261 |
+
def test_daily_due_after_23h(self):
|
| 262 |
+
from workers.digest_sender import _is_due
|
| 263 |
+
old = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
| 264 |
+
assert _is_due(old, "daily")
|
| 265 |
+
|
| 266 |
+
def test_weekly_not_due_recently(self):
|
| 267 |
+
from workers.digest_sender import _is_due
|
| 268 |
+
recent = (datetime.now(timezone.utc) - timedelta(days=3)).isoformat()
|
| 269 |
+
assert not _is_due(recent, "weekly")
|
| 270 |
+
|
| 271 |
+
def test_weekly_due_after_6_days(self):
|
| 272 |
+
from workers.digest_sender import _is_due
|
| 273 |
+
old = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
|
| 274 |
+
assert _is_due(old, "weekly")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class TestFrequencyDescription:
|
| 278 |
+
"""Verify frequency description generation."""
|
| 279 |
+
|
| 280 |
+
def test_event_driven_types(self):
|
| 281 |
+
from services.email_service import build_frequency_description
|
| 282 |
+
for ctype in ("spike", "rate_change", "price_threshold"):
|
| 283 |
+
desc = build_frequency_description(ctype, {})
|
| 284 |
+
assert "4Γ" in desc or "4x" in desc.lower()
|
| 285 |
+
|
| 286 |
+
def test_digest_type(self):
|
| 287 |
+
from services.email_service import build_frequency_description
|
| 288 |
+
desc = build_frequency_description("digest", {"frequency": "weekly"})
|
| 289 |
+
assert "weekly" in desc.lower()
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class TestConfigConstants:
|
| 293 |
+
"""Verify new config constants are accessible."""
|
| 294 |
+
|
| 295 |
+
def test_email_config_exists(self):
|
| 296 |
+
from config import EMAIL_SMTP_HOST, EMAIL_SMTP_PORT, EMAIL_FROM_ADDRESS
|
| 297 |
+
assert EMAIL_SMTP_HOST == "smtp.gmail.com"
|
| 298 |
+
assert EMAIL_SMTP_PORT == 587
|
| 299 |
+
assert "jouledynamicscto" in EMAIL_FROM_ADDRESS
|
| 300 |
+
|
| 301 |
+
def test_alert_config_exists(self):
|
| 302 |
+
from config import ALERT_COOLDOWN_HOURS, MAX_SUBSCRIPTIONS_PER_EMAIL, CONFIRMATION_EXPIRY_HOURS
|
| 303 |
+
assert ALERT_COOLDOWN_HOURS == 24
|
| 304 |
+
assert MAX_SUBSCRIPTIONS_PER_EMAIL == 5
|
| 305 |
+
assert CONFIRMATION_EXPIRY_HOURS == 48
|
| 306 |
+
|
| 307 |
+
def test_service_role_key_fallback(self):
|
| 308 |
+
from config import SUPABASE_SERVICE_ROLE_KEY, SUPABASE_KEY
|
| 309 |
+
assert SUPABASE_SERVICE_ROLE_KEY, "SUPABASE_SERVICE_ROLE_KEY should not be empty"
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
class TestRoutesRegistered:
|
| 313 |
+
"""Verify alert routes are registered in the v1 router."""
|
| 314 |
+
|
| 315 |
+
def test_alerts_router_imported(self):
|
| 316 |
+
from routes.v1.alerts import router
|
| 317 |
+
assert router is not None
|
| 318 |
+
|
| 319 |
+
def test_confirm_endpoint_exists(self):
|
| 320 |
+
from routes.v1.alerts import router
|
| 321 |
+
paths = [route.path for route in router.routes]
|
| 322 |
+
assert "/api/v1/alerts/confirm" in paths
|
| 323 |
+
|
| 324 |
+
def test_unsubscribe_endpoint_exists(self):
|
| 325 |
+
from routes.v1.alerts import router
|
| 326 |
+
paths = [route.path for route in router.routes]
|
| 327 |
+
assert "/api/v1/alerts/unsubscribe" in paths
|
| 328 |
+
|
| 329 |
+
def test_internal_evaluate_endpoint_exists(self):
|
| 330 |
+
from routes.v1.alerts import router
|
| 331 |
+
paths = [route.path for route in router.routes]
|
| 332 |
+
assert "/api/v1/internal/evaluate-alerts" in paths
|
tests/test_all_tools_simulated.py
CHANGED
|
@@ -91,7 +91,7 @@ def _assert_stream_events(events: list, tool_name: str):
|
|
| 91 |
assert tool_call_events[0]["tool"] == tool_name, f"Expected {tool_name}, got {tool_call_events[0]['tool']}"
|
| 92 |
|
| 93 |
|
| 94 |
-
async def
|
| 95 |
"""Tests a single tool execution within run_agent_loop with mocked LLM calls."""
|
| 96 |
call_count = 0
|
| 97 |
|
|
@@ -132,7 +132,7 @@ async def test_tool_non_streaming(tool_name: str, tool_args: dict) -> dict:
|
|
| 132 |
}
|
| 133 |
|
| 134 |
|
| 135 |
-
async def
|
| 136 |
"""Tests a single tool execution within run_agent_loop_streaming with mocked LLM calls."""
|
| 137 |
call_count = 0
|
| 138 |
|
|
@@ -176,7 +176,7 @@ async def _run_single_case(idx: int, tool_name: str, tool_args: dict) -> tuple[i
|
|
| 176 |
failures = []
|
| 177 |
|
| 178 |
try:
|
| 179 |
-
res_ns = await
|
| 180 |
print(f"[{idx:02d}/22] [SYNC] PASS: {tool_name.ljust(32)} -> {res_ns['preview']}")
|
| 181 |
passes += 1
|
| 182 |
except Exception as exc:
|
|
@@ -184,7 +184,7 @@ async def _run_single_case(idx: int, tool_name: str, tool_args: dict) -> tuple[i
|
|
| 184 |
failures.append((tool_name, "non-streaming", str(exc)))
|
| 185 |
|
| 186 |
try:
|
| 187 |
-
res_s = await
|
| 188 |
print(f"[{idx:02d}/22] [STREAM] PASS: {tool_name.ljust(32)} -> {res_s['events_count']} events yielded")
|
| 189 |
passes += 1
|
| 190 |
except Exception as exc:
|
|
|
|
| 91 |
assert tool_call_events[0]["tool"] == tool_name, f"Expected {tool_name}, got {tool_call_events[0]['tool']}"
|
| 92 |
|
| 93 |
|
| 94 |
+
async def _run_tool_non_streaming(tool_name: str, tool_args: dict) -> dict:
|
| 95 |
"""Tests a single tool execution within run_agent_loop with mocked LLM calls."""
|
| 96 |
call_count = 0
|
| 97 |
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
|
| 135 |
+
async def _run_tool_streaming(tool_name: str, tool_args: dict) -> dict:
|
| 136 |
"""Tests a single tool execution within run_agent_loop_streaming with mocked LLM calls."""
|
| 137 |
call_count = 0
|
| 138 |
|
|
|
|
| 176 |
failures = []
|
| 177 |
|
| 178 |
try:
|
| 179 |
+
res_ns = await _run_tool_non_streaming(tool_name, tool_args)
|
| 180 |
print(f"[{idx:02d}/22] [SYNC] PASS: {tool_name.ljust(32)} -> {res_ns['preview']}")
|
| 181 |
passes += 1
|
| 182 |
except Exception as exc:
|
|
|
|
| 184 |
failures.append((tool_name, "non-streaming", str(exc)))
|
| 185 |
|
| 186 |
try:
|
| 187 |
+
res_s = await _run_tool_streaming(tool_name, tool_args)
|
| 188 |
print(f"[{idx:02d}/22] [STREAM] PASS: {tool_name.ljust(32)} -> {res_s['events_count']} events yielded")
|
| 189 |
passes += 1
|
| 190 |
except Exception as exc:
|
workers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Workers package for background alert evaluation and maintenance jobs."""
|
workers/alert_cleanup.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cleanup job: deletes unconfirmed subscriptions older than the expiry window."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone, timedelta
|
| 4 |
+
|
| 5 |
+
from config import CONFIRMATION_EXPIRY_HOURS, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL
|
| 6 |
+
from services.observability import setup_logger
|
| 7 |
+
|
| 8 |
+
logger = setup_logger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _get_service_client():
|
| 12 |
+
"""Lazy-import Supabase service client to avoid circular imports at module level."""
|
| 13 |
+
from supabase import create_client
|
| 14 |
+
return create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def cleanup_stale_subscriptions() -> int:
|
| 18 |
+
"""Delete pending subscriptions where created_at < now() - expiry window.
|
| 19 |
+
|
| 20 |
+
Returns count of deleted subscriptions.
|
| 21 |
+
"""
|
| 22 |
+
client = _get_service_client()
|
| 23 |
+
cutoff = (datetime.now(timezone.utc) - timedelta(hours=CONFIRMATION_EXPIRY_HOURS)).isoformat()
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
result = (
|
| 27 |
+
client.table("alert_subscriptions")
|
| 28 |
+
.delete()
|
| 29 |
+
.eq("status", "pending")
|
| 30 |
+
.lt("created_at", cutoff)
|
| 31 |
+
.execute()
|
| 32 |
+
)
|
| 33 |
+
count = len(result.data) if result.data else 0
|
| 34 |
+
logger.info(f"[cleanup] deleted {count} stale pending subscriptions")
|
| 35 |
+
return count
|
| 36 |
+
except Exception as exc:
|
| 37 |
+
logger.error(f"[cleanup] failed: {exc}")
|
| 38 |
+
return 0
|
workers/alert_evaluator.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background worker: evaluates active alert subscriptions against fresh data.
|
| 2 |
+
|
| 3 |
+
Reuses existing Supabase RPCs via execute_tool_rpc() β zero duplicated query logic.
|
| 4 |
+
Each evaluator function is a small unit (CC <= 4) dispatched by criteria_type.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from datetime import datetime, timezone, timedelta
|
| 8 |
+
|
| 9 |
+
from config import ALERT_COOLDOWN_HOURS, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL
|
| 10 |
+
from services.email_service import build_criteria_summary, send_alert_email
|
| 11 |
+
from services.observability import setup_logger
|
| 12 |
+
from services.supabase_service import execute_tool_rpc
|
| 13 |
+
|
| 14 |
+
logger = setup_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _get_service_client():
|
| 18 |
+
"""Lazy-import Supabase service client to avoid circular imports at module level."""
|
| 19 |
+
from supabase import create_client
|
| 20 |
+
return create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _is_in_cooldown(sub: dict, cooldown_hours: int = ALERT_COOLDOWN_HOURS) -> bool:
|
| 24 |
+
"""Check if subscription is in cooldown window."""
|
| 25 |
+
last_fired = sub.get("last_fired_at")
|
| 26 |
+
if not last_fired:
|
| 27 |
+
return False
|
| 28 |
+
fired_dt = datetime.fromisoformat(last_fired.replace("Z", "+00:00"))
|
| 29 |
+
return datetime.now(timezone.utc) - fired_dt < timedelta(hours=cooldown_hours)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def _evaluate_spike(criteria: dict) -> tuple[bool, str]:
|
| 33 |
+
"""Check spike condition using get_spike_alerts RPC."""
|
| 34 |
+
market = criteria.get("market")
|
| 35 |
+
threshold = criteria.get("threshold_pct", 25)
|
| 36 |
+
result = await execute_tool_rpc("get_spike_alerts", {
|
| 37 |
+
"threshold_param": threshold,
|
| 38 |
+
"p_market": market,
|
| 39 |
+
"p_limit": 5,
|
| 40 |
+
"days_param": 1,
|
| 41 |
+
})
|
| 42 |
+
data = result.get("data", [])
|
| 43 |
+
if not data:
|
| 44 |
+
return False, ""
|
| 45 |
+
names = [f"{r.get('property_name', '?')} ({r.get('pct_change', '?')}%)" for r in data[:5]]
|
| 46 |
+
body = f"<h3>π Price Spike Alert β {market or 'All Markets'}</h3>"
|
| 47 |
+
body += f"<p>{len(data)} properties spiked β₯{threshold}%:</p><ul>"
|
| 48 |
+
body += "".join(f"<li>{n}</li>" for n in names) + "</ul>"
|
| 49 |
+
return True, body
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def _evaluate_rate_change(criteria: dict) -> tuple[bool, str]:
|
| 53 |
+
"""Check rate change condition using get_market_rate_changes RPC."""
|
| 54 |
+
market = criteria.get("market")
|
| 55 |
+
direction = criteria.get("direction", "both")
|
| 56 |
+
result = await execute_tool_rpc("get_market_rate_changes", {
|
| 57 |
+
"p_market": market,
|
| 58 |
+
"p_days": 1,
|
| 59 |
+
"p_limit": 5,
|
| 60 |
+
})
|
| 61 |
+
data = result.get("data", result)
|
| 62 |
+
total = data.get("total_changes", 0) if isinstance(data, dict) else 0
|
| 63 |
+
if total == 0:
|
| 64 |
+
return False, ""
|
| 65 |
+
|
| 66 |
+
if direction == "up" and not data.get("increases", 0):
|
| 67 |
+
return False, ""
|
| 68 |
+
if direction == "down" and not data.get("decreases", 0):
|
| 69 |
+
return False, ""
|
| 70 |
+
|
| 71 |
+
body = f"<h3>π Rate Change Alert β {market or 'All Markets'}</h3>"
|
| 72 |
+
body += f"<p>{total} properties had rate changes in the last scrape cycle.</p>"
|
| 73 |
+
examples = data.get("examples", [])
|
| 74 |
+
if examples:
|
| 75 |
+
body += "<ul>"
|
| 76 |
+
body += "".join(f"<li>{e.get('name', '?')}: {e.get('change', '?')}</li>" for e in examples[:5])
|
| 77 |
+
body += "</ul>"
|
| 78 |
+
return True, body
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def _evaluate_price_threshold(criteria: dict) -> tuple[bool, str]:
|
| 82 |
+
"""Check price threshold using get_property_snapshot RPC."""
|
| 83 |
+
prop = criteria.get("property_search", "")
|
| 84 |
+
operator = criteria.get("operator", "below")
|
| 85 |
+
value = criteria.get("value", 0)
|
| 86 |
+
result = await execute_tool_rpc("get_property_snapshot", {"p_property_search": prop})
|
| 87 |
+
data = result.get("data", result)
|
| 88 |
+
|
| 89 |
+
if isinstance(data, list) and data:
|
| 90 |
+
data = data[0]
|
| 91 |
+
rate = data.get("nightly_rate") or data.get("current_rate")
|
| 92 |
+
if rate is None:
|
| 93 |
+
return False, ""
|
| 94 |
+
|
| 95 |
+
rate = float(rate)
|
| 96 |
+
condition_met = (operator == "below" and rate < value) or (operator == "above" and rate > value)
|
| 97 |
+
if not condition_met:
|
| 98 |
+
return False, ""
|
| 99 |
+
|
| 100 |
+
name = data.get("property_name", prop)
|
| 101 |
+
body = f"<h3>π Price Threshold Alert</h3>"
|
| 102 |
+
body += f"<p><strong>{name}</strong> is now at <strong>{rate}/night</strong> "
|
| 103 |
+
body += f"({operator} your threshold of {value}).</p>"
|
| 104 |
+
return True, body
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def _evaluate_availability(criteria: dict) -> tuple[bool, str]:
|
| 108 |
+
"""Check availability change using get_property_snapshot RPC."""
|
| 109 |
+
prop = criteria.get("property_search", "")
|
| 110 |
+
watch_for = criteria.get("watch_for", "any")
|
| 111 |
+
result = await execute_tool_rpc("get_property_snapshot", {"p_property_search": prop})
|
| 112 |
+
data = result.get("data", result)
|
| 113 |
+
|
| 114 |
+
if isinstance(data, list) and data:
|
| 115 |
+
data = data[0]
|
| 116 |
+
is_available = data.get("is_available")
|
| 117 |
+
if is_available is None:
|
| 118 |
+
return False, ""
|
| 119 |
+
|
| 120 |
+
if watch_for == "available" and not is_available:
|
| 121 |
+
return False, ""
|
| 122 |
+
if watch_for == "unavailable" and is_available:
|
| 123 |
+
return False, ""
|
| 124 |
+
|
| 125 |
+
status_text = "available" if is_available else "unavailable"
|
| 126 |
+
name = data.get("property_name", prop)
|
| 127 |
+
body = f"<h3>π Availability Alert</h3>"
|
| 128 |
+
body += f"<p><strong>{name}</strong> is now <strong>{status_text}</strong>.</p>"
|
| 129 |
+
return True, body
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def _evaluate_new_listing(criteria: dict) -> tuple[bool, str]:
|
| 133 |
+
"""Check for new listings using get_recently_changed_tracking RPC."""
|
| 134 |
+
market = criteria.get("market")
|
| 135 |
+
result = await execute_tool_rpc("get_recently_changed_tracking", {"p_days": 1})
|
| 136 |
+
data = result.get("data", [])
|
| 137 |
+
added = [r for r in data if r.get("change_type") == "added"]
|
| 138 |
+
if market:
|
| 139 |
+
added = [r for r in added if market.lower() in (r.get("market", "") or "").lower()]
|
| 140 |
+
if not added:
|
| 141 |
+
return False, ""
|
| 142 |
+
|
| 143 |
+
body = f"<h3>π New Listing Alert β {market or 'All Markets'}</h3>"
|
| 144 |
+
body += f"<p>{len(added)} new properties added to tracking:</p><ul>"
|
| 145 |
+
body += "".join(f"<li>{r.get('property_name', '?')}</li>" for r in added[:5]) + "</ul>"
|
| 146 |
+
return True, body
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
async def _evaluate_tracking_removed(criteria: dict) -> tuple[bool, str]:
|
| 150 |
+
"""Check for removed listings using get_recently_changed_tracking RPC."""
|
| 151 |
+
market = criteria.get("market")
|
| 152 |
+
result = await execute_tool_rpc("get_recently_changed_tracking", {"p_days": 1})
|
| 153 |
+
data = result.get("data", [])
|
| 154 |
+
removed = [r for r in data if r.get("change_type") == "removed"]
|
| 155 |
+
if market:
|
| 156 |
+
removed = [r for r in removed if market.lower() in (r.get("market", "") or "").lower()]
|
| 157 |
+
if not removed:
|
| 158 |
+
return False, ""
|
| 159 |
+
|
| 160 |
+
body = f"<h3>π Tracking Removed Alert β {market or 'All Markets'}</h3>"
|
| 161 |
+
body += f"<p>{len(removed)} properties removed from tracking:</p><ul>"
|
| 162 |
+
body += "".join(f"<li>{r.get('property_name', '?')}</li>" for r in removed[:5]) + "</ul>"
|
| 163 |
+
return True, body
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
async def _evaluate_anomaly(criteria: dict) -> tuple[bool, str]:
|
| 167 |
+
"""Check for pricing anomalies using get_rate_anomaly_report RPC."""
|
| 168 |
+
prop = criteria.get("property_search", "")
|
| 169 |
+
threshold = criteria.get("deviation_threshold", 25)
|
| 170 |
+
result = await execute_tool_rpc("get_rate_anomaly_report", {
|
| 171 |
+
"p_property_search": prop,
|
| 172 |
+
"p_days": 7,
|
| 173 |
+
"p_deviation_threshold": threshold,
|
| 174 |
+
})
|
| 175 |
+
data = result.get("data", result)
|
| 176 |
+
if isinstance(data, list) and data:
|
| 177 |
+
data = data[0]
|
| 178 |
+
anomaly_count = data.get("anomaly_count", 0) if isinstance(data, dict) else 0
|
| 179 |
+
if not anomaly_count:
|
| 180 |
+
return False, ""
|
| 181 |
+
|
| 182 |
+
name = data.get("property_name", prop)
|
| 183 |
+
body = f"<h3>π Pricing Anomaly Alert</h3>"
|
| 184 |
+
body += f"<p><strong>{name}</strong> has {anomaly_count} anomalous pricing events "
|
| 185 |
+
body += f"(β₯{threshold}% deviation from baseline).</p>"
|
| 186 |
+
return True, body
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
async def _evaluate_volatility(criteria: dict) -> tuple[bool, str]:
|
| 190 |
+
"""Check volatility ranking using get_most_volatile_properties RPC."""
|
| 191 |
+
market = criteria.get("market")
|
| 192 |
+
top_n = criteria.get("top_n", 5)
|
| 193 |
+
result = await execute_tool_rpc("get_most_volatile_properties", {
|
| 194 |
+
"p_market": market,
|
| 195 |
+
"p_limit": top_n,
|
| 196 |
+
"p_days": 7,
|
| 197 |
+
})
|
| 198 |
+
data = result.get("data", [])
|
| 199 |
+
if not data:
|
| 200 |
+
return False, ""
|
| 201 |
+
|
| 202 |
+
body = f"<h3>π Volatility Alert β {market or 'All Markets'}</h3>"
|
| 203 |
+
body += f"<p>Top {len(data)} most volatile properties:</p><ul>"
|
| 204 |
+
for r in data[:top_n]:
|
| 205 |
+
name = r.get("property_name", "?")
|
| 206 |
+
changes = r.get("change_count", r.get("rate_change_count", "?"))
|
| 207 |
+
body += f"<li>{name} β {changes} rate changes</li>"
|
| 208 |
+
body += "</ul>"
|
| 209 |
+
return True, body
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
async def _evaluate_trend_reversal(criteria: dict) -> tuple[bool, str]:
|
| 213 |
+
"""Check market trend direction using get_market_trend RPC."""
|
| 214 |
+
market = criteria.get("market")
|
| 215 |
+
watch_for = criteria.get("watch_for", "falling")
|
| 216 |
+
result = await execute_tool_rpc("get_market_trend", {
|
| 217 |
+
"p_market": market,
|
| 218 |
+
"p_days": 14,
|
| 219 |
+
})
|
| 220 |
+
data = result.get("data", [])
|
| 221 |
+
if not data or len(data) < 7:
|
| 222 |
+
return False, ""
|
| 223 |
+
|
| 224 |
+
recent_half = data[len(data) // 2:]
|
| 225 |
+
rates = [float(d.get("avg_rate", 0)) for d in recent_half if d.get("avg_rate")]
|
| 226 |
+
if len(rates) < 2:
|
| 227 |
+
return False, ""
|
| 228 |
+
|
| 229 |
+
slope = rates[-1] - rates[0]
|
| 230 |
+
direction = "falling" if slope < 0 else "rising"
|
| 231 |
+
if direction != watch_for:
|
| 232 |
+
return False, ""
|
| 233 |
+
|
| 234 |
+
body = f"<h3>π Trend Reversal Alert β {market}</h3>"
|
| 235 |
+
body += f"<p>Market rates are now <strong>{direction}</strong> β "
|
| 236 |
+
body += f"average moved from {rates[0]:.0f} to {rates[-1]:.0f} over the last 7 days.</p>"
|
| 237 |
+
return True, body
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
_EVALUATORS = {
|
| 241 |
+
"spike": _evaluate_spike,
|
| 242 |
+
"rate_change": _evaluate_rate_change,
|
| 243 |
+
"price_threshold": _evaluate_price_threshold,
|
| 244 |
+
"availability_change": _evaluate_availability,
|
| 245 |
+
"new_listing": _evaluate_new_listing,
|
| 246 |
+
"tracking_removed": _evaluate_tracking_removed,
|
| 247 |
+
"anomaly": _evaluate_anomaly,
|
| 248 |
+
"volatility": _evaluate_volatility,
|
| 249 |
+
"trend_reversal": _evaluate_trend_reversal,
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
async def _evaluate_subscription(sub: dict) -> bool:
|
| 254 |
+
"""Evaluate a single subscription against live data. Returns True if alert was fired."""
|
| 255 |
+
criteria_type = sub["criteria_type"]
|
| 256 |
+
if criteria_type == "digest":
|
| 257 |
+
return False
|
| 258 |
+
|
| 259 |
+
evaluator = _EVALUATORS.get(criteria_type)
|
| 260 |
+
if not evaluator:
|
| 261 |
+
logger.warning(f"[evaluator] unknown criteria_type: {criteria_type}")
|
| 262 |
+
return False
|
| 263 |
+
|
| 264 |
+
if _is_in_cooldown(sub):
|
| 265 |
+
logger.info(f"[evaluator] sub {sub['id']} in cooldown, skipping")
|
| 266 |
+
return False
|
| 267 |
+
|
| 268 |
+
condition_met, alert_body = await evaluator(sub["criteria"])
|
| 269 |
+
if not condition_met:
|
| 270 |
+
return False
|
| 271 |
+
|
| 272 |
+
summary = build_criteria_summary(criteria_type, sub["criteria"])
|
| 273 |
+
subject = f"π {summary}"
|
| 274 |
+
await send_alert_email(sub["email"], subject, alert_body, sub["unsubscribe_token"])
|
| 275 |
+
|
| 276 |
+
client = _get_service_client()
|
| 277 |
+
client.table("alert_subscriptions").update({
|
| 278 |
+
"last_fired_at": datetime.now(timezone.utc).isoformat(),
|
| 279 |
+
"last_checked_at": datetime.now(timezone.utc).isoformat(),
|
| 280 |
+
}).eq("id", sub["id"]).execute()
|
| 281 |
+
|
| 282 |
+
logger.info(f"[evaluator] fired alert for sub {sub['id']}: {criteria_type}")
|
| 283 |
+
return True
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
async def evaluate_all_active_subscriptions() -> dict:
|
| 287 |
+
"""Fetch all active subscriptions, evaluate each, fire if condition met."""
|
| 288 |
+
client = _get_service_client()
|
| 289 |
+
try:
|
| 290 |
+
result = (
|
| 291 |
+
client.table("alert_subscriptions")
|
| 292 |
+
.select("id, email, criteria_type, criteria, unsubscribe_token, last_fired_at")
|
| 293 |
+
.eq("status", "active")
|
| 294 |
+
.eq("confirmed", True)
|
| 295 |
+
.execute()
|
| 296 |
+
)
|
| 297 |
+
except Exception as exc:
|
| 298 |
+
logger.error(f"[evaluator] failed to fetch subscriptions: {exc}")
|
| 299 |
+
return {"status": "error", "message": str(exc)}
|
| 300 |
+
|
| 301 |
+
subs = result.data or []
|
| 302 |
+
logger.info(f"[evaluator] evaluating {len(subs)} active subscriptions")
|
| 303 |
+
|
| 304 |
+
fired_count = 0
|
| 305 |
+
checked_count = 0
|
| 306 |
+
now_iso = datetime.now(timezone.utc).isoformat()
|
| 307 |
+
|
| 308 |
+
for sub in subs:
|
| 309 |
+
checked_count += 1
|
| 310 |
+
try:
|
| 311 |
+
fired = await _evaluate_subscription(sub)
|
| 312 |
+
if fired:
|
| 313 |
+
fired_count += 1
|
| 314 |
+
else:
|
| 315 |
+
client.table("alert_subscriptions").update({
|
| 316 |
+
"last_checked_at": now_iso,
|
| 317 |
+
}).eq("id", sub["id"]).execute()
|
| 318 |
+
except Exception as exc:
|
| 319 |
+
logger.error(f"[evaluator] error evaluating sub {sub['id']}: {exc}")
|
| 320 |
+
|
| 321 |
+
logger.info(f"[evaluator] done: checked={checked_count}, fired={fired_count}")
|
| 322 |
+
return {
|
| 323 |
+
"status": "success",
|
| 324 |
+
"checked": checked_count,
|
| 325 |
+
"fired": fired_count,
|
| 326 |
+
}
|
workers/digest_sender.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background worker: sends periodic digest emails for digest-type subscriptions."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
|
| 5 |
+
from config import SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL
|
| 6 |
+
from services.email_service import build_criteria_summary, send_alert_email
|
| 7 |
+
from services.observability import setup_logger
|
| 8 |
+
from services.supabase_service import execute_tool_rpc
|
| 9 |
+
|
| 10 |
+
logger = setup_logger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _get_service_client():
|
| 14 |
+
"""Lazy-import Supabase service client to avoid circular imports at module level."""
|
| 15 |
+
from supabase import create_client
|
| 16 |
+
return create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def _compile_market_digest(market: str) -> str:
|
| 20 |
+
"""Compile a market digest summary using existing RPCs."""
|
| 21 |
+
snapshot = await execute_tool_rpc("get_market_snapshot", {
|
| 22 |
+
"p_market": market,
|
| 23 |
+
"p_start_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
| 24 |
+
"p_end_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
| 25 |
+
})
|
| 26 |
+
averages = await execute_tool_rpc("get_market_averages", {"market_param": market})
|
| 27 |
+
spikes = await execute_tool_rpc("get_spike_alerts", {
|
| 28 |
+
"p_market": market,
|
| 29 |
+
"threshold_param": 25,
|
| 30 |
+
"days_param": 7,
|
| 31 |
+
"p_limit": 3,
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
body = f"<h3>π Market Digest β {market}</h3>"
|
| 35 |
+
body += "<h4>Snapshot</h4>"
|
| 36 |
+
|
| 37 |
+
snap_data = snapshot.get("data", snapshot)
|
| 38 |
+
if isinstance(snap_data, list) and snap_data:
|
| 39 |
+
snap_data = snap_data[0]
|
| 40 |
+
if isinstance(snap_data, dict):
|
| 41 |
+
body += f"<p>Active Properties: {snap_data.get('active_count', '?')}, "
|
| 42 |
+
body += f"Avg Rate: {snap_data.get('avg_rate', '?')}/night, "
|
| 43 |
+
body += f"Availability: {snap_data.get('availability_pct', '?')}%</p>"
|
| 44 |
+
|
| 45 |
+
avg_data = averages.get("data", averages)
|
| 46 |
+
if isinstance(avg_data, list) and avg_data:
|
| 47 |
+
body += "<h4>Market Averages</h4><ul>"
|
| 48 |
+
for item in avg_data[:5]:
|
| 49 |
+
body += f"<li>{item.get('property_name', '?')}: {item.get('avg_rate', '?')}/night</li>"
|
| 50 |
+
body += "</ul>"
|
| 51 |
+
|
| 52 |
+
spike_data = spikes.get("data", [])
|
| 53 |
+
if spike_data:
|
| 54 |
+
body += f"<h4>Notable Spikes (β₯25%)</h4><ul>"
|
| 55 |
+
for s in spike_data[:3]:
|
| 56 |
+
body += f"<li>{s.get('property_name', '?')}: {s.get('pct_change', '?')}%</li>"
|
| 57 |
+
body += "</ul>"
|
| 58 |
+
else:
|
| 59 |
+
body += "<p>No significant spikes in the last 7 days.</p>"
|
| 60 |
+
|
| 61 |
+
return body
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def send_pending_digests() -> dict:
|
| 65 |
+
"""Fetch digest subscriptions due for send, compile market summary, send email."""
|
| 66 |
+
client = _get_service_client()
|
| 67 |
+
try:
|
| 68 |
+
result = (
|
| 69 |
+
client.table("alert_subscriptions")
|
| 70 |
+
.select("id, email, criteria, unsubscribe_token, last_fired_at")
|
| 71 |
+
.eq("status", "active")
|
| 72 |
+
.eq("confirmed", True)
|
| 73 |
+
.eq("criteria_type", "digest")
|
| 74 |
+
.execute()
|
| 75 |
+
)
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
logger.error(f"[digest] failed to fetch digest subs: {exc}")
|
| 78 |
+
return {"status": "error", "message": str(exc)}
|
| 79 |
+
|
| 80 |
+
subs = result.data or []
|
| 81 |
+
sent_count = 0
|
| 82 |
+
|
| 83 |
+
for sub in subs:
|
| 84 |
+
criteria = sub["criteria"]
|
| 85 |
+
frequency = criteria.get("frequency", "weekly")
|
| 86 |
+
|
| 87 |
+
if not _is_due(sub.get("last_fired_at"), frequency):
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
market = criteria.get("market", "All Markets")
|
| 91 |
+
digest_body = await _compile_market_digest(market)
|
| 92 |
+
summary = build_criteria_summary("digest", criteria)
|
| 93 |
+
subject = f"π {summary}"
|
| 94 |
+
|
| 95 |
+
await send_alert_email(sub["email"], subject, digest_body, sub["unsubscribe_token"])
|
| 96 |
+
|
| 97 |
+
client.table("alert_subscriptions").update({
|
| 98 |
+
"last_fired_at": datetime.now(timezone.utc).isoformat(),
|
| 99 |
+
"last_checked_at": datetime.now(timezone.utc).isoformat(),
|
| 100 |
+
}).eq("id", sub["id"]).execute()
|
| 101 |
+
|
| 102 |
+
sent_count += 1
|
| 103 |
+
logger.info(f"[digest] sent digest for sub {sub['id']}")
|
| 104 |
+
|
| 105 |
+
return {"status": "success", "sent": sent_count}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _is_due(last_fired_at: str | None, frequency: str) -> bool:
|
| 109 |
+
"""Check if a digest subscription is due for sending."""
|
| 110 |
+
if not last_fired_at:
|
| 111 |
+
return True
|
| 112 |
+
|
| 113 |
+
fired_dt = datetime.fromisoformat(last_fired_at.replace("Z", "+00:00"))
|
| 114 |
+
elapsed = datetime.now(timezone.utc) - fired_dt
|
| 115 |
+
|
| 116 |
+
if frequency == "daily":
|
| 117 |
+
return elapsed.total_seconds() >= 23 * 3600
|
| 118 |
+
return elapsed.days >= 6
|