Jodinho commited on
Commit
311871d
Β·
1 Parent(s): 76398fd

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 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 six 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,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 test_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,7 +132,7 @@ async def test_tool_non_streaming(tool_name: str, tool_args: dict) -> dict:
132
  }
133
 
134
 
135
- async def test_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,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 test_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,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 test_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:
 
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