Spaces:
Running
Running
Commit ·
85625af
1
Parent(s): 4cd6c08
fyp
Browse files- HOW_TO_RUN.md +0 -58
- app/__pycache__/config.cpython-313.pyc +0 -0
- app/__pycache__/database.cpython-313.pyc +0 -0
- app/ai/__pycache__/config.cpython-313.pyc +0 -0
- app/ai/agent/__pycache__/graph.cpython-313.pyc +0 -0
- app/ai/agent/__pycache__/schemas.cpython-313.pyc +0 -0
- app/ai/agent/__pycache__/state.cpython-313.pyc +0 -0
- app/ai/agent/agent_hub.py +389 -0
- app/ai/agent/brain.py +110 -19
- app/ai/agent/broker_brain.py +347 -0
- app/ai/agent/concierge_brain.py +291 -0
- app/ai/agent/dm_brain.py +49 -2
- app/ai/agent/graph.py +3 -3
- app/ai/agent/matcher_brain.py +313 -0
- app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/greeting.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/respond.cpython-313.pyc +0 -0
- app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc +0 -0
- app/ai/agent/nodes/listing_collect.py +2 -1
- app/ai/agent/nodes/listing_publish.py +1 -0
- app/ai/agent/planner.py +20 -3
- app/ai/agent/schema.py +38 -3
- app/ai/agent/schemas.py +2 -0
- app/ai/agent/state.py +9 -0
- app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc +0 -0
- app/ai/prompts/system_prompt.py +72 -3
- app/ai/services/__pycache__/search_service.cpython-313.pyc +0 -0
- app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc +0 -0
- app/ai/tools/__pycache__/greeting_tool.cpython-313.pyc +0 -0
- app/ai/tools/__pycache__/intent_detector_tool.cpython-313.pyc +0 -0
- app/ai/tools/__pycache__/listing_tool.cpython-313.pyc +0 -0
- app/jobs/stay_notification_jobs.py +227 -152
- app/jobs/viewing_notification_jobs.py +186 -0
- app/models/viewing.py +52 -0
- app/routes/booking.py +53 -22
- app/routes/websocket_chat.py +114 -1
- app/schemas/__pycache__/user.cpython-313.pyc +0 -0
- app/schemas/user.py +6 -0
- app/services/__pycache__/otp_service.cpython-313.pyc +0 -0
- app/services/aida_dm_service.py +161 -0
- app/services/landlord_notifications.py +22 -15
- app/services/proactive_service.py +56 -1
- app/services/translate_service.py +116 -20
- app/services/viewing_service.py +375 -0
- main.py +17 -3
- tests/test_todays_features.py +314 -0
HOW_TO_RUN.md
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 1 |
-
# How to Run AIDA Agent for Testing
|
| 2 |
-
|
| 3 |
-
## 1. Start the Backend Server
|
| 4 |
-
|
| 5 |
-
In the AIDA directory, run:
|
| 6 |
-
|
| 7 |
-
```bash
|
| 8 |
-
python -m uvicorn main:app --reload
|
| 9 |
-
```
|
| 10 |
-
|
| 11 |
-
**Note:** Use `main:app` not `app.main:app` (main.py is in the root, not in the app folder)
|
| 12 |
-
|
| 13 |
-
The server will start on `http://127.0.0.1:8000`
|
| 14 |
-
|
| 15 |
-
You should see:
|
| 16 |
-
```
|
| 17 |
-
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
| 18 |
-
INFO: Started reloader process [xxxx] using WatchFiles
|
| 19 |
-
```
|
| 20 |
-
|
| 21 |
-
## 2. Open the Test UI
|
| 22 |
-
|
| 23 |
-
Simply open `test_chat_ui.html` in your browser:
|
| 24 |
-
- Double-click the file, OR
|
| 25 |
-
- Right-click → Open with → Chrome/Edge/Firefox
|
| 26 |
-
|
| 27 |
-
The UI will automatically connect to `http://localhost:8000`
|
| 28 |
-
|
| 29 |
-
## 3. Test the Agent
|
| 30 |
-
|
| 31 |
-
1. **Login** (in the sidebar):
|
| 32 |
-
- Enter your test credentials
|
| 33 |
-
- Or paste a JWT token in the "Manual Token Override" field
|
| 34 |
-
|
| 35 |
-
2. **Chat**:
|
| 36 |
-
- Type messages in the input box at the bottom
|
| 37 |
-
- Click Send or press Enter
|
| 38 |
-
- View responses in the chat area
|
| 39 |
-
|
| 40 |
-
3. **Debug**:
|
| 41 |
-
- Check the "Debug Output" section in the sidebar for raw JSON responses
|
| 42 |
-
- Monitor the server terminal for backend logs
|
| 43 |
-
|
| 44 |
-
## Troubleshooting
|
| 45 |
-
|
| 46 |
-
### Server won't start
|
| 47 |
-
- Check if port 8000 is already in use
|
| 48 |
-
- Verify MongoDB, Redis, Qdrant connection strings in `.env`
|
| 49 |
-
- Check the terminal for specific error messages
|
| 50 |
-
|
| 51 |
-
### UI shows "Offline"
|
| 52 |
-
- Make sure the server is running on port 8000
|
| 53 |
-
- Check browser console (F12) for CORS or network errors
|
| 54 |
-
|
| 55 |
-
### Authentication fails
|
| 56 |
-
- Verify your test user exists in the database
|
| 57 |
-
- Check the credentials match
|
| 58 |
-
- Use the manual token input as a fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/__pycache__/config.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/config.cpython-313.pyc and b/app/__pycache__/config.cpython-313.pyc differ
|
|
|
app/__pycache__/database.cpython-313.pyc
CHANGED
|
Binary files a/app/__pycache__/database.cpython-313.pyc and b/app/__pycache__/database.cpython-313.pyc differ
|
|
|
app/ai/__pycache__/config.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/__pycache__/config.cpython-313.pyc and b/app/ai/__pycache__/config.cpython-313.pyc differ
|
|
|
app/ai/agent/__pycache__/graph.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/__pycache__/graph.cpython-313.pyc and b/app/ai/agent/__pycache__/graph.cpython-313.pyc differ
|
|
|
app/ai/agent/__pycache__/schemas.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/__pycache__/schemas.cpython-313.pyc and b/app/ai/agent/__pycache__/schemas.cpython-313.pyc differ
|
|
|
app/ai/agent/__pycache__/state.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/__pycache__/state.cpython-313.pyc and b/app/ai/agent/__pycache__/state.cpython-313.pyc differ
|
|
|
app/ai/agent/agent_hub.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/ai/agent/agent_hub.py
|
| 2 |
+
"""
|
| 3 |
+
AIDA Agent Hub — Multi-Agent Router
|
| 4 |
+
|
| 5 |
+
Architecture:
|
| 6 |
+
GENERAL (default) — Search, listing, market price, general queries, alerts setup.
|
| 7 |
+
Handles everything until a specialist flow is triggered.
|
| 8 |
+
CONCIERGE — Triggered when user taps Book / Help me choose on a short-stay listing.
|
| 9 |
+
Owns the full booking flow + pre-checkin reminders + post-checkout reviews.
|
| 10 |
+
BROKER — Triggered when user wants to schedule a property viewing (rent/sale).
|
| 11 |
+
Owns viewing scheduling, calendar cross-check, post-viewing review.
|
| 12 |
+
MATCHER — Triggered when roommate intent detected.
|
| 13 |
+
Owns both Host and Seeker roommate flows + double opt-in.
|
| 14 |
+
|
| 15 |
+
The user NEVER sees agent switching — transitions are seamless.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from typing import Optional
|
| 19 |
+
from structlog import get_logger
|
| 20 |
+
from langchain_core.messages import HumanMessage
|
| 21 |
+
|
| 22 |
+
from app.ai.agent.state import AgentState
|
| 23 |
+
from app.ai.agent.brain import _invoke_with_fallback, generate_localized_response
|
| 24 |
+
|
| 25 |
+
logger = get_logger(__name__)
|
| 26 |
+
|
| 27 |
+
# ============================================================
|
| 28 |
+
# AGENT IDENTIFIERS
|
| 29 |
+
# ============================================================
|
| 30 |
+
|
| 31 |
+
AGENT_GENERAL = "general" # brain.py — default for everything
|
| 32 |
+
AGENT_CONCIERGE = "concierge" # short-stay booking flow
|
| 33 |
+
AGENT_BROKER = "broker" # viewing scheduling + deal flow
|
| 34 |
+
AGENT_MATCHER = "matcher" # roommate matching
|
| 35 |
+
|
| 36 |
+
HANDOFF_MAP = {
|
| 37 |
+
"HANDOFF_CONCIERGE": AGENT_CONCIERGE,
|
| 38 |
+
"HANDOFF_BROKER": AGENT_BROKER,
|
| 39 |
+
"HANDOFF_MATCHER": AGENT_MATCHER,
|
| 40 |
+
"HANDOFF_GENERAL": AGENT_GENERAL,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# ============================================================
|
| 44 |
+
# FAST KEYWORD ROUTING
|
| 45 |
+
# ============================================================
|
| 46 |
+
|
| 47 |
+
# Booking flow — only when user is actively booking a short-stay
|
| 48 |
+
# Triggered by "Book" button action or explicit booking language
|
| 49 |
+
_BOOKING_SIGNALS = [
|
| 50 |
+
"book this", "book now", "i want to book", "confirm booking", "proceed to book",
|
| 51 |
+
"book it", "reserve this", "reserve now", "check availability", "check-in",
|
| 52 |
+
"checkout", "check out", "réserver", "je veux réserver", "confirm my booking",
|
| 53 |
+
"initiate booking", "start booking",
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
# Viewing scheduling — only when user wants to schedule a visit for rent/sale
|
| 57 |
+
_SCHEDULING_SIGNALS = [
|
| 58 |
+
"schedule a viewing", "schedule viewing", "book a viewing", "book a visit",
|
| 59 |
+
"schedule a visit", "arrange a visit", "visit the property", "see the property",
|
| 60 |
+
"view the property", "when can i visit", "when can i see it",
|
| 61 |
+
"planifier une visite", "prendre rendez-vous", "organiser une visite",
|
| 62 |
+
"schedule an appointment", "book an appointment",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
# Roommate — anyone looking to share or find someone to share with
|
| 66 |
+
_ROOMMATE_SIGNALS = [
|
| 67 |
+
"roommate", "room mate", "colocataire", "colocation", "co-living", "coliving",
|
| 68 |
+
"share apartment", "share my apartment", "share flat", "share my place", "share a place",
|
| 69 |
+
"looking for someone to share", "cherche colocataire", "find a roommate",
|
| 70 |
+
"list a room", "list my room", "have a room", "spare room",
|
| 71 |
+
"looking for a room to share", "trouver un colocataire",
|
| 72 |
+
"cherche quelqu'un pour partager", "want a flatmate", "need a flatmate",
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _fast_route(message: str, state: AgentState) -> Optional[str]:
|
| 77 |
+
"""
|
| 78 |
+
Fast keyword routing — no LLM call.
|
| 79 |
+
Returns agent id if confident, None if ambiguous (let LLM decide).
|
| 80 |
+
"""
|
| 81 |
+
msg = message.lower()
|
| 82 |
+
|
| 83 |
+
# Roommate is the highest-confidence signal — very specific vocabulary
|
| 84 |
+
if any(s in msg for s in _ROOMMATE_SIGNALS):
|
| 85 |
+
return AGENT_MATCHER
|
| 86 |
+
|
| 87 |
+
# Viewing scheduling — unambiguous when present
|
| 88 |
+
if any(s in msg for s in _SCHEDULING_SIGNALS):
|
| 89 |
+
return AGENT_BROKER
|
| 90 |
+
|
| 91 |
+
# Booking flow — only if user is clearly trying to book
|
| 92 |
+
if any(s in msg for s in _BOOKING_SIGNALS):
|
| 93 |
+
return AGENT_CONCIERGE
|
| 94 |
+
|
| 95 |
+
# Listing signals — always handled by general brain regardless of role
|
| 96 |
+
# (The general brain's listing flow handles the extra fee prompt for rent/sale)
|
| 97 |
+
listing_signals = [
|
| 98 |
+
"list my property", "list my apartment", "list my house", "list my place",
|
| 99 |
+
"list my flat", "i want to list", "publish my property",
|
| 100 |
+
"publier mon bien", "mettre en location", "je veux lister",
|
| 101 |
+
]
|
| 102 |
+
if any(s in msg for s in listing_signals):
|
| 103 |
+
# Renters can only list roommate → redirect to matcher
|
| 104 |
+
if state.user_role == "renter":
|
| 105 |
+
return AGENT_MATCHER
|
| 106 |
+
return AGENT_GENERAL # landlords: general brain handles full listing flow
|
| 107 |
+
|
| 108 |
+
return None # ambiguous — let LLM classify
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ============================================================
|
| 112 |
+
# LLM CLASSIFIER (for ambiguous messages)
|
| 113 |
+
# ============================================================
|
| 114 |
+
|
| 115 |
+
_CLASSIFICATION_PROMPT = """You are the routing layer for AIDA, a real estate AI on Lojiz.
|
| 116 |
+
|
| 117 |
+
Classify the user's message into ONE of these routing targets:
|
| 118 |
+
|
| 119 |
+
- "general" → Search for properties (any type), listing creation/management, market prices,
|
| 120 |
+
alerts, general questions, greetings, anything not covered below.
|
| 121 |
+
This is the DEFAULT — when in doubt, use "general".
|
| 122 |
+
|
| 123 |
+
- "concierge" → User is actively BOOKING a short-stay property right now.
|
| 124 |
+
Triggers: "book this", "I want to book", confirming a booking, payment step.
|
| 125 |
+
NOT for searching short-stays — only for the booking transaction itself.
|
| 126 |
+
|
| 127 |
+
- "broker" → User wants to SCHEDULE A VIEWING for a rental or sale property.
|
| 128 |
+
Triggers: "schedule a viewing", "when can I visit", "arrange an appointment".
|
| 129 |
+
NOT for searching rentals/sales — only for the viewing scheduling flow.
|
| 130 |
+
|
| 131 |
+
- "matcher" → Roommate search or co-living: looking for a flatmate, listing a room to share,
|
| 132 |
+
wanting someone to share their apartment with.
|
| 133 |
+
|
| 134 |
+
User message: "{message}"
|
| 135 |
+
Conversation context (last 2 turns): {context}
|
| 136 |
+
Current agent: {current_agent}
|
| 137 |
+
User role: {user_role}
|
| 138 |
+
|
| 139 |
+
Rules:
|
| 140 |
+
1. DEFAULT IS "general" — only route to a specialist when the trigger is clearly present.
|
| 141 |
+
2. Searching for a house/apartment → "general" (not broker).
|
| 142 |
+
3. Booking a short-stay → "concierge". Searching for short-stays → "general".
|
| 143 |
+
4. Scheduling a viewing → "broker". Looking for rental/sale listings → "general".
|
| 144 |
+
5. Roommate intent → "matcher".
|
| 145 |
+
6. Listing creation (rent/sale/short-stay) → "general" (general brain handles all listing flows).
|
| 146 |
+
7. If user role is "renter" and they want to list → "matcher" (renters can only list roommate spaces).
|
| 147 |
+
8. If current_agent is set and message is a follow-up in the same flow, keep the same agent.
|
| 148 |
+
9. Reply with ONLY one word: general, concierge, broker, or matcher.
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
async def classify_intent(
|
| 153 |
+
message: str,
|
| 154 |
+
state: AgentState,
|
| 155 |
+
current_agent: Optional[str] = None,
|
| 156 |
+
) -> str:
|
| 157 |
+
"""
|
| 158 |
+
Classify user intent → select the right agent.
|
| 159 |
+
Fast keyword check first, LLM fallback for ambiguous messages.
|
| 160 |
+
"""
|
| 161 |
+
# Fast path
|
| 162 |
+
fast = _fast_route(message, state)
|
| 163 |
+
if fast:
|
| 164 |
+
logger.info("Hub fast-routed", agent=fast, message=message[:60])
|
| 165 |
+
return fast
|
| 166 |
+
|
| 167 |
+
# LLM classification
|
| 168 |
+
try:
|
| 169 |
+
history = state.conversation_history or []
|
| 170 |
+
ctx_turns = history[-4:] if len(history) >= 4 else history
|
| 171 |
+
ctx_text = " | ".join(
|
| 172 |
+
f"{m.get('role', '')}: {str(m.get('content', ''))[:80]}"
|
| 173 |
+
for m in ctx_turns
|
| 174 |
+
) or "No prior context"
|
| 175 |
+
|
| 176 |
+
prompt = _CLASSIFICATION_PROMPT.format(
|
| 177 |
+
message=message,
|
| 178 |
+
context=ctx_text,
|
| 179 |
+
current_agent=current_agent or "none",
|
| 180 |
+
user_role=state.user_role or "unknown",
|
| 181 |
+
)
|
| 182 |
+
response = await _invoke_with_fallback(
|
| 183 |
+
[HumanMessage(content=prompt)],
|
| 184 |
+
caller="hub_classify_intent",
|
| 185 |
+
)
|
| 186 |
+
result = response.content.strip().lower().split()[0]
|
| 187 |
+
if result in (AGENT_GENERAL, AGENT_CONCIERGE, AGENT_BROKER, AGENT_MATCHER):
|
| 188 |
+
logger.info("Hub LLM-classified intent", result=result, message=message[:60])
|
| 189 |
+
return result
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.warning("Hub LLM classification failed, falling back", error=str(e))
|
| 192 |
+
|
| 193 |
+
# Fallback: stay with current agent or go to general
|
| 194 |
+
return current_agent or AGENT_GENERAL
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ============================================================
|
| 198 |
+
# AGENT DISPATCH
|
| 199 |
+
# ============================================================
|
| 200 |
+
|
| 201 |
+
async def _dispatch(agent_id: str, state: AgentState) -> AgentState:
|
| 202 |
+
"""Route to the correct brain."""
|
| 203 |
+
if agent_id == AGENT_CONCIERGE:
|
| 204 |
+
from app.ai.agent.concierge_brain import process
|
| 205 |
+
return await process(state)
|
| 206 |
+
|
| 207 |
+
elif agent_id == AGENT_BROKER:
|
| 208 |
+
from app.ai.agent.broker_brain import process
|
| 209 |
+
return await process(state)
|
| 210 |
+
|
| 211 |
+
elif agent_id == AGENT_MATCHER:
|
| 212 |
+
from app.ai.agent.matcher_brain import process
|
| 213 |
+
return await process(state)
|
| 214 |
+
|
| 215 |
+
else:
|
| 216 |
+
# general or unknown → general brain (brain.py agent_think)
|
| 217 |
+
from app.ai.agent.brain import agent_think
|
| 218 |
+
return await agent_think(state)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ============================================================
|
| 222 |
+
# HANDOFF HANDLER
|
| 223 |
+
# ============================================================
|
| 224 |
+
|
| 225 |
+
async def _handle_handoff(state: AgentState, max_hops: int = 2) -> AgentState:
|
| 226 |
+
"""
|
| 227 |
+
Re-dispatch when a brain returns a HANDOFF signal.
|
| 228 |
+
Caps at max_hops to prevent loops.
|
| 229 |
+
"""
|
| 230 |
+
hops = 0
|
| 231 |
+
while hops < max_hops:
|
| 232 |
+
handoff = state.temp_data.pop("handoff", None)
|
| 233 |
+
if not handoff:
|
| 234 |
+
break
|
| 235 |
+
|
| 236 |
+
target = HANDOFF_MAP.get(handoff)
|
| 237 |
+
if not target:
|
| 238 |
+
logger.warning("Unknown handoff signal", signal=handoff)
|
| 239 |
+
break
|
| 240 |
+
|
| 241 |
+
reason = state.temp_data.pop("handoff_reason", "")
|
| 242 |
+
logger.info(
|
| 243 |
+
"Hub executing handoff",
|
| 244 |
+
from_agent=state.temp_data.get("active_agent"),
|
| 245 |
+
to_agent=target,
|
| 246 |
+
reason=reason[:80],
|
| 247 |
+
)
|
| 248 |
+
state.temp_data["pre_handoff_context"] = reason
|
| 249 |
+
state = await _dispatch(target, state)
|
| 250 |
+
hops += 1
|
| 251 |
+
|
| 252 |
+
return state
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# ============================================================
|
| 256 |
+
# MAIN ENTRY POINT
|
| 257 |
+
# ============================================================
|
| 258 |
+
|
| 259 |
+
async def _load_sticky_agent(user_id: str) -> Optional[str]:
|
| 260 |
+
"""
|
| 261 |
+
Load the persisted active agent for this user from MongoDB.
|
| 262 |
+
Returns None if no session exists yet.
|
| 263 |
+
"""
|
| 264 |
+
try:
|
| 265 |
+
from app.database import get_db
|
| 266 |
+
db = await get_db()
|
| 267 |
+
doc = await db["aida_sessions"].find_one({"user_id": user_id})
|
| 268 |
+
return doc.get("active_agent") if doc else None
|
| 269 |
+
except Exception as e:
|
| 270 |
+
logger.warning("Failed to load sticky agent", user_id=user_id, error=str(e))
|
| 271 |
+
return None
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
async def _save_sticky_agent(user_id: str, agent_id: str) -> None:
|
| 275 |
+
"""
|
| 276 |
+
Persist the active agent for this user to MongoDB so it survives session resume.
|
| 277 |
+
Uses upsert so we never create duplicates.
|
| 278 |
+
"""
|
| 279 |
+
try:
|
| 280 |
+
from app.database import get_db
|
| 281 |
+
from datetime import datetime
|
| 282 |
+
db = await get_db()
|
| 283 |
+
await db["aida_sessions"].update_one(
|
| 284 |
+
{"user_id": user_id},
|
| 285 |
+
{"$set": {"active_agent": agent_id, "updated_at": datetime.utcnow()}},
|
| 286 |
+
upsert=True,
|
| 287 |
+
)
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.warning("Failed to save sticky agent", user_id=user_id, error=str(e))
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
async def run(state: AgentState) -> AgentState:
|
| 293 |
+
"""
|
| 294 |
+
Primary entry point for ALL messages through the LangGraph "brain" node.
|
| 295 |
+
|
| 296 |
+
Flow:
|
| 297 |
+
1. Load persisted active_agent from DB (session resume stickiness)
|
| 298 |
+
2. Classify intent — if user is continuing a specialist flow, stay there
|
| 299 |
+
3. Dispatch to the correct agent brain
|
| 300 |
+
4. Handle any handoff signals (max 2 hops)
|
| 301 |
+
5. Persist the active_agent back to DB for next session
|
| 302 |
+
6. Guarantee a response is always set
|
| 303 |
+
"""
|
| 304 |
+
message = state.last_user_message or ""
|
| 305 |
+
|
| 306 |
+
# 1. Load sticky agent — prefer what's already on state (current session),
|
| 307 |
+
# fall back to DB (cross-session resume)
|
| 308 |
+
if not state.active_agent:
|
| 309 |
+
state.active_agent = await _load_sticky_agent(state.user_id)
|
| 310 |
+
|
| 311 |
+
current_agent = state.active_agent # may still be None on first ever message
|
| 312 |
+
|
| 313 |
+
logger.info(
|
| 314 |
+
"Hub received message",
|
| 315 |
+
user_id=state.user_id,
|
| 316 |
+
current_agent=current_agent,
|
| 317 |
+
message=message[:60],
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
# 2. Classify
|
| 321 |
+
agent_id = await classify_intent(message, state, current_agent)
|
| 322 |
+
logger.info("Hub dispatching", agent=agent_id)
|
| 323 |
+
|
| 324 |
+
# 3. Dispatch — run context loader on resume if agent switched or first load
|
| 325 |
+
is_resume = current_agent == agent_id and agent_id != AGENT_GENERAL and not state.agent_context
|
| 326 |
+
if is_resume:
|
| 327 |
+
state = await _run_context_loader(agent_id, state)
|
| 328 |
+
|
| 329 |
+
state = await _dispatch(agent_id, state)
|
| 330 |
+
|
| 331 |
+
# 4. Handle handoffs — update agent_id after any hop
|
| 332 |
+
if state.temp_data.get("handoff"):
|
| 333 |
+
state = await _handle_handoff(state)
|
| 334 |
+
# After handoff the actual agent may have changed — read from temp_data
|
| 335 |
+
agent_id = state.temp_data.get("active_agent", agent_id)
|
| 336 |
+
|
| 337 |
+
# 5. Persist active agent — general is transient, specialists are sticky
|
| 338 |
+
state.active_agent = agent_id
|
| 339 |
+
if agent_id != AGENT_GENERAL:
|
| 340 |
+
await _save_sticky_agent(state.user_id, agent_id)
|
| 341 |
+
else:
|
| 342 |
+
# If user explicitly returned to general (e.g. after booking complete), clear sticky
|
| 343 |
+
await _save_sticky_agent(state.user_id, AGENT_GENERAL)
|
| 344 |
+
|
| 345 |
+
# 6. Safety net — always have a response
|
| 346 |
+
if not state.temp_data.get("response_text"):
|
| 347 |
+
lang = state.language_detected or state.app_language or "en"
|
| 348 |
+
state.temp_data["response_text"] = await generate_localized_response(
|
| 349 |
+
context="Something unexpected happened. Tell the user AIDA is ready to help and ask what they need.",
|
| 350 |
+
language=lang,
|
| 351 |
+
tone="friendly",
|
| 352 |
+
max_length="short",
|
| 353 |
+
)
|
| 354 |
+
state.temp_data["action"] = "respond"
|
| 355 |
+
|
| 356 |
+
return state
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
async def _run_context_loader(agent_id: str, state: AgentState) -> AgentState:
|
| 360 |
+
"""
|
| 361 |
+
On session resume (same specialist agent, no context loaded yet),
|
| 362 |
+
call that brain's context loader to pre-populate agent_context.
|
| 363 |
+
"""
|
| 364 |
+
try:
|
| 365 |
+
if agent_id == AGENT_CONCIERGE:
|
| 366 |
+
from app.ai.agent.concierge_brain import load_context
|
| 367 |
+
state = await load_context(state)
|
| 368 |
+
elif agent_id == AGENT_BROKER:
|
| 369 |
+
from app.ai.agent.broker_brain import load_context
|
| 370 |
+
state = await load_context(state)
|
| 371 |
+
elif agent_id == AGENT_MATCHER:
|
| 372 |
+
from app.ai.agent.matcher_brain import load_context
|
| 373 |
+
state = await load_context(state)
|
| 374 |
+
except Exception as e:
|
| 375 |
+
logger.warning("Context loader failed (non-fatal)", agent=agent_id, error=str(e))
|
| 376 |
+
return state
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# ============================================================
|
| 380 |
+
# HELPERS
|
| 381 |
+
# ============================================================
|
| 382 |
+
|
| 383 |
+
def get_agent_display_name(agent_id: str) -> str:
|
| 384 |
+
return {
|
| 385 |
+
AGENT_GENERAL: "AIDA",
|
| 386 |
+
AGENT_CONCIERGE: "AIDA-Primary (Concierge)",
|
| 387 |
+
AGENT_BROKER: "AIDA-Market (Broker)",
|
| 388 |
+
AGENT_MATCHER: "AIDA-Social (Matcher)",
|
| 389 |
+
}.get(agent_id, "AIDA")
|
app/ai/agent/brain.py
CHANGED
|
@@ -1170,7 +1170,7 @@ async def execute_tool(tool_name: str, params: Dict[str, Any], state: AgentState
|
|
| 1170 |
rlm_used = False
|
| 1171 |
|
| 1172 |
# ================================================================
|
| 1173 |
-
# Step
|
| 1174 |
# ================================================================
|
| 1175 |
strategy_result = await select_search_strategy(state.last_user_message, search_params)
|
| 1176 |
|
|
@@ -1473,8 +1473,8 @@ async def execute_tool(tool_name: str, params: Dict[str, Any], state: AgentState
|
|
| 1473 |
elif tool_name == "create_alert":
|
| 1474 |
# Save user's search alert for notifications
|
| 1475 |
from app.services.alert_service import create_alert
|
| 1476 |
-
from app.ai.
|
| 1477 |
-
|
| 1478 |
# Get search params - either from params or from last search
|
| 1479 |
search_params = params.get("criteria") or state.temp_data.get("last_search_params") or state.temp_data.get("search_params") or {}
|
| 1480 |
search_query = state.temp_data.get("last_search_query", state.last_user_message)
|
|
@@ -1494,14 +1494,15 @@ async def execute_tool(tool_name: str, params: Dict[str, Any], state: AgentState
|
|
| 1494 |
# Step 1: Search current listings immediately
|
| 1495 |
current_results = []
|
| 1496 |
try:
|
| 1497 |
-
|
| 1498 |
-
|
| 1499 |
-
|
| 1500 |
-
|
| 1501 |
-
|
| 1502 |
-
|
| 1503 |
-
|
| 1504 |
-
|
|
|
|
| 1505 |
limit=10
|
| 1506 |
)
|
| 1507 |
|
|
@@ -2278,6 +2279,92 @@ Instructions:
|
|
| 2278 |
# No action, just respond
|
| 2279 |
return True, "Response ready", None
|
| 2280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2281 |
else:
|
| 2282 |
# Try dynamic tool from registry (api / db_query)
|
| 2283 |
try:
|
|
@@ -2612,16 +2699,20 @@ Generate one friendly question (1 sentence max):"""
|
|
| 2612 |
if count > 0:
|
| 2613 |
if is_suggestion:
|
| 2614 |
# Relaxed search — no exact match, showing closest alternatives
|
|
|
|
| 2615 |
context = (
|
| 2616 |
f"The user said: \"{user_msg_snippet}\"\n"
|
| 2617 |
-
f"You searched the market for '{search_desc}'
|
| 2618 |
-
f"However you found {count} similar propert{'y' if count == 1 else 'ies'} that
|
| 2619 |
-
f"
|
| 2620 |
-
f"
|
| 2621 |
-
f"
|
| 2622 |
-
f"
|
| 2623 |
-
f"
|
| 2624 |
-
f"
|
|
|
|
|
|
|
|
|
|
| 2625 |
)
|
| 2626 |
return await generate_localized_response(
|
| 2627 |
context=context,
|
|
|
|
| 1170 |
rlm_used = False
|
| 1171 |
|
| 1172 |
# ================================================================
|
| 1173 |
+
# Step 3: CHECK IF RLM SHOULD BE USED
|
| 1174 |
# ================================================================
|
| 1175 |
strategy_result = await select_search_strategy(state.last_user_message, search_params)
|
| 1176 |
|
|
|
|
| 1473 |
elif tool_name == "create_alert":
|
| 1474 |
# Save user's search alert for notifications
|
| 1475 |
from app.services.alert_service import create_alert
|
| 1476 |
+
from app.ai.services.search_service import search_mongodb
|
| 1477 |
+
|
| 1478 |
# Get search params - either from params or from last search
|
| 1479 |
search_params = params.get("criteria") or state.temp_data.get("last_search_params") or state.temp_data.get("search_params") or {}
|
| 1480 |
search_query = state.temp_data.get("last_search_query", state.last_user_message)
|
|
|
|
| 1494 |
# Step 1: Search current listings immediately
|
| 1495 |
current_results = []
|
| 1496 |
try:
|
| 1497 |
+
current_results = await search_mongodb(
|
| 1498 |
+
params={
|
| 1499 |
+
"location": search_params.get("location"),
|
| 1500 |
+
"listing_type": search_params.get("listing_type"),
|
| 1501 |
+
"min_price": search_params.get("min_price"),
|
| 1502 |
+
"max_price": search_params.get("max_price"),
|
| 1503 |
+
"bedrooms": search_params.get("bedrooms"),
|
| 1504 |
+
"bathrooms": search_params.get("bathrooms"),
|
| 1505 |
+
},
|
| 1506 |
limit=10
|
| 1507 |
)
|
| 1508 |
|
|
|
|
| 2279 |
# No action, just respond
|
| 2280 |
return True, "Response ready", None
|
| 2281 |
|
| 2282 |
+
elif tool_name == "get_price_trends":
|
| 2283 |
+
# Market price query — run aggregation and format with disclaimer
|
| 2284 |
+
try:
|
| 2285 |
+
from app.database import get_db
|
| 2286 |
+
db = await get_db()
|
| 2287 |
+
location = params.get("location") or state.user_location
|
| 2288 |
+
|
| 2289 |
+
pipeline = []
|
| 2290 |
+
if location:
|
| 2291 |
+
# Use country expansion for broad locations
|
| 2292 |
+
from app.ai.services.search_service import _get_country_cities
|
| 2293 |
+
cities = await _get_country_cities(location)
|
| 2294 |
+
if cities:
|
| 2295 |
+
city_regexes = [{"location": {"$regex": c, "$options": "i"}} for c in cities]
|
| 2296 |
+
city_regexes.append({"location": {"$regex": location, "$options": "i"}})
|
| 2297 |
+
pipeline.append({"$match": {"status": "active", "$or": city_regexes}})
|
| 2298 |
+
else:
|
| 2299 |
+
pipeline.append({"$match": {"status": "active", "location": {"$regex": location, "$options": "i"}}})
|
| 2300 |
+
else:
|
| 2301 |
+
pipeline.append({"$match": {"status": "active"}})
|
| 2302 |
+
|
| 2303 |
+
pipeline += [
|
| 2304 |
+
{"$group": {
|
| 2305 |
+
"_id": {"location": "$location", "listing_type": "$listing_type"},
|
| 2306 |
+
"avg_price": {"$avg": "$price"},
|
| 2307 |
+
"min_price": {"$min": "$price"},
|
| 2308 |
+
"max_price": {"$max": "$price"},
|
| 2309 |
+
"count": {"$sum": 1},
|
| 2310 |
+
"currency": {"$first": "$currency"},
|
| 2311 |
+
}},
|
| 2312 |
+
{"$match": {"count": {"$gte": 1}}},
|
| 2313 |
+
{"$sort": {"count": -1}},
|
| 2314 |
+
{"$limit": 15},
|
| 2315 |
+
]
|
| 2316 |
+
|
| 2317 |
+
cursor = db["listings"].aggregate(pipeline)
|
| 2318 |
+
rows = await cursor.to_list(length=15)
|
| 2319 |
+
|
| 2320 |
+
if not rows:
|
| 2321 |
+
no_data_msg = await generate_localized_response(
|
| 2322 |
+
context=f"Tell the user there is no listing price data available{' for ' + location if location else ''} on the platform yet. Be empathetic and suggest they check back later.",
|
| 2323 |
+
language=state.language_detected or state.app_language or "en",
|
| 2324 |
+
tone="friendly",
|
| 2325 |
+
max_length="short"
|
| 2326 |
+
)
|
| 2327 |
+
state.temp_data["response_text"] = no_data_msg
|
| 2328 |
+
state.temp_data["action"] = "respond"
|
| 2329 |
+
return True, "No price data", None
|
| 2330 |
+
|
| 2331 |
+
# Build structured summary for the LLM to narrate
|
| 2332 |
+
summary_lines = []
|
| 2333 |
+
for row in rows:
|
| 2334 |
+
loc = row["_id"].get("location", "Unknown")
|
| 2335 |
+
ltype = row["_id"].get("listing_type", "property")
|
| 2336 |
+
curr = row.get("currency") or ""
|
| 2337 |
+
avg = row.get("avg_price", 0)
|
| 2338 |
+
mn = row.get("min_price", 0)
|
| 2339 |
+
mx = row.get("max_price", 0)
|
| 2340 |
+
cnt = row.get("count", 0)
|
| 2341 |
+
summary_lines.append(
|
| 2342 |
+
f"{loc} ({ltype}): avg {curr} {avg:,.0f}/month, range {curr} {mn:,.0f}–{mx:,.0f} ({cnt} listings)"
|
| 2343 |
+
)
|
| 2344 |
+
data_summary = "\n".join(summary_lines)
|
| 2345 |
+
|
| 2346 |
+
price_response = await generate_localized_response(
|
| 2347 |
+
context=(
|
| 2348 |
+
f"Present the following real estate market price data clearly and naturally to the user. "
|
| 2349 |
+
f"Group by location if multiple cities. Use natural human language — not a raw table. "
|
| 2350 |
+
f"After presenting the data, add a short disclaimer that these prices are based on "
|
| 2351 |
+
f"listings currently available on the Lojiz platform only, and actual market prices "
|
| 2352 |
+
f"may vary by exact neighborhood and current market conditions.\n\n"
|
| 2353 |
+
f"Price data:\n{data_summary}"
|
| 2354 |
+
),
|
| 2355 |
+
language=state.language_detected or state.app_language or "en",
|
| 2356 |
+
tone="professional",
|
| 2357 |
+
max_length="long"
|
| 2358 |
+
)
|
| 2359 |
+
state.temp_data["response_text"] = price_response
|
| 2360 |
+
state.temp_data["action"] = "respond"
|
| 2361 |
+
await log_reward(state.session_id, REWARD_MARKET_INSIGHT, "price_trends", {"location": location})
|
| 2362 |
+
return True, "Price trends generated", rows
|
| 2363 |
+
|
| 2364 |
+
except Exception as price_err:
|
| 2365 |
+
logger.error("get_price_trends failed", error=str(price_err))
|
| 2366 |
+
return False, str(price_err), None
|
| 2367 |
+
|
| 2368 |
else:
|
| 2369 |
# Try dynamic tool from registry (api / db_query)
|
| 2370 |
try:
|
|
|
|
| 2699 |
if count > 0:
|
| 2700 |
if is_suggestion:
|
| 2701 |
# Relaxed search — no exact match, showing closest alternatives
|
| 2702 |
+
# REQUIREMENT (Problem 19): MUST explicitly restate what wasn't found before showing alternatives
|
| 2703 |
context = (
|
| 2704 |
f"The user said: \"{user_msg_snippet}\"\n"
|
| 2705 |
+
f"You searched the market specifically for '{search_desc}' and found ZERO exact matches. "
|
| 2706 |
+
f"However you found {count} similar propert{'y' if count == 1 else 'ies'} that might interest them. "
|
| 2707 |
+
f"\n\nCRITICAL RULES for your response:\n"
|
| 2708 |
+
f"1. You MUST explicitly state what you couldn't find — name the exact criteria: '{search_desc}'. "
|
| 2709 |
+
f" Example: 'I couldn't find any {search_desc} right now, but...' or "
|
| 2710 |
+
f" 'Nothing came up for {search_desc} specifically, but I found something close...'\n"
|
| 2711 |
+
f"2. Then present the alternatives warmly.\n"
|
| 2712 |
+
f"3. If their message has emotional context (honeymoon, moving, new job, etc.) acknowledge it naturally.\n"
|
| 2713 |
+
f"4. Suggest they activate alerts so you can notify them when an exact match appears — "
|
| 2714 |
+
f" phrase it naturally in their language (do NOT hardcode 'Notify me' in English if different language).\n"
|
| 2715 |
+
f"5. Sound like a friend who genuinely searched on their behalf. Include 👇 emoji."
|
| 2716 |
)
|
| 2717 |
return await generate_localized_response(
|
| 2718 |
context=context,
|
app/ai/agent/broker_brain.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/ai/agent/broker_brain.py
|
| 2 |
+
"""
|
| 3 |
+
AIDA-Market — The Deal Broker Brain
|
| 4 |
+
|
| 5 |
+
Domain: Long-term rentals (monthly/yearly), property sales, viewing scheduling,
|
| 6 |
+
post-viewing follow-up, anti-scam transparency, async alert hunting.
|
| 7 |
+
|
| 8 |
+
Triggered when:
|
| 9 |
+
- User clicks "Schedule Viewing" in a landlord chat
|
| 10 |
+
- General brain detects rent/sale deal-flow intent (not just search)
|
| 11 |
+
|
| 12 |
+
Responsibilities:
|
| 13 |
+
- Viewing scheduling: cross-check both calendars, suggest 3 slots, notify landlord, confirm
|
| 14 |
+
- Pre-viewing reminder via AIDA DM (1–3 hours before)
|
| 15 |
+
- Post-viewing follow-up: check in with visitor, collect rating for landlord/agent profile
|
| 16 |
+
- Anti-scam transparency checks
|
| 17 |
+
- Async alert hunting: scan market when user is offline, notify via AIDA DM on match
|
| 18 |
+
- Market intelligence (price trends, insights)
|
| 19 |
+
|
| 20 |
+
NOT responsible for:
|
| 21 |
+
- Listing creation/management — handled by general brain
|
| 22 |
+
- Short-stay booking — handled by AIDA-Concierge
|
| 23 |
+
- Roommate matching — handled by AIDA-Social
|
| 24 |
+
- Negotiation — user initiates contact with landlord directly
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from structlog import get_logger
|
| 28 |
+
from bson import ObjectId
|
| 29 |
+
|
| 30 |
+
from app.ai.agent.state import AgentState
|
| 31 |
+
from app.ai.agent.brain import (
|
| 32 |
+
brain_decide,
|
| 33 |
+
execute_tool,
|
| 34 |
+
generate_localized_response,
|
| 35 |
+
generate_contextual_response,
|
| 36 |
+
)
|
| 37 |
+
from app.ai.lightning.rewards import log_reward, REWARD_SEARCH_COMPLETED
|
| 38 |
+
|
| 39 |
+
logger = get_logger(__name__)
|
| 40 |
+
|
| 41 |
+
# ============================================================
|
| 42 |
+
# BROKER IDENTITY
|
| 43 |
+
# ============================================================
|
| 44 |
+
|
| 45 |
+
AGENT_ID = "broker"
|
| 46 |
+
AGENT_NAME = "AIDA-Market"
|
| 47 |
+
AGENT_DOMAIN = "long-term rent, property sales, viewing scheduling, anti-scam"
|
| 48 |
+
|
| 49 |
+
OWNED_LISTING_TYPES = {"rent", "sale"}
|
| 50 |
+
|
| 51 |
+
BROKER_TOOLS = """
|
| 52 |
+
AVAILABLE TOOLS (use ONLY these):
|
| 53 |
+
|
| 54 |
+
SEARCH & DISCOVERY:
|
| 55 |
+
1. search_properties(query, location, min_price, max_price, beds, listing_type)
|
| 56 |
+
- Find long-term rentals (listing_type="rent") or properties for sale (listing_type="sale").
|
| 57 |
+
- NEVER search for short-stay here — that belongs to the Concierge.
|
| 58 |
+
|
| 59 |
+
2. analyze_listing(listing_id)
|
| 60 |
+
- Deep analysis: price fairness, market position, hidden fees check.
|
| 61 |
+
- ANTI-SCAM: Always flag if price is suspiciously low or listing lacks key details.
|
| 62 |
+
|
| 63 |
+
3. get_landlord_info(user_id)
|
| 64 |
+
- Public profile of a landlord/agent. Use for trust checks.
|
| 65 |
+
|
| 66 |
+
4. get_price_trends(location)
|
| 67 |
+
- Average rent/sale prices for an area.
|
| 68 |
+
|
| 69 |
+
5. get_market_insights(location)
|
| 70 |
+
- Demand trends, best areas, investment signals.
|
| 71 |
+
|
| 72 |
+
VIEWING SCHEDULING:
|
| 73 |
+
6. schedule_visit(listing_id, preferred_slots, message)
|
| 74 |
+
- Propose up to 3 viewing time slots to the landlord after cross-checking availability.
|
| 75 |
+
- Triggers RSVP notification to the landlord.
|
| 76 |
+
|
| 77 |
+
7. check_calendar_availability(user_id, date_range)
|
| 78 |
+
- Check a user's or landlord's available windows within a date range.
|
| 79 |
+
|
| 80 |
+
8. confirm_viewing(visit_id)
|
| 81 |
+
- Confirm a viewing once landlord accepts. Saves to both parties' schedules.
|
| 82 |
+
|
| 83 |
+
9. cancel_viewing(visit_id, reason)
|
| 84 |
+
- Cancel a confirmed or pending viewing.
|
| 85 |
+
|
| 86 |
+
ALERTS & ASYNC HUNTING:
|
| 87 |
+
10. create_alert(criteria)
|
| 88 |
+
- Set up background market scanning. Broker hunts even when user is offline.
|
| 89 |
+
- Notify via AIDA DM when a match appears or price drops.
|
| 90 |
+
|
| 91 |
+
11. delete_alert(alert_id) / pause_alert(alert_id) / resume_alert(alert_id)
|
| 92 |
+
12. get_my_alerts()
|
| 93 |
+
|
| 94 |
+
NOTIFICATIONS (triggered by Broker events):
|
| 95 |
+
13. send_viewing_reminder(visit_id, recipient_user_id)
|
| 96 |
+
- Send AIDA DM reminder to a party 1–3 hours before their viewing.
|
| 97 |
+
|
| 98 |
+
14. send_post_viewing_followup(visit_id, visitor_user_id)
|
| 99 |
+
- Send AIDA DM to the visitor after a viewing to collect their experience + rating.
|
| 100 |
+
|
| 101 |
+
15. submit_person_review(reviewed_user_id, rating, review_text, context)
|
| 102 |
+
- Save rating and review to a landlord's or agent's profile (NOT the property).
|
| 103 |
+
- Use after post-viewing follow-up is complete.
|
| 104 |
+
|
| 105 |
+
GENERAL:
|
| 106 |
+
16. remember_preference(key, value, category)
|
| 107 |
+
- Save search criteria or preferences for future sessions.
|
| 108 |
+
|
| 109 |
+
17. track_user_feedback(feedback_type, context, correction_text)
|
| 110 |
+
- Internally log corrections or negative reactions.
|
| 111 |
+
|
| 112 |
+
18. respond(message)
|
| 113 |
+
- Market Q&A, anti-scam education, scheduling updates, general deal chat.
|
| 114 |
+
|
| 115 |
+
HANDOFF SIGNALS (do NOT handle — return handoff instead):
|
| 116 |
+
- Short-stay booking intent → set tool: "HANDOFF_CONCIERGE"
|
| 117 |
+
- Roommate search/listing intent → set tool: "HANDOFF_MATCHER"
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
BROKER_SYSTEM_PROMPT = """You are AIDA-Market, the deal specialist on Lojiz.
|
| 121 |
+
|
| 122 |
+
YOUR SPECIALTY: Helping people find long-term rentals and properties to buy — then making the
|
| 123 |
+
viewing and deal process smooth, safe, and transparent.
|
| 124 |
+
|
| 125 |
+
YOUR PERSONALITY:
|
| 126 |
+
- Professional, calm, and thorough
|
| 127 |
+
- You protect users — proactively flag suspicious listings before they commit
|
| 128 |
+
- You work even when users are offline — alerts scan the market continuously on their behalf
|
| 129 |
+
- You follow through: you check in after viewings and hold people accountable
|
| 130 |
+
|
| 131 |
+
══════════════════���════════════════════════════
|
| 132 |
+
VIEWING SCHEDULING FLOW
|
| 133 |
+
═══════════════════════════════════════════════
|
| 134 |
+
|
| 135 |
+
Triggered when user wants to schedule a viewing (via "Schedule Viewing" button or request):
|
| 136 |
+
|
| 137 |
+
1. Ask the user: "When are you generally free this week or next?"
|
| 138 |
+
2. Call check_calendar_availability for the landlord (get their open slots).
|
| 139 |
+
3. Cross-reference → suggest up to 3 overlapping time slots to the user.
|
| 140 |
+
4. User picks one → call schedule_visit to send the proposal to the landlord.
|
| 141 |
+
5. Landlord confirms → call confirm_viewing → notify both parties via AIDA DM.
|
| 142 |
+
6. 1–3 hours before viewing → call send_viewing_reminder for both parties.
|
| 143 |
+
7. After viewing → call send_post_viewing_followup to the visitor.
|
| 144 |
+
8. Visitor responds → call submit_person_review → saved to landlord/agent profile.
|
| 145 |
+
|
| 146 |
+
NOTE: If user prefers to schedule directly with the landlord without AIDA, that's fine —
|
| 147 |
+
both paths exist. AIDA only takes over when asked.
|
| 148 |
+
|
| 149 |
+
═══════════════════════════════════════════════
|
| 150 |
+
POST-VIEWING FOLLOW-UP
|
| 151 |
+
═══════════════════════════════════════════════
|
| 152 |
+
|
| 153 |
+
After a viewing, send the visitor an AIDA DM:
|
| 154 |
+
"Hey! How did the viewing go? Was [Landlord/Agent name] professional and the property
|
| 155 |
+
as described? Rate them 1–5 ⭐ — it helps the next person."
|
| 156 |
+
|
| 157 |
+
The rating and review go to the LANDLORD/AGENT profile, not the property.
|
| 158 |
+
This is because for rentals and sales, the person matters more than the walls.
|
| 159 |
+
|
| 160 |
+
═══════════════════════════════════════════════
|
| 161 |
+
ANTI-SCAM RULES (MANDATORY)
|
| 162 |
+
═══════════════════════════════════════════════
|
| 163 |
+
|
| 164 |
+
- If listing price is 40%+ below market average for that area → flag it as suspicious
|
| 165 |
+
- If listing demands cash before viewing → flag it immediately
|
| 166 |
+
- Always say: "After your viewing, let me know if the property matched the listing"
|
| 167 |
+
- Educate naturally: "Always insist on a signed agreement before any payment"
|
| 168 |
+
|
| 169 |
+
═══════════════════════════════════════════════
|
| 170 |
+
YOUR RULES
|
| 171 |
+
═══════════════════════════════════════════════
|
| 172 |
+
1. Long-term rent and sale ONLY.
|
| 173 |
+
Short-stay → HANDOFF_CONCIERGE. Roommate → HANDOFF_MATCHER.
|
| 174 |
+
2. Listing creation and management is handled elsewhere — do NOT run listing flows here.
|
| 175 |
+
3. No negotiation on behalf of users — they talk directly to landlords. You only assist with scheduling.
|
| 176 |
+
4. Always suggest setting an alert when search returns no results.
|
| 177 |
+
5. Respond entirely in the user's detected language.
|
| 178 |
+
6. Never mention "HANDOFF", "broker", or internal agent names to the user.
|
| 179 |
+
"""
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ============================================================
|
| 183 |
+
# MAIN ENTRY POINT
|
| 184 |
+
# ============================================================
|
| 185 |
+
|
| 186 |
+
async def process(state: AgentState) -> AgentState:
|
| 187 |
+
"""
|
| 188 |
+
Broker brain entry point.
|
| 189 |
+
Handles rent/sale deal flow: search, viewing scheduling, post-viewing follow-up.
|
| 190 |
+
"""
|
| 191 |
+
logger.info("AIDA-Market activated", user_id=state.user_id, message=state.last_user_message[:60])
|
| 192 |
+
|
| 193 |
+
state.temp_data["active_agent"] = AGENT_ID
|
| 194 |
+
state.temp_data["agent_system_prompt"] = BROKER_SYSTEM_PROMPT
|
| 195 |
+
state.temp_data["agent_tools"] = BROKER_TOOLS
|
| 196 |
+
|
| 197 |
+
try:
|
| 198 |
+
decision = await brain_decide(state)
|
| 199 |
+
except Exception as e:
|
| 200 |
+
logger.error("Broker brain_decide failed", error=str(e))
|
| 201 |
+
state.temp_data["response_text"] = await generate_localized_response(
|
| 202 |
+
context="Tell the user there was a brief issue and you're ready to help them now.",
|
| 203 |
+
language=state.language_detected or state.app_language or "en",
|
| 204 |
+
tone="apologetic",
|
| 205 |
+
max_length="short",
|
| 206 |
+
)
|
| 207 |
+
state.temp_data["action"] = "respond"
|
| 208 |
+
return state
|
| 209 |
+
|
| 210 |
+
# Handoff signals
|
| 211 |
+
if decision.tool in ("HANDOFF_CONCIERGE", "HANDOFF_MATCHER"):
|
| 212 |
+
state.temp_data["handoff"] = decision.tool
|
| 213 |
+
state.temp_data["handoff_reason"] = decision.thinking
|
| 214 |
+
logger.info("Broker signalling handoff", target=decision.tool)
|
| 215 |
+
return state
|
| 216 |
+
|
| 217 |
+
# Execute tool
|
| 218 |
+
if decision.tool and decision.tool != "respond":
|
| 219 |
+
try:
|
| 220 |
+
success, message, result = await execute_tool(decision.tool, decision.params, state)
|
| 221 |
+
if not success:
|
| 222 |
+
logger.warning("Broker tool failed", tool=decision.tool, message=message)
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.error("Broker tool execution error", tool=decision.tool, error=str(e))
|
| 225 |
+
|
| 226 |
+
# Generate response
|
| 227 |
+
if decision.tool == "search_properties":
|
| 228 |
+
try:
|
| 229 |
+
log_reward(REWARD_SEARCH_COMPLETED, state.user_id, {"agent": AGENT_ID})
|
| 230 |
+
except Exception:
|
| 231 |
+
pass
|
| 232 |
+
state.temp_data["response_text"] = await generate_contextual_response(state, decision)
|
| 233 |
+
elif not state.temp_data.get("response_text"):
|
| 234 |
+
if decision.response:
|
| 235 |
+
state.temp_data["response_text"] = decision.response
|
| 236 |
+
else:
|
| 237 |
+
state.temp_data["response_text"] = await generate_contextual_response(state, decision)
|
| 238 |
+
|
| 239 |
+
return state
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ============================================================
|
| 243 |
+
# CONTEXT LOADER — session resume
|
| 244 |
+
# ============================================================
|
| 245 |
+
|
| 246 |
+
async def load_context(state: AgentState) -> AgentState:
|
| 247 |
+
"""
|
| 248 |
+
Called on session resume. Loads the user's most recent active viewing
|
| 249 |
+
into state.agent_context["viewing"] — includes scheduling step, proposed slots,
|
| 250 |
+
and availability data — so the brain can resume mid-flow without re-asking.
|
| 251 |
+
"""
|
| 252 |
+
try:
|
| 253 |
+
from app.database import get_db
|
| 254 |
+
db = await get_db()
|
| 255 |
+
|
| 256 |
+
# User could be the visitor OR the landlord — check both sides
|
| 257 |
+
viewing = await db["viewings"].find_one(
|
| 258 |
+
{
|
| 259 |
+
"$or": [
|
| 260 |
+
{"visitor_id": state.user_id},
|
| 261 |
+
{"landlord_id": state.user_id},
|
| 262 |
+
],
|
| 263 |
+
"status": {"$in": ["pending", "confirmed"]},
|
| 264 |
+
},
|
| 265 |
+
sort=[("created_at", -1)],
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
if viewing:
|
| 269 |
+
viewing["_id"] = str(viewing["_id"])
|
| 270 |
+
# Derive which step the flow is at
|
| 271 |
+
step = _infer_viewing_step(viewing, state.user_id)
|
| 272 |
+
state.agent_context["viewing"] = {
|
| 273 |
+
**viewing,
|
| 274 |
+
"current_step": step,
|
| 275 |
+
"user_is_visitor": viewing.get("visitor_id") == state.user_id,
|
| 276 |
+
}
|
| 277 |
+
logger.info(
|
| 278 |
+
"Broker context loaded",
|
| 279 |
+
user_id=state.user_id,
|
| 280 |
+
viewing_id=viewing["_id"],
|
| 281 |
+
status=viewing.get("status"),
|
| 282 |
+
step=step,
|
| 283 |
+
)
|
| 284 |
+
else:
|
| 285 |
+
logger.info("Broker context: no active viewing found", user_id=state.user_id)
|
| 286 |
+
|
| 287 |
+
except Exception as e:
|
| 288 |
+
logger.warning("Broker load_context failed (non-fatal)", error=str(e))
|
| 289 |
+
|
| 290 |
+
return state
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _infer_viewing_step(viewing: dict, user_id: str) -> str:
|
| 294 |
+
"""Map viewing document fields → which step the user is currently on."""
|
| 295 |
+
is_visitor = viewing.get("visitor_id") == user_id
|
| 296 |
+
is_landlord = viewing.get("landlord_id") == user_id
|
| 297 |
+
status = viewing.get("status", "pending")
|
| 298 |
+
|
| 299 |
+
if status == "confirmed":
|
| 300 |
+
return "viewing_confirmed"
|
| 301 |
+
|
| 302 |
+
visitor_avail = viewing.get("visitor_availability", [])
|
| 303 |
+
landlord_avail = viewing.get("landlord_availability", [])
|
| 304 |
+
suggested = viewing.get("suggested_slots", [])
|
| 305 |
+
confirmed_slot = viewing.get("confirmed_slot")
|
| 306 |
+
|
| 307 |
+
if confirmed_slot:
|
| 308 |
+
return "landlord_confirm_slot"
|
| 309 |
+
if suggested:
|
| 310 |
+
return "visitor_pick_slot"
|
| 311 |
+
if is_visitor and not visitor_avail:
|
| 312 |
+
return "collect_visitor_availability"
|
| 313 |
+
if is_landlord and not landlord_avail:
|
| 314 |
+
return "collect_landlord_availability"
|
| 315 |
+
if visitor_avail and not landlord_avail:
|
| 316 |
+
return "collect_landlord_availability"
|
| 317 |
+
if landlord_avail and not visitor_avail:
|
| 318 |
+
return "collect_visitor_availability"
|
| 319 |
+
return "cross_check"
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# ============================================================
|
| 323 |
+
# TOOL FILTER — only broker-relevant tools
|
| 324 |
+
# ============================================================
|
| 325 |
+
|
| 326 |
+
BROKER_TOOL_NAMES = {
|
| 327 |
+
"search_properties",
|
| 328 |
+
"analyze_listing",
|
| 329 |
+
"get_landlord_info",
|
| 330 |
+
"get_price_trends",
|
| 331 |
+
"get_market_insights",
|
| 332 |
+
"schedule_visit",
|
| 333 |
+
"check_calendar_availability",
|
| 334 |
+
"confirm_viewing",
|
| 335 |
+
"cancel_viewing",
|
| 336 |
+
"create_alert",
|
| 337 |
+
"delete_alert",
|
| 338 |
+
"get_my_alerts",
|
| 339 |
+
"pause_alert",
|
| 340 |
+
"resume_alert",
|
| 341 |
+
"send_viewing_reminder",
|
| 342 |
+
"send_post_viewing_followup",
|
| 343 |
+
"submit_person_review",
|
| 344 |
+
"remember_preference",
|
| 345 |
+
"track_user_feedback",
|
| 346 |
+
"respond",
|
| 347 |
+
}
|
app/ai/agent/concierge_brain.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/ai/agent/concierge_brain.py
|
| 2 |
+
"""
|
| 3 |
+
AIDA-Primary — The Concierge Brain
|
| 4 |
+
|
| 5 |
+
Domain: Short-stay bookings, guest relations, booking flow, post-stay reviews.
|
| 6 |
+
|
| 7 |
+
Triggered when:
|
| 8 |
+
- User clicks "Book" or "Help me choose" on a short-stay listing
|
| 9 |
+
- General brain detects short-stay / travel / booking intent
|
| 10 |
+
|
| 11 |
+
Responsibilities:
|
| 12 |
+
- End-to-end booking flow (availability check, payment trigger)
|
| 13 |
+
- Pre-check-in reminder via AIDA DM (3 hours before)
|
| 14 |
+
- Post-checkout review collection via AIDA DM → saved to PROPERTY profile
|
| 15 |
+
- Short-stay property comparison to help user decide
|
| 16 |
+
- Handoff to other agents when intent is outside short-stay
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from structlog import get_logger
|
| 20 |
+
from bson import ObjectId
|
| 21 |
+
|
| 22 |
+
from app.ai.agent.state import AgentState
|
| 23 |
+
from app.ai.agent.brain import (
|
| 24 |
+
brain_decide,
|
| 25 |
+
execute_tool,
|
| 26 |
+
generate_localized_response,
|
| 27 |
+
generate_contextual_response,
|
| 28 |
+
)
|
| 29 |
+
from app.ai.lightning.rewards import log_reward, REWARD_SEARCH_COMPLETED
|
| 30 |
+
|
| 31 |
+
logger = get_logger(__name__)
|
| 32 |
+
|
| 33 |
+
# ============================================================
|
| 34 |
+
# CONCIERGE IDENTITY
|
| 35 |
+
# ============================================================
|
| 36 |
+
|
| 37 |
+
AGENT_ID = "concierge"
|
| 38 |
+
AGENT_NAME = "AIDA-Primary"
|
| 39 |
+
AGENT_DOMAIN = "short-stay, booking flow, guest relations, post-stay reviews"
|
| 40 |
+
|
| 41 |
+
OWNED_LISTING_TYPES = {"short-stay", "short_stay"}
|
| 42 |
+
|
| 43 |
+
CONCIERGE_TOOLS = """
|
| 44 |
+
AVAILABLE TOOLS (use ONLY these):
|
| 45 |
+
|
| 46 |
+
SEARCH & COMPARISON (before booking):
|
| 47 |
+
1. search_properties(query, location, min_price, max_price, beds, listing_type)
|
| 48 |
+
- Find short-stay properties. ALWAYS set listing_type="short-stay".
|
| 49 |
+
- Trigger: "trip", "stay", "accommodation", "weekend", "holiday", "travel", "book a place"
|
| 50 |
+
|
| 51 |
+
2. analyze_listing(listing_id)
|
| 52 |
+
- Deep overview of a specific short-stay property.
|
| 53 |
+
- Use when user taps "Tell me more" or asks about a specific listing.
|
| 54 |
+
|
| 55 |
+
3. compare_properties(listing_ids)
|
| 56 |
+
- Compare 2–4 short-stay properties side by side.
|
| 57 |
+
- Trigger: user taps "Help me choose", "which is better", "compare these".
|
| 58 |
+
|
| 59 |
+
4. get_price_trends(location)
|
| 60 |
+
- Short-stay market price data for an area.
|
| 61 |
+
|
| 62 |
+
BOOKING FLOW:
|
| 63 |
+
5. check_availability(listing_id, check_in, check_out)
|
| 64 |
+
- Verify a property is free for the requested dates.
|
| 65 |
+
|
| 66 |
+
6. calculate_booking_cost(listing_id, check_in, check_out, guests)
|
| 67 |
+
- Show total cost breakdown before user commits (nightly rate + fees).
|
| 68 |
+
|
| 69 |
+
7. initiate_booking(listing_id, check_in, check_out, guests)
|
| 70 |
+
- Start the booking transaction. Triggers payment flow.
|
| 71 |
+
|
| 72 |
+
8. confirm_booking(booking_id)
|
| 73 |
+
- Finalize a booking after payment is authorised.
|
| 74 |
+
|
| 75 |
+
9. cancel_booking(booking_id, reason)
|
| 76 |
+
- Cancel an existing booking.
|
| 77 |
+
|
| 78 |
+
10. get_my_bookings()
|
| 79 |
+
- List user's upcoming and past short-stay bookings.
|
| 80 |
+
|
| 81 |
+
NOTIFICATIONS (triggered by booking events):
|
| 82 |
+
11. send_checkin_reminder(booking_id, guest_user_id)
|
| 83 |
+
- Send AIDA DM reminder to guest 3 hours before check-in.
|
| 84 |
+
- Also notify host 3 hours before.
|
| 85 |
+
|
| 86 |
+
12. send_checkout_review_request(booking_id, guest_user_id)
|
| 87 |
+
- Send AIDA DM to guest after checkout asking for their review.
|
| 88 |
+
- Message: conversational, warm — not a form.
|
| 89 |
+
|
| 90 |
+
13. submit_property_review(listing_id, booking_id, rating, review_text)
|
| 91 |
+
- Save the guest's rating and review to the PROPERTY profile.
|
| 92 |
+
- Triggered after guest responds to the review request.
|
| 93 |
+
|
| 94 |
+
ALERTS:
|
| 95 |
+
14. create_alert(criteria)
|
| 96 |
+
- Notify user when a matching short-stay appears.
|
| 97 |
+
15. delete_alert(alert_id) / pause_alert(alert_id) / resume_alert(alert_id)
|
| 98 |
+
16. get_my_alerts()
|
| 99 |
+
|
| 100 |
+
GENERAL:
|
| 101 |
+
17. remember_preference(key, value, category)
|
| 102 |
+
- Save guest preference (areas, budget, amenities, travel style).
|
| 103 |
+
|
| 104 |
+
18. track_user_feedback(feedback_type, context, correction_text)
|
| 105 |
+
- Log corrections or negative reactions silently.
|
| 106 |
+
|
| 107 |
+
19. respond(message)
|
| 108 |
+
- General chat, trip planning, booking clarification, empathy.
|
| 109 |
+
|
| 110 |
+
HANDOFF SIGNALS (do NOT handle — return handoff instead):
|
| 111 |
+
- Long-term rental or property sale intent → set tool: "HANDOFF_BROKER"
|
| 112 |
+
- Roommate search intent → set tool: "HANDOFF_MATCHER"
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
CONCIERGE_SYSTEM_PROMPT = """You are AIDA-Primary, the short-stay concierge on Lojiz.
|
| 116 |
+
|
| 117 |
+
YOUR SPECIALTY: Helping guests find, compare, and book short-stay properties —
|
| 118 |
+
and making sure their experience is great from booking to checkout.
|
| 119 |
+
|
| 120 |
+
YOUR PERSONALITY:
|
| 121 |
+
- Warm, fast, and traveller-friendly
|
| 122 |
+
- Think like a knowledgeable local friend who knows the best places to stay
|
| 123 |
+
- Proactive: if user mentions a trip or travel, start searching immediately
|
| 124 |
+
- Empathetic: acknowledge emotional context (honeymoon, work trip, family visit) before showing results
|
| 125 |
+
- You follow through — you don't just book and disappear
|
| 126 |
+
|
| 127 |
+
═══════════════════════════════════════════════
|
| 128 |
+
BOOKING FLOW (your core job)
|
| 129 |
+
═══════════════════════════════════════════════
|
| 130 |
+
|
| 131 |
+
When user taps "Book" or "Help me choose" on a listing:
|
| 132 |
+
|
| 133 |
+
1. Call check_availability for their requested dates.
|
| 134 |
+
2. Call calculate_booking_cost so they see the full price before committing.
|
| 135 |
+
3. On user confirmation → call initiate_booking → triggers payment.
|
| 136 |
+
4. Payment authorised → call confirm_booking → send confirmation via AIDA DM.
|
| 137 |
+
5. 3 hours before check-in → call send_checkin_reminder for both guest and host.
|
| 138 |
+
6. After checkout → call send_checkout_review_request to the guest.
|
| 139 |
+
7. Guest replies (voice or text) → extract rating + review → call submit_property_review.
|
| 140 |
+
The review is saved to the PROPERTY profile for future guests to see.
|
| 141 |
+
|
| 142 |
+
═══════════════════════════════════════════════
|
| 143 |
+
POST-CHECKOUT REVIEW (conversational, not a form)
|
| 144 |
+
═══════════════════════════════════════════════
|
| 145 |
+
|
| 146 |
+
After checkout send via AIDA DM:
|
| 147 |
+
"Hey! Hope you had a great stay at [property name] 🏠
|
| 148 |
+
How was it? Drop a rating (1–5 ⭐) and tell me anything you want other guests to know."
|
| 149 |
+
|
| 150 |
+
User can respond by text or voice note. Extract the rating and review text naturally.
|
| 151 |
+
Save it to the property profile with submit_property_review.
|
| 152 |
+
|
| 153 |
+
═══════════════════════════════════════════════
|
| 154 |
+
YOUR RULES
|
| 155 |
+
═══════════════════════════════════════════════
|
| 156 |
+
1. Short-stay ONLY. Long-term/sale → HANDOFF_BROKER. Roommate → HANDOFF_MATCHER.
|
| 157 |
+
2. When user gives location + travel context, call search_properties immediately. No unnecessary questions.
|
| 158 |
+
3. Suggest alerts warmly when nothing is found — never robotically.
|
| 159 |
+
4. Respond entirely in the user's detected language.
|
| 160 |
+
5. Never mention "HANDOFF", agent names, or internal routing to the user.
|
| 161 |
+
6. Listing creation is handled elsewhere — do NOT run listing flows here.
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ============================================================
|
| 166 |
+
# MAIN ENTRY POINT
|
| 167 |
+
# ============================================================
|
| 168 |
+
|
| 169 |
+
async def process(state: AgentState) -> AgentState:
|
| 170 |
+
"""
|
| 171 |
+
Concierge brain entry point.
|
| 172 |
+
Handles short-stay booking flow, pre-checkin reminders, post-checkout reviews.
|
| 173 |
+
"""
|
| 174 |
+
logger.info("AIDA-Primary activated", user_id=state.user_id, message=state.last_user_message[:60])
|
| 175 |
+
|
| 176 |
+
state.temp_data["active_agent"] = AGENT_ID
|
| 177 |
+
state.temp_data["agent_system_prompt"] = CONCIERGE_SYSTEM_PROMPT
|
| 178 |
+
state.temp_data["agent_tools"] = CONCIERGE_TOOLS
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
decision = await brain_decide(state)
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error("Concierge brain_decide failed", error=str(e))
|
| 184 |
+
state.temp_data["response_text"] = await generate_localized_response(
|
| 185 |
+
context="Tell the user there was a temporary issue and you're back now. Ask how you can help.",
|
| 186 |
+
language=state.language_detected or state.app_language or "en",
|
| 187 |
+
tone="apologetic",
|
| 188 |
+
max_length="short",
|
| 189 |
+
)
|
| 190 |
+
state.temp_data["action"] = "respond"
|
| 191 |
+
return state
|
| 192 |
+
|
| 193 |
+
# Handoff signals
|
| 194 |
+
if decision.tool in ("HANDOFF_BROKER", "HANDOFF_MATCHER"):
|
| 195 |
+
state.temp_data["handoff"] = decision.tool
|
| 196 |
+
state.temp_data["handoff_reason"] = decision.thinking
|
| 197 |
+
logger.info("Concierge signalling handoff", target=decision.tool)
|
| 198 |
+
return state
|
| 199 |
+
|
| 200 |
+
# Execute tool
|
| 201 |
+
if decision.tool and decision.tool != "respond":
|
| 202 |
+
try:
|
| 203 |
+
success, message, result = await execute_tool(decision.tool, decision.params, state)
|
| 204 |
+
if not success:
|
| 205 |
+
logger.warning("Concierge tool failed", tool=decision.tool, message=message)
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.error("Concierge tool execution error", tool=decision.tool, error=str(e))
|
| 208 |
+
|
| 209 |
+
# Generate response
|
| 210 |
+
if decision.tool in ("search_properties", "compare_properties"):
|
| 211 |
+
try:
|
| 212 |
+
log_reward(REWARD_SEARCH_COMPLETED, state.user_id, {"agent": AGENT_ID})
|
| 213 |
+
except Exception:
|
| 214 |
+
pass
|
| 215 |
+
state.temp_data["response_text"] = await generate_contextual_response(state, decision)
|
| 216 |
+
elif not state.temp_data.get("response_text"):
|
| 217 |
+
if decision.response:
|
| 218 |
+
state.temp_data["response_text"] = decision.response
|
| 219 |
+
else:
|
| 220 |
+
state.temp_data["response_text"] = await generate_contextual_response(state, decision)
|
| 221 |
+
|
| 222 |
+
return state
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ============================================================
|
| 226 |
+
# CONTEXT LOADER — session resume
|
| 227 |
+
# ============================================================
|
| 228 |
+
|
| 229 |
+
async def load_context(state: AgentState) -> AgentState:
|
| 230 |
+
"""
|
| 231 |
+
Called on session resume. Loads the user's most recent active booking
|
| 232 |
+
into state.agent_context["booking"] so the brain has full context without
|
| 233 |
+
needing to ask the user to re-explain what they were doing.
|
| 234 |
+
"""
|
| 235 |
+
try:
|
| 236 |
+
from app.database import get_db
|
| 237 |
+
db = await get_db()
|
| 238 |
+
|
| 239 |
+
booking = await db["bookings"].find_one(
|
| 240 |
+
{
|
| 241 |
+
"user_id": state.user_id,
|
| 242 |
+
"status": {"$in": ["Pending", "Confirmed", "ActiveStay"]},
|
| 243 |
+
},
|
| 244 |
+
sort=[("created_at", -1)],
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
if booking:
|
| 248 |
+
booking["_id"] = str(booking["_id"])
|
| 249 |
+
state.agent_context["booking"] = booking
|
| 250 |
+
logger.info(
|
| 251 |
+
"Concierge context loaded",
|
| 252 |
+
user_id=state.user_id,
|
| 253 |
+
booking_id=booking["_id"],
|
| 254 |
+
status=booking.get("status"),
|
| 255 |
+
)
|
| 256 |
+
else:
|
| 257 |
+
logger.info("Concierge context: no active booking found", user_id=state.user_id)
|
| 258 |
+
|
| 259 |
+
except Exception as e:
|
| 260 |
+
logger.warning("Concierge load_context failed (non-fatal)", error=str(e))
|
| 261 |
+
|
| 262 |
+
return state
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ============================================================
|
| 266 |
+
# TOOL FILTER — only concierge-relevant tools
|
| 267 |
+
# ============================================================
|
| 268 |
+
|
| 269 |
+
CONCIERGE_TOOL_NAMES = {
|
| 270 |
+
"search_properties",
|
| 271 |
+
"analyze_listing",
|
| 272 |
+
"compare_properties",
|
| 273 |
+
"get_price_trends",
|
| 274 |
+
"check_availability",
|
| 275 |
+
"calculate_booking_cost",
|
| 276 |
+
"initiate_booking",
|
| 277 |
+
"confirm_booking",
|
| 278 |
+
"cancel_booking",
|
| 279 |
+
"get_my_bookings",
|
| 280 |
+
"send_checkin_reminder",
|
| 281 |
+
"send_checkout_review_request",
|
| 282 |
+
"submit_property_review",
|
| 283 |
+
"create_alert",
|
| 284 |
+
"delete_alert",
|
| 285 |
+
"get_my_alerts",
|
| 286 |
+
"pause_alert",
|
| 287 |
+
"resume_alert",
|
| 288 |
+
"remember_preference",
|
| 289 |
+
"track_user_feedback",
|
| 290 |
+
"respond",
|
| 291 |
+
}
|
app/ai/agent/dm_brain.py
CHANGED
|
@@ -164,7 +164,17 @@ DM_TOOLS = """
|
|
| 164 |
|
| 165 |
|
| 166 |
class DmBrain:
|
| 167 |
-
async def process(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
# Initialize state
|
| 169 |
state = AgentState(
|
| 170 |
user_id=user_id,
|
|
@@ -172,13 +182,50 @@ class DmBrain:
|
|
| 172 |
user_role=user_role,
|
| 173 |
source=source,
|
| 174 |
last_user_message=message,
|
|
|
|
|
|
|
| 175 |
)
|
| 176 |
if app_language:
|
| 177 |
state.app_language = app_language
|
| 178 |
if reply_context:
|
| 179 |
state.temp_data["reply_context"] = reply_context
|
| 180 |
-
|
| 181 |
logger.info("DM Brain processing message", user_id=user_id, message=message[:50])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
try:
|
| 184 |
# Fast-path: detect [BOOKING_OVERVIEW:id] tag — skip LLM routing
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
class DmBrain:
|
| 167 |
+
async def process(
|
| 168 |
+
self,
|
| 169 |
+
message: str,
|
| 170 |
+
user_id: str,
|
| 171 |
+
source: str = "dm",
|
| 172 |
+
reply_context: Optional[dict] = None,
|
| 173 |
+
user_role: str = "renter",
|
| 174 |
+
app_language: Optional[str] = None,
|
| 175 |
+
user_name: Optional[str] = None,
|
| 176 |
+
user_location: Optional[str] = None,
|
| 177 |
+
) -> Any:
|
| 178 |
# Initialize state
|
| 179 |
state = AgentState(
|
| 180 |
user_id=user_id,
|
|
|
|
| 182 |
user_role=user_role,
|
| 183 |
source=source,
|
| 184 |
last_user_message=message,
|
| 185 |
+
user_name=user_name,
|
| 186 |
+
user_location=user_location,
|
| 187 |
)
|
| 188 |
if app_language:
|
| 189 |
state.app_language = app_language
|
| 190 |
if reply_context:
|
| 191 |
state.temp_data["reply_context"] = reply_context
|
| 192 |
+
|
| 193 |
logger.info("DM Brain processing message", user_id=user_id, message=message[:50])
|
| 194 |
+
|
| 195 |
+
# ── Agent Hub routing for general search / listing / roommate intents ──
|
| 196 |
+
# DM-Brain handles DM-specific tools (alerts, booking, overview, landlord).
|
| 197 |
+
# For broader intents that need a specialist (broker/concierge/matcher),
|
| 198 |
+
# delegate to the hub and convert its AgentState back to a DM response dict.
|
| 199 |
+
import re as _re_hub
|
| 200 |
+
_is_booking_tag = bool(_re_hub.search(r'\[BOOKING_OVERVIEW:([\w]+)\]', message))
|
| 201 |
+
_dm_specific_signals = [
|
| 202 |
+
"my alert", "my alerts", "notify me", "alert me", "book it",
|
| 203 |
+
"book this", "check in", "check out", "is anyone checking",
|
| 204 |
+
"my bookings", "booking summary", "pursue", "stop searching",
|
| 205 |
+
"resume my search", "found my place", "i got it", "deal fell through",
|
| 206 |
+
]
|
| 207 |
+
_needs_hub = not _is_booking_tag and not any(s in message.lower() for s in _dm_specific_signals)
|
| 208 |
+
|
| 209 |
+
if _needs_hub:
|
| 210 |
+
from app.ai.agent.agent_hub import classify_intent as hub_classify
|
| 211 |
+
hub_intent = await hub_classify(message, state, current_agent=None)
|
| 212 |
+
if hub_intent in ("concierge", "broker", "matcher"):
|
| 213 |
+
try:
|
| 214 |
+
from app.ai.agent.agent_hub import run as hub_run
|
| 215 |
+
state = await hub_run(state)
|
| 216 |
+
return {
|
| 217 |
+
"text": state.temp_data.get("response_text", ""),
|
| 218 |
+
"metadata": {
|
| 219 |
+
"action": state.temp_data.get("action", "respond"),
|
| 220 |
+
"active_agent": state.temp_data.get("active_agent"),
|
| 221 |
+
**{k: v for k, v in state.temp_data.items()
|
| 222 |
+
if k in ("search_results", "my_listings", "draft", "draft_ui",
|
| 223 |
+
"alert_results", "comparison_data")},
|
| 224 |
+
},
|
| 225 |
+
"action": state.temp_data.get("action", "respond"),
|
| 226 |
+
}
|
| 227 |
+
except Exception as hub_err:
|
| 228 |
+
logger.warning("Hub routing failed in DM, falling through to dm_brain", error=str(hub_err))
|
| 229 |
|
| 230 |
try:
|
| 231 |
# Fast-path: detect [BOOKING_OVERVIEW:id] tag — skip LLM routing
|
app/ai/agent/graph.py
CHANGED
|
@@ -17,7 +17,7 @@ from structlog import get_logger
|
|
| 17 |
from app.ai.agent.state import AgentState, FlowState
|
| 18 |
from app.ai.lightning.tracer import wrap_graph_if_enabled
|
| 19 |
from app.ai.agent.nodes.authenticate import authenticate
|
| 20 |
-
from app.ai.agent.
|
| 21 |
from app.ai.agent.nodes.validate_output import validate_output_node
|
| 22 |
from app.ai.agent.nodes.respond import respond_to_user
|
| 23 |
from app.ai.agent.nodes.listing_publish import listing_publish_handler
|
|
@@ -67,8 +67,8 @@ def build_aida_graph():
|
|
| 67 |
# 1. Authenticate user
|
| 68 |
graph.add_node("authenticate", authenticate)
|
| 69 |
|
| 70 |
-
# 2.
|
| 71 |
-
graph.add_node("brain",
|
| 72 |
|
| 73 |
# 3. Publish flow (for DB operations)
|
| 74 |
graph.add_node("publish", listing_publish_handler)
|
|
|
|
| 17 |
from app.ai.agent.state import AgentState, FlowState
|
| 18 |
from app.ai.lightning.tracer import wrap_graph_if_enabled
|
| 19 |
from app.ai.agent.nodes.authenticate import authenticate
|
| 20 |
+
from app.ai.agent.agent_hub import run as hub_run
|
| 21 |
from app.ai.agent.nodes.validate_output import validate_output_node
|
| 22 |
from app.ai.agent.nodes.respond import respond_to_user
|
| 23 |
from app.ai.agent.nodes.listing_publish import listing_publish_handler
|
|
|
|
| 67 |
# 1. Authenticate user
|
| 68 |
graph.add_node("authenticate", authenticate)
|
| 69 |
|
| 70 |
+
# 2. Agent Hub — routes to the right specialist brain (THE CORE)
|
| 71 |
+
graph.add_node("brain", hub_run)
|
| 72 |
|
| 73 |
# 3. Publish flow (for DB operations)
|
| 74 |
graph.add_node("publish", listing_publish_handler)
|
app/ai/agent/matcher_brain.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/ai/agent/matcher_brain.py
|
| 2 |
+
"""
|
| 3 |
+
AIDA-Social — The Roommate Matcher Brain
|
| 4 |
+
|
| 5 |
+
Domain: Co-living compatibility, roommate discovery, lifestyle-first matching,
|
| 6 |
+
double opt-in gating.
|
| 7 |
+
|
| 8 |
+
Two flows:
|
| 9 |
+
HOST — Has a space, wants to find the right person to share with.
|
| 10 |
+
Routed here when someone lists a roommate space OR says they have a
|
| 11 |
+
place and want a roommate.
|
| 12 |
+
SEEKER — No place yet, looking to join someone's space.
|
| 13 |
+
Routed here when someone says they're looking for a room to share.
|
| 14 |
+
|
| 15 |
+
Both flows build a lifestyle profile and enter the same matching pool.
|
| 16 |
+
The double opt-in shield ensures neither party gets the other's contact until
|
| 17 |
+
both have confirmed interest.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from structlog import get_logger
|
| 21 |
+
from bson import ObjectId
|
| 22 |
+
|
| 23 |
+
from app.ai.agent.state import AgentState
|
| 24 |
+
from app.ai.agent.brain import (
|
| 25 |
+
brain_decide,
|
| 26 |
+
execute_tool,
|
| 27 |
+
generate_localized_response,
|
| 28 |
+
generate_contextual_response,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
logger = get_logger(__name__)
|
| 32 |
+
|
| 33 |
+
# ============================================================
|
| 34 |
+
# MATCHER IDENTITY
|
| 35 |
+
# ============================================================
|
| 36 |
+
|
| 37 |
+
AGENT_ID = "matcher"
|
| 38 |
+
AGENT_NAME = "AIDA-Social"
|
| 39 |
+
AGENT_DOMAIN = "roommate matching, co-living, lifestyle compatibility"
|
| 40 |
+
|
| 41 |
+
OWNED_LISTING_TYPES = {"roommate"}
|
| 42 |
+
|
| 43 |
+
MATCHER_TOOLS = """
|
| 44 |
+
AVAILABLE TOOLS (use ONLY these):
|
| 45 |
+
|
| 46 |
+
SEARCH & DISCOVERY:
|
| 47 |
+
1. search_properties(query, location, min_price, max_price, beds, listing_type)
|
| 48 |
+
- Always set listing_type="roommate".
|
| 49 |
+
- Use ONLY after extracting the user's lifestyle profile from their free-form message.
|
| 50 |
+
|
| 51 |
+
2. analyze_listing(listing_id)
|
| 52 |
+
- Deep profile of a shared living space and its current occupant(s).
|
| 53 |
+
|
| 54 |
+
3. create_alert(criteria)
|
| 55 |
+
- Notify user when a matching roommate listing appears.
|
| 56 |
+
|
| 57 |
+
4. delete_alert(alert_id) / pause_alert(alert_id) / resume_alert(alert_id)
|
| 58 |
+
5. get_my_alerts()
|
| 59 |
+
6. get_price_trends(location)
|
| 60 |
+
|
| 61 |
+
LISTING MANAGEMENT (HOST flow only):
|
| 62 |
+
7. update_listing(fields, list_mode, replace_index)
|
| 63 |
+
- listing_type is always "roommate".
|
| 64 |
+
- Collect: location, price, available rooms, house rules, preferred flatmate lifestyle.
|
| 65 |
+
|
| 66 |
+
8. edit_listing(listing_id)
|
| 67 |
+
9. publish_listing()
|
| 68 |
+
10. delete_listing(listing_id)
|
| 69 |
+
11. get_my_listings()
|
| 70 |
+
12. geocode_address(address)
|
| 71 |
+
|
| 72 |
+
PROFILE & COMPATIBILITY:
|
| 73 |
+
13. remember_preference(key, value, category)
|
| 74 |
+
- Save extracted lifestyle data. Call once per extracted field.
|
| 75 |
+
- Keys: sleep_schedule, cleanliness, pets, smoking, wfh, social_preference,
|
| 76 |
+
budget, location, move_in_date, gender_preference, occupation
|
| 77 |
+
|
| 78 |
+
14. track_user_feedback(feedback_type, context, correction_text)
|
| 79 |
+
|
| 80 |
+
15. respond(message)
|
| 81 |
+
- Free-form open question, follow-up if something is missing, compatibility summary.
|
| 82 |
+
|
| 83 |
+
HANDOFF SIGNALS (do NOT handle — return handoff instead):
|
| 84 |
+
- Short-stay accommodation intent → set tool: "HANDOFF_CONCIERGE"
|
| 85 |
+
- Long-term rental or sale intent → set tool: "HANDOFF_BROKER"
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
MATCHER_SYSTEM_PROMPT = """You are AIDA-Social, the roommate matching specialist on Lojiz.
|
| 89 |
+
|
| 90 |
+
YOUR SPECIALTY: Lifestyle-first co-living discovery. You find compatible people, not just available rooms.
|
| 91 |
+
|
| 92 |
+
YOUR PERSONALITY:
|
| 93 |
+
- Friendly, perceptive, socially intelligent
|
| 94 |
+
- You listen deeply and extract meaning from natural, free-flowing messages
|
| 95 |
+
- You are a discreet gatekeeper who protects users from unwanted contact
|
| 96 |
+
- You celebrate great matches — it's genuinely exciting when lifestyles align
|
| 97 |
+
|
| 98 |
+
═══════════════════════════════════════════════
|
| 99 |
+
TWO FLOWS — DETECT WHICH ONE THIS IS
|
| 100 |
+
═══════════════════════════════════════════════
|
| 101 |
+
|
| 102 |
+
SEEKER FLOW — User has no place, wants to join someone's space
|
| 103 |
+
Trigger: "looking for a room", "need a flatmate", "want to share", "find a roommate"
|
| 104 |
+
Opening: "Tell me a bit about yourself and what you're looking for in a place and a flatmate —
|
| 105 |
+
lifestyle, schedule, habits, anything on your mind. Voice or text, whatever works."
|
| 106 |
+
|
| 107 |
+
HOST FLOW — User has a space, wants to find the right person
|
| 108 |
+
Trigger: "I have a 2-bedroom", "looking for someone to share with me", "list my room",
|
| 109 |
+
"want a roommate for my place", or roommate listing type detected
|
| 110 |
+
Opening: "Tell me about yourself and the kind of person you'd love to share your space with —
|
| 111 |
+
your lifestyle, your vibe, any house rules, anything you want them to know."
|
| 112 |
+
|
| 113 |
+
═══════════════════════════════════════════════
|
| 114 |
+
FREE-FORM EXTRACTION — HOW IT WORKS
|
| 115 |
+
═══════════════════════════════════════════════
|
| 116 |
+
|
| 117 |
+
Step 1 — Ask ONE open question (the opening above). Do NOT ask a Q&A form.
|
| 118 |
+
|
| 119 |
+
Step 2 — Extract ALL lifestyle signals from their free-form response:
|
| 120 |
+
• sleep_schedule (early bird / night owl / flexible)
|
| 121 |
+
• cleanliness (relaxed / moderate / very tidy)
|
| 122 |
+
• pets (has pets / no pets / allergic)
|
| 123 |
+
• smoking (smoker / non-smoker / outdoor only)
|
| 124 |
+
• wfh (never / sometimes / full-time)
|
| 125 |
+
• social_preference (quiet / moderate / social)
|
| 126 |
+
• budget (price range)
|
| 127 |
+
• location (area/city)
|
| 128 |
+
• move_in_date (when they want to move)
|
| 129 |
+
• gender_preference (if stated)
|
| 130 |
+
• occupation (if stated — affects schedule compatibility)
|
| 131 |
+
|
| 132 |
+
Step 3 — Save each extracted signal with remember_preference.
|
| 133 |
+
|
| 134 |
+
Step 4 — If ONE critical field is missing (location or budget), ask exactly ONE follow-up.
|
| 135 |
+
Never ask more than one follow-up question. If other fields are missing, infer or leave blank.
|
| 136 |
+
|
| 137 |
+
Step 5 — Search for matches. Present results showing WHY each is compatible
|
| 138 |
+
(e.g. "Both night owls, both non-smokers, similar cleanliness standards").
|
| 139 |
+
|
| 140 |
+
═══════════════════════════════════════════════
|
| 141 |
+
DOUBLE OPT-IN SHIELD (NON-NEGOTIABLE)
|
| 142 |
+
═══════════════════════════════════════════════
|
| 143 |
+
|
| 144 |
+
When showing a match, NEVER share contact details, full name, or exact address.
|
| 145 |
+
Instead say: "I'll send them a summary of your profile. If they're interested too, I'll connect you."
|
| 146 |
+
Only open contact after BOTH parties confirm interest.
|
| 147 |
+
This protects everyone from harassment.
|
| 148 |
+
|
| 149 |
+
═══════════════════════════════════════════════
|
| 150 |
+
YOUR RULES
|
| 151 |
+
═══════════════════════════════════════════════
|
| 152 |
+
1. Roommate/co-living ONLY.
|
| 153 |
+
Short-stay → HANDOFF_CONCIERGE. Long-term/sale → HANDOFF_BROKER.
|
| 154 |
+
2. If a renter asks to list for rent or sale (not roommate), tell them:
|
| 155 |
+
"On Lojiz, renters can list a shared space to find a roommate — listing for
|
| 156 |
+
full rent or sale is for landlords. Want to post a roommate listing instead?"
|
| 157 |
+
Do NOT hand off — just explain and offer the roommate option.
|
| 158 |
+
3. Never share another user's personal details before double opt-in.
|
| 159 |
+
4. One open question → extract → at most one follow-up. Never a questionnaire.
|
| 160 |
+
5. Respond entirely in the user's detected language.
|
| 161 |
+
6. Never mention "HANDOFF" or internal agent names to the user.
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ============================================================
|
| 166 |
+
# MAIN ENTRY POINT
|
| 167 |
+
# ============================================================
|
| 168 |
+
|
| 169 |
+
async def process(state: AgentState) -> AgentState:
|
| 170 |
+
"""
|
| 171 |
+
Matcher brain entry point.
|
| 172 |
+
Handles both HOST and SEEKER flows via free-form extraction.
|
| 173 |
+
"""
|
| 174 |
+
logger.info("AIDA-Social activated", user_id=state.user_id, message=state.last_user_message[:60])
|
| 175 |
+
|
| 176 |
+
state.temp_data["active_agent"] = AGENT_ID
|
| 177 |
+
state.temp_data["agent_system_prompt"] = MATCHER_SYSTEM_PROMPT
|
| 178 |
+
state.temp_data["agent_tools"] = MATCHER_TOOLS
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
decision = await brain_decide(state)
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error("Matcher brain_decide failed", error=str(e))
|
| 184 |
+
state.temp_data["response_text"] = await generate_localized_response(
|
| 185 |
+
context="Tell the user there was a brief issue and you're ready to help them find the perfect roommate.",
|
| 186 |
+
language=state.language_detected or state.app_language or "en",
|
| 187 |
+
tone="apologetic",
|
| 188 |
+
max_length="short",
|
| 189 |
+
)
|
| 190 |
+
state.temp_data["action"] = "respond"
|
| 191 |
+
return state
|
| 192 |
+
|
| 193 |
+
# Handoff signals
|
| 194 |
+
if decision.tool in ("HANDOFF_CONCIERGE", "HANDOFF_BROKER"):
|
| 195 |
+
state.temp_data["handoff"] = decision.tool
|
| 196 |
+
state.temp_data["handoff_reason"] = decision.thinking
|
| 197 |
+
logger.info("Matcher signalling handoff", target=decision.tool)
|
| 198 |
+
return state
|
| 199 |
+
|
| 200 |
+
# Execute tool
|
| 201 |
+
if decision.tool and decision.tool != "respond":
|
| 202 |
+
try:
|
| 203 |
+
success, message, result = await execute_tool(decision.tool, decision.params, state)
|
| 204 |
+
if not success:
|
| 205 |
+
logger.warning("Matcher tool failed", tool=decision.tool, message=message)
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.error("Matcher tool execution error", tool=decision.tool, error=str(e))
|
| 208 |
+
|
| 209 |
+
# Generate response
|
| 210 |
+
if decision.tool == "search_properties":
|
| 211 |
+
state.temp_data["response_text"] = await generate_contextual_response(state, decision)
|
| 212 |
+
elif not state.temp_data.get("response_text"):
|
| 213 |
+
if decision.response:
|
| 214 |
+
state.temp_data["response_text"] = decision.response
|
| 215 |
+
else:
|
| 216 |
+
state.temp_data["response_text"] = await generate_contextual_response(state, decision)
|
| 217 |
+
|
| 218 |
+
return state
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ============================================================
|
| 222 |
+
# CONTEXT LOADER — session resume
|
| 223 |
+
# ============================================================
|
| 224 |
+
|
| 225 |
+
async def load_context(state: AgentState) -> AgentState:
|
| 226 |
+
"""
|
| 227 |
+
Called on session resume. Loads the user's active matcher profile:
|
| 228 |
+
- HOST: their most recent published roommate listing (with preferred_flatmate)
|
| 229 |
+
- SEEKER: their stored lifestyle preferences (from user profile or listings collection)
|
| 230 |
+
|
| 231 |
+
Both are placed in state.agent_context["matcher_profile"] so the brain
|
| 232 |
+
can resume the conversation naturally — e.g. "Still looking for a flatmate?"
|
| 233 |
+
instead of starting from scratch.
|
| 234 |
+
"""
|
| 235 |
+
try:
|
| 236 |
+
from app.database import get_db
|
| 237 |
+
db = await get_db()
|
| 238 |
+
|
| 239 |
+
profile: dict = {}
|
| 240 |
+
|
| 241 |
+
# 1. Check if user has an active roommate HOST listing
|
| 242 |
+
host_listing = await db["listings"].find_one(
|
| 243 |
+
{
|
| 244 |
+
"user_id": state.user_id,
|
| 245 |
+
"listing_type": "roommate",
|
| 246 |
+
"status": {"$in": ["published", "active", "pending"]},
|
| 247 |
+
},
|
| 248 |
+
sort=[("created_at", -1)],
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
if host_listing:
|
| 252 |
+
host_listing["_id"] = str(host_listing["_id"])
|
| 253 |
+
profile["flow"] = "host"
|
| 254 |
+
profile["listing"] = host_listing
|
| 255 |
+
profile["preferred_flatmate"] = host_listing.get("preferred_flatmate", {})
|
| 256 |
+
logger.info(
|
| 257 |
+
"Matcher context: host flow",
|
| 258 |
+
user_id=state.user_id,
|
| 259 |
+
listing_id=host_listing["_id"],
|
| 260 |
+
)
|
| 261 |
+
else:
|
| 262 |
+
# 2. Seeker — load lifestyle prefs from user profile
|
| 263 |
+
user_doc = await db["users"].find_one(
|
| 264 |
+
{"_id": ObjectId(state.user_id)},
|
| 265 |
+
{"lifestyle_prefs": 1, "roommate_seeker": 1},
|
| 266 |
+
)
|
| 267 |
+
if user_doc:
|
| 268 |
+
seeker_data = user_doc.get("roommate_seeker") or user_doc.get("lifestyle_prefs") or {}
|
| 269 |
+
if seeker_data:
|
| 270 |
+
profile["flow"] = "seeker"
|
| 271 |
+
profile["lifestyle_prefs"] = seeker_data
|
| 272 |
+
logger.info(
|
| 273 |
+
"Matcher context: seeker flow",
|
| 274 |
+
user_id=state.user_id,
|
| 275 |
+
fields=list(seeker_data.keys()),
|
| 276 |
+
)
|
| 277 |
+
else:
|
| 278 |
+
logger.info("Matcher context: seeker with no stored prefs yet", user_id=state.user_id)
|
| 279 |
+
profile["flow"] = "seeker"
|
| 280 |
+
profile["lifestyle_prefs"] = {}
|
| 281 |
+
|
| 282 |
+
if profile:
|
| 283 |
+
state.agent_context["matcher_profile"] = profile
|
| 284 |
+
|
| 285 |
+
except Exception as e:
|
| 286 |
+
logger.warning("Matcher load_context failed (non-fatal)", error=str(e))
|
| 287 |
+
|
| 288 |
+
return state
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
# ============================================================
|
| 292 |
+
# TOOL FILTER — only matcher-relevant tools
|
| 293 |
+
# ============================================================
|
| 294 |
+
|
| 295 |
+
MATCHER_TOOL_NAMES = {
|
| 296 |
+
"search_properties",
|
| 297 |
+
"analyze_listing",
|
| 298 |
+
"create_alert",
|
| 299 |
+
"delete_alert",
|
| 300 |
+
"get_my_alerts",
|
| 301 |
+
"pause_alert",
|
| 302 |
+
"resume_alert",
|
| 303 |
+
"get_price_trends",
|
| 304 |
+
"update_listing",
|
| 305 |
+
"edit_listing",
|
| 306 |
+
"publish_listing",
|
| 307 |
+
"delete_listing",
|
| 308 |
+
"get_my_listings",
|
| 309 |
+
"geocode_address",
|
| 310 |
+
"remember_preference",
|
| 311 |
+
"track_user_feedback",
|
| 312 |
+
"respond",
|
| 313 |
+
}
|
app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc differ
|
|
|
app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc differ
|
|
|
app/ai/agent/nodes/__pycache__/greeting.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/nodes/__pycache__/greeting.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/greeting.cpython-313.pyc differ
|
|
|
app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc differ
|
|
|
app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc differ
|
|
|
app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc differ
|
|
|
app/ai/agent/nodes/__pycache__/respond.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/nodes/__pycache__/respond.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/respond.cpython-313.pyc differ
|
|
|
app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc and b/app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc differ
|
|
|
app/ai/agent/nodes/listing_collect.py
CHANGED
|
@@ -470,7 +470,7 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
|
|
| 470 |
# IMPORTANT: Only sync raw data fields - NOT translated content fields
|
| 471 |
# amenities, requirements are stored in user's original language in provided_fields
|
| 472 |
# but listing_draft may have LLM-translated versions - preserve those
|
| 473 |
-
sync_fields = ["location", "address", "latitude", "longitude", "bedrooms", "bathrooms", "price", "price_type", "images"]
|
| 474 |
for field in sync_fields:
|
| 475 |
value = state.provided_fields.get(field)
|
| 476 |
if value is not None and field in state.listing_draft:
|
|
@@ -602,6 +602,7 @@ async def listing_collect_handler(state: AgentState) -> AgentState:
|
|
| 602 |
"amenities": state.listing_draft.get("amenities", []),
|
| 603 |
"images": state.listing_draft.get("images", []),
|
| 604 |
"listing_type": state.listing_draft.get("listing_type"),
|
|
|
|
| 605 |
"updated_at": datetime.utcnow(),
|
| 606 |
}
|
| 607 |
|
|
|
|
| 470 |
# IMPORTANT: Only sync raw data fields - NOT translated content fields
|
| 471 |
# amenities, requirements are stored in user's original language in provided_fields
|
| 472 |
# but listing_draft may have LLM-translated versions - preserve those
|
| 473 |
+
sync_fields = ["location", "address", "latitude", "longitude", "bedrooms", "bathrooms", "price", "price_type", "images", "preferred_flatmate"]
|
| 474 |
for field in sync_fields:
|
| 475 |
value = state.provided_fields.get(field)
|
| 476 |
if value is not None and field in state.listing_draft:
|
|
|
|
| 602 |
"amenities": state.listing_draft.get("amenities", []),
|
| 603 |
"images": state.listing_draft.get("images", []),
|
| 604 |
"listing_type": state.listing_draft.get("listing_type"),
|
| 605 |
+
"preferred_flatmate": state.listing_draft.get("preferred_flatmate") or {},
|
| 606 |
"updated_at": datetime.utcnow(),
|
| 607 |
}
|
| 608 |
|
app/ai/agent/nodes/listing_publish.py
CHANGED
|
@@ -275,6 +275,7 @@ async def listing_publish_handler(state: AgentState) -> AgentState:
|
|
| 275 |
"images": draft.images,
|
| 276 |
"video": draft.video,
|
| 277 |
"video_thumbnail": draft.video_thumbnail,
|
|
|
|
| 278 |
"status": "active",
|
| 279 |
"created_at": datetime.utcnow(),
|
| 280 |
"updated_at": datetime.utcnow(),
|
|
|
|
| 275 |
"images": draft.images,
|
| 276 |
"video": draft.video,
|
| 277 |
"video_thumbnail": draft.video_thumbnail,
|
| 278 |
+
"preferred_flatmate": draft.preferred_flatmate or {}, # Populated for roommate listings
|
| 279 |
"status": "active",
|
| 280 |
"created_at": datetime.utcnow(),
|
| 281 |
"updated_at": datetime.utcnow(),
|
app/ai/agent/planner.py
CHANGED
|
@@ -81,6 +81,21 @@ async def detect_complexity(user_message: str, state) -> bool:
|
|
| 81 |
logger.info("Complexity detection: edit/update operation → single-tool", message=user_message[:60])
|
| 82 |
return False
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
prompt = f"""Analyze this user request and determine if it requires multiple steps/tools to complete.
|
| 85 |
|
| 86 |
User request: "{user_message}"
|
|
@@ -93,13 +108,15 @@ Does this request need 2 or more DIFFERENT tools to fulfill completely?
|
|
| 93 |
|
| 94 |
ALWAYS answer "no" for these (they are conversational single-tool flows):
|
| 95 |
- ANY edit, update, modify, change, or delete request → "no" (these need user interaction)
|
|
|
|
|
|
|
|
|
|
| 96 |
- "edit my listing" → "no"
|
| 97 |
-
- "update the price on my listing" → "no"
|
| 98 |
-
- "delete my listing" → "no"
|
| 99 |
-
- "publish my listing" → "no"
|
| 100 |
- "find me apartments" → "no" (just search_properties)
|
| 101 |
- "show my listings" → "no" (just get_my_listings)
|
| 102 |
- "find houses in Nigeria" → "no" (search handles location expansion)
|
|
|
|
|
|
|
| 103 |
|
| 104 |
Answer "yes" ONLY when user explicitly asks for 2+ distinct actions in one message:
|
| 105 |
- "find apartments in Paris AND set up an alert" → "yes" (search + create_alert)
|
|
|
|
| 81 |
logger.info("Complexity detection: edit/update operation → single-tool", message=user_message[:60])
|
| 82 |
return False
|
| 83 |
|
| 84 |
+
# Fast-path: market price / price trends are always single-tool (get_price_trends)
|
| 85 |
+
price_keywords = ["market price", "market prices", "prix du marché", "precio del mercado",
|
| 86 |
+
"price trend", "average price", "how much does it cost", "average cost",
|
| 87 |
+
"prix moyen", "combien ça coûte", "💰"]
|
| 88 |
+
if any(pk in msg_lower for pk in price_keywords):
|
| 89 |
+
logger.info("Complexity detection: market price query → single-tool", message=user_message[:60])
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
# Fast-path: single-entity search requests are always single-tool
|
| 93 |
+
single_search_keywords = ["find me", "show me", "search for", "look for", "i want to find",
|
| 94 |
+
"find a", "find an", "show a", "i need a place", "looking for a place"]
|
| 95 |
+
if any(sk in msg_lower for sk in single_search_keywords) and "and" not in msg_lower and "also" not in msg_lower:
|
| 96 |
+
logger.info("Complexity detection: single search → single-tool", message=user_message[:60])
|
| 97 |
+
return False
|
| 98 |
+
|
| 99 |
prompt = f"""Analyze this user request and determine if it requires multiple steps/tools to complete.
|
| 100 |
|
| 101 |
User request: "{user_message}"
|
|
|
|
| 108 |
|
| 109 |
ALWAYS answer "no" for these (they are conversational single-tool flows):
|
| 110 |
- ANY edit, update, modify, change, or delete request → "no" (these need user interaction)
|
| 111 |
+
- ANY market price / price trend query → "no" (just get_price_trends)
|
| 112 |
+
- "market prices" → "no"
|
| 113 |
+
- "💰 Market prices" → "no"
|
| 114 |
- "edit my listing" → "no"
|
|
|
|
|
|
|
|
|
|
| 115 |
- "find me apartments" → "no" (just search_properties)
|
| 116 |
- "show my listings" → "no" (just get_my_listings)
|
| 117 |
- "find houses in Nigeria" → "no" (search handles location expansion)
|
| 118 |
+
- "I want to buy a house in Lagos" → "no" (just search_properties)
|
| 119 |
+
- "I'm planning a trip to Lagos, where can I stay?" → "no" (just search_properties short-stay)
|
| 120 |
|
| 121 |
Answer "yes" ONLY when user explicitly asks for 2+ distinct actions in one message:
|
| 122 |
- "find apartments in Paris AND set up an alert" → "yes" (search + create_alert)
|
app/ai/agent/schema.py
CHANGED
|
@@ -111,14 +111,43 @@ LISTING_FIELDS = {
|
|
| 111 |
"description": "Geocoded longitude. Auto from address",
|
| 112 |
"required": False,
|
| 113 |
"auto_infer": True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
}
|
| 116 |
|
| 117 |
# Required fields by listing type
|
| 118 |
REQUIRED_BY_TYPE = {
|
| 119 |
-
"rent":
|
| 120 |
-
"sale":
|
| 121 |
-
"short-stay": ["location", "address", "bedrooms", "bathrooms", "price", "price_type", "images"]
|
|
|
|
| 122 |
}
|
| 123 |
|
| 124 |
# Price type → Listing type inference
|
|
@@ -159,6 +188,12 @@ def get_schema_for_llm() -> str:
|
|
| 159 |
lines.append("• nightly/daily price → short-stay")
|
| 160 |
lines.append("• one-time price → sale")
|
| 161 |
lines.append("• monthly/yearly price → rent")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
return "\n".join(lines)
|
| 164 |
|
|
|
|
| 111 |
"description": "Geocoded longitude. Auto from address",
|
| 112 |
"required": False,
|
| 113 |
"auto_infer": True
|
| 114 |
+
},
|
| 115 |
+
"preferred_flatmate": {
|
| 116 |
+
"type": "object",
|
| 117 |
+
"description": (
|
| 118 |
+
"Roommate listings only. Lifestyle profile of the ideal flatmate. "
|
| 119 |
+
"Fields: sleep_schedule (early bird|night owl|flexible), "
|
| 120 |
+
"cleanliness (very clean|average|relaxed), pets (bool), "
|
| 121 |
+
"smoking (bool — indoor OK?), wfh (bool), "
|
| 122 |
+
"social_preference (social|quiet|balanced), "
|
| 123 |
+
"gender_preference (any|male|female|non-binary), "
|
| 124 |
+
"move_in_date (ISO string), occupation (string), "
|
| 125 |
+
"age_preference (string), notes (string)."
|
| 126 |
+
),
|
| 127 |
+
"required": False,
|
| 128 |
+
"roommate_only": True,
|
| 129 |
+
"example": {
|
| 130 |
+
"sleep_schedule": "flexible",
|
| 131 |
+
"cleanliness": "very clean",
|
| 132 |
+
"pets": False,
|
| 133 |
+
"smoking": False,
|
| 134 |
+
"wfh": True,
|
| 135 |
+
"social_preference": "balanced",
|
| 136 |
+
"gender_preference": "any",
|
| 137 |
+
"move_in_date": "2025-07-01",
|
| 138 |
+
"occupation": "professional",
|
| 139 |
+
"age_preference": "any",
|
| 140 |
+
"notes": ""
|
| 141 |
+
}
|
| 142 |
}
|
| 143 |
}
|
| 144 |
|
| 145 |
# Required fields by listing type
|
| 146 |
REQUIRED_BY_TYPE = {
|
| 147 |
+
"rent": ["location", "address", "bedrooms", "bathrooms", "price", "price_type", "images"],
|
| 148 |
+
"sale": ["location", "address", "bedrooms", "bathrooms", "price", "images"],
|
| 149 |
+
"short-stay": ["location", "address", "bedrooms", "bathrooms", "price", "price_type", "images"],
|
| 150 |
+
"roommate": ["location", "bedrooms", "price", "price_type"], # preferred_flatmate added after lifestyle interview
|
| 151 |
}
|
| 152 |
|
| 153 |
# Price type → Listing type inference
|
|
|
|
| 188 |
lines.append("• nightly/daily price → short-stay")
|
| 189 |
lines.append("• one-time price → sale")
|
| 190 |
lines.append("• monthly/yearly price → rent")
|
| 191 |
+
lines.append("• shared space / roommate context → roommate")
|
| 192 |
+
lines.append("• renter role → always roommate")
|
| 193 |
+
lines.append("\n=== ROOMMATE LISTINGS ===")
|
| 194 |
+
lines.append("• After standard fields: ask ONE lifestyle question")
|
| 195 |
+
lines.append("• Extract preferred_flatmate object from free-form answer")
|
| 196 |
+
lines.append("• Save to listing document (NOT user profile)")
|
| 197 |
|
| 198 |
return "\n".join(lines)
|
| 199 |
|
app/ai/agent/schemas.py
CHANGED
|
@@ -80,6 +80,7 @@ class ListingDraft(BaseModel):
|
|
| 80 |
images: List[str] = Field(default_factory=list)
|
| 81 |
video: Optional[str] = None # Video URL (R2 temp → Cloudinary permanent on publish)
|
| 82 |
video_thumbnail: Optional[str] = None # Video thumbnail URL
|
|
|
|
| 83 |
|
| 84 |
@validator("images")
|
| 85 |
def validate_image_urls(cls, v):
|
|
@@ -109,6 +110,7 @@ class ListingExtracted(BaseModel):
|
|
| 109 |
images: List[str] = Field(default_factory=list)
|
| 110 |
video: Optional[str] = None
|
| 111 |
video_thumbnail: Optional[str] = None
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
# ============================================================
|
|
|
|
| 80 |
images: List[str] = Field(default_factory=list)
|
| 81 |
video: Optional[str] = None # Video URL (R2 temp → Cloudinary permanent on publish)
|
| 82 |
video_thumbnail: Optional[str] = None # Video thumbnail URL
|
| 83 |
+
preferred_flatmate: Optional[Dict[str, Any]] = None # Roommate listings only — lifestyle profile of ideal flatmate
|
| 84 |
|
| 85 |
@validator("images")
|
| 86 |
def validate_image_urls(cls, v):
|
|
|
|
| 110 |
images: List[str] = Field(default_factory=list)
|
| 111 |
video: Optional[str] = None
|
| 112 |
video_thumbnail: Optional[str] = None
|
| 113 |
+
preferred_flatmate: Optional[Dict[str, Any]] = None # Extracted from lifestyle interview (roommate only)
|
| 114 |
|
| 115 |
|
| 116 |
# ============================================================
|
app/ai/agent/state.py
CHANGED
|
@@ -100,6 +100,15 @@ class AgentState(BaseModel):
|
|
| 100 |
retries: int = 0
|
| 101 |
max_retries: int = 3
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
# Temporary data for current operation
|
| 104 |
temp_data: Dict[str, Any] = Field(default_factory=dict)
|
| 105 |
|
|
|
|
| 100 |
retries: int = 0
|
| 101 |
max_retries: int = 3
|
| 102 |
|
| 103 |
+
# Active specialist agent — persisted to DB so it survives session resume.
|
| 104 |
+
# Values: "general" | "concierge" | "broker" | "matcher"
|
| 105 |
+
# None means: not yet set (hub will classify on next message).
|
| 106 |
+
active_agent: Optional[str] = None
|
| 107 |
+
|
| 108 |
+
# Loaded by each specialist brain's context loader on session resume.
|
| 109 |
+
# Concierge loads booking doc, Broker loads viewing doc, Matcher loads profile.
|
| 110 |
+
agent_context: Dict[str, Any] = Field(default_factory=dict)
|
| 111 |
+
|
| 112 |
# Temporary data for current operation
|
| 113 |
temp_data: Dict[str, Any] = Field(default_factory=dict)
|
| 114 |
|
app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc and b/app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc differ
|
|
|
app/ai/prompts/system_prompt.py
CHANGED
|
@@ -137,6 +137,15 @@ REQUIRED FIELDS TO COLLECT:
|
|
| 137 |
- Amenities (optional but ask: wifi, parking, furnished, washing machine, ac, balcony, etc.)
|
| 138 |
- Requirements (optional but ask: "3-month deposit", "no pets", "stable income", etc.)
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
IMPORTANT SALE HANDLING:
|
| 141 |
- If user says "for sale", "sell", "selling" → listing_type = "sale", price_type = "one-time"
|
| 142 |
- DO NOT ask "is it monthly or yearly?" for sales - sales are ALWAYS one-time purchases!
|
|
@@ -226,8 +235,49 @@ Description Generation:
|
|
| 226 |
- Example: "Spacious 3-bedroom, 2-bathroom rental in Lagos with wifi, parking, and balcony. Priced at 50,000 NGN per month. Tenants must provide 3-month security deposit."
|
| 227 |
- CRITICAL: Always ensure at least 3 complete sentences
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
STEP 5: WHEN ALL FIELDS COLLECTED
|
| 230 |
-
Once all required fields complete:
|
| 231 |
- Say a SHORT confirmation message like "Perfect! Here's your listing preview:"
|
| 232 |
- DO NOT write out the draft in text - the UI will display a visual card automatically
|
| 233 |
- Just ask: "Ready to publish? Say 'publish', 'edit [field]' to change, or 'discard' to cancel."
|
|
@@ -407,8 +457,26 @@ SMART INTENT DETECTION — be proactive, do NOT wait for explicit "search" comma
|
|
| 407 |
- "Short stay in Abuja" → search_properties, listing_type="short-stay", location="Abuja"
|
| 408 |
- "Houses for sale" → search_properties, listing_type="sale"
|
| 409 |
- "Any studios available in Ibadan?" → search_properties, listing_type="rent", location="Ibadan"
|
|
|
|
|
|
|
| 410 |
Users do NOT need to say "show me" or "search for" — detect intent from context and call the tool.
|
| 411 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
NEVER say "go to your DM", "continue in DM", "the DM has booking tools" — these are internal details.
|
| 413 |
The user experience is seamless: they search here, tap a card, booking opens automatically.
|
| 414 |
|
|
@@ -421,8 +489,9 @@ SIMPLIFIED LISTING FLOW:
|
|
| 421 |
4. Extract fields, ask missing ones ONE at a time
|
| 422 |
5. Ask about amenities/requirements (ONCE)
|
| 423 |
6. Auto-detect: listing_type, currency, title, description
|
| 424 |
-
7.
|
| 425 |
-
8.
|
|
|
|
| 426 |
|
| 427 |
NO PROPERTY TYPE QUESTIONS - it's auto-detected
|
| 428 |
NO CURRENCY QUESTIONS - it's auto-detected
|
|
|
|
| 137 |
- Amenities (optional but ask: wifi, parking, furnished, washing machine, ac, balcony, etc.)
|
| 138 |
- Requirements (optional but ask: "3-month deposit", "no pets", "stable income", etc.)
|
| 139 |
|
| 140 |
+
EXTRA FEES QUESTION (rent and sale listings ONLY — never ask for short-stay or roommate):
|
| 141 |
+
After collecting requirements, ask ONE additional question:
|
| 142 |
+
"Are there any extra charges the {{"renter" if rent else "buyer"}} should know about upfront?
|
| 143 |
+
For example: agency/consultation fee, inspection fee, legal fees, service charge, caution fee, etc."
|
| 144 |
+
- If YES: save the fee details into the requirements field (e.g., "Agency fee: 10%", "Caution: 2 months")
|
| 145 |
+
- If NO or "none": skip it, don't add anything
|
| 146 |
+
- This ensures full transparency and protects users from hidden costs
|
| 147 |
+
- Phrase the question naturally in the user's language
|
| 148 |
+
|
| 149 |
IMPORTANT SALE HANDLING:
|
| 150 |
- If user says "for sale", "sell", "selling" → listing_type = "sale", price_type = "one-time"
|
| 151 |
- DO NOT ask "is it monthly or yearly?" for sales - sales are ALWAYS one-time purchases!
|
|
|
|
| 235 |
- Example: "Spacious 3-bedroom, 2-bathroom rental in Lagos with wifi, parking, and balcony. Priced at 50,000 NGN per month. Tenants must provide 3-month security deposit."
|
| 236 |
- CRITICAL: Always ensure at least 3 complete sentences
|
| 237 |
|
| 238 |
+
STEP 4B: ROOMMATE LIFESTYLE INTERVIEW (ONLY when listing_type = "roommate")
|
| 239 |
+
|
| 240 |
+
Trigger: After collecting standard listing fields AND auto-detecting listing_type = "roommate"
|
| 241 |
+
This applies to BOTH landlords sharing a room AND renters listing their space.
|
| 242 |
+
|
| 243 |
+
WHY: The Matcher agent needs lifestyle compatibility data attached to THIS LISTING
|
| 244 |
+
(not just the user's profile). Flatmate search uses this to find compatible matches.
|
| 245 |
+
|
| 246 |
+
HOW — Ask ONE open-ended question (after standard fields are done):
|
| 247 |
+
|
| 248 |
+
"One more thing — tell me a bit about yourself and the kind of person you'd love to share with!
|
| 249 |
+
Things like your sleep schedule, how tidy you like things, whether you're okay with pets or
|
| 250 |
+
smoking indoors, if you work from home, that kind of vibe 🏠"
|
| 251 |
+
|
| 252 |
+
EXTRACT these lifestyle fields from their free-form answer:
|
| 253 |
+
|
| 254 |
+
sleep_schedule → "early bird" | "night owl" | "flexible"
|
| 255 |
+
cleanliness → "very clean" | "average" | "relaxed"
|
| 256 |
+
pets → true | false (are pets welcome?)
|
| 257 |
+
smoking → true | false (is indoor smoking okay?)
|
| 258 |
+
wfh → true | false (do they / will flatmate work from home?)
|
| 259 |
+
social_preference → "social" | "quiet" | "balanced"
|
| 260 |
+
gender_preference → "any" | "male" | "female" | "non-binary"
|
| 261 |
+
move_in_date → ISO date string e.g. "2025-06-01" (when do they want someone?)
|
| 262 |
+
occupation → string e.g. "student", "professional", "remote worker" (optional)
|
| 263 |
+
age_preference → string e.g. "20s", "any", "25-35" (optional, if mentioned)
|
| 264 |
+
notes → any extra details that don't fit above categories
|
| 265 |
+
|
| 266 |
+
SAVE all extracted fields into: listing.preferred_flatmate = {{ ...extracted fields }}
|
| 267 |
+
|
| 268 |
+
This is SEPARATE from the user's profile — it lives on the listing document so the
|
| 269 |
+
Matcher agent can score compatibility between seekers and this specific listing.
|
| 270 |
+
|
| 271 |
+
IF the user gives a short or vague answer (e.g. "I'm chill with anything"):
|
| 272 |
+
- Extract what you can, default unmentioned fields to permissive values:
|
| 273 |
+
sleep_schedule="flexible", cleanliness="average", pets=true, smoking=false,
|
| 274 |
+
social_preference="balanced", gender_preference="any"
|
| 275 |
+
- Do NOT interrogate further — one question is the limit.
|
| 276 |
+
|
| 277 |
+
AFTER the lifestyle question is answered → proceed to STEP 5 as normal.
|
| 278 |
+
|
| 279 |
STEP 5: WHEN ALL FIELDS COLLECTED
|
| 280 |
+
Once all required fields complete (including preferred_flatmate for roommate listings):
|
| 281 |
- Say a SHORT confirmation message like "Perfect! Here's your listing preview:"
|
| 282 |
- DO NOT write out the draft in text - the UI will display a visual card automatically
|
| 283 |
- Just ask: "Ready to publish? Say 'publish', 'edit [field]' to change, or 'discard' to cancel."
|
|
|
|
| 457 |
- "Short stay in Abuja" → search_properties, listing_type="short-stay", location="Abuja"
|
| 458 |
- "Houses for sale" → search_properties, listing_type="sale"
|
| 459 |
- "Any studios available in Ibadan?" → search_properties, listing_type="rent", location="Ibadan"
|
| 460 |
+
- "I'm planning a trip to Lagos, I don't know where to stay" → search_properties, listing_type="short-stay", location="Lagos"
|
| 461 |
+
- "Heading to Abuja next week, need accommodation" → search_properties, listing_type="short-stay", location="Abuja"
|
| 462 |
Users do NOT need to say "show me" or "search for" — detect intent from context and call the tool.
|
| 463 |
|
| 464 |
+
BROAD LOCATION INTELLIGENCE — You understand country-level searches:
|
| 465 |
+
- "houses in Nigeria" → search across all major Nigerian cities (Lagos, Abuja, Owerri, Port Harcourt, etc.)
|
| 466 |
+
- "apartments in Benin" → search across Cotonou, Porto-Novo, Parakou, etc.
|
| 467 |
+
- The search engine handles this automatically — just pass the country name as location.
|
| 468 |
+
|
| 469 |
+
MISSING LOCATION — If user gives NO location in a search request, ASK for it:
|
| 470 |
+
- Do NOT silently assume a location. Always confirm with the user.
|
| 471 |
+
- Example: "Find me a rental" → "Sure! Which city or area are you looking in? 😊"
|
| 472 |
+
- Exception: For market price questions, use the user's profile location automatically — it makes sense to show them prices for where they live.
|
| 473 |
+
|
| 474 |
+
EMPATHY IN CONTEXT — Respond with warmth when user shares personal situations:
|
| 475 |
+
- "I'm planning a trip and I don't know where to stay" → "Don't worry, I've got you! Let me search for short-stay options right now..." → then search
|
| 476 |
+
- "I'm moving to Lagos soon" → acknowledge the exciting move, then search for rentals
|
| 477 |
+
- "I need a place urgently" → acknowledge the urgency, search immediately, skip small talk
|
| 478 |
+
- Always ACTION first (start the search), then empathy words — not the other way around.
|
| 479 |
+
|
| 480 |
NEVER say "go to your DM", "continue in DM", "the DM has booking tools" — these are internal details.
|
| 481 |
The user experience is seamless: they search here, tap a card, booking opens automatically.
|
| 482 |
|
|
|
|
| 489 |
4. Extract fields, ask missing ones ONE at a time
|
| 490 |
5. Ask about amenities/requirements (ONCE)
|
| 491 |
6. Auto-detect: listing_type, currency, title, description
|
| 492 |
+
7. IF listing_type = "roommate" → Ask ONE lifestyle question → Extract + save preferred_flatmate to listing
|
| 493 |
+
8. Generate and show draft preview
|
| 494 |
+
9. User: publish / edit / discard
|
| 495 |
|
| 496 |
NO PROPERTY TYPE QUESTIONS - it's auto-detected
|
| 497 |
NO CURRENCY QUESTIONS - it's auto-detected
|
app/ai/services/__pycache__/search_service.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/services/__pycache__/search_service.cpython-313.pyc and b/app/ai/services/__pycache__/search_service.cpython-313.pyc differ
|
|
|
app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc and b/app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc differ
|
|
|
app/ai/tools/__pycache__/greeting_tool.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/tools/__pycache__/greeting_tool.cpython-313.pyc and b/app/ai/tools/__pycache__/greeting_tool.cpython-313.pyc differ
|
|
|
app/ai/tools/__pycache__/intent_detector_tool.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/tools/__pycache__/intent_detector_tool.cpython-313.pyc and b/app/ai/tools/__pycache__/intent_detector_tool.cpython-313.pyc differ
|
|
|
app/ai/tools/__pycache__/listing_tool.cpython-313.pyc
CHANGED
|
Binary files a/app/ai/tools/__pycache__/listing_tool.cpython-313.pyc and b/app/ai/tools/__pycache__/listing_tool.cpython-313.pyc differ
|
|
|
app/jobs/stay_notification_jobs.py
CHANGED
|
@@ -1,223 +1,298 @@
|
|
| 1 |
import logging
|
| 2 |
-
from datetime import datetime,
|
| 3 |
from bson import ObjectId
|
| 4 |
from app.database import get_db
|
| 5 |
from app.models.booking import BookingStatus
|
| 6 |
-
from app.services.
|
|
|
|
| 7 |
|
| 8 |
logger = logging.getLogger(__name__)
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
})
|
| 16 |
-
|
| 17 |
-
if conv:
|
| 18 |
-
return str(conv["_id"])
|
| 19 |
-
|
| 20 |
-
# Create new
|
| 21 |
-
insert = await db["conversations"].insert_one({
|
| 22 |
-
"participants": [user_id, "aida"],
|
| 23 |
-
"is_aida": True,
|
| 24 |
-
"created_at": datetime.utcnow(),
|
| 25 |
-
"updated_at": datetime.utcnow(),
|
| 26 |
-
"last_message": "AIDA Chat initialized."
|
| 27 |
-
})
|
| 28 |
-
return str(insert.inserted_id)
|
| 29 |
|
| 30 |
async def dispatch_stay_control_cards():
|
| 31 |
"""
|
| 32 |
-
Background job
|
| 33 |
-
Dispatches:
|
| 34 |
1. Check-In cards at 08:00 AM on check-in day.
|
| 35 |
2. Check-Out reminders at 10:00 AM on check-out day.
|
|
|
|
| 36 |
"""
|
| 37 |
logger.info("Executing stay notification job...")
|
| 38 |
db = await get_db()
|
| 39 |
now_dt = datetime.utcnow()
|
| 40 |
-
|
| 41 |
-
# Simple check to enforce time thresholds (only dispatch if it's past the designated hour today)
|
| 42 |
-
# We use UTC time here. In production, localized timechecks are better.
|
| 43 |
-
should_dispatch_checkin = now_dt.hour >= 8 # Past 8 AM UTC
|
| 44 |
-
should_dispatch_checkout = now_dt.hour >= 10 # Past 10 AM UTC
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
| 47 |
if should_dispatch_checkin:
|
| 48 |
-
# Find bookings Confirmed, Check-in is today (or past), but card not sent yet
|
| 49 |
cursor = db["bookings"].find({
|
| 50 |
"status": BookingStatus.CONFIRMED.value,
|
| 51 |
-
"check_in_card_sent": {"$ne": True}
|
| 52 |
})
|
| 53 |
-
|
| 54 |
async for booking in cursor:
|
| 55 |
try:
|
| 56 |
-
# Compare check_in_date (ignoring time)
|
| 57 |
check_in = booking.get("check_in_date")
|
| 58 |
if isinstance(check_in, str):
|
| 59 |
check_in = datetime.fromisoformat(check_in)
|
| 60 |
-
|
| 61 |
if check_in.date() <= now_dt.date():
|
| 62 |
-
guest_id
|
| 63 |
property_id = booking["listing_id"]
|
| 64 |
-
|
| 65 |
listing = await db["listings"].find_one({"_id": ObjectId(property_id)})
|
| 66 |
property_data = {
|
| 67 |
-
"id":
|
| 68 |
-
"title":
|
| 69 |
"images": listing.get("images", []),
|
| 70 |
-
"image":
|
| 71 |
}
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
booking.pop("_id", None)
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
booking["updated_at"] = booking["updated_at"].isoformat()
|
| 82 |
booking["check_in_date"] = check_in.isoformat()
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
conv_id = await get_or_create_aida_conversation(db, guest_id)
|
| 89 |
-
|
| 90 |
-
# Guest Language Preferences
|
| 91 |
guest = await db["users"].find_one({"_id": ObjectId(guest_id)})
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
"
|
| 102 |
-
"
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 106 |
"stay_control_card": {
|
| 107 |
-
"bookingData":
|
| 108 |
-
"propertyData": property_data
|
| 109 |
}
|
| 110 |
-
}
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
await db["messages"].insert_one(new_msg)
|
| 114 |
-
|
| 115 |
-
# Update conversation
|
| 116 |
-
await db["conversations"].update_one(
|
| 117 |
-
{"_id": ObjectId(conv_id)},
|
| 118 |
-
{
|
| 119 |
-
"$set": {
|
| 120 |
-
"updated_at": datetime.utcnow(),
|
| 121 |
-
"last_message": translated_content,
|
| 122 |
-
"last_sender_id": "aida"
|
| 123 |
-
}
|
| 124 |
-
}
|
| 125 |
)
|
| 126 |
-
|
| 127 |
-
# Mark booking
|
| 128 |
await db["bookings"].update_one(
|
| 129 |
{"_id": ObjectId(booking["id"])},
|
| 130 |
-
{"$set": {"check_in_card_sent": True}}
|
| 131 |
)
|
| 132 |
-
|
| 133 |
logger.info(f"Dispatched check-in card for booking {booking['id']}")
|
|
|
|
| 134 |
except Exception as e:
|
| 135 |
-
logger.error(f"Failed handling check-in card for booking {booking.get('
|
| 136 |
|
| 137 |
-
# 2.
|
| 138 |
if should_dispatch_checkout:
|
| 139 |
cursor = db["bookings"].find({
|
| 140 |
"status": BookingStatus.ACTIVE_STAY.value,
|
| 141 |
-
"check_out_card_sent": {"$ne": True}
|
| 142 |
})
|
| 143 |
-
|
| 144 |
async for booking in cursor:
|
| 145 |
try:
|
| 146 |
check_out = booking.get("check_out_date")
|
| 147 |
if isinstance(check_out, str):
|
| 148 |
check_out = datetime.fromisoformat(check_out)
|
| 149 |
-
|
| 150 |
if check_out.date() <= now_dt.date():
|
| 151 |
-
guest_id
|
| 152 |
property_id = booking["listing_id"]
|
| 153 |
-
|
| 154 |
listing = await db["listings"].find_one({"_id": ObjectId(property_id)})
|
| 155 |
property_data = {
|
| 156 |
-
"id":
|
| 157 |
-
"title":
|
| 158 |
"images": listing.get("images", []),
|
| 159 |
-
"image":
|
| 160 |
}
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
| 163 |
booking["booking_id"] = str(booking["_id"])
|
| 164 |
booking.pop("_id", None)
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
if isinstance(booking.get("
|
| 169 |
-
booking["
|
| 170 |
-
|
| 171 |
-
checkin_dt = booking.get("check_in_date")
|
| 172 |
-
if isinstance(checkin_dt, datetime):
|
| 173 |
-
booking["check_in_date"] = checkin_dt.isoformat()
|
| 174 |
booking["check_out_date"] = check_out.isoformat()
|
| 175 |
-
|
| 176 |
-
conv_id = await get_or_create_aida_conversation(db, guest_id)
|
| 177 |
-
|
| 178 |
-
# Guest Language Preferences
|
| 179 |
guest = await db["users"].find_one({"_id": ObjectId(guest_id)})
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
"
|
| 191 |
-
"
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
"stay_control_card": {
|
| 195 |
-
"bookingData":
|
| 196 |
-
"propertyData": property_data
|
| 197 |
}
|
| 198 |
-
}
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
await db["messages"].insert_one(new_msg)
|
| 202 |
-
|
| 203 |
-
# Update conversation
|
| 204 |
-
await db["conversations"].update_one(
|
| 205 |
-
{"_id": ObjectId(conv_id)},
|
| 206 |
-
{
|
| 207 |
-
"$set": {
|
| 208 |
-
"updated_at": datetime.utcnow(),
|
| 209 |
-
"last_message": translated_content,
|
| 210 |
-
"last_sender_id": "aida"
|
| 211 |
-
}
|
| 212 |
-
}
|
| 213 |
)
|
| 214 |
-
|
| 215 |
-
# Mark booking
|
| 216 |
await db["bookings"].update_one(
|
| 217 |
{"_id": ObjectId(booking["id"])},
|
| 218 |
-
{"$set": {"check_out_card_sent": True}}
|
| 219 |
)
|
| 220 |
-
|
| 221 |
logger.info(f"Dispatched check-out card for booking {booking['id']}")
|
|
|
|
| 222 |
except Exception as e:
|
| 223 |
-
logger.error(f"Failed handling check-out card for booking {booking.get('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
from bson import ObjectId
|
| 4 |
from app.database import get_db
|
| 5 |
from app.models.booking import BookingStatus
|
| 6 |
+
from app.services.aida_dm_service import send_aida_dm
|
| 7 |
+
from app.ai.agent.brain import generate_localized_response
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
| 11 |
+
|
| 12 |
+
def _lang(user: dict) -> str:
|
| 13 |
+
"""Return the user's preferred language code, defaulting to English."""
|
| 14 |
+
return (user or {}).get("preferredLanguage", "en")
|
| 15 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
async def dispatch_stay_control_cards():
|
| 18 |
"""
|
| 19 |
+
Background job — runs daily.
|
|
|
|
| 20 |
1. Check-In cards at 08:00 AM on check-in day.
|
| 21 |
2. Check-Out reminders at 10:00 AM on check-out day.
|
| 22 |
+
Messages are generated directly in the guest's preferred language.
|
| 23 |
"""
|
| 24 |
logger.info("Executing stay notification job...")
|
| 25 |
db = await get_db()
|
| 26 |
now_dt = datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
should_dispatch_checkin = now_dt.hour >= 8
|
| 29 |
+
should_dispatch_checkout = now_dt.hour >= 10
|
| 30 |
+
|
| 31 |
+
# ── 1. Check-In Cards ────────────────────────────────────────────────────
|
| 32 |
if should_dispatch_checkin:
|
|
|
|
| 33 |
cursor = db["bookings"].find({
|
| 34 |
"status": BookingStatus.CONFIRMED.value,
|
| 35 |
+
"check_in_card_sent": {"$ne": True},
|
| 36 |
})
|
| 37 |
+
|
| 38 |
async for booking in cursor:
|
| 39 |
try:
|
|
|
|
| 40 |
check_in = booking.get("check_in_date")
|
| 41 |
if isinstance(check_in, str):
|
| 42 |
check_in = datetime.fromisoformat(check_in)
|
| 43 |
+
|
| 44 |
if check_in.date() <= now_dt.date():
|
| 45 |
+
guest_id = booking["user_id"]
|
| 46 |
property_id = booking["listing_id"]
|
| 47 |
+
|
| 48 |
listing = await db["listings"].find_one({"_id": ObjectId(property_id)})
|
| 49 |
property_data = {
|
| 50 |
+
"id": str(listing.get("_id", "")),
|
| 51 |
+
"title": listing.get("title", "Property"),
|
| 52 |
"images": listing.get("images", []),
|
| 53 |
+
"image": listing.get("image", None),
|
| 54 |
}
|
| 55 |
+
property_name = listing.get("title", "the property") if listing else "the property"
|
| 56 |
+
|
| 57 |
+
# Serialise booking for metadata
|
| 58 |
+
booking["id"] = str(booking["_id"])
|
| 59 |
+
booking["booking_id"] = str(booking["_id"])
|
| 60 |
booking.pop("_id", None)
|
| 61 |
+
for field in ("created_at", "updated_at"):
|
| 62 |
+
if isinstance(booking.get(field), datetime):
|
| 63 |
+
booking[field] = booking[field].isoformat()
|
|
|
|
| 64 |
booking["check_in_date"] = check_in.isoformat()
|
| 65 |
+
if isinstance(booking.get("check_out_date"), datetime):
|
| 66 |
+
booking["check_out_date"] = booking["check_out_date"].isoformat()
|
| 67 |
+
|
| 68 |
+
# Get guest's preferred language — generate message directly in it
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
guest = await db["users"].find_one({"_id": ObjectId(guest_id)})
|
| 70 |
+
lang = _lang(guest)
|
| 71 |
+
|
| 72 |
+
content = await generate_localized_response(
|
| 73 |
+
context=(
|
| 74 |
+
f"Send a warm check-in day greeting to a guest staying at '{property_name}'. "
|
| 75 |
+
"Tell them today is their check-in day and they can check in securely "
|
| 76 |
+
"using the card below when they arrive. Keep it short and welcoming."
|
| 77 |
+
),
|
| 78 |
+
language=lang,
|
| 79 |
+
tone="friendly",
|
| 80 |
+
max_length="short",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
await send_aida_dm(
|
| 84 |
+
guest_id,
|
| 85 |
+
content,
|
| 86 |
+
metadata={
|
| 87 |
"stay_control_card": {
|
| 88 |
+
"bookingData": booking,
|
| 89 |
+
"propertyData": property_data,
|
| 90 |
}
|
| 91 |
+
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
)
|
| 93 |
+
|
|
|
|
| 94 |
await db["bookings"].update_one(
|
| 95 |
{"_id": ObjectId(booking["id"])},
|
| 96 |
+
{"$set": {"check_in_card_sent": True}},
|
| 97 |
)
|
|
|
|
| 98 |
logger.info(f"Dispatched check-in card for booking {booking['id']}")
|
| 99 |
+
|
| 100 |
except Exception as e:
|
| 101 |
+
logger.error(f"Failed handling check-in card for booking {booking.get('id')}: {e}")
|
| 102 |
|
| 103 |
+
# ── 2. Check-Out Cards ───────────────────────────────────────────────────
|
| 104 |
if should_dispatch_checkout:
|
| 105 |
cursor = db["bookings"].find({
|
| 106 |
"status": BookingStatus.ACTIVE_STAY.value,
|
| 107 |
+
"check_out_card_sent": {"$ne": True},
|
| 108 |
})
|
| 109 |
+
|
| 110 |
async for booking in cursor:
|
| 111 |
try:
|
| 112 |
check_out = booking.get("check_out_date")
|
| 113 |
if isinstance(check_out, str):
|
| 114 |
check_out = datetime.fromisoformat(check_out)
|
| 115 |
+
|
| 116 |
if check_out.date() <= now_dt.date():
|
| 117 |
+
guest_id = booking["user_id"]
|
| 118 |
property_id = booking["listing_id"]
|
| 119 |
+
|
| 120 |
listing = await db["listings"].find_one({"_id": ObjectId(property_id)})
|
| 121 |
property_data = {
|
| 122 |
+
"id": str(listing.get("_id", "")),
|
| 123 |
+
"title": listing.get("title", "Property"),
|
| 124 |
"images": listing.get("images", []),
|
| 125 |
+
"image": listing.get("image", None),
|
| 126 |
}
|
| 127 |
+
property_name = listing.get("title", "the property") if listing else "the property"
|
| 128 |
+
|
| 129 |
+
# Serialise booking for metadata
|
| 130 |
+
booking["id"] = str(booking["_id"])
|
| 131 |
booking["booking_id"] = str(booking["_id"])
|
| 132 |
booking.pop("_id", None)
|
| 133 |
+
for field in ("created_at", "updated_at"):
|
| 134 |
+
if isinstance(booking.get(field), datetime):
|
| 135 |
+
booking[field] = booking[field].isoformat()
|
| 136 |
+
if isinstance(booking.get("check_in_date"), datetime):
|
| 137 |
+
booking["check_in_date"] = booking["check_in_date"].isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
booking["check_out_date"] = check_out.isoformat()
|
| 139 |
+
|
|
|
|
|
|
|
|
|
|
| 140 |
guest = await db["users"].find_one({"_id": ObjectId(guest_id)})
|
| 141 |
+
lang = _lang(guest)
|
| 142 |
+
|
| 143 |
+
content = await generate_localized_response(
|
| 144 |
+
context=(
|
| 145 |
+
f"Send a warm check-out day message to a guest at '{property_name}'. "
|
| 146 |
+
"Hope they enjoyed their stay. Remind them today is their check-out day, "
|
| 147 |
+
"to gather their belongings, and to use the Check-Out button below when "
|
| 148 |
+
"they leave. Keep it friendly and brief."
|
| 149 |
+
),
|
| 150 |
+
language=lang,
|
| 151 |
+
tone="friendly",
|
| 152 |
+
max_length="short",
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
await send_aida_dm(
|
| 156 |
+
guest_id,
|
| 157 |
+
content,
|
| 158 |
+
metadata={
|
| 159 |
"stay_control_card": {
|
| 160 |
+
"bookingData": booking,
|
| 161 |
+
"propertyData": property_data,
|
| 162 |
}
|
| 163 |
+
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
)
|
| 165 |
+
|
|
|
|
| 166 |
await db["bookings"].update_one(
|
| 167 |
{"_id": ObjectId(booking["id"])},
|
| 168 |
+
{"$set": {"check_out_card_sent": True}},
|
| 169 |
)
|
|
|
|
| 170 |
logger.info(f"Dispatched check-out card for booking {booking['id']}")
|
| 171 |
+
|
| 172 |
except Exception as e:
|
| 173 |
+
logger.error(f"Failed handling check-out card for booking {booking.get('id')}: {e}")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
async def dispatch_checkin_reminders():
|
| 177 |
+
"""
|
| 178 |
+
Send a warm AIDA DM to the guest and host ~3 hours before check-in.
|
| 179 |
+
Runs every 30 minutes — duplicate-safe via sent flag.
|
| 180 |
+
Messages generated directly in each user's preferred language.
|
| 181 |
+
"""
|
| 182 |
+
logger.info("Running pre-check-in reminder job...")
|
| 183 |
+
db = await get_db()
|
| 184 |
+
now = datetime.utcnow()
|
| 185 |
+
window_start = now + timedelta(hours=2, minutes=45)
|
| 186 |
+
window_end = now + timedelta(hours=3, minutes=15)
|
| 187 |
+
|
| 188 |
+
cursor = db["bookings"].find({
|
| 189 |
+
"status": BookingStatus.CONFIRMED.value,
|
| 190 |
+
"checkin_reminder_sent": {"$ne": True},
|
| 191 |
+
"check_in_date": {"$gte": window_start, "$lte": window_end},
|
| 192 |
+
})
|
| 193 |
+
|
| 194 |
+
async for booking in cursor:
|
| 195 |
+
try:
|
| 196 |
+
guest_id = booking["user_id"]
|
| 197 |
+
host_id = booking["landlord_id"]
|
| 198 |
+
listing = await db["listings"].find_one({"_id": ObjectId(booking["listing_id"])})
|
| 199 |
+
property_name = listing.get("title", "your property") if listing else "your property"
|
| 200 |
+
|
| 201 |
+
# ── Guest ─────────────────────────────────────────────────────
|
| 202 |
+
guest = await db["users"].find_one({"_id": ObjectId(guest_id)})
|
| 203 |
+
guest_lang = _lang(guest)
|
| 204 |
+
|
| 205 |
+
guest_msg = await generate_localized_response(
|
| 206 |
+
context=(
|
| 207 |
+
f"Remind the guest that their check-in at '{property_name}' is in about 3 hours. "
|
| 208 |
+
"Tell them to make sure they're packed and ready. Sound warm and excited for them. 🏠"
|
| 209 |
+
),
|
| 210 |
+
language=guest_lang,
|
| 211 |
+
tone="friendly",
|
| 212 |
+
max_length="short",
|
| 213 |
+
)
|
| 214 |
+
await send_aida_dm(guest_id, guest_msg, push_body=guest_msg[:80])
|
| 215 |
+
|
| 216 |
+
# ── Host ──────────────────────────────────────────────────────
|
| 217 |
+
host = await db["users"].find_one({"_id": ObjectId(host_id)})
|
| 218 |
+
host_lang = _lang(host)
|
| 219 |
+
|
| 220 |
+
host_msg = await generate_localized_response(
|
| 221 |
+
context=(
|
| 222 |
+
f"Remind the property host that a guest is checking in to '{property_name}' "
|
| 223 |
+
"in about 3 hours. Ask them to make sure everything is ready for the guest. 🏠"
|
| 224 |
+
),
|
| 225 |
+
language=host_lang,
|
| 226 |
+
tone="friendly",
|
| 227 |
+
max_length="short",
|
| 228 |
+
)
|
| 229 |
+
await send_aida_dm(host_id, host_msg, push_body=host_msg[:80])
|
| 230 |
+
|
| 231 |
+
await db["bookings"].update_one(
|
| 232 |
+
{"_id": booking["_id"]},
|
| 233 |
+
{"$set": {"checkin_reminder_sent": True}},
|
| 234 |
+
)
|
| 235 |
+
logger.info(f"Sent pre-check-in reminder for booking {booking['_id']}")
|
| 236 |
+
|
| 237 |
+
except Exception as e:
|
| 238 |
+
logger.error(f"Pre-check-in reminder failed for booking {booking.get('_id')}: {e}")
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
async def dispatch_checkout_review_requests():
|
| 242 |
+
"""
|
| 243 |
+
After booking status moves to COMPLETED, send a conversational AIDA DM
|
| 244 |
+
asking the guest to review the property.
|
| 245 |
+
Runs every 30 minutes — duplicate-safe via sent flag.
|
| 246 |
+
Message generated directly in the guest's preferred language.
|
| 247 |
+
"""
|
| 248 |
+
logger.info("Running post-checkout review request job...")
|
| 249 |
+
db = await get_db()
|
| 250 |
+
|
| 251 |
+
cursor = db["bookings"].find({
|
| 252 |
+
"status": BookingStatus.COMPLETED.value,
|
| 253 |
+
"review_request_sent": {"$ne": True},
|
| 254 |
+
})
|
| 255 |
+
|
| 256 |
+
async for booking in cursor:
|
| 257 |
+
try:
|
| 258 |
+
guest_id = booking["user_id"]
|
| 259 |
+
listing = await db["listings"].find_one({"_id": ObjectId(booking["listing_id"])})
|
| 260 |
+
property_name = listing.get("title", "the property") if listing else "the property"
|
| 261 |
+
|
| 262 |
+
guest = await db["users"].find_one({"_id": ObjectId(guest_id)})
|
| 263 |
+
lang = _lang(guest)
|
| 264 |
+
|
| 265 |
+
msg = await generate_localized_response(
|
| 266 |
+
context=(
|
| 267 |
+
f"Ask the guest how their stay at '{property_name}' was. "
|
| 268 |
+
"Invite them to drop a 1–5 star rating and share anything "
|
| 269 |
+
"future guests should know — voice or text, whatever works. "
|
| 270 |
+
"Sound casual, warm, and genuinely interested. Include a 🏠 emoji."
|
| 271 |
+
),
|
| 272 |
+
language=lang,
|
| 273 |
+
tone="friendly",
|
| 274 |
+
max_length="short",
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
await send_aida_dm(
|
| 278 |
+
guest_id,
|
| 279 |
+
msg,
|
| 280 |
+
metadata={
|
| 281 |
+
"review_request": {
|
| 282 |
+
"booking_id": str(booking["_id"]),
|
| 283 |
+
"listing_id": str(booking["listing_id"]),
|
| 284 |
+
"review_type": "property",
|
| 285 |
+
}
|
| 286 |
+
},
|
| 287 |
+
push_title="AIDA — Rate your stay",
|
| 288 |
+
push_body=f"How was your stay at '{property_name}'? Tap to rate.",
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
await db["bookings"].update_one(
|
| 292 |
+
{"_id": booking["_id"]},
|
| 293 |
+
{"$set": {"review_request_sent": True}},
|
| 294 |
+
)
|
| 295 |
+
logger.info(f"Sent checkout review request for booking {booking['_id']}")
|
| 296 |
+
|
| 297 |
+
except Exception as e:
|
| 298 |
+
logger.error(f"Checkout review request failed for booking {booking.get('_id')}: {e}")
|
app/jobs/viewing_notification_jobs.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/jobs/viewing_notification_jobs.py
|
| 2 |
+
"""
|
| 3 |
+
Background jobs for viewing appointment notifications (rent/sale).
|
| 4 |
+
|
| 5 |
+
dispatch_viewing_reminders() — 3 hours before viewing → AIDA DM to visitor + landlord
|
| 6 |
+
dispatch_post_viewing_followups() — After viewing passes → AIDA DM to visitor asking for
|
| 7 |
+
landlord/agent review (saved to their profile, not property)
|
| 8 |
+
|
| 9 |
+
Messages are generated directly in each user's preferred language — no English template,
|
| 10 |
+
no translation step. Language is read from user.preferredLanguage in the DB.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
from datetime import datetime, timedelta
|
| 15 |
+
from bson import ObjectId
|
| 16 |
+
|
| 17 |
+
from app.database import get_db
|
| 18 |
+
from app.models.viewing import ViewingStatus
|
| 19 |
+
from app.services.aida_dm_service import send_aida_dm as _send_aida_dm
|
| 20 |
+
from app.ai.agent.brain import generate_localized_response
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _lang(user: dict) -> str:
|
| 26 |
+
return (user or {}).get("preferredLanguage", "en")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ============================================================
|
| 30 |
+
# JOB 1 — PRE-VIEWING REMINDER (3 hours before)
|
| 31 |
+
# ============================================================
|
| 32 |
+
|
| 33 |
+
async def dispatch_viewing_reminders():
|
| 34 |
+
"""
|
| 35 |
+
Find confirmed viewings scheduled ~3 hours from now and send reminders
|
| 36 |
+
to both the visitor and the landlord via AIDA DM.
|
| 37 |
+
Runs every 30 minutes — duplicate-safe via reminder flags.
|
| 38 |
+
"""
|
| 39 |
+
logger.info("Running viewing reminder job...")
|
| 40 |
+
db = await get_db()
|
| 41 |
+
now = datetime.utcnow()
|
| 42 |
+
window_start = now + timedelta(hours=2, minutes=45)
|
| 43 |
+
window_end = now + timedelta(hours=3, minutes=15)
|
| 44 |
+
|
| 45 |
+
cursor = db["viewings"].find({
|
| 46 |
+
"status": ViewingStatus.CONFIRMED.value,
|
| 47 |
+
"scheduled_at": {"$gte": window_start, "$lte": window_end},
|
| 48 |
+
"$or": [
|
| 49 |
+
{"visitor_reminder_sent": {"$ne": True}},
|
| 50 |
+
{"landlord_reminder_sent": {"$ne": True}},
|
| 51 |
+
],
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
async for viewing in cursor:
|
| 55 |
+
viewing_id = viewing["_id"]
|
| 56 |
+
visitor_id = viewing["visitor_id"]
|
| 57 |
+
landlord_id = viewing["landlord_id"]
|
| 58 |
+
scheduled_at = viewing["scheduled_at"]
|
| 59 |
+
time_str = scheduled_at.strftime("%I:%M %p") if isinstance(scheduled_at, datetime) else str(scheduled_at)
|
| 60 |
+
|
| 61 |
+
listing = await db["listings"].find_one({"_id": ObjectId(viewing["listing_id"])})
|
| 62 |
+
property_name = listing.get("title", "the property") if listing else "the property"
|
| 63 |
+
|
| 64 |
+
# ── Visitor reminder ──────────────────────────────────────────────
|
| 65 |
+
if not viewing.get("visitor_reminder_sent"):
|
| 66 |
+
try:
|
| 67 |
+
visitor = await db["users"].find_one({"_id": ObjectId(visitor_id)})
|
| 68 |
+
msg = await generate_localized_response(
|
| 69 |
+
context=(
|
| 70 |
+
f"Remind the visitor they have a property viewing at '{property_name}' "
|
| 71 |
+
f"today at {time_str}. Tell them not to be late. Sound friendly. 🏠"
|
| 72 |
+
),
|
| 73 |
+
language=_lang(visitor),
|
| 74 |
+
tone="friendly",
|
| 75 |
+
max_length="short",
|
| 76 |
+
)
|
| 77 |
+
await _send_aida_dm(visitor_id, msg)
|
| 78 |
+
await db["viewings"].update_one(
|
| 79 |
+
{"_id": viewing_id},
|
| 80 |
+
{"$set": {"visitor_reminder_sent": True}},
|
| 81 |
+
)
|
| 82 |
+
logger.info(f"Sent visitor viewing reminder for viewing {viewing_id}")
|
| 83 |
+
except Exception as e:
|
| 84 |
+
logger.error(f"Visitor reminder failed for viewing {viewing_id}: {e}")
|
| 85 |
+
|
| 86 |
+
# ── Landlord reminder ─────────────────────────────────────────────
|
| 87 |
+
if not viewing.get("landlord_reminder_sent"):
|
| 88 |
+
try:
|
| 89 |
+
landlord = await db["users"].find_one({"_id": ObjectId(landlord_id)})
|
| 90 |
+
msg = await generate_localized_response(
|
| 91 |
+
context=(
|
| 92 |
+
f"Remind the landlord/agent that a prospective tenant or buyer is visiting "
|
| 93 |
+
f"'{property_name}' today at {time_str}. Ask them to make sure the property "
|
| 94 |
+
"is ready. Sound professional but warm. 🏠"
|
| 95 |
+
),
|
| 96 |
+
language=_lang(landlord),
|
| 97 |
+
tone="friendly",
|
| 98 |
+
max_length="short",
|
| 99 |
+
)
|
| 100 |
+
await _send_aida_dm(landlord_id, msg)
|
| 101 |
+
await db["viewings"].update_one(
|
| 102 |
+
{"_id": viewing_id},
|
| 103 |
+
{"$set": {"landlord_reminder_sent": True}},
|
| 104 |
+
)
|
| 105 |
+
logger.info(f"Sent landlord viewing reminder for viewing {viewing_id}")
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"Landlord reminder failed for viewing {viewing_id}: {e}")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ============================================================
|
| 111 |
+
# JOB 2 — POST-VIEWING FOLLOW-UP (review of landlord/agent)
|
| 112 |
+
# ============================================================
|
| 113 |
+
|
| 114 |
+
async def dispatch_post_viewing_followups():
|
| 115 |
+
"""
|
| 116 |
+
After a confirmed viewing's scheduled time has passed, send a follow-up
|
| 117 |
+
AIDA DM to the visitor asking for a review of the landlord/agent.
|
| 118 |
+
|
| 119 |
+
The review goes to the LANDLORD/AGENT profile — not the property —
|
| 120 |
+
because for rentals and sales, the person matters more than the walls.
|
| 121 |
+
|
| 122 |
+
Runs every 30 minutes — duplicate-safe via followup_sent flag.
|
| 123 |
+
"""
|
| 124 |
+
logger.info("Running post-viewing follow-up job...")
|
| 125 |
+
db = await get_db()
|
| 126 |
+
now = datetime.utcnow()
|
| 127 |
+
|
| 128 |
+
cursor = db["viewings"].find({
|
| 129 |
+
"status": ViewingStatus.CONFIRMED.value,
|
| 130 |
+
"scheduled_at": {"$lte": now - timedelta(hours=1)},
|
| 131 |
+
"followup_sent": {"$ne": True},
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
async for viewing in cursor:
|
| 135 |
+
viewing_id = viewing["_id"]
|
| 136 |
+
visitor_id = viewing["visitor_id"]
|
| 137 |
+
landlord_id = viewing["landlord_id"]
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
await db["viewings"].update_one(
|
| 141 |
+
{"_id": viewing_id},
|
| 142 |
+
{"$set": {"status": ViewingStatus.COMPLETED.value}},
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
visitor = await db["users"].find_one({"_id": ObjectId(visitor_id)})
|
| 146 |
+
landlord = await db["users"].find_one({"_id": ObjectId(landlord_id)})
|
| 147 |
+
listing = await db["listings"].find_one({"_id": ObjectId(viewing["listing_id"])})
|
| 148 |
+
|
| 149 |
+
property_name = listing.get("title", "the property") if listing else "the property"
|
| 150 |
+
landlord_name = (landlord or {}).get("name", "the landlord/agent")
|
| 151 |
+
|
| 152 |
+
msg = await generate_localized_response(
|
| 153 |
+
context=(
|
| 154 |
+
f"Ask the visitor how the property viewing at '{property_name}' went. "
|
| 155 |
+
f"Ask if {landlord_name} was professional and honest. "
|
| 156 |
+
"Invite them to give a 1–5 star rating — it helps the next person decide. "
|
| 157 |
+
"Sound casual and genuine. Include a ⭐ emoji."
|
| 158 |
+
),
|
| 159 |
+
language=_lang(visitor),
|
| 160 |
+
tone="friendly",
|
| 161 |
+
max_length="short",
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
await _send_aida_dm(
|
| 165 |
+
visitor_id,
|
| 166 |
+
msg,
|
| 167 |
+
metadata={
|
| 168 |
+
"review_request": {
|
| 169 |
+
"viewing_id": str(viewing_id),
|
| 170 |
+
"reviewed_user_id": landlord_id,
|
| 171 |
+
"listing_id": str(viewing["listing_id"]),
|
| 172 |
+
"review_type": "landlord_agent",
|
| 173 |
+
}
|
| 174 |
+
},
|
| 175 |
+
push_title="AIDA — Rate your viewing",
|
| 176 |
+
push_body=f"How did the viewing at '{property_name}' go? Tap to rate.",
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
await db["viewings"].update_one(
|
| 180 |
+
{"_id": viewing_id},
|
| 181 |
+
{"$set": {"followup_sent": True}},
|
| 182 |
+
)
|
| 183 |
+
logger.info(f"Sent post-viewing follow-up for viewing {viewing_id}")
|
| 184 |
+
|
| 185 |
+
except Exception as e:
|
| 186 |
+
logger.error(f"Post-viewing follow-up failed for viewing {viewing_id}: {e}")
|
app/models/viewing.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/models/viewing.py
|
| 2 |
+
"""
|
| 3 |
+
Viewing appointment model — for rent and sale property visits.
|
| 4 |
+
Created when AIDA-Market schedules a viewing between a visitor and a landlord.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from bson import ObjectId
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
from app.database import get_db
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ViewingStatus(str, Enum):
|
| 16 |
+
PENDING = "pending" # Proposed, waiting for landlord to confirm
|
| 17 |
+
CONFIRMED = "confirmed" # Landlord accepted
|
| 18 |
+
CANCELLED = "cancelled" # Cancelled by either party
|
| 19 |
+
COMPLETED = "completed" # Visit happened (past scheduled time)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Viewing(BaseModel):
|
| 23 |
+
id: Optional[str] = Field(default_factory=lambda: str(ObjectId()), alias="_id")
|
| 24 |
+
visitor_id: str # User requesting the viewing
|
| 25 |
+
landlord_id: str # Property owner / agent
|
| 26 |
+
listing_id: str # Property being viewed
|
| 27 |
+
scheduled_at: datetime # Agreed date & time
|
| 28 |
+
status: ViewingStatus = ViewingStatus.PENDING
|
| 29 |
+
notes: Optional[str] = None # Optional message from visitor
|
| 30 |
+
|
| 31 |
+
# Notification flags — prevent duplicate sends
|
| 32 |
+
visitor_reminder_sent: bool = False
|
| 33 |
+
landlord_reminder_sent: bool = False
|
| 34 |
+
followup_sent: bool = False # Post-viewing review request sent
|
| 35 |
+
|
| 36 |
+
created_at: Optional[datetime] = None
|
| 37 |
+
updated_at: Optional[datetime] = None
|
| 38 |
+
|
| 39 |
+
class Config:
|
| 40 |
+
populate_by_name = True
|
| 41 |
+
json_encoders = {ObjectId: str}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def ensure_viewing_indexes():
|
| 45 |
+
"""Create indexes at startup."""
|
| 46 |
+
db = await get_db()
|
| 47 |
+
col = db["viewings"]
|
| 48 |
+
await col.create_index("visitor_id")
|
| 49 |
+
await col.create_index("landlord_id")
|
| 50 |
+
await col.create_index("listing_id")
|
| 51 |
+
await col.create_index("status")
|
| 52 |
+
await col.create_index("scheduled_at")
|
app/routes/booking.py
CHANGED
|
@@ -36,7 +36,7 @@ def _format_natural_date(dt) -> str:
|
|
| 36 |
return str(dt)
|
| 37 |
|
| 38 |
|
| 39 |
-
def _build_host_confirmation_message(
|
| 40 |
host_name: str,
|
| 41 |
guest_name: str,
|
| 42 |
property_title: str,
|
|
@@ -44,30 +44,44 @@ def _build_host_confirmation_message(
|
|
| 44 |
check_out,
|
| 45 |
guest_count: int,
|
| 46 |
payout_amount: float,
|
|
|
|
| 47 |
) -> str:
|
| 48 |
check_in_str = _format_natural_date(check_in)
|
| 49 |
check_out_str = _format_natural_date(check_out)
|
| 50 |
payout_str = f"₦{payout_amount:,.0f}" if payout_amount else ""
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
async def _build_guest_confirmation_message(
|
|
@@ -414,8 +428,9 @@ async def stripe_webhook(request: Request):
|
|
| 414 |
f"{_h.get('firstName', '')} {_h.get('lastName', '')}".strip()
|
| 415 |
or _h.get("name") or _h.get("full_name") or "there"
|
| 416 |
)
|
|
|
|
| 417 |
|
| 418 |
-
msg = _build_host_confirmation_message(
|
| 419 |
host_name=host_name,
|
| 420 |
guest_name=guest_name,
|
| 421 |
property_title=property_title,
|
|
@@ -423,6 +438,7 @@ async def stripe_webhook(request: Request):
|
|
| 423 |
check_out=booking.get("check_out_date"),
|
| 424 |
guest_count=booking.get("guest_count", 1),
|
| 425 |
payout_amount=booking.get("payout_amount", 0),
|
|
|
|
| 426 |
)
|
| 427 |
import asyncio
|
| 428 |
asyncio.create_task(notify_landlord_via_aida(booking["landlord_id"], msg))
|
|
@@ -731,8 +747,9 @@ async def simulate_payment(booking_id: str, current_user: dict = Depends(get_cur
|
|
| 731 |
f"{_hd.get('firstName', '')} {_hd.get('lastName', '')}".strip()
|
| 732 |
or _hd.get("name") or _hd.get("full_name") or "there"
|
| 733 |
)
|
|
|
|
| 734 |
|
| 735 |
-
msg = _build_host_confirmation_message(
|
| 736 |
host_name=host_name,
|
| 737 |
guest_name=guest_name,
|
| 738 |
property_title=property_title,
|
|
@@ -740,6 +757,7 @@ async def simulate_payment(booking_id: str, current_user: dict = Depends(get_cur
|
|
| 740 |
check_out=booking.get("check_out_date"),
|
| 741 |
guest_count=booking.get("guest_count", 1),
|
| 742 |
payout_amount=booking.get("payout_amount", 0),
|
|
|
|
| 743 |
)
|
| 744 |
import asyncio
|
| 745 |
asyncio.create_task(notify_landlord_via_aida(landlord_id, msg))
|
|
@@ -810,6 +828,19 @@ async def simulate_payment(booking_id: str, current_user: dict = Depends(get_cur
|
|
| 810 |
}
|
| 811 |
)
|
| 812 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
# Broadcast via WebSocket
|
| 814 |
try:
|
| 815 |
from app.routes.websocket_chat import chat_manager
|
|
|
|
| 36 |
return str(dt)
|
| 37 |
|
| 38 |
|
| 39 |
+
async def _build_host_confirmation_message(
|
| 40 |
host_name: str,
|
| 41 |
guest_name: str,
|
| 42 |
property_title: str,
|
|
|
|
| 44 |
check_out,
|
| 45 |
guest_count: int,
|
| 46 |
payout_amount: float,
|
| 47 |
+
language: str = "en",
|
| 48 |
) -> str:
|
| 49 |
check_in_str = _format_natural_date(check_in)
|
| 50 |
check_out_str = _format_natural_date(check_out)
|
| 51 |
payout_str = f"₦{payout_amount:,.0f}" if payout_amount else ""
|
| 52 |
|
| 53 |
+
try:
|
| 54 |
+
from app.ai.agent.brain import generate_localized_response
|
| 55 |
+
context = (
|
| 56 |
+
f"You are AIDA, the AI co-host assistant. Send a booking confirmation notification to host '{host_name}'.\n"
|
| 57 |
+
f"A guest named '{guest_name}' just confirmed a booking for '{property_title}'.\n"
|
| 58 |
+
f"Check-in: {check_in_str}\n"
|
| 59 |
+
f"Check-out: {check_out_str}\n"
|
| 60 |
+
f"Guests: {guest_count}\n"
|
| 61 |
+
+ (f"Host payout: {payout_str}\n" if payout_str else "") +
|
| 62 |
+
"\nWrite a warm, professional notification. Include: 🎉 opener, guest name, property name, "
|
| 63 |
+
"check-in and check-out dates on separate lines, number of guests, and payout amount if provided. "
|
| 64 |
+
"End with a short encouraging note about hosting. Keep it concise."
|
| 65 |
+
)
|
| 66 |
+
return await generate_localized_response(
|
| 67 |
+
context=context,
|
| 68 |
+
language=language,
|
| 69 |
+
tone="professional",
|
| 70 |
+
max_length="medium",
|
| 71 |
+
)
|
| 72 |
+
except Exception:
|
| 73 |
+
# Fallback to English template if LLM fails
|
| 74 |
+
payout_line = f"\n\n💰 Your payout: {payout_str}" if payout_str else ""
|
| 75 |
+
return (
|
| 76 |
+
f"🎉 New Booking Confirmed!\n\n"
|
| 77 |
+
f"Hi {host_name}! You have a new confirmed booking for **{property_title}**.\n\n"
|
| 78 |
+
f"📅 Check-in: {check_in_str}\n\n"
|
| 79 |
+
f"📅 Check-out: {check_out_str}\n\n"
|
| 80 |
+
f"👥 Guests: {guest_count}"
|
| 81 |
+
f"{payout_line}\n\n"
|
| 82 |
+
f"Your guest **{guest_name}** has completed payment and their reservation is secured. "
|
| 83 |
+
"Prepare for a wonderful hosting experience! 🏡"
|
| 84 |
+
)
|
| 85 |
|
| 86 |
|
| 87 |
async def _build_guest_confirmation_message(
|
|
|
|
| 428 |
f"{_h.get('firstName', '')} {_h.get('lastName', '')}".strip()
|
| 429 |
or _h.get("name") or _h.get("full_name") or "there"
|
| 430 |
)
|
| 431 |
+
host_language = _h.get("preferredLanguage", "en") or "en"
|
| 432 |
|
| 433 |
+
msg = await _build_host_confirmation_message(
|
| 434 |
host_name=host_name,
|
| 435 |
guest_name=guest_name,
|
| 436 |
property_title=property_title,
|
|
|
|
| 438 |
check_out=booking.get("check_out_date"),
|
| 439 |
guest_count=booking.get("guest_count", 1),
|
| 440 |
payout_amount=booking.get("payout_amount", 0),
|
| 441 |
+
language=host_language,
|
| 442 |
)
|
| 443 |
import asyncio
|
| 444 |
asyncio.create_task(notify_landlord_via_aida(booking["landlord_id"], msg))
|
|
|
|
| 747 |
f"{_hd.get('firstName', '')} {_hd.get('lastName', '')}".strip()
|
| 748 |
or _hd.get("name") or _hd.get("full_name") or "there"
|
| 749 |
)
|
| 750 |
+
host_language = _hd.get("preferredLanguage", "en") or "en"
|
| 751 |
|
| 752 |
+
msg = await _build_host_confirmation_message(
|
| 753 |
host_name=host_name,
|
| 754 |
guest_name=guest_name,
|
| 755 |
property_title=property_title,
|
|
|
|
| 757 |
check_out=booking.get("check_out_date"),
|
| 758 |
guest_count=booking.get("guest_count", 1),
|
| 759 |
payout_amount=booking.get("payout_amount", 0),
|
| 760 |
+
language=host_language,
|
| 761 |
)
|
| 762 |
import asyncio
|
| 763 |
asyncio.create_task(notify_landlord_via_aida(landlord_id, msg))
|
|
|
|
| 828 |
}
|
| 829 |
)
|
| 830 |
|
| 831 |
+
# FCM push to guest — fires even when app is closed/backgrounded
|
| 832 |
+
try:
|
| 833 |
+
from app.services.push_service import push_service
|
| 834 |
+
await push_service.send_to_user(
|
| 835 |
+
user_id=str(booking["user_id"]),
|
| 836 |
+
title="🎉 Booking Confirmed!",
|
| 837 |
+
body=guest_content[:100],
|
| 838 |
+
category="booking_updates",
|
| 839 |
+
data={"type": "booking_confirmation", "conversation_id": str(conversation["_id"])},
|
| 840 |
+
)
|
| 841 |
+
except Exception as push_err:
|
| 842 |
+
logging.getLogger(__name__).warning(f"Guest push notification failed (non-fatal): {push_err}")
|
| 843 |
+
|
| 844 |
# Broadcast via WebSocket
|
| 845 |
try:
|
| 846 |
from app.routes.websocket_chat import chat_manager
|
app/routes/websocket_chat.py
CHANGED
|
@@ -34,6 +34,9 @@ async def _process_aida_dm_response(
|
|
| 34 |
reply_context: Optional[dict] = None,
|
| 35 |
audio_url: Optional[str] = None,
|
| 36 |
app_language: Optional[str] = None,
|
|
|
|
|
|
|
|
|
|
| 37 |
):
|
| 38 |
"""
|
| 39 |
Process user message through AIDA AI brain and send response in DM.
|
|
@@ -150,6 +153,9 @@ async def _process_aida_dm_response(
|
|
| 150 |
source="dm",
|
| 151 |
reply_context=reply_context,
|
| 152 |
app_language=app_language,
|
|
|
|
|
|
|
|
|
|
| 153 |
)
|
| 154 |
|
| 155 |
# Extract response
|
|
@@ -712,7 +718,19 @@ async def websocket_chat_endpoint(websocket: WebSocket, token: str = Query(...))
|
|
| 712 |
|
| 713 |
elif action == "clear_chat":
|
| 714 |
await handle_clear_chat(user_id, websocket, message)
|
| 715 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
except WebSocketDisconnect:
|
| 717 |
chat_manager.disconnect(websocket)
|
| 718 |
# Only set offline if user has no other connections
|
|
@@ -728,6 +746,98 @@ async def websocket_chat_endpoint(websocket: WebSocket, token: str = Query(...))
|
|
| 728 |
await broadcast_user_status(user_id, is_online=False)
|
| 729 |
|
| 730 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
async def handle_send_message(user_id: str, user_name: str, user_avatar: Optional[str], data: dict):
|
| 732 |
"""Handle sending a message via WebSocket"""
|
| 733 |
conversation_id = data.get("conversation_id")
|
|
@@ -902,6 +1012,9 @@ async def handle_send_message(user_id: str, user_name: str, user_avatar: Optiona
|
|
| 902 |
reply_context=reply_context,
|
| 903 |
audio_url=audio_url,
|
| 904 |
app_language=app_language,
|
|
|
|
|
|
|
|
|
|
| 905 |
))
|
| 906 |
|
| 907 |
|
|
|
|
| 34 |
reply_context: Optional[dict] = None,
|
| 35 |
audio_url: Optional[str] = None,
|
| 36 |
app_language: Optional[str] = None,
|
| 37 |
+
user_name: Optional[str] = None,
|
| 38 |
+
user_location: Optional[str] = None,
|
| 39 |
+
user_role: Optional[str] = "renter",
|
| 40 |
):
|
| 41 |
"""
|
| 42 |
Process user message through AIDA AI brain and send response in DM.
|
|
|
|
| 153 |
source="dm",
|
| 154 |
reply_context=reply_context,
|
| 155 |
app_language=app_language,
|
| 156 |
+
user_name=user_name,
|
| 157 |
+
user_location=user_location,
|
| 158 |
+
user_role=user_role or "renter",
|
| 159 |
)
|
| 160 |
|
| 161 |
# Extract response
|
|
|
|
| 718 |
|
| 719 |
elif action == "clear_chat":
|
| 720 |
await handle_clear_chat(user_id, websocket, message)
|
| 721 |
+
|
| 722 |
+
elif action == "schedule_viewing":
|
| 723 |
+
await handle_schedule_viewing(user_id, websocket, message)
|
| 724 |
+
|
| 725 |
+
elif action == "viewing_availability":
|
| 726 |
+
await handle_viewing_availability(user_id, message)
|
| 727 |
+
|
| 728 |
+
elif action == "viewing_pick_slot":
|
| 729 |
+
await handle_viewing_pick_slot(user_id, message)
|
| 730 |
+
|
| 731 |
+
elif action == "viewing_confirm":
|
| 732 |
+
await handle_viewing_confirm(user_id, message)
|
| 733 |
+
|
| 734 |
except WebSocketDisconnect:
|
| 735 |
chat_manager.disconnect(websocket)
|
| 736 |
# Only set offline if user has no other connections
|
|
|
|
| 746 |
await broadcast_user_status(user_id, is_online=False)
|
| 747 |
|
| 748 |
|
| 749 |
+
async def handle_schedule_viewing(user_id: str, websocket, message: dict) -> None:
|
| 750 |
+
"""
|
| 751 |
+
Triggered when user taps "Schedule Viewing" on a rent/sale listing.
|
| 752 |
+
Creates a pending Viewing doc and sends opening AIDA DMs to both parties.
|
| 753 |
+
Payload: { action: "schedule_viewing", listing_id, landlord_id, message? }
|
| 754 |
+
"""
|
| 755 |
+
try:
|
| 756 |
+
from app.services.viewing_service import initiate_viewing_request
|
| 757 |
+
listing_id = message.get("listing_id")
|
| 758 |
+
landlord_id = message.get("landlord_id")
|
| 759 |
+
note = message.get("message")
|
| 760 |
+
|
| 761 |
+
if not listing_id or not landlord_id:
|
| 762 |
+
await websocket.send_json({"action": "error", "message": "listing_id and landlord_id are required"})
|
| 763 |
+
return
|
| 764 |
+
|
| 765 |
+
viewing_id = await initiate_viewing_request(
|
| 766 |
+
visitor_id=user_id,
|
| 767 |
+
landlord_id=landlord_id,
|
| 768 |
+
listing_id=listing_id,
|
| 769 |
+
visitor_message=note,
|
| 770 |
+
)
|
| 771 |
+
await websocket.send_json({
|
| 772 |
+
"action": "schedule_viewing_ack",
|
| 773 |
+
"viewing_id": viewing_id,
|
| 774 |
+
"message": "Viewing request initiated. Check your AIDA messages for next steps.",
|
| 775 |
+
})
|
| 776 |
+
logger.info(f"[Viewing] Initiated: {viewing_id} by user {user_id}")
|
| 777 |
+
except Exception as e:
|
| 778 |
+
logger.error(f"[Viewing] handle_schedule_viewing error: {e}")
|
| 779 |
+
await websocket.send_json({"action": "error", "message": "Failed to initiate viewing request"})
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
async def handle_viewing_availability(user_id: str, message: dict) -> None:
|
| 783 |
+
"""
|
| 784 |
+
User replies with their availability for a scheduled viewing.
|
| 785 |
+
Payload: { action: "viewing_availability", viewing_id, raw_text, slots: ["Monday 10am", ...] }
|
| 786 |
+
"""
|
| 787 |
+
try:
|
| 788 |
+
from app.services.viewing_service import store_availability
|
| 789 |
+
viewing_id = message.get("viewing_id")
|
| 790 |
+
raw_text = message.get("raw_text", "")
|
| 791 |
+
slots = message.get("slots", [])
|
| 792 |
+
|
| 793 |
+
if not viewing_id:
|
| 794 |
+
return
|
| 795 |
+
|
| 796 |
+
await store_availability(
|
| 797 |
+
viewing_id=viewing_id,
|
| 798 |
+
user_id=user_id,
|
| 799 |
+
raw_availability=raw_text,
|
| 800 |
+
parsed_slots=slots,
|
| 801 |
+
)
|
| 802 |
+
logger.info(f"[Viewing] Availability stored for viewing {viewing_id} by {user_id}")
|
| 803 |
+
except Exception as e:
|
| 804 |
+
logger.error(f"[Viewing] handle_viewing_availability error: {e}")
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
async def handle_viewing_pick_slot(user_id: str, message: dict) -> None:
|
| 808 |
+
"""
|
| 809 |
+
Visitor picks one of the suggested time slots.
|
| 810 |
+
Payload: { action: "viewing_pick_slot", viewing_id, chosen_slot }
|
| 811 |
+
"""
|
| 812 |
+
try:
|
| 813 |
+
from app.services.viewing_service import visitor_picked_slot
|
| 814 |
+
viewing_id = message.get("viewing_id")
|
| 815 |
+
chosen_slot = message.get("chosen_slot")
|
| 816 |
+
if not viewing_id or not chosen_slot:
|
| 817 |
+
return
|
| 818 |
+
await visitor_picked_slot(viewing_id=viewing_id, chosen_slot=chosen_slot)
|
| 819 |
+
logger.info(f"[Viewing] Slot picked for viewing {viewing_id}: {chosen_slot}")
|
| 820 |
+
except Exception as e:
|
| 821 |
+
logger.error(f"[Viewing] handle_viewing_pick_slot error: {e}")
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
async def handle_viewing_confirm(user_id: str, message: dict) -> None:
|
| 825 |
+
"""
|
| 826 |
+
Landlord confirms the proposed slot.
|
| 827 |
+
Payload: { action: "viewing_confirm", viewing_id, confirmed_slot }
|
| 828 |
+
"""
|
| 829 |
+
try:
|
| 830 |
+
from app.services.viewing_service import confirm_viewing
|
| 831 |
+
viewing_id = message.get("viewing_id")
|
| 832 |
+
confirmed_slot = message.get("confirmed_slot")
|
| 833 |
+
if not viewing_id or not confirmed_slot:
|
| 834 |
+
return
|
| 835 |
+
await confirm_viewing(viewing_id=viewing_id, confirmed_slot=confirmed_slot)
|
| 836 |
+
logger.info(f"[Viewing] Confirmed: viewing {viewing_id} at {confirmed_slot}")
|
| 837 |
+
except Exception as e:
|
| 838 |
+
logger.error(f"[Viewing] handle_viewing_confirm error: {e}")
|
| 839 |
+
|
| 840 |
+
|
| 841 |
async def handle_send_message(user_id: str, user_name: str, user_avatar: Optional[str], data: dict):
|
| 842 |
"""Handle sending a message via WebSocket"""
|
| 843 |
conversation_id = data.get("conversation_id")
|
|
|
|
| 1012 |
reply_context=reply_context,
|
| 1013 |
audio_url=audio_url,
|
| 1014 |
app_language=app_language,
|
| 1015 |
+
user_name=user_name,
|
| 1016 |
+
user_location=data.get("user_location"),
|
| 1017 |
+
user_role=data.get("user_role", "renter"),
|
| 1018 |
))
|
| 1019 |
|
| 1020 |
|
app/schemas/__pycache__/user.cpython-313.pyc
CHANGED
|
Binary files a/app/schemas/__pycache__/user.cpython-313.pyc and b/app/schemas/__pycache__/user.cpython-313.pyc differ
|
|
|
app/schemas/user.py
CHANGED
|
@@ -142,6 +142,12 @@ class ProfileUpdateRequest(BaseModel):
|
|
| 142 |
description="URL to the user's profile picture",
|
| 143 |
examples=["https://example.com/images/profile.jpg"]
|
| 144 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
class Config:
|
| 147 |
json_schema_extra = {
|
|
|
|
| 142 |
description="URL to the user's profile picture",
|
| 143 |
examples=["https://example.com/images/profile.jpg"]
|
| 144 |
)
|
| 145 |
+
preferredLanguage: Optional[str] = Field(
|
| 146 |
+
None,
|
| 147 |
+
pattern="^(en|fr|es|ar|pt)$",
|
| 148 |
+
description="User's preferred language for notifications and messages",
|
| 149 |
+
examples=["fr"]
|
| 150 |
+
)
|
| 151 |
|
| 152 |
class Config:
|
| 153 |
json_schema_extra = {
|
app/services/__pycache__/otp_service.cpython-313.pyc
CHANGED
|
Binary files a/app/services/__pycache__/otp_service.cpython-313.pyc and b/app/services/__pycache__/otp_service.cpython-313.pyc differ
|
|
|
app/services/aida_dm_service.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/services/aida_dm_service.py
|
| 3 |
+
#
|
| 4 |
+
# Centralized helper for sending AIDA-initiated DMs.
|
| 5 |
+
# Every call does THREE things atomically:
|
| 6 |
+
# 1. Insert message into MongoDB
|
| 7 |
+
# 2. Broadcast via WebSocket → in-app notification banner fires
|
| 8 |
+
# 3. FCM push to recipient → works even when user is offline
|
| 9 |
+
#
|
| 10 |
+
# Canonical AIDA participant ID: "AIDA_BOT"
|
| 11 |
+
# - Matches Flutter's DirectMessage.isAi check (senderId == 'AIDA_BOT')
|
| 12 |
+
# - Matches booking.py, conversations.py, landlord_notifications.py
|
| 13 |
+
# - Never creates a duplicate conversation — delegates to
|
| 14 |
+
# get_or_create_aida_conversation() which has full multi-step
|
| 15 |
+
# lookup + legacy "aida" → "AIDA_BOT" migration built in.
|
| 16 |
+
#
|
| 17 |
+
# Usage:
|
| 18 |
+
# from app.services.aida_dm_service import send_aida_dm
|
| 19 |
+
# await send_aida_dm(user_id="abc123", text="Hello!", metadata={...})
|
| 20 |
+
# ============================================================
|
| 21 |
+
import logging
|
| 22 |
+
from datetime import datetime
|
| 23 |
+
|
| 24 |
+
from bson import ObjectId
|
| 25 |
+
|
| 26 |
+
from app.database import get_db
|
| 27 |
+
from app.services.push_service import push_service
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
AIDA_BOT_ID = "AIDA_BOT" # canonical participant ID — must match Flutter & all routes
|
| 32 |
+
|
| 33 |
+
AIDA_AVATAR = (
|
| 34 |
+
"https://imagedelivery.net/0utJlkqgAVuawL5OpMWxgw/"
|
| 35 |
+
"3922956f-b69d-4cb3-97b9-3a185abec900/public"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _build_message_doc(conv_id: str, text: str, metadata: dict | None) -> dict:
|
| 40 |
+
"""Build the raw MongoDB document for an AIDA-sent message."""
|
| 41 |
+
return {
|
| 42 |
+
"conversation_id": conv_id,
|
| 43 |
+
"sender_id": AIDA_BOT_ID, # Flutter checks senderId == 'AIDA_BOT'
|
| 44 |
+
"sender_name": "AIDA",
|
| 45 |
+
"sender_avatar": AIDA_AVATAR,
|
| 46 |
+
"message_type": "ai",
|
| 47 |
+
"content": text,
|
| 48 |
+
"metadata": metadata or {},
|
| 49 |
+
"is_read": False,
|
| 50 |
+
"is_edited": False,
|
| 51 |
+
"is_deleted": False,
|
| 52 |
+
"reactions": {},
|
| 53 |
+
"created_at": datetime.utcnow(),
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _format_for_ws(msg_doc: dict) -> dict:
|
| 58 |
+
"""Convert the raw MongoDB doc to the shape Flutter's DirectMessage.fromJson expects."""
|
| 59 |
+
return {
|
| 60 |
+
"id": str(msg_doc["_id"]),
|
| 61 |
+
"conversation_id": msg_doc["conversation_id"],
|
| 62 |
+
"sender_id": msg_doc["sender_id"],
|
| 63 |
+
"sender_name": msg_doc.get("sender_name", "AIDA"),
|
| 64 |
+
"sender_avatar": msg_doc.get("sender_avatar"),
|
| 65 |
+
"message_type": msg_doc.get("message_type", "ai"),
|
| 66 |
+
"content": msg_doc.get("content", ""),
|
| 67 |
+
"metadata": msg_doc.get("metadata", {}),
|
| 68 |
+
"is_read": False,
|
| 69 |
+
"is_edited": False,
|
| 70 |
+
"is_deleted": False,
|
| 71 |
+
"reactions": {},
|
| 72 |
+
"created_at": msg_doc["created_at"].isoformat(),
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def send_aida_dm(
|
| 77 |
+
user_id: str,
|
| 78 |
+
text: str,
|
| 79 |
+
metadata: dict | None = None,
|
| 80 |
+
*,
|
| 81 |
+
push_title: str = "AIDA",
|
| 82 |
+
push_body: str | None = None,
|
| 83 |
+
conv_id: str | None = None,
|
| 84 |
+
) -> str:
|
| 85 |
+
"""
|
| 86 |
+
Send an AIDA DM to *user_id*.
|
| 87 |
+
|
| 88 |
+
Steps:
|
| 89 |
+
1. Resolve the EXISTING AIDA conversation for this user (create only if none exists).
|
| 90 |
+
Uses get_or_create_aida_conversation() which:
|
| 91 |
+
- Looks for AIDA_BOT participant first (canonical)
|
| 92 |
+
- Falls back to participants_key lookup
|
| 93 |
+
- Migrates legacy "aida" conversations to AIDA_BOT in-place
|
| 94 |
+
- Only creates a new conversation as last resort
|
| 95 |
+
2. Insert message into `messages` collection.
|
| 96 |
+
3. Update conversation preview.
|
| 97 |
+
4. Broadcast `new_message` WebSocket event (triggers in-app banner).
|
| 98 |
+
5. Send FCM push notification (triggers system notification when offline).
|
| 99 |
+
|
| 100 |
+
Returns the conversation ID.
|
| 101 |
+
"""
|
| 102 |
+
db = await get_db()
|
| 103 |
+
|
| 104 |
+
# 1. Resolve conversation — reuse canonical lookup, never duplicate
|
| 105 |
+
if conv_id is None:
|
| 106 |
+
from app.services.landlord_notifications import get_or_create_aida_conversation
|
| 107 |
+
conv_id = await get_or_create_aida_conversation(db, user_id)
|
| 108 |
+
|
| 109 |
+
# 2. Insert message
|
| 110 |
+
doc = _build_message_doc(conv_id, text, metadata)
|
| 111 |
+
result = await db["messages"].insert_one(doc)
|
| 112 |
+
doc["_id"] = result.inserted_id
|
| 113 |
+
|
| 114 |
+
# 3. Update conversation preview
|
| 115 |
+
await db["conversations"].update_one(
|
| 116 |
+
{"_id": ObjectId(conv_id)},
|
| 117 |
+
{
|
| 118 |
+
"$set": {
|
| 119 |
+
"updated_at": datetime.utcnow(),
|
| 120 |
+
"last_message": text[:120],
|
| 121 |
+
"last_sender_id": AIDA_BOT_ID,
|
| 122 |
+
}
|
| 123 |
+
},
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# 4. WebSocket broadcast — lazy import to avoid circular dependency
|
| 127 |
+
try:
|
| 128 |
+
from app.routes.websocket_chat import chat_manager # type: ignore
|
| 129 |
+
|
| 130 |
+
participants = [user_id, AIDA_BOT_ID]
|
| 131 |
+
payload = {
|
| 132 |
+
"action": "new_message",
|
| 133 |
+
"conversation_id": conv_id,
|
| 134 |
+
"message": _format_for_ws(doc),
|
| 135 |
+
}
|
| 136 |
+
await chat_manager.broadcast_to_conversation(conv_id, participants, payload)
|
| 137 |
+
logger.debug(f"[AIDA DM] WS broadcast sent for conv {conv_id}")
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
logger.warning(f"[AIDA DM] WS broadcast failed: {exc}")
|
| 140 |
+
|
| 141 |
+
# 5. FCM push
|
| 142 |
+
try:
|
| 143 |
+
body = push_body or (text[:100] + "…" if len(text) > 100 else text)
|
| 144 |
+
await push_service.send_to_user(
|
| 145 |
+
user_id=user_id,
|
| 146 |
+
title=push_title,
|
| 147 |
+
body=body,
|
| 148 |
+
category="messages",
|
| 149 |
+
data={
|
| 150 |
+
"category": "messages",
|
| 151 |
+
"sender_name": "AIDA",
|
| 152 |
+
"avatar_url": AIDA_AVATAR,
|
| 153 |
+
"route": "/direct_chat",
|
| 154 |
+
"conversation_id": conv_id,
|
| 155 |
+
},
|
| 156 |
+
)
|
| 157 |
+
logger.debug(f"[AIDA DM] Push sent for user {user_id}")
|
| 158 |
+
except Exception as exc:
|
| 159 |
+
logger.warning(f"[AIDA DM] Push failed: {exc}")
|
| 160 |
+
|
| 161 |
+
return conv_id
|
app/services/landlord_notifications.py
CHANGED
|
@@ -2,7 +2,6 @@ import logging
|
|
| 2 |
from datetime import datetime
|
| 3 |
from bson import ObjectId
|
| 4 |
from app.database import get_db
|
| 5 |
-
from app.services.translate_service import translate_notification
|
| 6 |
|
| 7 |
logger = logging.getLogger(__name__)
|
| 8 |
|
|
@@ -108,25 +107,22 @@ async def notify_landlord_via_aida(landlord_id: str, message_content: str):
|
|
| 108 |
Acts as AIDA the Co-Host, sending proactive DMs to the landlord
|
| 109 |
about their properties. All messages go into the ONE canonical
|
| 110 |
AIDA_BOT conversation (no duplicate DM is created).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
"""
|
| 112 |
try:
|
| 113 |
db = await get_db()
|
| 114 |
conv_id = await get_or_create_aida_conversation(db, landlord_id)
|
| 115 |
|
| 116 |
-
# Determine landlord preferred language
|
| 117 |
-
landlord = await db["users"].find_one({"_id": ObjectId(landlord_id)})
|
| 118 |
-
pref_lang = landlord.get("preferredLanguage", "en") if landlord else "en"
|
| 119 |
-
|
| 120 |
-
# Translate if needed
|
| 121 |
-
translated_msg = translate_notification(message_content, pref_lang)
|
| 122 |
-
|
| 123 |
now = datetime.utcnow()
|
| 124 |
|
| 125 |
new_msg = {
|
| 126 |
"_id": str(ObjectId()),
|
| 127 |
"conversation_id": conv_id,
|
| 128 |
"sender_id": AIDA_BOT_ID,
|
| 129 |
-
"content":
|
| 130 |
"type": "text",
|
| 131 |
"is_user": False,
|
| 132 |
"is_ai": True,
|
|
@@ -147,7 +143,7 @@ async def notify_landlord_via_aida(landlord_id: str, message_content: str):
|
|
| 147 |
"updated_at": now,
|
| 148 |
"last_message": {
|
| 149 |
"id": new_msg["_id"],
|
| 150 |
-
"content":
|
| 151 |
"sender_id": AIDA_BOT_ID,
|
| 152 |
"type": "text",
|
| 153 |
"created_at": now,
|
|
@@ -157,7 +153,21 @@ async def notify_landlord_via_aida(landlord_id: str, message_content: str):
|
|
| 157 |
}
|
| 158 |
)
|
| 159 |
|
| 160 |
-
# ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
try:
|
| 162 |
from app.routes.websocket_chat import chat_manager
|
| 163 |
|
|
@@ -188,9 +198,6 @@ async def notify_landlord_via_aida(landlord_id: str, message_content: str):
|
|
| 188 |
except Exception as ws_err:
|
| 189 |
logger.warning(f"AIDA notification WS broadcast failed (non-fatal): {ws_err}")
|
| 190 |
|
| 191 |
-
logger.info(
|
| 192 |
-
f"Landlord {landlord_id} notified via AIDA [{pref_lang}]: "
|
| 193 |
-
f"{translated_msg[:50]}..."
|
| 194 |
-
)
|
| 195 |
except Exception as e:
|
| 196 |
logger.error(f"Failed to send landlord AIDA notification: {e}")
|
|
|
|
| 2 |
from datetime import datetime
|
| 3 |
from bson import ObjectId
|
| 4 |
from app.database import get_db
|
|
|
|
| 5 |
|
| 6 |
logger = logging.getLogger(__name__)
|
| 7 |
|
|
|
|
| 107 |
Acts as AIDA the Co-Host, sending proactive DMs to the landlord
|
| 108 |
about their properties. All messages go into the ONE canonical
|
| 109 |
AIDA_BOT conversation (no duplicate DM is created).
|
| 110 |
+
|
| 111 |
+
NOTE: message_content is expected to already be in the host's preferred
|
| 112 |
+
language (built by booking.py using generate_localized_response).
|
| 113 |
+
No translation is applied here.
|
| 114 |
"""
|
| 115 |
try:
|
| 116 |
db = await get_db()
|
| 117 |
conv_id = await get_or_create_aida_conversation(db, landlord_id)
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
now = datetime.utcnow()
|
| 120 |
|
| 121 |
new_msg = {
|
| 122 |
"_id": str(ObjectId()),
|
| 123 |
"conversation_id": conv_id,
|
| 124 |
"sender_id": AIDA_BOT_ID,
|
| 125 |
+
"content": message_content,
|
| 126 |
"type": "text",
|
| 127 |
"is_user": False,
|
| 128 |
"is_ai": True,
|
|
|
|
| 143 |
"updated_at": now,
|
| 144 |
"last_message": {
|
| 145 |
"id": new_msg["_id"],
|
| 146 |
+
"content": message_content,
|
| 147 |
"sender_id": AIDA_BOT_ID,
|
| 148 |
"type": "text",
|
| 149 |
"created_at": now,
|
|
|
|
| 153 |
}
|
| 154 |
)
|
| 155 |
|
| 156 |
+
# ── FCM push notification ──────────────────────────────────────────
|
| 157 |
+
# Fires even when the user's app is closed / backgrounded.
|
| 158 |
+
try:
|
| 159 |
+
from app.services.push_service import push_service
|
| 160 |
+
await push_service.send_to_user(
|
| 161 |
+
user_id=landlord_id,
|
| 162 |
+
title="🎉 New Booking Confirmed!",
|
| 163 |
+
body=message_content[:100],
|
| 164 |
+
category="booking_updates",
|
| 165 |
+
data={"type": "booking_confirmation", "conversation_id": conv_id},
|
| 166 |
+
)
|
| 167 |
+
except Exception as push_err:
|
| 168 |
+
logger.warning(f"Push notification failed for landlord {landlord_id} (non-fatal): {push_err}")
|
| 169 |
+
|
| 170 |
+
# ── Broadcast via WebSocket so the message appears instantly ──────
|
| 171 |
try:
|
| 172 |
from app.routes.websocket_chat import chat_manager
|
| 173 |
|
|
|
|
| 198 |
except Exception as ws_err:
|
| 199 |
logger.warning(f"AIDA notification WS broadcast failed (non-fatal): {ws_err}")
|
| 200 |
|
| 201 |
+
logger.info(f"Landlord {landlord_id} notified via AIDA: {message_content[:50]}...")
|
|
|
|
|
|
|
|
|
|
| 202 |
except Exception as e:
|
| 203 |
logger.error(f"Failed to send landlord AIDA notification: {e}")
|
app/services/proactive_service.py
CHANGED
|
@@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional
|
|
| 11 |
from structlog import get_logger
|
| 12 |
|
| 13 |
from app.database import get_db
|
|
|
|
| 14 |
|
| 15 |
logger = get_logger(__name__)
|
| 16 |
|
|
@@ -115,6 +116,37 @@ async def _send_proactive_push(
|
|
| 115 |
return count
|
| 116 |
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
async def _send_proactive_email(
|
| 119 |
user_id: str,
|
| 120 |
title: str,
|
|
@@ -270,7 +302,14 @@ async def check_new_listings_against_preferences(
|
|
| 270 |
location = listing.get("location", "")
|
| 271 |
price = listing.get("price", "")
|
| 272 |
currency = listing.get("currency", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
|
|
|
|
|
|
|
|
|
| 274 |
push_count = await _send_proactive_push(
|
| 275 |
user_id=user_id,
|
| 276 |
title="New listing matches your preferences!",
|
|
@@ -347,7 +386,14 @@ async def check_alerts_against_new_listings(
|
|
| 347 |
location = listing.get("location", "")
|
| 348 |
price = listing.get("price", "")
|
| 349 |
currency = listing.get("currency", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
|
|
|
|
|
|
|
|
|
| 351 |
push_count = await _send_proactive_push(
|
| 352 |
user_id=alert.user_id,
|
| 353 |
title="Alert match found!",
|
|
@@ -357,7 +403,6 @@ async def check_alerts_against_new_listings(
|
|
| 357 |
)
|
| 358 |
sent_count += push_count
|
| 359 |
|
| 360 |
-
# Optional email for alert matches
|
| 361 |
await _send_proactive_email(
|
| 362 |
user_id=alert.user_id,
|
| 363 |
title="A property matches your alert!",
|
|
@@ -482,6 +527,11 @@ async def _notify_price_drop(
|
|
| 482 |
if await _was_already_notified(user_id, listing_id, "price_drop"):
|
| 483 |
continue
|
| 484 |
if _preferences_match_listing(prefs, listing):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
push_count = await _send_proactive_push(
|
| 486 |
user_id=user_id,
|
| 487 |
title="Price dropped!",
|
|
@@ -509,6 +559,11 @@ async def _notify_price_drop(
|
|
| 509 |
|
| 510 |
try:
|
| 511 |
if await check_listing_matches_alert(listing, alert):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
push_count = await _send_proactive_push(
|
| 513 |
user_id=alert.user_id,
|
| 514 |
title="Price dropped on a match!",
|
|
|
|
| 11 |
from structlog import get_logger
|
| 12 |
|
| 13 |
from app.database import get_db
|
| 14 |
+
from app.services.aida_dm_service import send_aida_dm
|
| 15 |
|
| 16 |
logger = get_logger(__name__)
|
| 17 |
|
|
|
|
| 116 |
return count
|
| 117 |
|
| 118 |
|
| 119 |
+
async def _send_aida_dm_alert(
|
| 120 |
+
user_id: str,
|
| 121 |
+
message: str,
|
| 122 |
+
listing_id: str,
|
| 123 |
+
listing: dict,
|
| 124 |
+
) -> None:
|
| 125 |
+
"""
|
| 126 |
+
Send an alert match or price drop as an AIDA DM.
|
| 127 |
+
Delegates to send_aida_dm which handles DB + WebSocket broadcast + push notification.
|
| 128 |
+
"""
|
| 129 |
+
try:
|
| 130 |
+
await send_aida_dm(
|
| 131 |
+
user_id=user_id,
|
| 132 |
+
text=message,
|
| 133 |
+
metadata={
|
| 134 |
+
"alert_card": {
|
| 135 |
+
"listing_id": listing_id,
|
| 136 |
+
"title": listing.get("title", ""),
|
| 137 |
+
"price": listing.get("price"),
|
| 138 |
+
"currency": listing.get("currency", ""),
|
| 139 |
+
"location": listing.get("location", ""),
|
| 140 |
+
"images": listing.get("images", []),
|
| 141 |
+
}
|
| 142 |
+
},
|
| 143 |
+
push_title="AIDA — New match found",
|
| 144 |
+
push_body=message[:100],
|
| 145 |
+
)
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.warning("Failed to send AIDA DM alert", user_id=user_id, error=str(e))
|
| 148 |
+
|
| 149 |
+
|
| 150 |
async def _send_proactive_email(
|
| 151 |
user_id: str,
|
| 152 |
title: str,
|
|
|
|
| 302 |
location = listing.get("location", "")
|
| 303 |
price = listing.get("price", "")
|
| 304 |
currency = listing.get("currency", "")
|
| 305 |
+
dm_msg = (
|
| 306 |
+
f"Found one! '{title}' in {location} — {currency} {price} 🏠\n"
|
| 307 |
+
"Tap to view details."
|
| 308 |
+
)
|
| 309 |
|
| 310 |
+
# Primary: AIDA DM (in-app, actionable)
|
| 311 |
+
await _send_aida_dm_alert(user_id, dm_msg, listing_id, listing)
|
| 312 |
+
# Fallback: push notification (for when user is outside the app)
|
| 313 |
push_count = await _send_proactive_push(
|
| 314 |
user_id=user_id,
|
| 315 |
title="New listing matches your preferences!",
|
|
|
|
| 386 |
location = listing.get("location", "")
|
| 387 |
price = listing.get("price", "")
|
| 388 |
currency = listing.get("currency", "")
|
| 389 |
+
dm_msg = (
|
| 390 |
+
f"Your alert matched! '{title}' in {location} — {currency} {price} 🏠\n"
|
| 391 |
+
"Tap to view details."
|
| 392 |
+
)
|
| 393 |
|
| 394 |
+
# Primary: AIDA DM
|
| 395 |
+
await _send_aida_dm_alert(alert.user_id, dm_msg, listing_id, listing)
|
| 396 |
+
# Fallback: push + email
|
| 397 |
push_count = await _send_proactive_push(
|
| 398 |
user_id=alert.user_id,
|
| 399 |
title="Alert match found!",
|
|
|
|
| 403 |
)
|
| 404 |
sent_count += push_count
|
| 405 |
|
|
|
|
| 406 |
await _send_proactive_email(
|
| 407 |
user_id=alert.user_id,
|
| 408 |
title="A property matches your alert!",
|
|
|
|
| 527 |
if await _was_already_notified(user_id, listing_id, "price_drop"):
|
| 528 |
continue
|
| 529 |
if _preferences_match_listing(prefs, listing):
|
| 530 |
+
dm_msg = (
|
| 531 |
+
f"Price drop! '{title}' just dropped {drop_pct:.0f}% "
|
| 532 |
+
f"to {currency} {new_price:,.0f} 📉 Tap to check it out."
|
| 533 |
+
)
|
| 534 |
+
await _send_aida_dm_alert(user_id, dm_msg, listing_id, listing)
|
| 535 |
push_count = await _send_proactive_push(
|
| 536 |
user_id=user_id,
|
| 537 |
title="Price dropped!",
|
|
|
|
| 559 |
|
| 560 |
try:
|
| 561 |
if await check_listing_matches_alert(listing, alert):
|
| 562 |
+
dm_msg = (
|
| 563 |
+
f"Your alert matched a price drop! '{title}' is now "
|
| 564 |
+
f"{currency} {new_price:,.0f} (down {drop_pct:.0f}%) 📉 Tap to view."
|
| 565 |
+
)
|
| 566 |
+
await _send_aida_dm_alert(alert.user_id, dm_msg, listing_id, listing)
|
| 567 |
push_count = await _send_proactive_push(
|
| 568 |
user_id=alert.user_id,
|
| 569 |
title="Price dropped on a match!",
|
app/services/translate_service.py
CHANGED
|
@@ -1,34 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
from typing import Optional
|
| 3 |
|
| 4 |
logger = logging.getLogger(__name__)
|
| 5 |
|
| 6 |
-
#
|
| 7 |
try:
|
| 8 |
from deep_translator import GoogleTranslator
|
| 9 |
-
|
| 10 |
except ImportError:
|
| 11 |
-
logger.warning("deep-translator
|
| 12 |
-
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
-
|
| 17 |
-
|
| 18 |
"""
|
| 19 |
-
if
|
| 20 |
-
return text
|
| 21 |
-
|
| 22 |
-
if not has_translator:
|
| 23 |
-
return text
|
| 24 |
-
|
| 25 |
try:
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
except Exception as e:
|
| 33 |
-
logger.warning(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
translate_service.py
|
| 3 |
+
--------------------
|
| 4 |
+
Translates AIDA system-generated notification messages into the user's preferred language.
|
| 5 |
+
|
| 6 |
+
Strategy (in order):
|
| 7 |
+
1. LLM — generate_localized_response(): natural, warm, AIDA-voiced output in target language
|
| 8 |
+
2. Google Translate — deep-translator fallback if LLM call fails
|
| 9 |
+
3. English original — last resort
|
| 10 |
+
|
| 11 |
+
The LLM path also rewrites the tone so it feels like AIDA wrote it in that language natively,
|
| 12 |
+
not like English that was run through a translator.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
import logging
|
| 17 |
from typing import Optional
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
+
# ── Google Translate fallback ─────────────────────────────────────────────────
|
| 22 |
try:
|
| 23 |
from deep_translator import GoogleTranslator
|
| 24 |
+
_has_google = True
|
| 25 |
except ImportError:
|
| 26 |
+
logger.warning("deep-translator not installed — Google Translate fallback unavailable.")
|
| 27 |
+
_has_google = False
|
| 28 |
|
| 29 |
+
|
| 30 |
+
def _google_translate(text: str, target_lang: str) -> Optional[str]:
|
| 31 |
+
"""Attempt a Google Translate call. Returns None on any failure."""
|
| 32 |
+
if not _has_google or not text.strip():
|
| 33 |
+
return None
|
| 34 |
+
try:
|
| 35 |
+
return GoogleTranslator(source="auto", target=target_lang[:2]).translate(text)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.warning(f"[translate] Google Translate failed ({target_lang}): {e}")
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ── LLM-primary translation ───────────────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
async def _llm_translate(text: str, target_lang: str, context_hint: str = "") -> Optional[str]:
|
| 44 |
"""
|
| 45 |
+
Use generate_localized_response to rewrite the message natively in target_lang.
|
| 46 |
+
Returns None if the LLM call fails.
|
| 47 |
"""
|
| 48 |
+
if target_lang.lower().startswith("en"):
|
| 49 |
+
return text # Already English — no-op
|
| 50 |
+
|
|
|
|
|
|
|
|
|
|
| 51 |
try:
|
| 52 |
+
from app.ai.agent.brain import generate_localized_response
|
| 53 |
+
result = await generate_localized_response(
|
| 54 |
+
context=(
|
| 55 |
+
f"Rewrite the following AIDA notification message natively in the language "
|
| 56 |
+
f"with ISO code '{target_lang}'. Keep the same meaning and all details. "
|
| 57 |
+
f"Preserve emojis. Sound warm and friendly — as if AIDA wrote it in that "
|
| 58 |
+
f"language from scratch, NOT like a translation.\n\n"
|
| 59 |
+
f"Message: {text}"
|
| 60 |
+
+ (f"\n\nContext: {context_hint}" if context_hint else "")
|
| 61 |
+
),
|
| 62 |
+
language=target_lang,
|
| 63 |
+
tone="friendly",
|
| 64 |
+
max_length="medium",
|
| 65 |
+
)
|
| 66 |
+
return result.strip() if result else None
|
| 67 |
except Exception as e:
|
| 68 |
+
logger.warning(f"[translate] LLM translation failed ({target_lang}): {e}")
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── Public API ────────────────────────────────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
async def translate_notification_async(
|
| 75 |
+
text: str,
|
| 76 |
+
target_lang: str,
|
| 77 |
+
context_hint: str = "",
|
| 78 |
+
) -> str:
|
| 79 |
+
"""
|
| 80 |
+
Async version — preferred for all background jobs and services.
|
| 81 |
+
|
| 82 |
+
1. Skip translation entirely if already English.
|
| 83 |
+
2. Try LLM (warm, AIDA-voiced).
|
| 84 |
+
3. Fallback to Google Translate (mechanical but correct).
|
| 85 |
+
4. Fallback to original English text.
|
| 86 |
+
"""
|
| 87 |
+
if not target_lang or target_lang.lower().startswith("en"):
|
| 88 |
return text
|
| 89 |
+
|
| 90 |
+
# 1. LLM — primary
|
| 91 |
+
result = await _llm_translate(text, target_lang, context_hint)
|
| 92 |
+
if result:
|
| 93 |
+
return result
|
| 94 |
+
|
| 95 |
+
# 2. Google Translate — fallback
|
| 96 |
+
result = _google_translate(text, target_lang)
|
| 97 |
+
if result:
|
| 98 |
+
logger.info(f"[translate] Used Google Translate fallback for lang={target_lang}")
|
| 99 |
+
return result
|
| 100 |
+
|
| 101 |
+
# 3. Original text
|
| 102 |
+
logger.warning(f"[translate] All translation paths failed for lang={target_lang}. Returning English.")
|
| 103 |
+
return text
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def translate_notification(text: str, target_lang: str) -> str:
|
| 107 |
+
"""
|
| 108 |
+
Sync shim kept for backward compatibility with existing callers.
|
| 109 |
+
|
| 110 |
+
Runs the async path in a new event loop if no loop is running,
|
| 111 |
+
otherwise falls straight to Google Translate (since you can't nest
|
| 112 |
+
await in a sync context that's already inside an event loop).
|
| 113 |
+
|
| 114 |
+
Prefer calling translate_notification_async() directly from async code.
|
| 115 |
+
"""
|
| 116 |
+
if not target_lang or target_lang.lower().startswith("en"):
|
| 117 |
+
return text
|
| 118 |
+
|
| 119 |
+
# Try to run async version
|
| 120 |
+
try:
|
| 121 |
+
loop = asyncio.get_event_loop()
|
| 122 |
+
if loop.is_running():
|
| 123 |
+
# We're inside an async context — can't nest; use Google Translate
|
| 124 |
+
result = _google_translate(text, target_lang)
|
| 125 |
+
return result or text
|
| 126 |
+
else:
|
| 127 |
+
return loop.run_until_complete(translate_notification_async(text, target_lang))
|
| 128 |
+
except Exception:
|
| 129 |
+
result = _google_translate(text, target_lang)
|
| 130 |
+
return result or text
|
app/services/viewing_service.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/services/viewing_service.py
|
| 2 |
+
"""
|
| 3 |
+
Viewing Scheduling Service — AIDA-Broker flow for rent/sale property visits.
|
| 4 |
+
|
| 5 |
+
Flow:
|
| 6 |
+
1. initiate_viewing_request()
|
| 7 |
+
- Flutter taps "Schedule Viewing" → creates pending Viewing doc
|
| 8 |
+
- Sends AIDA DM to visitor: "When are you free this week?"
|
| 9 |
+
- Sends AIDA DM to landlord: "Someone wants to visit. When are you available?"
|
| 10 |
+
|
| 11 |
+
2. store_availability()
|
| 12 |
+
- Called when either party replies with their available times
|
| 13 |
+
- Stored on the Viewing doc (visitor_availability / landlord_availability)
|
| 14 |
+
- When BOTH are stored → trigger cross_check_and_suggest()
|
| 15 |
+
|
| 16 |
+
3. cross_check_and_suggest()
|
| 17 |
+
- Compares both availability lists → finds overlapping slots
|
| 18 |
+
- Sends AIDA DM to visitor with up to 3 suggested times to pick from
|
| 19 |
+
|
| 20 |
+
4. confirm_viewing_slot()
|
| 21 |
+
- Visitor picks a slot → sends confirmation request to landlord
|
| 22 |
+
- Landlord confirms → status = CONFIRMED → both get confirmation DM
|
| 23 |
+
|
| 24 |
+
How AIDA knows who the visitor is:
|
| 25 |
+
The visitor_id is stored in the Viewing document when initiated.
|
| 26 |
+
Post-viewing follow-up jobs use visitor_id to send the review request.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import logging
|
| 30 |
+
from datetime import datetime, timezone
|
| 31 |
+
from typing import List, Optional
|
| 32 |
+
from bson import ObjectId
|
| 33 |
+
|
| 34 |
+
from app.database import get_db
|
| 35 |
+
from app.models.viewing import ViewingStatus
|
| 36 |
+
from app.services.aida_dm_service import send_aida_dm as _send_aida_dm
|
| 37 |
+
from app.ai.agent.brain import generate_localized_response
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _lang(user: dict) -> str:
|
| 41 |
+
return (user or {}).get("preferredLanguage", "en")
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _user_lang(user: dict) -> str:
|
| 47 |
+
return (user or {}).get("preferredLanguage", "en")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ============================================================
|
| 51 |
+
# STEP 1 — INITIATE
|
| 52 |
+
# ============================================================
|
| 53 |
+
|
| 54 |
+
async def initiate_viewing_request(
|
| 55 |
+
visitor_id: str,
|
| 56 |
+
landlord_id: str,
|
| 57 |
+
listing_id: str,
|
| 58 |
+
visitor_message: Optional[str] = None,
|
| 59 |
+
) -> str:
|
| 60 |
+
"""
|
| 61 |
+
Create a pending Viewing document and send opening AIDA DMs
|
| 62 |
+
to both the visitor and the landlord asking for their availability.
|
| 63 |
+
|
| 64 |
+
Returns the new viewing_id.
|
| 65 |
+
"""
|
| 66 |
+
db = await get_db()
|
| 67 |
+
|
| 68 |
+
# Create pending viewing
|
| 69 |
+
doc = {
|
| 70 |
+
"visitor_id": visitor_id,
|
| 71 |
+
"landlord_id": landlord_id,
|
| 72 |
+
"listing_id": listing_id,
|
| 73 |
+
"status": ViewingStatus.PENDING.value,
|
| 74 |
+
"visitor_availability": [], # filled when visitor responds
|
| 75 |
+
"landlord_availability": [], # filled when landlord responds
|
| 76 |
+
"visitor_availability_raw": None,
|
| 77 |
+
"landlord_availability_raw": None,
|
| 78 |
+
"suggested_slots": [],
|
| 79 |
+
"confirmed_slot": None,
|
| 80 |
+
"visitor_reminder_sent": False,
|
| 81 |
+
"landlord_reminder_sent": False,
|
| 82 |
+
"followup_sent": False,
|
| 83 |
+
"notes": visitor_message,
|
| 84 |
+
"created_at": datetime.now(timezone.utc),
|
| 85 |
+
"updated_at": datetime.now(timezone.utc),
|
| 86 |
+
}
|
| 87 |
+
result = await db["viewings"].insert_one(doc)
|
| 88 |
+
viewing_id = str(result.inserted_id)
|
| 89 |
+
|
| 90 |
+
# Fetch names and listing
|
| 91 |
+
visitor = await db["users"].find_one({"_id": ObjectId(visitor_id)})
|
| 92 |
+
landlord = await db["users"].find_one({"_id": ObjectId(landlord_id)})
|
| 93 |
+
listing = await db["listings"].find_one({"_id": ObjectId(listing_id)})
|
| 94 |
+
|
| 95 |
+
property_name = (listing or {}).get("title", "the property")
|
| 96 |
+
listing_type_str = "tenant" if listing and listing.get("listing_type") == "rent" else "buyer"
|
| 97 |
+
|
| 98 |
+
# DM to visitor — in visitor's preferred language
|
| 99 |
+
visitor_msg = await generate_localized_response(
|
| 100 |
+
context=(
|
| 101 |
+
f"Tell the visitor you're going to schedule a viewing for '{property_name}'. "
|
| 102 |
+
"Ask them to share a few days and time slots that work for them this week. "
|
| 103 |
+
"Sound excited and helpful. Include a 📅 emoji."
|
| 104 |
+
),
|
| 105 |
+
language=_lang(visitor),
|
| 106 |
+
tone="friendly",
|
| 107 |
+
max_length="short",
|
| 108 |
+
)
|
| 109 |
+
await _send_aida_dm(visitor_id, visitor_msg, metadata={
|
| 110 |
+
"viewing_flow": {
|
| 111 |
+
"viewing_id": viewing_id,
|
| 112 |
+
"step": "collect_visitor_availability",
|
| 113 |
+
}
|
| 114 |
+
})
|
| 115 |
+
|
| 116 |
+
# DM to landlord — in landlord's preferred language
|
| 117 |
+
landlord_msg = await generate_localized_response(
|
| 118 |
+
context=(
|
| 119 |
+
f"Tell the landlord/agent that a potential {listing_type_str} wants to visit "
|
| 120 |
+
f"'{property_name}'. Ask them when they're available this week for a viewing — "
|
| 121 |
+
"a few time slots would be great. Sound professional and warm. Include a 📅 emoji."
|
| 122 |
+
),
|
| 123 |
+
language=_lang(landlord),
|
| 124 |
+
tone="friendly",
|
| 125 |
+
max_length="short",
|
| 126 |
+
)
|
| 127 |
+
await _send_aida_dm(landlord_id, landlord_msg, metadata={
|
| 128 |
+
"viewing_flow": {
|
| 129 |
+
"viewing_id": viewing_id,
|
| 130 |
+
"step": "collect_landlord_availability",
|
| 131 |
+
}
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
logger.info(f"Viewing initiated: {viewing_id} | visitor: {visitor_id} | landlord: {landlord_id}")
|
| 135 |
+
return viewing_id
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ============================================================
|
| 139 |
+
# STEP 2 — STORE AVAILABILITY
|
| 140 |
+
# ============================================================
|
| 141 |
+
|
| 142 |
+
async def store_availability(
|
| 143 |
+
viewing_id: str,
|
| 144 |
+
user_id: str,
|
| 145 |
+
raw_availability: str,
|
| 146 |
+
parsed_slots: List[str],
|
| 147 |
+
) -> None:
|
| 148 |
+
"""
|
| 149 |
+
Store a party's availability on the Viewing document.
|
| 150 |
+
If both parties now have availability → trigger cross-check.
|
| 151 |
+
|
| 152 |
+
parsed_slots: list of human-readable strings like ["Monday 10am", "Tuesday 2pm"]
|
| 153 |
+
raw_availability: the user's original free-form message
|
| 154 |
+
"""
|
| 155 |
+
db = await get_db()
|
| 156 |
+
viewing = await db["viewings"].find_one({"_id": ObjectId(viewing_id)})
|
| 157 |
+
if not viewing:
|
| 158 |
+
logger.warning(f"store_availability: viewing {viewing_id} not found")
|
| 159 |
+
return
|
| 160 |
+
|
| 161 |
+
is_visitor = viewing["visitor_id"] == user_id
|
| 162 |
+
is_landlord = viewing["landlord_id"] == user_id
|
| 163 |
+
|
| 164 |
+
if not is_visitor and not is_landlord:
|
| 165 |
+
logger.warning(f"store_availability: user {user_id} is not a party to viewing {viewing_id}")
|
| 166 |
+
return
|
| 167 |
+
|
| 168 |
+
update_field = "visitor_availability" if is_visitor else "landlord_availability"
|
| 169 |
+
raw_field = "visitor_availability_raw" if is_visitor else "landlord_availability_raw"
|
| 170 |
+
|
| 171 |
+
await db["viewings"].update_one(
|
| 172 |
+
{"_id": ObjectId(viewing_id)},
|
| 173 |
+
{"$set": {
|
| 174 |
+
update_field: parsed_slots,
|
| 175 |
+
raw_field: raw_availability,
|
| 176 |
+
"updated_at": datetime.now(timezone.utc),
|
| 177 |
+
}},
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# Reload and check if both are ready
|
| 181 |
+
viewing = await db["viewings"].find_one({"_id": ObjectId(viewing_id)})
|
| 182 |
+
if viewing.get("visitor_availability") and viewing.get("landlord_availability"):
|
| 183 |
+
await cross_check_and_suggest(viewing_id)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ============================================================
|
| 187 |
+
# STEP 3 — CROSS-CHECK & SUGGEST
|
| 188 |
+
# ============================================================
|
| 189 |
+
|
| 190 |
+
async def cross_check_and_suggest(viewing_id: str) -> None:
|
| 191 |
+
"""
|
| 192 |
+
Compare visitor and landlord availability → find overlapping slots.
|
| 193 |
+
Send up to 3 suggestions to the visitor to pick from.
|
| 194 |
+
If no overlap → ask each party for more options.
|
| 195 |
+
"""
|
| 196 |
+
db = await get_db()
|
| 197 |
+
viewing = await db["viewings"].find_one({"_id": ObjectId(viewing_id)})
|
| 198 |
+
if not viewing:
|
| 199 |
+
return
|
| 200 |
+
|
| 201 |
+
visitor_slots = viewing.get("visitor_availability", [])
|
| 202 |
+
landlord_slots = viewing.get("landlord_availability", [])
|
| 203 |
+
visitor_id = viewing["visitor_id"]
|
| 204 |
+
landlord_id = viewing["landlord_id"]
|
| 205 |
+
|
| 206 |
+
listing = await db["listings"].find_one({"_id": ObjectId(viewing["listing_id"])})
|
| 207 |
+
property_name = (listing or {}).get("title", "the property")
|
| 208 |
+
|
| 209 |
+
# Simple overlap check (string match on normalised slot labels)
|
| 210 |
+
v_norm = {s.lower().strip() for s in visitor_slots}
|
| 211 |
+
l_norm = {s.lower().strip() for s in landlord_slots}
|
| 212 |
+
overlap = list(v_norm & l_norm)[:3] # up to 3 overlapping slots
|
| 213 |
+
|
| 214 |
+
visitor = await db["users"].find_one({"_id": ObjectId(visitor_id)})
|
| 215 |
+
landlord = await db["users"].find_one({"_id": ObjectId(landlord_id)})
|
| 216 |
+
|
| 217 |
+
if overlap:
|
| 218 |
+
await db["viewings"].update_one(
|
| 219 |
+
{"_id": ObjectId(viewing_id)},
|
| 220 |
+
{"$set": {"suggested_slots": overlap}},
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
slots_text = "\n".join(f" {i+1}. {s.title()}" for i, s in enumerate(overlap))
|
| 224 |
+
visitor_msg = await generate_localized_response(
|
| 225 |
+
context=(
|
| 226 |
+
f"Tell the visitor you found {len(overlap)} time slot(s) that work for both them "
|
| 227 |
+
f"and the landlord for a viewing of '{property_name}'. "
|
| 228 |
+
f"List the slots:\n{slots_text}\n"
|
| 229 |
+
"Ask which one works best — they can reply with the number. Sound excited. Include a 📅 emoji."
|
| 230 |
+
),
|
| 231 |
+
language=_lang(visitor),
|
| 232 |
+
tone="friendly",
|
| 233 |
+
max_length="short",
|
| 234 |
+
)
|
| 235 |
+
await _send_aida_dm(visitor_id, visitor_msg, metadata={
|
| 236 |
+
"viewing_flow": {
|
| 237 |
+
"viewing_id": viewing_id,
|
| 238 |
+
"step": "visitor_pick_slot",
|
| 239 |
+
"suggested_slots": overlap,
|
| 240 |
+
}
|
| 241 |
+
})
|
| 242 |
+
else:
|
| 243 |
+
# No overlap — ask each party for more options in their own language
|
| 244 |
+
visitor_msg = await generate_localized_response(
|
| 245 |
+
context=(
|
| 246 |
+
f"Tell the visitor their availability doesn't overlap with the landlord's "
|
| 247 |
+
f"for a viewing of '{property_name}'. Ask if they can suggest a few more "
|
| 248 |
+
"time slots this week or next. Sound understanding. Include a 📅 emoji."
|
| 249 |
+
),
|
| 250 |
+
language=_lang(visitor),
|
| 251 |
+
tone="friendly",
|
| 252 |
+
max_length="short",
|
| 253 |
+
)
|
| 254 |
+
landlord_msg = await generate_localized_response(
|
| 255 |
+
context=(
|
| 256 |
+
f"Tell the landlord/agent the visitor's schedule for '{property_name}' "
|
| 257 |
+
"doesn't overlap with theirs yet. Ask if they can share a couple more "
|
| 258 |
+
"available windows. Sound professional and warm. Include a 📅 emoji."
|
| 259 |
+
),
|
| 260 |
+
language=_lang(landlord),
|
| 261 |
+
tone="friendly",
|
| 262 |
+
max_length="short",
|
| 263 |
+
)
|
| 264 |
+
await _send_aida_dm(visitor_id, visitor_msg, metadata={
|
| 265 |
+
"viewing_flow": {"viewing_id": viewing_id, "step": "collect_visitor_availability"}
|
| 266 |
+
})
|
| 267 |
+
await _send_aida_dm(landlord_id, landlord_msg, metadata={
|
| 268 |
+
"viewing_flow": {"viewing_id": viewing_id, "step": "collect_landlord_availability"}
|
| 269 |
+
})
|
| 270 |
+
|
| 271 |
+
await db["viewings"].update_one(
|
| 272 |
+
{"_id": ObjectId(viewing_id)},
|
| 273 |
+
{"$set": {"visitor_availability": [], "landlord_availability": []}},
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ============================================================
|
| 278 |
+
# STEP 4 — VISITOR PICKS SLOT → LANDLORD CONFIRMS
|
| 279 |
+
# ============================================================
|
| 280 |
+
|
| 281 |
+
async def visitor_picked_slot(viewing_id: str, chosen_slot: str) -> None:
|
| 282 |
+
"""
|
| 283 |
+
Visitor chose a time slot. Send confirmation request to landlord.
|
| 284 |
+
"""
|
| 285 |
+
db = await get_db()
|
| 286 |
+
viewing = await db["viewings"].find_one({"_id": ObjectId(viewing_id)})
|
| 287 |
+
if not viewing:
|
| 288 |
+
return
|
| 289 |
+
|
| 290 |
+
landlord_id = viewing["landlord_id"]
|
| 291 |
+
visitor_id = viewing["visitor_id"]
|
| 292 |
+
listing = await db["listings"].find_one({"_id": ObjectId(viewing["listing_id"])})
|
| 293 |
+
property_name = (listing or {}).get("title", "the property")
|
| 294 |
+
|
| 295 |
+
visitor = await db["users"].find_one({"_id": ObjectId(visitor_id)})
|
| 296 |
+
landlord_user = await db["users"].find_one({"_id": ObjectId(landlord_id)})
|
| 297 |
+
visitor_name = (visitor or {}).get("name", "The visitor").split()[0]
|
| 298 |
+
|
| 299 |
+
await db["viewings"].update_one(
|
| 300 |
+
{"_id": ObjectId(viewing_id)},
|
| 301 |
+
{"$set": {"pending_slot": chosen_slot, "updated_at": datetime.now(timezone.utc)}},
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
landlord_msg = await generate_localized_response(
|
| 305 |
+
context=(
|
| 306 |
+
f"Tell the landlord/agent that {visitor_name} would like to visit "
|
| 307 |
+
f"'{property_name}' on {chosen_slot}. Ask if that still works for them — "
|
| 308 |
+
"they can reply Yes to confirm or suggest a different time. Include a 📅 emoji."
|
| 309 |
+
),
|
| 310 |
+
language=_lang(landlord_user),
|
| 311 |
+
tone="friendly",
|
| 312 |
+
max_length="short",
|
| 313 |
+
)
|
| 314 |
+
await _send_aida_dm(landlord_id, landlord_msg, metadata={
|
| 315 |
+
"viewing_flow": {
|
| 316 |
+
"viewing_id": viewing_id,
|
| 317 |
+
"step": "landlord_confirm_slot",
|
| 318 |
+
"proposed_slot": chosen_slot,
|
| 319 |
+
}
|
| 320 |
+
})
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
async def confirm_viewing(viewing_id: str, confirmed_slot: str) -> None:
|
| 324 |
+
"""
|
| 325 |
+
Landlord confirmed the slot. Finalise the viewing and notify both parties.
|
| 326 |
+
"""
|
| 327 |
+
db = await get_db()
|
| 328 |
+
viewing = await db["viewings"].find_one({"_id": ObjectId(viewing_id)})
|
| 329 |
+
if not viewing:
|
| 330 |
+
return
|
| 331 |
+
|
| 332 |
+
visitor_id = viewing["visitor_id"]
|
| 333 |
+
landlord_id = viewing["landlord_id"]
|
| 334 |
+
listing = await db["listings"].find_one({"_id": ObjectId(viewing["listing_id"])})
|
| 335 |
+
property_name = (listing or {}).get("title", "the property")
|
| 336 |
+
|
| 337 |
+
# Finalise
|
| 338 |
+
await db["viewings"].update_one(
|
| 339 |
+
{"_id": ObjectId(viewing_id)},
|
| 340 |
+
{"$set": {
|
| 341 |
+
"status": ViewingStatus.CONFIRMED.value,
|
| 342 |
+
"confirmed_slot": confirmed_slot,
|
| 343 |
+
"updated_at": datetime.now(timezone.utc),
|
| 344 |
+
}},
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
visitor = await db["users"].find_one({"_id": ObjectId(visitor_id)})
|
| 348 |
+
landlord = await db["users"].find_one({"_id": ObjectId(landlord_id)})
|
| 349 |
+
|
| 350 |
+
# Confirmation DMs — each in the recipient's preferred language
|
| 351 |
+
visitor_msg = await generate_localized_response(
|
| 352 |
+
context=(
|
| 353 |
+
f"Tell the visitor their viewing of '{property_name}' is confirmed for "
|
| 354 |
+
f"{confirmed_slot}. Let them know you'll remind them a few hours before. "
|
| 355 |
+
"Sound warm and reassuring. Include a 🏠 emoji."
|
| 356 |
+
),
|
| 357 |
+
language=_lang(visitor),
|
| 358 |
+
tone="friendly",
|
| 359 |
+
max_length="short",
|
| 360 |
+
)
|
| 361 |
+
landlord_msg = await generate_localized_response(
|
| 362 |
+
context=(
|
| 363 |
+
f"Tell the landlord/agent the viewing of '{property_name}' is confirmed — "
|
| 364 |
+
f"a visitor will come on {confirmed_slot}. Let them know you'll send a reminder "
|
| 365 |
+
"closer to the time. Sound professional and warm. Include a 🏠 emoji."
|
| 366 |
+
),
|
| 367 |
+
language=_lang(landlord),
|
| 368 |
+
tone="friendly",
|
| 369 |
+
max_length="short",
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
await _send_aida_dm(visitor_id, visitor_msg)
|
| 373 |
+
await _send_aida_dm(landlord_id, landlord_msg)
|
| 374 |
+
|
| 375 |
+
logger.info(f"Viewing {viewing_id} confirmed for slot: {confirmed_slot}")
|
main.py
CHANGED
|
@@ -91,8 +91,17 @@ except ImportError as e:
|
|
| 91 |
|
| 92 |
from app.models.listing import ensure_listing_indexes
|
| 93 |
from app.models.booking import ensure_booking_indexes
|
|
|
|
| 94 |
from app.jobs.payout_jobs import process_escrow_payouts
|
| 95 |
-
from app.jobs.stay_notification_jobs import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
# ENVIRONMENT
|
| 98 |
environment = os.getenv("ENVIRONMENT", "development")
|
|
@@ -116,6 +125,7 @@ async def lifespan(app: FastAPI):
|
|
| 116 |
await ensure_auth_indexes()
|
| 117 |
await ensure_listing_indexes()
|
| 118 |
await ensure_booking_indexes()
|
|
|
|
| 119 |
await ensure_review_indexes()
|
| 120 |
|
| 121 |
# Import and create chat indexes
|
|
@@ -190,8 +200,12 @@ async def lifespan(app: FastAPI):
|
|
| 190 |
|
| 191 |
# Initialize APScheduler for Background Jobs (Payouts & Escrow)
|
| 192 |
scheduler = AsyncIOScheduler()
|
| 193 |
-
scheduler.add_job(process_escrow_payouts,
|
| 194 |
-
scheduler.add_job(dispatch_stay_control_cards,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
# Register proactive alert scheduler
|
| 197 |
try:
|
|
|
|
| 91 |
|
| 92 |
from app.models.listing import ensure_listing_indexes
|
| 93 |
from app.models.booking import ensure_booking_indexes
|
| 94 |
+
from app.models.viewing import ensure_viewing_indexes
|
| 95 |
from app.jobs.payout_jobs import process_escrow_payouts
|
| 96 |
+
from app.jobs.stay_notification_jobs import (
|
| 97 |
+
dispatch_stay_control_cards,
|
| 98 |
+
dispatch_checkin_reminders,
|
| 99 |
+
dispatch_checkout_review_requests,
|
| 100 |
+
)
|
| 101 |
+
from app.jobs.viewing_notification_jobs import (
|
| 102 |
+
dispatch_viewing_reminders,
|
| 103 |
+
dispatch_post_viewing_followups,
|
| 104 |
+
)
|
| 105 |
|
| 106 |
# ENVIRONMENT
|
| 107 |
environment = os.getenv("ENVIRONMENT", "development")
|
|
|
|
| 125 |
await ensure_auth_indexes()
|
| 126 |
await ensure_listing_indexes()
|
| 127 |
await ensure_booking_indexes()
|
| 128 |
+
await ensure_viewing_indexes()
|
| 129 |
await ensure_review_indexes()
|
| 130 |
|
| 131 |
# Import and create chat indexes
|
|
|
|
| 200 |
|
| 201 |
# Initialize APScheduler for Background Jobs (Payouts & Escrow)
|
| 202 |
scheduler = AsyncIOScheduler()
|
| 203 |
+
scheduler.add_job(process_escrow_payouts, "interval", minutes=30)
|
| 204 |
+
scheduler.add_job(dispatch_stay_control_cards, "interval", hours=1) # check-in/checkout cards at 8 AM / 10 AM
|
| 205 |
+
scheduler.add_job(dispatch_checkin_reminders, "interval", minutes=30) # 3-hour pre-check-in reminder
|
| 206 |
+
scheduler.add_job(dispatch_checkout_review_requests, "interval", minutes=30) # post-checkout review request
|
| 207 |
+
scheduler.add_job(dispatch_viewing_reminders, "interval", minutes=30) # 3-hour pre-viewing reminder
|
| 208 |
+
scheduler.add_job(dispatch_post_viewing_followups, "interval", minutes=30) # post-viewing follow-up
|
| 209 |
|
| 210 |
# Register proactive alert scheduler
|
| 211 |
try:
|
tests/test_todays_features.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tests/test_todays_features.py
|
| 2 |
+
"""
|
| 3 |
+
Automated tests for logic implemented in today's session.
|
| 4 |
+
Covers everything that does NOT require a running DB or LLM.
|
| 5 |
+
|
| 6 |
+
Run with:
|
| 7 |
+
cd python-Backend/lojiz-backend/AIDA
|
| 8 |
+
pytest tests/test_todays_features.py -v
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
import os
|
| 13 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ============================================================
|
| 20 |
+
# 1. AGENT HUB — fast keyword routing
|
| 21 |
+
# ============================================================
|
| 22 |
+
|
| 23 |
+
from app.ai.agent.agent_hub import (
|
| 24 |
+
_fast_route,
|
| 25 |
+
AGENT_GENERAL,
|
| 26 |
+
AGENT_CONCIERGE,
|
| 27 |
+
AGENT_BROKER,
|
| 28 |
+
AGENT_MATCHER,
|
| 29 |
+
HANDOFF_MAP,
|
| 30 |
+
get_agent_display_name,
|
| 31 |
+
)
|
| 32 |
+
from app.ai.agent.state import AgentState
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _state(role: str = "renter") -> AgentState:
|
| 36 |
+
return AgentState(
|
| 37 |
+
user_id="test_user_001",
|
| 38 |
+
session_id="sess_001",
|
| 39 |
+
user_role=role,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TestFastRoute:
|
| 44 |
+
def test_roommate_keywords_route_to_matcher(self):
|
| 45 |
+
msgs = [
|
| 46 |
+
"I'm looking for a roommate",
|
| 47 |
+
"cherche colocataire",
|
| 48 |
+
"want a flatmate",
|
| 49 |
+
"I have a spare room",
|
| 50 |
+
"share my apartment with someone",
|
| 51 |
+
"find a roommate for me",
|
| 52 |
+
]
|
| 53 |
+
for msg in msgs:
|
| 54 |
+
assert _fast_route(msg, _state()) == AGENT_MATCHER, f"Failed for: {msg}"
|
| 55 |
+
|
| 56 |
+
def test_viewing_keywords_route_to_broker(self):
|
| 57 |
+
msgs = [
|
| 58 |
+
"schedule a viewing",
|
| 59 |
+
"book a viewing",
|
| 60 |
+
"schedule a visit",
|
| 61 |
+
"when can i visit the property",
|
| 62 |
+
"planifier une visite",
|
| 63 |
+
"book an appointment",
|
| 64 |
+
]
|
| 65 |
+
for msg in msgs:
|
| 66 |
+
assert _fast_route(msg, _state()) == AGENT_BROKER, f"Failed for: {msg}"
|
| 67 |
+
|
| 68 |
+
def test_booking_keywords_route_to_concierge(self):
|
| 69 |
+
msgs = [
|
| 70 |
+
"book this place",
|
| 71 |
+
"i want to book",
|
| 72 |
+
"confirm booking",
|
| 73 |
+
"check-in next friday",
|
| 74 |
+
"réserver cet appartement",
|
| 75 |
+
"initiate booking",
|
| 76 |
+
]
|
| 77 |
+
for msg in msgs:
|
| 78 |
+
assert _fast_route(msg, _state()) == AGENT_CONCIERGE, f"Failed for: {msg}"
|
| 79 |
+
|
| 80 |
+
def test_listing_by_landlord_routes_to_general(self):
|
| 81 |
+
msgs = [
|
| 82 |
+
"list my property",
|
| 83 |
+
"list my apartment",
|
| 84 |
+
"i want to list",
|
| 85 |
+
"publish my property",
|
| 86 |
+
]
|
| 87 |
+
for msg in msgs:
|
| 88 |
+
result = _fast_route(msg, _state(role="landlord"))
|
| 89 |
+
assert result == AGENT_GENERAL, f"Landlord listing failed for: {msg}"
|
| 90 |
+
|
| 91 |
+
def test_listing_by_renter_routes_to_matcher(self):
|
| 92 |
+
"""Renters can only list roommate spaces — redirect to matcher."""
|
| 93 |
+
msgs = [
|
| 94 |
+
"list my property",
|
| 95 |
+
"i want to list",
|
| 96 |
+
]
|
| 97 |
+
for msg in msgs:
|
| 98 |
+
result = _fast_route(msg, _state(role="renter"))
|
| 99 |
+
assert result == AGENT_MATCHER, f"Renter listing should go to matcher: {msg}"
|
| 100 |
+
|
| 101 |
+
def test_ambiguous_message_returns_none(self):
|
| 102 |
+
msgs = [
|
| 103 |
+
"hello",
|
| 104 |
+
"what are the prices in Lagos?",
|
| 105 |
+
"tell me about this property",
|
| 106 |
+
"how does lojiz work?",
|
| 107 |
+
]
|
| 108 |
+
for msg in msgs:
|
| 109 |
+
assert _fast_route(msg, _state()) is None, f"Should be None for ambiguous: {msg}"
|
| 110 |
+
|
| 111 |
+
def test_roommate_takes_priority_over_booking(self):
|
| 112 |
+
"""Roommate is highest-confidence — checked before booking."""
|
| 113 |
+
msg = "I want to book a roommate place"
|
| 114 |
+
assert _fast_route(msg, _state()) == AGENT_MATCHER
|
| 115 |
+
|
| 116 |
+
def test_case_insensitive(self):
|
| 117 |
+
assert _fast_route("SCHEDULE A VIEWING", _state()) == AGENT_BROKER
|
| 118 |
+
assert _fast_route("Book This", _state()) == AGENT_CONCIERGE
|
| 119 |
+
assert _fast_route("ROOMMATE needed", _state()) == AGENT_MATCHER
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ============================================================
|
| 123 |
+
# 2. HANDOFF MAP completeness
|
| 124 |
+
# ============================================================
|
| 125 |
+
|
| 126 |
+
class TestHandoffMap:
|
| 127 |
+
def test_all_handoff_signals_are_mapped(self):
|
| 128 |
+
required = {"HANDOFF_CONCIERGE", "HANDOFF_BROKER", "HANDOFF_MATCHER", "HANDOFF_GENERAL"}
|
| 129 |
+
assert required == set(HANDOFF_MAP.keys())
|
| 130 |
+
|
| 131 |
+
def test_handoff_targets_are_valid_agent_ids(self):
|
| 132 |
+
valid = {AGENT_GENERAL, AGENT_CONCIERGE, AGENT_BROKER, AGENT_MATCHER}
|
| 133 |
+
for signal, target in HANDOFF_MAP.items():
|
| 134 |
+
assert target in valid, f"Bad target {target} for {signal}"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ============================================================
|
| 138 |
+
# 3. AGENT DISPLAY NAMES
|
| 139 |
+
# ============================================================
|
| 140 |
+
|
| 141 |
+
class TestGetAgentDisplayName:
|
| 142 |
+
def test_known_agents(self):
|
| 143 |
+
assert get_agent_display_name(AGENT_GENERAL) == "AIDA"
|
| 144 |
+
assert get_agent_display_name(AGENT_CONCIERGE) == "AIDA-Primary (Concierge)"
|
| 145 |
+
assert get_agent_display_name(AGENT_BROKER) == "AIDA-Market (Broker)"
|
| 146 |
+
assert get_agent_display_name(AGENT_MATCHER) == "AIDA-Social (Matcher)"
|
| 147 |
+
|
| 148 |
+
def test_unknown_agent_falls_back(self):
|
| 149 |
+
assert get_agent_display_name("unknown_agent") == "AIDA"
|
| 150 |
+
assert get_agent_display_name("") == "AIDA"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ============================================================
|
| 154 |
+
# 4. BROKER BRAIN — viewing step inference
|
| 155 |
+
# ============================================================
|
| 156 |
+
|
| 157 |
+
from app.ai.agent.broker_brain import _infer_viewing_step
|
| 158 |
+
|
| 159 |
+
VISITOR_ID = "visitor_123"
|
| 160 |
+
LANDLORD_ID = "landlord_456"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _viewing(
|
| 164 |
+
status="pending",
|
| 165 |
+
visitor_avail=None,
|
| 166 |
+
landlord_avail=None,
|
| 167 |
+
suggested=None,
|
| 168 |
+
confirmed_slot=None,
|
| 169 |
+
):
|
| 170 |
+
return {
|
| 171 |
+
"visitor_id": VISITOR_ID,
|
| 172 |
+
"landlord_id": LANDLORD_ID,
|
| 173 |
+
"status": status,
|
| 174 |
+
"visitor_availability": visitor_avail or [],
|
| 175 |
+
"landlord_availability": landlord_avail or [],
|
| 176 |
+
"suggested_slots": suggested or [],
|
| 177 |
+
"confirmed_slot": confirmed_slot,
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class TestInferViewingStep:
|
| 182 |
+
def test_confirmed_viewing(self):
|
| 183 |
+
v = _viewing(status="confirmed")
|
| 184 |
+
assert _infer_viewing_step(v, VISITOR_ID) == "viewing_confirmed"
|
| 185 |
+
assert _infer_viewing_step(v, LANDLORD_ID) == "viewing_confirmed"
|
| 186 |
+
|
| 187 |
+
def test_confirmed_slot_pending_landlord(self):
|
| 188 |
+
v = _viewing(confirmed_slot="Monday 10am")
|
| 189 |
+
assert _infer_viewing_step(v, VISITOR_ID) == "landlord_confirm_slot"
|
| 190 |
+
|
| 191 |
+
def test_suggested_slots_visitor_picks(self):
|
| 192 |
+
v = _viewing(suggested=["Mon 10am", "Tue 2pm"])
|
| 193 |
+
assert _infer_viewing_step(v, VISITOR_ID) == "visitor_pick_slot"
|
| 194 |
+
|
| 195 |
+
def test_visitor_has_no_availability_yet(self):
|
| 196 |
+
v = _viewing()
|
| 197 |
+
assert _infer_viewing_step(v, VISITOR_ID) == "collect_visitor_availability"
|
| 198 |
+
|
| 199 |
+
def test_landlord_has_no_availability_yet(self):
|
| 200 |
+
v = _viewing()
|
| 201 |
+
assert _infer_viewing_step(v, LANDLORD_ID) == "collect_landlord_availability"
|
| 202 |
+
|
| 203 |
+
def test_visitor_submitted_waiting_for_landlord(self):
|
| 204 |
+
v = _viewing(visitor_avail=["Monday 9am"])
|
| 205 |
+
assert _infer_viewing_step(v, LANDLORD_ID) == "collect_landlord_availability"
|
| 206 |
+
|
| 207 |
+
def test_landlord_submitted_waiting_for_visitor(self):
|
| 208 |
+
v = _viewing(landlord_avail=["Tuesday 3pm"])
|
| 209 |
+
assert _infer_viewing_step(v, VISITOR_ID) == "collect_visitor_availability"
|
| 210 |
+
|
| 211 |
+
def test_both_submitted_ready_for_crosscheck(self):
|
| 212 |
+
v = _viewing(visitor_avail=["Mon 9am"], landlord_avail=["Mon 9am", "Tue 11am"])
|
| 213 |
+
assert _infer_viewing_step(v, VISITOR_ID) == "cross_check"
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ============================================================
|
| 217 |
+
# 5. AGENT STATE — new fields exist and default correctly
|
| 218 |
+
# ============================================================
|
| 219 |
+
|
| 220 |
+
class TestAgentStateNewFields:
|
| 221 |
+
def test_active_agent_defaults_to_none(self):
|
| 222 |
+
state = _state()
|
| 223 |
+
assert state.active_agent is None
|
| 224 |
+
|
| 225 |
+
def test_agent_context_defaults_to_empty_dict(self):
|
| 226 |
+
state = _state()
|
| 227 |
+
assert state.agent_context == {}
|
| 228 |
+
|
| 229 |
+
def test_active_agent_can_be_set(self):
|
| 230 |
+
state = _state()
|
| 231 |
+
state.active_agent = AGENT_CONCIERGE
|
| 232 |
+
assert state.active_agent == AGENT_CONCIERGE
|
| 233 |
+
|
| 234 |
+
def test_agent_context_can_be_populated(self):
|
| 235 |
+
state = _state()
|
| 236 |
+
state.agent_context["booking"] = {"_id": "abc", "status": "Pending"}
|
| 237 |
+
assert state.agent_context["booking"]["status"] == "Pending"
|
| 238 |
+
|
| 239 |
+
def test_serialization_includes_new_fields(self):
|
| 240 |
+
state = _state()
|
| 241 |
+
state.active_agent = AGENT_BROKER
|
| 242 |
+
state.agent_context["viewing"] = {"_id": "xyz"}
|
| 243 |
+
dumped = state.model_dump()
|
| 244 |
+
assert dumped["active_agent"] == AGENT_BROKER
|
| 245 |
+
assert dumped["agent_context"]["viewing"]["_id"] == "xyz"
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ============================================================
|
| 249 |
+
# 6. AGENT STATE — existing flow transitions still work
|
| 250 |
+
# ============================================================
|
| 251 |
+
|
| 252 |
+
from app.ai.agent.state import FlowState
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class TestFlowTransitions:
|
| 256 |
+
def test_idle_to_classify_intent(self):
|
| 257 |
+
state = _state()
|
| 258 |
+
ok, err = state.transition_to(FlowState.CLASSIFY_INTENT, "test")
|
| 259 |
+
assert ok and err is None
|
| 260 |
+
|
| 261 |
+
def test_listing_collect_to_validate(self):
|
| 262 |
+
state = _state()
|
| 263 |
+
state.current_flow = FlowState.LISTING_COLLECT
|
| 264 |
+
ok, err = state.transition_to(FlowState.LISTING_VALIDATE, "test")
|
| 265 |
+
assert ok
|
| 266 |
+
|
| 267 |
+
def test_invalid_transition_rejected(self):
|
| 268 |
+
state = _state()
|
| 269 |
+
state.current_flow = FlowState.SEARCH_RESULTS
|
| 270 |
+
ok, err = state.transition_to(FlowState.LISTING_PUBLISH, "test")
|
| 271 |
+
assert not ok
|
| 272 |
+
assert "Cannot transition" in err
|
| 273 |
+
|
| 274 |
+
def test_steps_taken_increments(self):
|
| 275 |
+
state = _state()
|
| 276 |
+
initial = state.steps_taken
|
| 277 |
+
state.transition_to(FlowState.CLASSIFY_INTENT, "test")
|
| 278 |
+
assert state.steps_taken == initial + 1
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ============================================================
|
| 282 |
+
# 7. CONCIERGE / MATCHER TOOL NAME SETS
|
| 283 |
+
# ============================================================
|
| 284 |
+
|
| 285 |
+
from app.ai.agent.concierge_brain import CONCIERGE_TOOL_NAMES
|
| 286 |
+
from app.ai.agent.broker_brain import BROKER_TOOL_NAMES
|
| 287 |
+
from app.ai.agent.matcher_brain import MATCHER_TOOL_NAMES
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
class TestToolNameSets:
|
| 291 |
+
def test_concierge_has_booking_tools(self):
|
| 292 |
+
required = {"initiate_booking", "confirm_booking", "check_availability", "calculate_booking_cost"}
|
| 293 |
+
assert required.issubset(CONCIERGE_TOOL_NAMES)
|
| 294 |
+
|
| 295 |
+
def test_concierge_does_not_have_viewing_tools(self):
|
| 296 |
+
assert "schedule_visit" not in CONCIERGE_TOOL_NAMES
|
| 297 |
+
assert "confirm_viewing" not in CONCIERGE_TOOL_NAMES
|
| 298 |
+
|
| 299 |
+
def test_broker_has_viewing_tools(self):
|
| 300 |
+
required = {"schedule_visit", "confirm_viewing", "check_calendar_availability"}
|
| 301 |
+
assert required.issubset(BROKER_TOOL_NAMES)
|
| 302 |
+
|
| 303 |
+
def test_broker_does_not_have_booking_tools(self):
|
| 304 |
+
assert "initiate_booking" not in BROKER_TOOL_NAMES
|
| 305 |
+
assert "confirm_booking" not in BROKER_TOOL_NAMES
|
| 306 |
+
|
| 307 |
+
def test_matcher_has_listing_tools(self):
|
| 308 |
+
required = {"update_listing", "publish_listing", "get_my_listings"}
|
| 309 |
+
assert required.issubset(MATCHER_TOOL_NAMES)
|
| 310 |
+
|
| 311 |
+
def test_all_sets_contain_respond(self):
|
| 312 |
+
assert "respond" in CONCIERGE_TOOL_NAMES
|
| 313 |
+
assert "respond" in BROKER_TOOL_NAMES
|
| 314 |
+
assert "respond" in MATCHER_TOOL_NAMES
|