destinyebuka commited on
Commit
85625af
·
1 Parent(s): 4cd6c08
Files changed (50) hide show
  1. HOW_TO_RUN.md +0 -58
  2. app/__pycache__/config.cpython-313.pyc +0 -0
  3. app/__pycache__/database.cpython-313.pyc +0 -0
  4. app/ai/__pycache__/config.cpython-313.pyc +0 -0
  5. app/ai/agent/__pycache__/graph.cpython-313.pyc +0 -0
  6. app/ai/agent/__pycache__/schemas.cpython-313.pyc +0 -0
  7. app/ai/agent/__pycache__/state.cpython-313.pyc +0 -0
  8. app/ai/agent/agent_hub.py +389 -0
  9. app/ai/agent/brain.py +110 -19
  10. app/ai/agent/broker_brain.py +347 -0
  11. app/ai/agent/concierge_brain.py +291 -0
  12. app/ai/agent/dm_brain.py +49 -2
  13. app/ai/agent/graph.py +3 -3
  14. app/ai/agent/matcher_brain.py +313 -0
  15. app/ai/agent/nodes/__pycache__/casual_chat.cpython-313.pyc +0 -0
  16. app/ai/agent/nodes/__pycache__/classify_intent.cpython-313.pyc +0 -0
  17. app/ai/agent/nodes/__pycache__/greeting.cpython-313.pyc +0 -0
  18. app/ai/agent/nodes/__pycache__/listing_collect.cpython-313.pyc +0 -0
  19. app/ai/agent/nodes/__pycache__/listing_publish.cpython-313.pyc +0 -0
  20. app/ai/agent/nodes/__pycache__/listing_validate.cpython-313.pyc +0 -0
  21. app/ai/agent/nodes/__pycache__/respond.cpython-313.pyc +0 -0
  22. app/ai/agent/nodes/__pycache__/search_query.cpython-313.pyc +0 -0
  23. app/ai/agent/nodes/listing_collect.py +2 -1
  24. app/ai/agent/nodes/listing_publish.py +1 -0
  25. app/ai/agent/planner.py +20 -3
  26. app/ai/agent/schema.py +38 -3
  27. app/ai/agent/schemas.py +2 -0
  28. app/ai/agent/state.py +9 -0
  29. app/ai/prompts/__pycache__/system_prompt.cpython-313.pyc +0 -0
  30. app/ai/prompts/system_prompt.py +72 -3
  31. app/ai/services/__pycache__/search_service.cpython-313.pyc +0 -0
  32. app/ai/tools/__pycache__/casual_chat_tool.cpython-313.pyc +0 -0
  33. app/ai/tools/__pycache__/greeting_tool.cpython-313.pyc +0 -0
  34. app/ai/tools/__pycache__/intent_detector_tool.cpython-313.pyc +0 -0
  35. app/ai/tools/__pycache__/listing_tool.cpython-313.pyc +0 -0
  36. app/jobs/stay_notification_jobs.py +227 -152
  37. app/jobs/viewing_notification_jobs.py +186 -0
  38. app/models/viewing.py +52 -0
  39. app/routes/booking.py +53 -22
  40. app/routes/websocket_chat.py +114 -1
  41. app/schemas/__pycache__/user.cpython-313.pyc +0 -0
  42. app/schemas/user.py +6 -0
  43. app/services/__pycache__/otp_service.cpython-313.pyc +0 -0
  44. app/services/aida_dm_service.py +161 -0
  45. app/services/landlord_notifications.py +22 -15
  46. app/services/proactive_service.py +56 -1
  47. app/services/translate_service.py +116 -20
  48. app/services/viewing_service.py +375 -0
  49. main.py +17 -3
  50. 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 2.5: CHECK IF RLM SHOULD BE USED (NEW!)
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.tools.search_tool import perform_search
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
- from app.ai.tools.search_tool import rigid_search
1498
- current_results = await rigid_search(
1499
- search_params.get("location"),
1500
- search_params.get("listing_type"),
1501
- search_params.get("min_price"),
1502
- search_params.get("max_price"),
1503
- search_params.get("bedrooms"),
1504
- search_params.get("bathrooms"),
 
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}' but found no exact matches. "
2618
- f"However you found {count} similar propert{'y' if count == 1 else 'ies'} that could work for them. "
2619
- f"Be warm and personal — if their message contains emotional context (honeymoon, new job, family move, etc.) "
2620
- f"acknowledge it naturally before presenting results. "
2621
- f"Sound like a friend who went out and searched on their behalf — not a robot. "
2622
- f"Do NOT say phrases like 'I am thrilled to share' or 'I found X properties matching your search'. "
2623
- f"Suggest they activate alerts to be notified when an exact match becomes available — "
2624
- f"phrase it naturally in their language, do NOT hardcode 'Notify me' in English if the language is different. Include 👇 emoji."
 
 
 
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(self, message: str, user_id: str, source: str = "dm", reply_context: Optional[dict] = None, user_role: str = "renter", app_language: Optional[str] = None) -> Any:
 
 
 
 
 
 
 
 
 
 
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.brain import agent_think
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. Brain thinks and acts (THE CORE)
71
- graph.add_node("brain", agent_think)
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": ["location", "address", "bedrooms", "bathrooms", "price", "price_type", "images"],
120
- "sale": ["location", "address", "bedrooms", "bathrooms", "price", "images"],
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. Generate and show draft preview
425
- 8. User: publish / edit / discard
 
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, time
3
  from bson import ObjectId
4
  from app.database import get_db
5
  from app.models.booking import BookingStatus
6
- from app.services.translate_service import translate_notification
 
7
 
8
  logger = logging.getLogger(__name__)
9
 
10
- async def get_or_create_aida_conversation(db, user_id: str):
11
- """Retrieve or create the persistent AIDA DM thread for a user."""
12
- conv = await db["conversations"].find_one({
13
- "participants": [user_id, "aida"],
14
- "is_aida": True
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 to run daily (e.g., via cron) or periodically.
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
- # 1. Dispatch Check-In Cards
 
 
 
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 = booking["user_id"]
63
  property_id = booking["listing_id"]
64
-
65
  listing = await db["listings"].find_one({"_id": ObjectId(property_id)})
66
  property_data = {
67
- "id": str(listing.get("_id", "")),
68
- "title": listing.get("title", "Property"),
69
  "images": listing.get("images", []),
70
- "image": listing.get("image", None)
71
  }
72
-
73
- booking["id"] = str(booking["_id"])
74
- booking["booking_id"] = str(booking["_id"])
75
-
76
- # Ensure JSON serialization friendly booking dict
77
  booking.pop("_id", None)
78
- if isinstance(booking.get("created_at"), datetime):
79
- booking["created_at"] = booking["created_at"].isoformat()
80
- if isinstance(booking.get("updated_at"), datetime):
81
- booking["updated_at"] = booking["updated_at"].isoformat()
82
  booking["check_in_date"] = check_in.isoformat()
83
-
84
- checkout_dt = booking.get("check_out_date")
85
- if isinstance(checkout_dt, datetime):
86
- booking["check_out_date"] = checkout_dt.isoformat()
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
- pref_lang = guest.get("preferredLanguage", "en") if guest else "en"
93
-
94
- raw_content = "Good morning! Today is your check-in day. When you arrive at the property, you can check in securely using the card below."
95
- translated_content = translate_notification(raw_content, pref_lang)
96
-
97
- # Inject message
98
- new_msg = {
99
- "conversation_id": conv_id,
100
- "sender_id": "aida",
101
- "content": translated_content,
102
- "is_user": False,
103
- "timestamp": datetime.utcnow(),
104
- "read": False,
105
- "metadata": {
 
 
 
106
  "stay_control_card": {
107
- "bookingData": booking,
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('_id')}: {e}")
136
 
137
- # 2. Dispatch Check-Out Cards
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 = booking["user_id"]
152
  property_id = booking["listing_id"]
153
-
154
  listing = await db["listings"].find_one({"_id": ObjectId(property_id)})
155
  property_data = {
156
- "id": str(listing.get("_id", "")),
157
- "title": listing.get("title", "Property"),
158
  "images": listing.get("images", []),
159
- "image": listing.get("image", None)
160
  }
161
-
162
- booking["id"] = str(booking["_id"])
 
 
163
  booking["booking_id"] = str(booking["_id"])
164
  booking.pop("_id", None)
165
-
166
- if isinstance(booking.get("created_at"), datetime):
167
- booking["created_at"] = booking["created_at"].isoformat()
168
- if isinstance(booking.get("updated_at"), datetime):
169
- booking["updated_at"] = booking["updated_at"].isoformat()
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
- pref_lang = guest.get("preferredLanguage", "en") if guest else "en"
181
-
182
- raw_content = "Good morning! We hope you enjoyed your stay. Today is your scheduled check-out day. Please remember to gather all your belongings and use the Check-Out button below when you leave."
183
- translated_content = translate_notification(raw_content, pref_lang)
184
-
185
- # Inject message
186
- new_msg = {
187
- "conversation_id": conv_id,
188
- "sender_id": "aida",
189
- "content": translated_content,
190
- "is_user": False,
191
- "timestamp": datetime.utcnow(),
192
- "read": False,
193
- "metadata": {
 
 
 
 
194
  "stay_control_card": {
195
- "bookingData": booking,
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('_id')}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- lines = [
53
- "🎉 New Booking Confirmed!",
54
- "",
55
- f"Hi {host_name}! You have a new confirmed booking for **{property_title}**.",
56
- "",
57
- f"📅 Check-in: {check_in_str}",
58
- "",
59
- f"📅 Check-out: {check_out_str}",
60
- "",
61
- f"👥 Guests: {guest_count}",
62
- ]
63
- if payout_str:
64
- lines += ["", f"💰 Your payout: {payout_str}"]
65
- lines += [
66
- "",
67
- f"Your guest **{guest_name}** has completed payment and their reservation is secured. "
68
- "Prepare for a wonderful hosting experience! 🏡",
69
- ]
70
- return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
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": translated_msg,
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": translated_msg,
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
- # ── Broadcast via WebSocket so the message appears instantly ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Try importing the translator. If the module isn't available due to pip errors, fallback gracefully.
7
  try:
8
  from deep_translator import GoogleTranslator
9
- has_translator = True
10
  except ImportError:
11
- logger.warning("deep-translator is not installed. Background translations will fallback to English.")
12
- has_translator = False
13
 
14
- def translate_notification(text: str, target_lang: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  """
16
- Translates a backend system notification into the user's preferred language.
17
- If the target_lang is 'en' or translation fails, returns the original text.
18
  """
19
- if not target_lang or target_lang.lower() == 'en':
20
- return text
21
-
22
- if not has_translator:
23
- return text
24
-
25
  try:
26
- # Prevent errors from empty strings
27
- if not text.strip():
28
- return text
29
-
30
- result = GoogleTranslator(source='auto', target=target_lang[:2]).translate(text)
31
- return result
 
 
 
 
 
 
 
 
 
32
  except Exception as e:
33
- logger.warning(f"Translation to {target_lang} failed: {e}. Falling back to English.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 dispatch_stay_control_cards
 
 
 
 
 
 
 
 
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, 'interval', minutes=30)
194
- scheduler.add_job(dispatch_stay_control_cards, 'interval', hours=1) # Runs every hour to check for 8 AM and 10 AM thresholds
 
 
 
 
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