destinyebuka commited on
Commit
6e36fae
·
1 Parent(s): c0bba0b
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
- # ── Fix: coerce list fields and strip unknown fields ─────────────
1104
- # LLM sometimes:
1105
- # passes list fields (requirements, amenities) as plain strings
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 the user has uploaded images.
1540
- # Images are the trigger not text completeness.
1541
- if has_images:
 
 
 
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
- snippets = "\n".join(
2949
- f"- {r['title']}: {r['snippet']}" for r in web_results
 
 
 
 
 
 
 
 
 
 
 
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 speaking (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
  ========== 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 logging
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 = logging.getLogger(__name__)
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 = logging.getLogger(__name__)
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
- response_text = temp_data.get("response_text") or "I'm here to help! What would you like to do?"
 
 
 
 
 
 
 
 
 
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 logging
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 = logging.getLogger(__name__)
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 logging
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 = logging.getLogger(__name__)
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 logging
8
  import random
9
  from app.core.mimo_client import get_mimo_client
10
 
11
- logger = logging.getLogger(__name__)
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 logging
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 = logging.getLogger(__name__)
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 logging
3
  import json
4
  from typing import Dict, Any, List
5
  from app.core.mimo_client import get_mimo_client
6
 
7
- logger = logging.getLogger(__name__)
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 logging
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 = logging.getLogger(__name__)
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
- Lightweight async web search via DuckDuckGo HTML endpoint.
3
- No API key required. Returns title + snippet for each result.
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
- import re
6
- import httpx
 
7
 
8
- _HEADERS = {
9
- "User-Agent": (
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
- _TAG_RE = re.compile(r"<[^>]+>")
19
- _WS_RE = re.compile(r"\s+")
20
- _ENT_RE = re.compile(r"&[a-z]+;|&#\d+;")
21
 
22
- # DuckDuckGo HTML wraps each result in <div class="result results_links...">
23
- # Title link: <a class="result__a" href="...">text</a>
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 _clean(html: str) -> str:
31
- text = _TAG_RE.sub("", html)
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 DuckDuckGo and return up to `max_results` dicts with title + snippet.
39
- Returns [] on any failure.
 
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
- "https://html.duckduckgo.com/html/",
47
- data={"q": query, "kl": "us-en"},
 
 
 
 
 
 
 
 
 
48
  )
49
  resp.raise_for_status()
50
- html = resp.text
51
 
 
52
  results = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- # Strategy 1: match result blocks and extract title + snippet from each
55
- for block in _BLOCK_RE.finditer(html):
56
- content = block.group(1)
57
- t = _TITLE_RE.search(content)
58
- s = _SNIPPET_RE.search(content)
59
- if t and s:
60
- title = _clean(t.group(2))
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
- async def search_real_estate_prices(location: str) -> list[dict]:
84
- """Search for real estate market prices for a location."""
85
- query = f"{location} real estate property rental prices market 2024 2025"
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
- # Get user's language from alert params (default to English)
467
- user_language = (alert.search_params.get("user_language") or "en").lower()
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
- user_language = (alert.search_params.get("user_language") or "en").lower()
580
- audio_bytes, duration = await voice_service.text_to_speech(text, language=user_language)
 
 
 
 
 
 
 
 
 
 
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="AIDA found a match! 🏠",
622
- body=f"A listing matching your search just went live: {listing_title}",
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 = logging.getLogger(__name__)
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
- # 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:
 
 
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="AIDA — New match found",
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
- await _send_aida_dm_alert(user_id, dm_msg, listing_id, listing)
332
- # Fallback: push notification (for when user is outside the app)
 
 
 
 
 
 
 
 
333
  push_count = await _send_proactive_push(
334
  user_id=user_id,
335
- title="New listing matches your preferences!",
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="Alert match found!",
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="A property matches your alert!",
555
- body=f"We found '{title}' in {location} for {currency} {price}. Open the app to check it out!",
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="Price dropped!",
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="Price dropped on a match!",
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 = logging.getLogger(__name__)
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 = logging.getLogger(__name__)
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: