Spaces:
Running
Running
Commit ·
6e36fae
1
Parent(s): c0bba0b
fyp
Browse files- app/ai/agent/brain.py +67 -24
- app/ai/agent/schemas.py +1 -0
- app/ai/prompts/system_prompt.py +37 -1
- app/ai/routes/translate.py +2 -2
- app/ai/services/agent_executor.py +15 -3
- app/ai/services/search_extractor.py +2 -2
- app/ai/services/search_intent_classifier.py +2 -2
- app/ai/services/search_responder.py +2 -2
- app/ai/services/search_strategy_selector.py +2 -2
- app/ai/services/translation_service.py +2 -2
- app/ai/services/vision_service.py +2 -2
- app/ai/tools/web_search_tool.py +134 -62
- app/services/alert_service.py +46 -9
- app/services/landlord_notifications.py +21 -5
- app/services/proactive_service.py +60 -12
- app/services/push_service.py +3 -2
- app/services/viewing_service.py +3 -2
app/ai/agent/brain.py
CHANGED
|
@@ -1099,23 +1099,10 @@ async def _execute_tool_impl(tool_name: str, params: Dict[str, Any], state: Agen
|
|
| 1099 |
if tool_name == "update_listing":
|
| 1100 |
# Update listing fields
|
| 1101 |
fields = params.get("fields", {})
|
| 1102 |
-
|
| 1103 |
-
#
|
| 1104 |
-
#
|
| 1105 |
-
#
|
| 1106 |
-
# • adds unknown fields (price_negotiable, floor_level, etc.)
|
| 1107 |
-
# Coerce/strip silently so validation never fails on these.
|
| 1108 |
-
from app.ai.agent.validators import ToolCallValidator as _TCV
|
| 1109 |
-
_allowed_keys = set(_TCV._LISTING_FIELD_TYPES.keys())
|
| 1110 |
-
for _key in list(fields.keys()):
|
| 1111 |
-
if _key not in _allowed_keys:
|
| 1112 |
-
logger.warning("Stripping unknown listing field", field=_key, value=fields[_key])
|
| 1113 |
-
del fields[_key]
|
| 1114 |
-
for _list_field in ("requirements", "amenities", "images"):
|
| 1115 |
-
_val = fields.get(_list_field)
|
| 1116 |
-
if isinstance(_val, str) and _val.strip():
|
| 1117 |
-
fields[_list_field] = [v.strip() for v in _val.split(",") if v.strip()]
|
| 1118 |
-
# ─────────────────────────────────────────────────────────────────
|
| 1119 |
|
| 1120 |
# Merge vision-extracted fields (from image analysis)
|
| 1121 |
# These come from POST /image-upload-result with vision_fields
|
|
@@ -1536,9 +1523,12 @@ async def _execute_tool_impl(tool_name: str, params: Dict[str, Any], state: Agen
|
|
| 1536 |
except Exception as e:
|
| 1537 |
logger.warning("Failed to generate title/description", error=str(e))
|
| 1538 |
|
| 1539 |
-
# Draft card only appears once
|
| 1540 |
-
#
|
| 1541 |
-
if
|
|
|
|
|
|
|
|
|
|
| 1542 |
from app.ai.agent.nodes.listing_validate import build_draft_ui_from_dict
|
| 1543 |
draft_ui = build_draft_ui_from_dict(state.listing_draft)
|
| 1544 |
draft_ui["status"] = "draft"
|
|
@@ -2895,7 +2885,7 @@ Instructions:
|
|
| 2895 |
|
| 2896 |
rows, web_results = await _asyncio.gather(
|
| 2897 |
cursor.to_list(length=15),
|
| 2898 |
-
search_real_estate_prices(loc_label),
|
| 2899 |
return_exceptions=True,
|
| 2900 |
)
|
| 2901 |
|
|
@@ -2944,11 +2934,22 @@ Instructions:
|
|
| 2944 |
|
| 2945 |
# ── Format web data ───────────────────────────────────────────
|
| 2946 |
web_section = ""
|
|
|
|
| 2947 |
if web_results:
|
| 2948 |
-
|
| 2949 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2950 |
)
|
| 2951 |
-
web_section = f"WEB SEARCH DATA (internet sources):\n{snippets}"
|
| 2952 |
|
| 2953 |
# ── Build combined narration prompt ───────────────────────────
|
| 2954 |
language_names = {
|
|
@@ -3021,6 +3022,30 @@ Instructions:
|
|
| 3021 |
)
|
| 3022 |
state.temp_data["response_text"] = price_response
|
| 3023 |
state.temp_data["action"] = "get_price_trends"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3024 |
await log_reward(state.session_id, REWARD_MARKET_INSIGHT, "price_trends", {"location": location})
|
| 3025 |
return True, "Price trends generated", rows or web_results
|
| 3026 |
|
|
@@ -3755,6 +3780,24 @@ async def agent_think(state: AgentState) -> AgentState:
|
|
| 3755 |
or decision.tool in _DATA_TOOLS
|
| 3756 |
):
|
| 3757 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3758 |
elif decision.response:
|
| 3759 |
state.temp_data["response_text"] = decision.response
|
| 3760 |
else:
|
|
|
|
| 1099 |
if tool_name == "update_listing":
|
| 1100 |
# Update listing fields
|
| 1101 |
fields = params.get("fields", {})
|
| 1102 |
+
# NOTE: field coercion (unknown-key strip + list coercion) was already
|
| 1103 |
+
# done in execute_tool's pre-validation block before we got here.
|
| 1104 |
+
# No need to repeat it — doing so would just log spurious "Stripping"
|
| 1105 |
+
# warnings on fields that were already cleaned.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1106 |
|
| 1107 |
# Merge vision-extracted fields (from image analysis)
|
| 1108 |
# These come from POST /image-upload-result with vision_fields
|
|
|
|
| 1523 |
except Exception as e:
|
| 1524 |
logger.warning("Failed to generate title/description", error=str(e))
|
| 1525 |
|
| 1526 |
+
# Draft card only appears once images AND generated title/description exist.
|
| 1527 |
+
# Showing the card with empty title/description confuses users — they see
|
| 1528 |
+
# a blank property card and can't tell if the listing is set up correctly.
|
| 1529 |
+
# Title+description are auto-generated above when has_images+has_core_data,
|
| 1530 |
+
# so this guard simply defers the card until that generation has run.
|
| 1531 |
+
if has_images and state.listing_draft.get("title") and state.listing_draft.get("description"):
|
| 1532 |
from app.ai.agent.nodes.listing_validate import build_draft_ui_from_dict
|
| 1533 |
draft_ui = build_draft_ui_from_dict(state.listing_draft)
|
| 1534 |
draft_ui["status"] = "draft"
|
|
|
|
| 2885 |
|
| 2886 |
rows, web_results = await _asyncio.gather(
|
| 2887 |
cursor.to_list(length=15),
|
| 2888 |
+
search_real_estate_prices(loc_label, lang_code=lang_code),
|
| 2889 |
return_exceptions=True,
|
| 2890 |
)
|
| 2891 |
|
|
|
|
| 2934 |
|
| 2935 |
# ── Format web data ───────────────────────────────────────────
|
| 2936 |
web_section = ""
|
| 2937 |
+
web_sources_for_ui = [] # passed to Flutter as structured attribution
|
| 2938 |
if web_results:
|
| 2939 |
+
lines = []
|
| 2940 |
+
for r in web_results:
|
| 2941 |
+
source = r.get("source") or r.get("link", "")
|
| 2942 |
+
lines.append(f"- [{source}] {r['title']}: {r['snippet']}")
|
| 2943 |
+
if r.get("link"):
|
| 2944 |
+
web_sources_for_ui.append({
|
| 2945 |
+
"title": r["title"],
|
| 2946 |
+
"source": source,
|
| 2947 |
+
"url": r["link"],
|
| 2948 |
+
})
|
| 2949 |
+
web_section = (
|
| 2950 |
+
"WEB SEARCH DATA (Google results via Serper):\n"
|
| 2951 |
+
+ "\n".join(lines)
|
| 2952 |
)
|
|
|
|
| 2953 |
|
| 2954 |
# ── Build combined narration prompt ───────────────────────────
|
| 2955 |
language_names = {
|
|
|
|
| 3022 |
)
|
| 3023 |
state.temp_data["response_text"] = price_response
|
| 3024 |
state.temp_data["action"] = "get_price_trends"
|
| 3025 |
+
|
| 3026 |
+
# ── Structured market payload for Flutter UI card ─────────────
|
| 3027 |
+
# Flutter reads state.temp_data["market_data"] and can render
|
| 3028 |
+
# a proper card with price segments + source chips instead of
|
| 3029 |
+
# just displaying the text blob.
|
| 3030 |
+
segments = []
|
| 3031 |
+
for row in (rows or []):
|
| 3032 |
+
segments.append({
|
| 3033 |
+
"location": row["_id"].get("location", loc_label),
|
| 3034 |
+
"listing_type": row["_id"].get("listing_type", "property"),
|
| 3035 |
+
"currency": row.get("currency") or "XOF",
|
| 3036 |
+
"low": round(row.get("min_price") or 0),
|
| 3037 |
+
"avg": round(row.get("avg_price") or 0),
|
| 3038 |
+
"high": round(row.get("max_price") or 0),
|
| 3039 |
+
"count": row.get("count", 0),
|
| 3040 |
+
})
|
| 3041 |
+
state.temp_data["market_data"] = {
|
| 3042 |
+
"location": loc_label,
|
| 3043 |
+
"segments": segments,
|
| 3044 |
+
"web_sources": web_sources_for_ui,
|
| 3045 |
+
"has_lojiz": has_lojiz,
|
| 3046 |
+
"has_web": has_web,
|
| 3047 |
+
}
|
| 3048 |
+
|
| 3049 |
await log_reward(state.session_id, REWARD_MARKET_INSIGHT, "price_trends", {"location": location})
|
| 3050 |
return True, "Price trends generated", rows or web_results
|
| 3051 |
|
|
|
|
| 3780 |
or decision.tool in _DATA_TOOLS
|
| 3781 |
):
|
| 3782 |
pass
|
| 3783 |
+
# ── "Lost in thought" fix ────────────────────────────────────────────────
|
| 3784 |
+
# update_listing is NEVER a terminal action in the listing flow — after a
|
| 3785 |
+
# field is saved there is always something to say next (next missing field,
|
| 3786 |
+
# or a final "you're good to publish" message).
|
| 3787 |
+
#
|
| 3788 |
+
# Problem: DeepSeek often returns is_final=True for update_listing calls
|
| 3789 |
+
# and includes a brief inline response like "Got it, noted ✅". When
|
| 3790 |
+
# decision.response is used directly, the user sees a confirmation with no
|
| 3791 |
+
# follow-up question and the conversation silently stalls — they have to
|
| 3792 |
+
# send another message to "wake" AIDA up.
|
| 3793 |
+
#
|
| 3794 |
+
# Fix: when update_listing ran and no draft UI was produced yet (i.e. we're
|
| 3795 |
+
# still mid-collection), ALWAYS route through generate_contextual_response
|
| 3796 |
+
# which knows to ask the next missing field. Only skip this when a full
|
| 3797 |
+
# draft card is already present (that path sets its own response_text above).
|
| 3798 |
+
elif decision.tool == "update_listing" and not state.temp_data.get("draft_ui"):
|
| 3799 |
+
state.temp_data["response_text"] = await generate_contextual_response(state, decision)
|
| 3800 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 3801 |
elif decision.response:
|
| 3802 |
state.temp_data["response_text"] = decision.response
|
| 3803 |
else:
|
app/ai/agent/schemas.py
CHANGED
|
@@ -177,6 +177,7 @@ class AgentResponse(BaseModel):
|
|
| 177 |
alert_results: Optional[List[Dict[str, Any]]] = None # For user's search alerts
|
| 178 |
alert_title: Optional[str] = None # Title for alert results display
|
| 179 |
comparison_data: Optional[List[Dict[str, Any]]] = None # Smart concierge property comparison
|
|
|
|
| 180 |
tool_result: Optional[ToolResult] = None
|
| 181 |
error: Optional[str] = None
|
| 182 |
# Intelligent reply context
|
|
|
|
| 177 |
alert_results: Optional[List[Dict[str, Any]]] = None # For user's search alerts
|
| 178 |
alert_title: Optional[str] = None # Title for alert results display
|
| 179 |
comparison_data: Optional[List[Dict[str, Any]]] = None # Smart concierge property comparison
|
| 180 |
+
market_data: Optional[Dict[str, Any]] = None # Structured market analysis payload for Flutter card
|
| 181 |
tool_result: Optional[ToolResult] = None
|
| 182 |
error: Optional[str] = None
|
| 183 |
# Intelligent reply context
|
app/ai/prompts/system_prompt.py
CHANGED
|
@@ -97,12 +97,48 @@ NO MIXING LANGUAGES - respond entirely in user's language
|
|
| 97 |
|
| 98 |
LANGUAGE SIGNAL (MANDATORY):
|
| 99 |
Every time you write a response or call a tool, start your text with [LANG:xx] where xx is
|
| 100 |
-
the ISO 639-1 code of the language the user is
|
| 101 |
Examples: "[LANG:fr] Je vais publier votre annonce..." | "[LANG:ar] سأنشر إعلانك..."
|
| 102 |
This tag is stripped before display — the user never sees it. It is required for correct
|
| 103 |
language routing of success/error messages. Even for single-word inputs like "publier" or
|
| 104 |
"publicar", you can tell the language — always output the tag.
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
========== YOUR PRIMARY ROLE ==========
|
| 107 |
Your role: {role_upper}
|
| 108 |
|
|
|
|
| 97 |
|
| 98 |
LANGUAGE SIGNAL (MANDATORY):
|
| 99 |
Every time you write a response or call a tool, start your text with [LANG:xx] where xx is
|
| 100 |
+
the ISO 639-1 code of the language the user is WRITING IN right now (en, fr, es, ar, pt, etc.).
|
| 101 |
Examples: "[LANG:fr] Je vais publier votre annonce..." | "[LANG:ar] سأنشر إعلانك..."
|
| 102 |
This tag is stripped before display — the user never sees it. It is required for correct
|
| 103 |
language routing of success/error messages. Even for single-word inputs like "publier" or
|
| 104 |
"publicar", you can tell the language — always output the tag.
|
| 105 |
|
| 106 |
+
========== CONVERSATION LANGUAGE vs LISTING CONTENT LANGUAGE ==========
|
| 107 |
+
These are TWO COMPLETELY SEPARATE things. Never confuse them.
|
| 108 |
+
|
| 109 |
+
1. CONVERSATION LANGUAGE — the language you and the user are TALKING in right now.
|
| 110 |
+
- Determined by the language the USER IS WRITING IN, not what they're asking for.
|
| 111 |
+
- Your replies to the user are ALWAYS in this language.
|
| 112 |
+
- [LANG:xx] tag always reflects this language.
|
| 113 |
+
- It NEVER changes just because the user wants their listing in a different language.
|
| 114 |
+
|
| 115 |
+
2. LISTING CONTENT LANGUAGE — the language of the listing title, description, and amenities.
|
| 116 |
+
- Only changes when the user explicitly says "put the listing in X" / "generate in X"
|
| 117 |
+
/ "mets l'annonce en anglais" / "haz el anuncio en español", etc.
|
| 118 |
+
- You set this via requested_language param in update_listing.
|
| 119 |
+
- Has ZERO effect on your conversational replies.
|
| 120 |
+
|
| 121 |
+
MANDATORY EXAMPLES — you MUST follow this pattern exactly:
|
| 122 |
+
|
| 123 |
+
Scenario A: User writes in French, asks to generate listing in Portuguese
|
| 124 |
+
User: "génère le listing en portugais"
|
| 125 |
+
→ call update_listing with requested_language="pt", regen_draft=true
|
| 126 |
+
→ your reply: [LANG:fr] "Parfait ! Je régénère votre annonce en portugais. ✅"
|
| 127 |
+
→ [LANG:fr] because the USER IS WRITING IN FRENCH — that never changed.
|
| 128 |
+
|
| 129 |
+
Scenario B: User writes in French, asks to generate listing in English
|
| 130 |
+
User: "mets l'annonce en anglais"
|
| 131 |
+
→ call update_listing with requested_language="en", regen_draft=true
|
| 132 |
+
→ your reply: [LANG:fr] "D'accord ! J'ai régénéré l'annonce en anglais."
|
| 133 |
+
|
| 134 |
+
Scenario C: User switches from French to English mid-conversation
|
| 135 |
+
User: "ok make the listing in spanish please"
|
| 136 |
+
→ The user is now writing in ENGLISH → [LANG:en]
|
| 137 |
+
→ call update_listing with requested_language="es"
|
| 138 |
+
→ your reply: [LANG:en] "Done! I've set your listing to Spanish."
|
| 139 |
+
|
| 140 |
+
RULE: [LANG:xx] = the language the user just typed in. requested_language = the listing content language. They can be different.
|
| 141 |
+
|
| 142 |
========== YOUR PRIMARY ROLE ==========
|
| 143 |
Your role: {role_upper}
|
| 144 |
|
app/ai/routes/translate.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
# app/ai/routes/translate.py
|
| 2 |
-
import
|
| 3 |
from fastapi import APIRouter, HTTPException
|
| 4 |
from pydantic import BaseModel, Field
|
| 5 |
from typing import Dict, Any
|
|
@@ -7,7 +7,7 @@ from typing import Dict, Any
|
|
| 7 |
from app.ai.services.translation_service import translate_listing
|
| 8 |
|
| 9 |
router = APIRouter(prefix="/translate", tags=["AIDA Translate"])
|
| 10 |
-
logger =
|
| 11 |
|
| 12 |
class TranslateListingRequest(BaseModel):
|
| 13 |
listing_data: Dict[str, Any]
|
|
|
|
| 1 |
# app/ai/routes/translate.py
|
| 2 |
+
import structlog
|
| 3 |
from fastapi import APIRouter, HTTPException
|
| 4 |
from pydantic import BaseModel, Field
|
| 5 |
from typing import Dict, Any
|
|
|
|
| 7 |
from app.ai.services.translation_service import translate_listing
|
| 8 |
|
| 9 |
router = APIRouter(prefix="/translate", tags=["AIDA Translate"])
|
| 10 |
+
logger = structlog.get_logger(__name__)
|
| 11 |
|
| 12 |
class TranslateListingRequest(BaseModel):
|
| 13 |
listing_data: Dict[str, Any]
|
app/ai/services/agent_executor.py
CHANGED
|
@@ -19,17 +19,18 @@ Usage:
|
|
| 19 |
import asyncio
|
| 20 |
import json
|
| 21 |
import hashlib
|
| 22 |
-
import logging
|
| 23 |
import re
|
| 24 |
from uuid import uuid4
|
| 25 |
from typing import Optional, Dict, Any, List
|
| 26 |
from dataclasses import dataclass
|
| 27 |
|
|
|
|
|
|
|
| 28 |
from app.ai.agent.graph import get_aida_graph
|
| 29 |
from app.ai.agent.schemas import AgentResponse
|
| 30 |
from app.ai.config import redis_client
|
| 31 |
|
| 32 |
-
logger =
|
| 33 |
|
| 34 |
# Concurrency Control: Max 5 concurrent AI requests
|
| 35 |
_ai_semaphore = asyncio.Semaphore(5)
|
|
@@ -384,7 +385,16 @@ class AgentExecutor:
|
|
| 384 |
"""Build AgentResponse from an AgentState object (not a dict)."""
|
| 385 |
temp_data = state.temp_data or {}
|
| 386 |
|
| 387 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
|
| 389 |
current_flow = state.current_flow
|
| 390 |
flow_str = current_flow.value if hasattr(current_flow, "value") else str(current_flow)
|
|
@@ -420,6 +430,7 @@ class AgentExecutor:
|
|
| 420 |
draft_ui=temp_data.get("draft_ui"),
|
| 421 |
search_results=temp_data.get("search_results"),
|
| 422 |
my_listings=temp_data.get("my_listings"),
|
|
|
|
| 423 |
error=state.last_error,
|
| 424 |
reply_to_id=reply_to_id,
|
| 425 |
replied_to_content=reply_to_context["content"] if reply_to_context else None,
|
|
@@ -489,6 +500,7 @@ class AgentExecutor:
|
|
| 489 |
draft_ui=temp_data.get("draft_ui"),
|
| 490 |
search_results=temp_data.get("search_results"),
|
| 491 |
my_listings=temp_data.get("my_listings"),
|
|
|
|
| 492 |
error=state.get("last_error"),
|
| 493 |
reply_to_id=reply_to_id,
|
| 494 |
replied_to_content=reply_to_context["content"] if reply_to_context else None,
|
|
|
|
| 19 |
import asyncio
|
| 20 |
import json
|
| 21 |
import hashlib
|
|
|
|
| 22 |
import re
|
| 23 |
from uuid import uuid4
|
| 24 |
from typing import Optional, Dict, Any, List
|
| 25 |
from dataclasses import dataclass
|
| 26 |
|
| 27 |
+
import structlog
|
| 28 |
+
|
| 29 |
from app.ai.agent.graph import get_aida_graph
|
| 30 |
from app.ai.agent.schemas import AgentResponse
|
| 31 |
from app.ai.config import redis_client
|
| 32 |
|
| 33 |
+
logger = structlog.get_logger(__name__)
|
| 34 |
|
| 35 |
# Concurrency Control: Max 5 concurrent AI requests
|
| 36 |
_ai_semaphore = asyncio.Semaphore(5)
|
|
|
|
| 385 |
"""Build AgentResponse from an AgentState object (not a dict)."""
|
| 386 |
temp_data = state.temp_data or {}
|
| 387 |
|
| 388 |
+
raw_response_text = temp_data.get("response_text")
|
| 389 |
+
if not raw_response_text:
|
| 390 |
+
logger.warning(
|
| 391 |
+
"Brain returned no response_text — using generic fallback",
|
| 392 |
+
user_id=input.user_id,
|
| 393 |
+
session_id=input.session_id,
|
| 394 |
+
flow=str(getattr(state, "current_flow", None)),
|
| 395 |
+
action=temp_data.get("action"),
|
| 396 |
+
)
|
| 397 |
+
response_text = raw_response_text or "I'm here to help! What would you like to do?"
|
| 398 |
|
| 399 |
current_flow = state.current_flow
|
| 400 |
flow_str = current_flow.value if hasattr(current_flow, "value") else str(current_flow)
|
|
|
|
| 430 |
draft_ui=temp_data.get("draft_ui"),
|
| 431 |
search_results=temp_data.get("search_results"),
|
| 432 |
my_listings=temp_data.get("my_listings"),
|
| 433 |
+
market_data=temp_data.get("market_data"),
|
| 434 |
error=state.last_error,
|
| 435 |
reply_to_id=reply_to_id,
|
| 436 |
replied_to_content=reply_to_context["content"] if reply_to_context else None,
|
|
|
|
| 500 |
draft_ui=temp_data.get("draft_ui"),
|
| 501 |
search_results=temp_data.get("search_results"),
|
| 502 |
my_listings=temp_data.get("my_listings"),
|
| 503 |
+
market_data=temp_data.get("market_data"),
|
| 504 |
error=state.get("last_error"),
|
| 505 |
reply_to_id=reply_to_id,
|
| 506 |
replied_to_content=reply_to_context["content"] if reply_to_context else None,
|
app/ai/services/search_extractor.py
CHANGED
|
@@ -4,12 +4,12 @@ Search Parameter Extractor - Shared service for extracting search criteria from
|
|
| 4 |
Used by both the REST API and the AI Agent.
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
import
|
| 8 |
from datetime import date
|
| 9 |
from app.core.mimo_client import get_mimo_client
|
| 10 |
from app.ai.agent.validators import JSONValidator
|
| 11 |
|
| 12 |
-
logger =
|
| 13 |
|
| 14 |
_mimo = get_mimo_client()
|
| 15 |
|
|
|
|
| 4 |
Used by both the REST API and the AI Agent.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
import structlog
|
| 8 |
from datetime import date
|
| 9 |
from app.core.mimo_client import get_mimo_client
|
| 10 |
from app.ai.agent.validators import JSONValidator
|
| 11 |
|
| 12 |
+
logger = structlog.get_logger(__name__)
|
| 13 |
|
| 14 |
_mimo = get_mimo_client()
|
| 15 |
|
app/ai/services/search_intent_classifier.py
CHANGED
|
@@ -5,12 +5,12 @@ Search Intent Classifier - Detects if user input is a property search query or c
|
|
| 5 |
This prevents the search endpoint from trying to search for greetings, thank yous, or general questions.
|
| 6 |
"""
|
| 7 |
|
| 8 |
-
import
|
| 9 |
from typing import Dict
|
| 10 |
from app.core.mimo_client import get_mimo_client
|
| 11 |
from app.ai.agent.validators import JSONValidator
|
| 12 |
|
| 13 |
-
logger =
|
| 14 |
|
| 15 |
_mimo = get_mimo_client()
|
| 16 |
|
|
|
|
| 5 |
This prevents the search endpoint from trying to search for greetings, thank yous, or general questions.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
import structlog
|
| 9 |
from typing import Dict
|
| 10 |
from app.core.mimo_client import get_mimo_client
|
| 11 |
from app.ai.agent.validators import JSONValidator
|
| 12 |
|
| 13 |
+
logger = structlog.get_logger(__name__)
|
| 14 |
|
| 15 |
_mimo = get_mimo_client()
|
| 16 |
|
app/ai/services/search_responder.py
CHANGED
|
@@ -4,11 +4,11 @@ Search Responder - Shared service for generating natural, multilingual, and enth
|
|
| 4 |
Used by both the REST API and the AI Agent.
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
import
|
| 8 |
import random
|
| 9 |
from app.core.mimo_client import get_mimo_client
|
| 10 |
|
| 11 |
-
logger =
|
| 12 |
|
| 13 |
_mimo = get_mimo_client()
|
| 14 |
|
|
|
|
| 4 |
Used by both the REST API and the AI Agent.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
import structlog
|
| 8 |
import random
|
| 9 |
from app.core.mimo_client import get_mimo_client
|
| 10 |
|
| 11 |
+
logger = structlog.get_logger(__name__)
|
| 12 |
|
| 13 |
_mimo = get_mimo_client()
|
| 14 |
|
app/ai/services/search_strategy_selector.py
CHANGED
|
@@ -16,13 +16,13 @@ RLM Strategies (Recursive Language Model):
|
|
| 16 |
- RLM_MULTI_FACTOR: "best family apartment" - multi-criteria ranking
|
| 17 |
"""
|
| 18 |
|
| 19 |
-
import
|
| 20 |
from typing import Dict, Literal
|
| 21 |
from enum import Enum
|
| 22 |
from app.core.mimo_client import get_mimo_client
|
| 23 |
from app.ai.agent.validators import JSONValidator
|
| 24 |
|
| 25 |
-
logger =
|
| 26 |
|
| 27 |
|
| 28 |
class SearchStrategy(str, Enum):
|
|
|
|
| 16 |
- RLM_MULTI_FACTOR: "best family apartment" - multi-criteria ranking
|
| 17 |
"""
|
| 18 |
|
| 19 |
+
import structlog
|
| 20 |
from typing import Dict, Literal
|
| 21 |
from enum import Enum
|
| 22 |
from app.core.mimo_client import get_mimo_client
|
| 23 |
from app.ai.agent.validators import JSONValidator
|
| 24 |
|
| 25 |
+
logger = structlog.get_logger(__name__)
|
| 26 |
|
| 27 |
|
| 28 |
class SearchStrategy(str, Enum):
|
app/ai/services/translation_service.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
# app/ai/services/translation_service.py
|
| 2 |
-
import
|
| 3 |
import json
|
| 4 |
from typing import Dict, Any, List
|
| 5 |
from app.core.mimo_client import get_mimo_client
|
| 6 |
|
| 7 |
-
logger =
|
| 8 |
|
| 9 |
_mimo = get_mimo_client()
|
| 10 |
|
|
|
|
| 1 |
# app/ai/services/translation_service.py
|
| 2 |
+
import structlog
|
| 3 |
import json
|
| 4 |
from typing import Dict, Any, List
|
| 5 |
from app.core.mimo_client import get_mimo_client
|
| 6 |
|
| 7 |
+
logger = structlog.get_logger(__name__)
|
| 8 |
|
| 9 |
_mimo = get_mimo_client()
|
| 10 |
|
app/ai/services/vision_service.py
CHANGED
|
@@ -18,7 +18,7 @@ import io
|
|
| 18 |
import os
|
| 19 |
import tempfile
|
| 20 |
import uuid
|
| 21 |
-
import
|
| 22 |
from datetime import datetime
|
| 23 |
from typing import Dict, Any, List, Optional, Tuple
|
| 24 |
|
|
@@ -31,7 +31,7 @@ from botocore.config import Config
|
|
| 31 |
from app.config import settings
|
| 32 |
from app.core.mimo_client import get_mimo_client
|
| 33 |
|
| 34 |
-
logger =
|
| 35 |
|
| 36 |
# ============================================================
|
| 37 |
# CONSTANTS
|
|
|
|
| 18 |
import os
|
| 19 |
import tempfile
|
| 20 |
import uuid
|
| 21 |
+
import structlog
|
| 22 |
from datetime import datetime
|
| 23 |
from typing import Dict, Any, List, Optional, Tuple
|
| 24 |
|
|
|
|
| 31 |
from app.config import settings
|
| 32 |
from app.core.mimo_client import get_mimo_client
|
| 33 |
|
| 34 |
+
logger = structlog.get_logger(__name__)
|
| 35 |
|
| 36 |
# ============================================================
|
| 37 |
# CONSTANTS
|
app/ai/tools/web_search_tool.py
CHANGED
|
@@ -1,86 +1,158 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
-
import
|
| 6 |
-
import
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 11 |
-
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 12 |
-
"Chrome/124.0.0.0 Safari/537.36"
|
| 13 |
-
),
|
| 14 |
-
"Accept-Language": "en-US,en;q=0.9",
|
| 15 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 16 |
-
}
|
| 17 |
|
| 18 |
-
|
| 19 |
-
_WS_RE = re.compile(r"\s+")
|
| 20 |
-
_ENT_RE = re.compile(r"&[a-z]+;|&#\d+;")
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
# Snippet: <a class="result__snippet">text</a>
|
| 25 |
-
_TITLE_RE = re.compile(r'class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)</a>', re.DOTALL)
|
| 26 |
-
_SNIPPET_RE = re.compile(r'class="result__snippet"[^>]*>(.*?)</a>', re.DOTALL)
|
| 27 |
-
_BLOCK_RE = re.compile(r'<div[^>]+class="[^"]*result[^"]*"[^>]*>(.*?)</div>\s*</div>', re.DOTALL)
|
| 28 |
|
| 29 |
|
| 30 |
-
def
|
| 31 |
-
|
| 32 |
-
text = _ENT_RE.sub(" ", text)
|
| 33 |
-
return _WS_RE.sub(" ", text).strip()
|
| 34 |
|
| 35 |
|
| 36 |
async def web_search(query: str, max_results: int = 6) -> list[dict]:
|
| 37 |
"""
|
| 38 |
-
Search
|
| 39 |
-
|
|
|
|
| 40 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
try:
|
| 42 |
-
async with httpx.AsyncClient(
|
| 43 |
-
headers=_HEADERS, follow_redirects=True, timeout=14.0
|
| 44 |
-
) as client:
|
| 45 |
resp = await client.post(
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
)
|
| 49 |
resp.raise_for_status()
|
| 50 |
-
|
| 51 |
|
|
|
|
| 52 |
results = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
snippet = _clean(s.group(1))
|
| 62 |
-
if title and snippet:
|
| 63 |
-
results.append({"title": title, "snippet": snippet})
|
| 64 |
-
if len(results) >= max_results:
|
| 65 |
-
break
|
| 66 |
-
|
| 67 |
-
# Strategy 2 (fallback): match titles and snippets globally in order
|
| 68 |
-
if not results:
|
| 69 |
-
titles = [_clean(m.group(2)) for m in _TITLE_RE.finditer(html)]
|
| 70 |
-
snippets = [_clean(m.group(1)) for m in _SNIPPET_RE.finditer(html)]
|
| 71 |
-
for title, snippet in zip(titles, snippets):
|
| 72 |
-
if title and snippet:
|
| 73 |
-
results.append({"title": title, "snippet": snippet})
|
| 74 |
-
if len(results) >= max_results:
|
| 75 |
-
break
|
| 76 |
|
| 77 |
-
return results
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
except Exception:
|
| 80 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
return await web_search(query, max_results=6)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Web search via Serper API (Google Search for LLM agents).
|
| 3 |
+
Replaces the old DuckDuckGo HTML scraper which was fragile and often
|
| 4 |
+
returned empty results because DDG blocks scrapers.
|
| 5 |
+
|
| 6 |
+
Setup:
|
| 7 |
+
- Sign up at https://serper.dev (free tier: 2 500 searches/month)
|
| 8 |
+
- Set SERPER_API_KEY in your .env file
|
| 9 |
+
|
| 10 |
+
The public interface is unchanged so brain.py needs no edits:
|
| 11 |
+
- web_search(query, max_results) → list[dict]
|
| 12 |
+
- search_real_estate_prices(location, lang_code) → list[dict]
|
| 13 |
+
|
| 14 |
+
Each result dict now carries: title, snippet, link, source
|
| 15 |
+
(link + source are new — brain.py ignores unknown keys, so no breakage)
|
| 16 |
"""
|
| 17 |
+
import asyncio
|
| 18 |
+
import os
|
| 19 |
+
from typing import Optional
|
| 20 |
|
| 21 |
+
import httpx
|
| 22 |
+
from structlog import get_logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
logger = get_logger(__name__)
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
_SERPER_ENDPOINT = "https://google.serper.dev/search"
|
| 27 |
+
_TIMEOUT = 10.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
+
def _api_key() -> Optional[str]:
|
| 31 |
+
return os.getenv("SERPER_API_KEY", "")
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
async def web_search(query: str, max_results: int = 6) -> list[dict]:
|
| 35 |
"""
|
| 36 |
+
Search Google via Serper and return up to ``max_results`` dicts.
|
| 37 |
+
Each dict: {title, snippet, link, source}
|
| 38 |
+
Returns [] on any failure (caller treats empty as no web data).
|
| 39 |
"""
|
| 40 |
+
key = _api_key()
|
| 41 |
+
if not key:
|
| 42 |
+
logger.warning("SERPER_API_KEY not set — web search disabled")
|
| 43 |
+
return []
|
| 44 |
+
|
| 45 |
try:
|
| 46 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
|
|
|
|
|
| 47 |
resp = await client.post(
|
| 48 |
+
_SERPER_ENDPOINT,
|
| 49 |
+
headers={
|
| 50 |
+
"X-API-KEY": key,
|
| 51 |
+
"Content-Type": "application/json",
|
| 52 |
+
},
|
| 53 |
+
json={
|
| 54 |
+
"q": query,
|
| 55 |
+
"num": max_results,
|
| 56 |
+
"gl": "us", # geolocation — Serper returns global results regardless
|
| 57 |
+
"hl": "en",
|
| 58 |
+
},
|
| 59 |
)
|
| 60 |
resp.raise_for_status()
|
| 61 |
+
data = resp.json()
|
| 62 |
|
| 63 |
+
organic = data.get("organic") or []
|
| 64 |
results = []
|
| 65 |
+
for item in organic[:max_results]:
|
| 66 |
+
title = (item.get("title") or "").strip()
|
| 67 |
+
snippet = (item.get("snippet") or "").strip()
|
| 68 |
+
link = (item.get("link") or "").strip()
|
| 69 |
+
source = (item.get("source") or _domain(link)).strip()
|
| 70 |
+
if title and snippet:
|
| 71 |
+
results.append({
|
| 72 |
+
"title": title,
|
| 73 |
+
"snippet": snippet,
|
| 74 |
+
"link": link,
|
| 75 |
+
"source": source,
|
| 76 |
+
})
|
| 77 |
+
logger.info("Serper search completed",
|
| 78 |
+
query=query[:60], results=len(results))
|
| 79 |
+
return results
|
| 80 |
|
| 81 |
+
except httpx.HTTPStatusError as e:
|
| 82 |
+
logger.warning("Serper API HTTP error",
|
| 83 |
+
status=e.response.status_code, query=query[:60])
|
| 84 |
+
return []
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.warning("Serper web search failed", error=str(e), query=query[:60])
|
| 87 |
+
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
|
|
|
| 89 |
|
| 90 |
+
def _domain(url: str) -> str:
|
| 91 |
+
"""Extract bare domain from a URL for readable source attribution."""
|
| 92 |
+
try:
|
| 93 |
+
from urllib.parse import urlparse
|
| 94 |
+
host = urlparse(url).netloc
|
| 95 |
+
return host.replace("www.", "") if host else ""
|
| 96 |
except Exception:
|
| 97 |
+
return ""
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ============================================================
|
| 101 |
+
# Real-estate–specific search helpers
|
| 102 |
+
# ============================================================
|
| 103 |
+
|
| 104 |
+
async def search_real_estate_prices(
|
| 105 |
+
location: str,
|
| 106 |
+
lang_code: str = "en",
|
| 107 |
+
) -> list[dict]:
|
| 108 |
+
"""
|
| 109 |
+
Run THREE parallel Serper queries targeting different angles of the
|
| 110 |
+
market and merge the deduplicated results.
|
| 111 |
+
|
| 112 |
+
Queries:
|
| 113 |
+
1. Rental + sale price ranges (with year for freshness)
|
| 114 |
+
2. Market trend direction (rising / stable / falling)
|
| 115 |
+
3. Neighbourhood breakdown (which areas are cheap / expensive)
|
| 116 |
+
|
| 117 |
+
Having three angles means the LLM gets enough data to write a real
|
| 118 |
+
market overview instead of repeating one stat three ways.
|
| 119 |
+
"""
|
| 120 |
+
year = "2025"
|
| 121 |
+
|
| 122 |
+
# Build queries — if location is non-English, also search in the
|
| 123 |
+
# local language so we pick up local portals (e.g. French sites for Cotonou)
|
| 124 |
+
_loc = location.strip()
|
| 125 |
+
|
| 126 |
+
queries = [
|
| 127 |
+
f"{_loc} rental apartment price range {year}",
|
| 128 |
+
f"{_loc} real estate market trend property prices {year}",
|
| 129 |
+
f"{_loc} neighbourhood area property prices cheapest expensive",
|
| 130 |
+
]
|
| 131 |
+
|
| 132 |
+
# For French-speaking West Africa add a French query to catch local portals
|
| 133 |
+
if lang_code in ("fr",):
|
| 134 |
+
queries.append(f"prix loyer appartement {_loc} {year}")
|
| 135 |
+
|
| 136 |
+
# Run all queries concurrently — Serper's free tier has no rate limit per second
|
| 137 |
+
all_results: list[list[dict]] = await asyncio.gather(
|
| 138 |
+
*[web_search(q, max_results=5) for q in queries],
|
| 139 |
+
return_exceptions=True,
|
| 140 |
+
)
|
| 141 |
|
| 142 |
+
# Merge + deduplicate by link (keep first occurrence)
|
| 143 |
+
seen_links: set[str] = set()
|
| 144 |
+
merged: list[dict] = []
|
| 145 |
+
for batch in all_results:
|
| 146 |
+
if isinstance(batch, Exception):
|
| 147 |
+
continue
|
| 148 |
+
for item in batch:
|
| 149 |
+
link = item.get("link", "")
|
| 150 |
+
if link and link in seen_links:
|
| 151 |
+
continue
|
| 152 |
+
if link:
|
| 153 |
+
seen_links.add(link)
|
| 154 |
+
merged.append(item)
|
| 155 |
|
| 156 |
+
logger.info("Real-estate search merged",
|
| 157 |
+
location=_loc, total_results=len(merged))
|
| 158 |
+
return merged[:15] # cap at 15 so the LLM prompt stays tight
|
|
|
app/services/alert_service.py
CHANGED
|
@@ -463,12 +463,19 @@ async def notify_user_of_match(alert: SearchAlert, listing: dict):
|
|
| 463 |
logger.info(f"Personalization: Found user name '{user_name}' for user {alert.user_id}")
|
| 464 |
|
| 465 |
# 3. Construct Message using AI (dynamic, personalized & language-aware)
|
| 466 |
-
#
|
| 467 |
-
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
# Generate message using AI for natural variety
|
| 470 |
text = await _generate_alert_message_with_ai(user_name, alert.user_query, user_language)
|
| 471 |
-
logger.info(f"Alert notification using language: {user_language}")
|
| 472 |
|
| 473 |
# Continue with sending the message
|
| 474 |
await _continue_notify_user_of_match(alert, listing, conv_id, text)
|
|
@@ -576,8 +583,18 @@ async def _continue_notify_user_of_match(alert, listing, conv_id, text):
|
|
| 576 |
try:
|
| 577 |
from app.services.voice_service import voice_service
|
| 578 |
|
| 579 |
-
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
audio_url = await voice_service.upload_audio_to_r2(audio_bytes)
|
| 582 |
|
| 583 |
voice_media = {
|
|
@@ -612,14 +629,34 @@ async def _continue_notify_user_of_match(alert, listing, conv_id, text):
|
|
| 612 |
{"$set": {"last_notified_at": datetime.utcnow()}}
|
| 613 |
)
|
| 614 |
|
| 615 |
-
# Push notification — wakes up the user even when the app is closed
|
|
|
|
| 616 |
try:
|
| 617 |
from app.services.push_service import PushService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
listing_title = listing.get("title", "a new property")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
await PushService.send_to_user(
|
| 620 |
user_id=alert.user_id,
|
| 621 |
-
title=
|
| 622 |
-
body=f"
|
| 623 |
category="property_alerts",
|
| 624 |
data={
|
| 625 |
"type": "alert_match",
|
|
|
|
| 463 |
logger.info(f"Personalization: Found user name '{user_name}' for user {alert.user_id}")
|
| 464 |
|
| 465 |
# 3. Construct Message using AI (dynamic, personalized & language-aware)
|
| 466 |
+
# Prefer the live preferredLanguage from the user's settings (what Flutter writes
|
| 467 |
+
# when the user changes their language in-app). Fall back to the language that was
|
| 468 |
+
# stored in the alert's search_params at creation time — which goes stale if the
|
| 469 |
+
# user changes their language after creating the alert.
|
| 470 |
+
user_language = (
|
| 471 |
+
(user or {}).get("preferredLanguage")
|
| 472 |
+
or alert.search_params.get("user_language")
|
| 473 |
+
or "en"
|
| 474 |
+
).lower()
|
| 475 |
+
|
| 476 |
# Generate message using AI for natural variety
|
| 477 |
text = await _generate_alert_message_with_ai(user_name, alert.user_query, user_language)
|
| 478 |
+
logger.info(f"Alert notification using language: {user_language} (source: {'db' if (user or {}).get('preferredLanguage') else 'stored_params'})")
|
| 479 |
|
| 480 |
# Continue with sending the message
|
| 481 |
await _continue_notify_user_of_match(alert, listing, conv_id, text)
|
|
|
|
| 583 |
try:
|
| 584 |
from app.services.voice_service import voice_service
|
| 585 |
|
| 586 |
+
# Use live preferredLanguage — same resolution as the push title above
|
| 587 |
+
_voice_lang = "en"
|
| 588 |
+
try:
|
| 589 |
+
from bson import ObjectId as _ObjId2
|
| 590 |
+
_vu = await db.users.find_one(
|
| 591 |
+
{"_id": _ObjId2(alert.user_id)}, {"preferredLanguage": 1}
|
| 592 |
+
)
|
| 593 |
+
_voice_lang = (_vu or {}).get("preferredLanguage") or \
|
| 594 |
+
(alert.search_params.get("user_language") or "en")
|
| 595 |
+
except Exception:
|
| 596 |
+
_voice_lang = (alert.search_params.get("user_language") or "en")
|
| 597 |
+
audio_bytes, duration = await voice_service.text_to_speech(text, language=_voice_lang)
|
| 598 |
audio_url = await voice_service.upload_audio_to_r2(audio_bytes)
|
| 599 |
|
| 600 |
voice_media = {
|
|
|
|
| 629 |
{"$set": {"last_notified_at": datetime.utcnow()}}
|
| 630 |
)
|
| 631 |
|
| 632 |
+
# Push notification — wakes up the user even when the app is closed.
|
| 633 |
+
# Title is localized to the user's preferredLanguage from settings.
|
| 634 |
try:
|
| 635 |
from app.services.push_service import PushService
|
| 636 |
+
from app.ai.agent.brain import generate_localized_response
|
| 637 |
+
from bson import ObjectId as _ObjId
|
| 638 |
+
|
| 639 |
+
# Fresh language lookup — not the stale value stored in search_params
|
| 640 |
+
_push_lang = "en"
|
| 641 |
+
try:
|
| 642 |
+
_u = await db.users.find_one(
|
| 643 |
+
{"_id": _ObjId(alert.user_id)}, {"preferredLanguage": 1}
|
| 644 |
+
)
|
| 645 |
+
_push_lang = (_u or {}).get("preferredLanguage") or "en"
|
| 646 |
+
except Exception:
|
| 647 |
+
pass
|
| 648 |
+
|
| 649 |
listing_title = listing.get("title", "a new property")
|
| 650 |
+
_push_title = await generate_localized_response(
|
| 651 |
+
context="Generate a very short push notification title (max 6 words) telling the user that AIDA found a new listing that matches their search.",
|
| 652 |
+
language=_push_lang,
|
| 653 |
+
tone="excited",
|
| 654 |
+
max_length="very_short",
|
| 655 |
+
)
|
| 656 |
await PushService.send_to_user(
|
| 657 |
user_id=alert.user_id,
|
| 658 |
+
title=_push_title,
|
| 659 |
+
body=f"{listing_title}",
|
| 660 |
category="property_alerts",
|
| 661 |
data={
|
| 662 |
"type": "alert_match",
|
app/services/landlord_notifications.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
-
import logging
|
| 2 |
from datetime import datetime
|
| 3 |
from bson import ObjectId
|
|
|
|
| 4 |
from app.database import get_db
|
| 5 |
|
| 6 |
-
logger =
|
| 7 |
|
| 8 |
# The ONE canonical participant ID for AIDA used system-wide.
|
| 9 |
AIDA_BOT_ID = "AIDA_BOT"
|
|
@@ -154,18 +154,34 @@ async def notify_landlord_via_aida(landlord_id: str, message_content: str):
|
|
| 154 |
)
|
| 155 |
|
| 156 |
# ── FCM push notification ──────────────────────────────────────────
|
| 157 |
-
#
|
|
|
|
| 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=
|
| 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(
|
|
|
|
| 169 |
|
| 170 |
# ── Broadcast via WebSocket so the message appears instantly ──────
|
| 171 |
try:
|
|
|
|
|
|
|
| 1 |
from datetime import datetime
|
| 2 |
from bson import ObjectId
|
| 3 |
+
from structlog import get_logger
|
| 4 |
from app.database import get_db
|
| 5 |
|
| 6 |
+
logger = get_logger(__name__)
|
| 7 |
|
| 8 |
# The ONE canonical participant ID for AIDA used system-wide.
|
| 9 |
AIDA_BOT_ID = "AIDA_BOT"
|
|
|
|
| 154 |
)
|
| 155 |
|
| 156 |
# ── FCM push notification ──────────────────────────────────────────
|
| 157 |
+
# Title is localized to the landlord's app language (preferredLanguage).
|
| 158 |
+
# Body is already localized by the caller (booking.py / payout_jobs.py).
|
| 159 |
try:
|
| 160 |
from app.services.push_service import push_service
|
| 161 |
+
from app.ai.agent.brain import generate_localized_response
|
| 162 |
+
|
| 163 |
+
# Look up landlord's preferred language — same field Flutter writes to Settings
|
| 164 |
+
_landlord_doc = await db["users"].find_one(
|
| 165 |
+
{"_id": ObjectId(landlord_id)}, {"preferredLanguage": 1}
|
| 166 |
+
)
|
| 167 |
+
_landlord_lang = (_landlord_doc or {}).get("preferredLanguage") or "en"
|
| 168 |
+
|
| 169 |
+
push_title = await generate_localized_response(
|
| 170 |
+
context="Generate a very short push notification title (max 6 words) telling the landlord about a booking or property update from AIDA.",
|
| 171 |
+
language=_landlord_lang,
|
| 172 |
+
tone="friendly",
|
| 173 |
+
max_length="very_short",
|
| 174 |
+
)
|
| 175 |
await push_service.send_to_user(
|
| 176 |
user_id=landlord_id,
|
| 177 |
+
title=push_title,
|
| 178 |
body=message_content[:100],
|
| 179 |
category="booking_updates",
|
| 180 |
data={"type": "booking_confirmation", "conversation_id": conv_id},
|
| 181 |
)
|
| 182 |
except Exception as push_err:
|
| 183 |
+
logger.warning("Push notification failed for landlord (non-fatal)",
|
| 184 |
+
landlord_id=landlord_id, error=str(push_err))
|
| 185 |
|
| 186 |
# ── Broadcast via WebSocket so the message appears instantly ──────
|
| 187 |
try:
|
app/services/proactive_service.py
CHANGED
|
@@ -122,12 +122,22 @@ async def _send_aida_dm_alert(
|
|
| 122 |
message: str,
|
| 123 |
listing_id: str,
|
| 124 |
listing: dict,
|
|
|
|
| 125 |
) -> None:
|
| 126 |
"""
|
| 127 |
Send an alert match or price drop as an AIDA DM.
|
| 128 |
Delegates to send_aida_dm which handles DB + WebSocket broadcast + push notification.
|
|
|
|
|
|
|
|
|
|
| 129 |
"""
|
| 130 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
await send_aida_dm(
|
| 132 |
user_id=user_id,
|
| 133 |
text=message,
|
|
@@ -141,7 +151,7 @@ async def _send_aida_dm_alert(
|
|
| 141 |
"images": listing.get("images", []),
|
| 142 |
}
|
| 143 |
},
|
| 144 |
-
push_title=
|
| 145 |
push_body=message[:100],
|
| 146 |
)
|
| 147 |
except Exception as e:
|
|
@@ -328,11 +338,19 @@ async def check_new_listings_against_preferences(
|
|
| 328 |
)
|
| 329 |
|
| 330 |
# Primary: AIDA DM (in-app, actionable)
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
push_count = await _send_proactive_push(
|
| 334 |
user_id=user_id,
|
| 335 |
-
title=
|
| 336 |
body=f"{title} in {location} - {currency} {price}",
|
| 337 |
listing_id=listing_id,
|
| 338 |
notification_type="new_match",
|
|
@@ -539,20 +557,38 @@ async def check_alerts_against_new_listings(
|
|
| 539 |
max_length="short",
|
| 540 |
)
|
| 541 |
|
| 542 |
-
await _send_aida_dm_alert(alert.user_id, dm_msg, listing_id, listing)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
push_count = await _send_proactive_push(
|
| 544 |
user_id=alert.user_id,
|
| 545 |
-
title=
|
| 546 |
body=f"{title} in {location} - {currency} {price}",
|
| 547 |
listing_id=listing_id,
|
| 548 |
notification_type="alert_match",
|
| 549 |
)
|
| 550 |
sent_count += push_count
|
| 551 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
await _send_proactive_email(
|
| 553 |
user_id=alert.user_id,
|
| 554 |
-
title=
|
| 555 |
-
body=
|
| 556 |
listing_id=listing_id,
|
| 557 |
notification_type="alert_match",
|
| 558 |
)
|
|
@@ -687,10 +723,16 @@ async def _notify_price_drop(
|
|
| 687 |
tone="excited",
|
| 688 |
max_length="short",
|
| 689 |
)
|
| 690 |
-
await _send_aida_dm_alert(user_id, dm_msg, listing_id, listing)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
push_count = await _send_proactive_push(
|
| 692 |
user_id=user_id,
|
| 693 |
-
title=
|
| 694 |
body=f"{title} dropped {drop_pct:.0f}% to {currency} {new_price:,.0f}",
|
| 695 |
listing_id=listing_id,
|
| 696 |
notification_type="price_drop",
|
|
@@ -728,10 +770,16 @@ async def _notify_price_drop(
|
|
| 728 |
tone="excited",
|
| 729 |
max_length="short",
|
| 730 |
)
|
| 731 |
-
await _send_aida_dm_alert(alert.user_id, dm_msg, listing_id, listing)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
push_count = await _send_proactive_push(
|
| 733 |
user_id=alert.user_id,
|
| 734 |
-
title=
|
| 735 |
body=f"{title} dropped {drop_pct:.0f}% to {currency} {new_price:,.0f}",
|
| 736 |
listing_id=listing_id,
|
| 737 |
notification_type="price_drop",
|
|
|
|
| 122 |
message: str,
|
| 123 |
listing_id: str,
|
| 124 |
listing: dict,
|
| 125 |
+
lang: str = "en",
|
| 126 |
) -> None:
|
| 127 |
"""
|
| 128 |
Send an alert match or price drop as an AIDA DM.
|
| 129 |
Delegates to send_aida_dm which handles DB + WebSocket broadcast + push notification.
|
| 130 |
+
|
| 131 |
+
``lang`` should be the user's preferredLanguage from DB so the push
|
| 132 |
+
title that appears on their lock screen is in the right language.
|
| 133 |
"""
|
| 134 |
try:
|
| 135 |
+
push_title = await generate_localized_response(
|
| 136 |
+
context="Generate a very short push notification title (max 6 words) telling the user that AIDA found a new property that matches what they are looking for.",
|
| 137 |
+
language=lang,
|
| 138 |
+
tone="friendly",
|
| 139 |
+
max_length="very_short",
|
| 140 |
+
)
|
| 141 |
await send_aida_dm(
|
| 142 |
user_id=user_id,
|
| 143 |
text=message,
|
|
|
|
| 151 |
"images": listing.get("images", []),
|
| 152 |
}
|
| 153 |
},
|
| 154 |
+
push_title=push_title,
|
| 155 |
push_body=message[:100],
|
| 156 |
)
|
| 157 |
except Exception as e:
|
|
|
|
| 338 |
)
|
| 339 |
|
| 340 |
# Primary: AIDA DM (in-app, actionable)
|
| 341 |
+
# Pass lang so the push title on the lock screen is also localized.
|
| 342 |
+
await _send_aida_dm_alert(user_id, dm_msg, listing_id, listing, lang=lang)
|
| 343 |
+
# Fallback: standalone push (for when user is fully outside the app).
|
| 344 |
+
# Title localized using the same lang already resolved above.
|
| 345 |
+
push_title_fallback = await generate_localized_response(
|
| 346 |
+
context="Generate a very short push notification title (max 6 words) telling the user a new property listing matches their preferences.",
|
| 347 |
+
language=lang,
|
| 348 |
+
tone="excited",
|
| 349 |
+
max_length="very_short",
|
| 350 |
+
)
|
| 351 |
push_count = await _send_proactive_push(
|
| 352 |
user_id=user_id,
|
| 353 |
+
title=push_title_fallback,
|
| 354 |
body=f"{title} in {location} - {currency} {price}",
|
| 355 |
listing_id=listing_id,
|
| 356 |
notification_type="new_match",
|
|
|
|
| 557 |
max_length="short",
|
| 558 |
)
|
| 559 |
|
| 560 |
+
await _send_aida_dm_alert(alert.user_id, dm_msg, listing_id, listing, lang=lang)
|
| 561 |
+
_push_title_alert = await generate_localized_response(
|
| 562 |
+
context="Generate a very short push notification title (max 6 words) telling the user a new property matches their search alert.",
|
| 563 |
+
language=lang,
|
| 564 |
+
tone="excited",
|
| 565 |
+
max_length="very_short",
|
| 566 |
+
)
|
| 567 |
push_count = await _send_proactive_push(
|
| 568 |
user_id=alert.user_id,
|
| 569 |
+
title=_push_title_alert,
|
| 570 |
body=f"{title} in {location} - {currency} {price}",
|
| 571 |
listing_id=listing_id,
|
| 572 |
notification_type="alert_match",
|
| 573 |
)
|
| 574 |
sent_count += push_count
|
| 575 |
|
| 576 |
+
_email_title_alert = await generate_localized_response(
|
| 577 |
+
context="Generate a short email subject line (max 8 words) telling the user a property was found that matches their search alert.",
|
| 578 |
+
language=lang,
|
| 579 |
+
tone="friendly",
|
| 580 |
+
max_length="very_short",
|
| 581 |
+
)
|
| 582 |
+
_email_body_alert = await generate_localized_response(
|
| 583 |
+
context=f"Write one sentence telling the user we found '{title}' in {location} for {currency} {price} and they should open the app to check it out.",
|
| 584 |
+
language=lang,
|
| 585 |
+
tone="friendly",
|
| 586 |
+
max_length="short",
|
| 587 |
+
)
|
| 588 |
await _send_proactive_email(
|
| 589 |
user_id=alert.user_id,
|
| 590 |
+
title=_email_title_alert,
|
| 591 |
+
body=_email_body_alert,
|
| 592 |
listing_id=listing_id,
|
| 593 |
notification_type="alert_match",
|
| 594 |
)
|
|
|
|
| 723 |
tone="excited",
|
| 724 |
max_length="short",
|
| 725 |
)
|
| 726 |
+
await _send_aida_dm_alert(user_id, dm_msg, listing_id, listing, lang=lang)
|
| 727 |
+
_push_title_drop = await generate_localized_response(
|
| 728 |
+
context=f"Generate a very short push notification title (max 6 words) telling the user the price dropped on a property they might like. Drop: {drop_pct:.0f}%.",
|
| 729 |
+
language=lang,
|
| 730 |
+
tone="excited",
|
| 731 |
+
max_length="very_short",
|
| 732 |
+
)
|
| 733 |
push_count = await _send_proactive_push(
|
| 734 |
user_id=user_id,
|
| 735 |
+
title=_push_title_drop,
|
| 736 |
body=f"{title} dropped {drop_pct:.0f}% to {currency} {new_price:,.0f}",
|
| 737 |
listing_id=listing_id,
|
| 738 |
notification_type="price_drop",
|
|
|
|
| 770 |
tone="excited",
|
| 771 |
max_length="short",
|
| 772 |
)
|
| 773 |
+
await _send_aida_dm_alert(alert.user_id, dm_msg, listing_id, listing, lang=lang)
|
| 774 |
+
_push_title_alert_drop = await generate_localized_response(
|
| 775 |
+
context=f"Generate a very short push notification title (max 6 words) telling the user the price dropped on a property that matches their saved search alert. Drop: {drop_pct:.0f}%.",
|
| 776 |
+
language=lang,
|
| 777 |
+
tone="excited",
|
| 778 |
+
max_length="very_short",
|
| 779 |
+
)
|
| 780 |
push_count = await _send_proactive_push(
|
| 781 |
user_id=alert.user_id,
|
| 782 |
+
title=_push_title_alert_drop,
|
| 783 |
body=f"{title} dropped {drop_pct:.0f}% to {currency} {new_price:,.0f}",
|
| 784 |
listing_id=listing_id,
|
| 785 |
notification_type="price_drop",
|
app/services/push_service.py
CHANGED
|
@@ -2,16 +2,17 @@
|
|
| 2 |
# app/services/push_service.py – FCM Push Notification Service
|
| 3 |
# ============================================================
|
| 4 |
import asyncio
|
| 5 |
-
import logging
|
| 6 |
import os
|
| 7 |
import random
|
| 8 |
from datetime import datetime, timezone
|
| 9 |
from pathlib import Path
|
| 10 |
from typing import Any, Iterable, List, Optional, Tuple
|
| 11 |
|
|
|
|
|
|
|
| 12 |
from app.database import get_db
|
| 13 |
|
| 14 |
-
logger =
|
| 15 |
|
| 16 |
|
| 17 |
# ============================================================
|
|
|
|
| 2 |
# app/services/push_service.py – FCM Push Notification Service
|
| 3 |
# ============================================================
|
| 4 |
import asyncio
|
|
|
|
| 5 |
import os
|
| 6 |
import random
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Any, Iterable, List, Optional, Tuple
|
| 10 |
|
| 11 |
+
from structlog import get_logger
|
| 12 |
+
|
| 13 |
from app.database import get_db
|
| 14 |
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
|
| 17 |
|
| 18 |
# ============================================================
|
app/services/viewing_service.py
CHANGED
|
@@ -27,7 +27,6 @@ How AIDA knows who the visitor is:
|
|
| 27 |
"""
|
| 28 |
|
| 29 |
import json
|
| 30 |
-
import logging
|
| 31 |
import re
|
| 32 |
from datetime import datetime, timezone
|
| 33 |
from typing import List, Optional
|
|
@@ -39,10 +38,12 @@ from app.services.aida_dm_service import send_aida_dm as _send_aida_dm
|
|
| 39 |
from app.ai.agent.brain import generate_localized_response
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
| 42 |
def _lang(user: dict) -> str:
|
| 43 |
return (user or {}).get("preferredLanguage", "en")
|
| 44 |
|
| 45 |
-
logger =
|
| 46 |
|
| 47 |
|
| 48 |
def _user_lang(user: dict) -> str:
|
|
|
|
| 27 |
"""
|
| 28 |
|
| 29 |
import json
|
|
|
|
| 30 |
import re
|
| 31 |
from datetime import datetime, timezone
|
| 32 |
from typing import List, Optional
|
|
|
|
| 38 |
from app.ai.agent.brain import generate_localized_response
|
| 39 |
|
| 40 |
|
| 41 |
+
from structlog import get_logger
|
| 42 |
+
|
| 43 |
def _lang(user: dict) -> str:
|
| 44 |
return (user or {}).get("preferredLanguage", "en")
|
| 45 |
|
| 46 |
+
logger = get_logger(__name__)
|
| 47 |
|
| 48 |
|
| 49 |
def _user_lang(user: dict) -> str:
|