destinyebuka commited on
Commit
9cfd5d9
Β·
1 Parent(s): 0e7f494
app/ai/graph.py CHANGED
@@ -1,4 +1,4 @@
1
- # app/ai/graph.py - COMPLETE WORKING WORKFLOW
2
  from langgraph.graph import StateGraph, START, END
3
  from typing import Dict
4
  from structlog import get_logger
@@ -6,8 +6,8 @@ from structlog import get_logger
6
  from app.ai.state import ChatState
7
  from app.ai.nodes.intent_node import intent_node
8
  from app.ai.nodes.draft_node import draft_node
9
- from app.ai.nodes.search_node import search_node # Existing
10
- from app.ai.nodes.publish_node import publish_node # Existing
11
 
12
  logger = get_logger(__name__)
13
 
@@ -25,64 +25,41 @@ workflow.add_node("publish", publish_node)
25
 
26
  # ===== ROUTING LOGIC =====
27
  def route_from_intent(state: Dict) -> str:
28
- """
29
- Route based on intent detected
30
-
31
- Returns next node name:
32
- - "draft" if listing intent
33
- - "search" if search intent
34
- - END if greeting
35
- """
36
  intent = state.get("intent")
37
 
38
- logger.info(f"πŸ”€ Routing", intent=intent)
39
 
40
  if intent == "list":
41
  return "draft"
42
  elif intent == "search":
43
  return "search"
44
  elif intent == "my_listings":
45
- return "search" # Use search node to fetch user's listings
46
  else:
47
  return END
48
 
49
 
50
  def route_from_draft(state: Dict) -> str:
51
- """
52
- Route from draft node based on status
53
-
54
- Returns:
55
- - "publish" if publishing
56
- - END if discarded/complete
57
- """
58
  status = state.get("status")
59
 
60
- logger.info(f"πŸ”€ Draft routing", status=status)
61
 
62
  if status == "publishing":
63
  return "publish"
64
  elif status == "discarded":
65
  return END
66
  elif status == "preview_shown":
67
- # Stay in preview, wait for user input
68
  return END
69
  else:
70
- # Still collecting/checking optional
71
  return END
72
 
73
 
74
  def route_after_publish(state: Dict) -> str:
75
- """Route after publishing"""
76
- status = state.get("status")
77
-
78
- logger.info(f"πŸ”€ Publish routing", status=status)
79
-
80
- if status == "published":
81
- return END
82
- elif status == "error":
83
- return END
84
- else:
85
- return END
86
 
87
 
88
  # ===== ADD EDGES =====
 
1
+ # app/ai/graph.py - COMPLETE LANGGRAPH WORKFLOW
2
  from langgraph.graph import StateGraph, START, END
3
  from typing import Dict
4
  from structlog import get_logger
 
6
  from app.ai.state import ChatState
7
  from app.ai.nodes.intent_node import intent_node
8
  from app.ai.nodes.draft_node import draft_node
9
+ from app.ai.nodes.search_node import search_node
10
+ from app.ai.nodes.publish_node import publish_node
11
 
12
  logger = get_logger(__name__)
13
 
 
25
 
26
  # ===== ROUTING LOGIC =====
27
  def route_from_intent(state: Dict) -> str:
28
+ """Route based on intent"""
 
 
 
 
 
 
 
29
  intent = state.get("intent")
30
 
31
+ logger.info(f"πŸ”€ Routing from intent", intent=intent)
32
 
33
  if intent == "list":
34
  return "draft"
35
  elif intent == "search":
36
  return "search"
37
  elif intent == "my_listings":
38
+ return "search" # Reuse search node
39
  else:
40
  return END
41
 
42
 
43
  def route_from_draft(state: Dict) -> str:
44
+ """Route from draft"""
 
 
 
 
 
 
45
  status = state.get("status")
46
 
47
+ logger.info(f"πŸ”€ Routing from draft", status=status)
48
 
49
  if status == "publishing":
50
  return "publish"
51
  elif status == "discarded":
52
  return END
53
  elif status == "preview_shown":
 
54
  return END
55
  else:
 
56
  return END
57
 
58
 
59
  def route_after_publish(state: Dict) -> str:
60
+ """Route after publish"""
61
+ logger.info(f"πŸ”€ Routing after publish")
62
+ return END
 
 
 
 
 
 
 
 
63
 
64
 
65
  # ===== ADD EDGES =====
app/ai/nodes/draft_node.py CHANGED
@@ -1,5 +1,5 @@
1
  # app/ai/nodes/draft_node.py - COMPLETE WORKING VERSION
2
- import datetime
3
  from typing import Dict, List
4
  from structlog import get_logger
5
 
@@ -9,28 +9,15 @@ logger = get_logger(__name__)
9
  # AMENITY ICONS
10
  # ============================================
11
  AMENITY_ICONS = {
12
- "wifi": "πŸ“Ά",
13
- "parking": "πŸ…ΏοΈ",
14
- "furnished": "πŸ›‹οΈ",
15
- "washing machine": "🧼",
16
- "washing": "🧼",
17
- "dryer": "πŸ”₯",
18
- "balcony": "πŸ–οΈ",
19
- "pool": "🏊",
20
- "gym": "πŸ’ͺ",
21
- "garden": "🌿",
22
- "air conditioning": "❄️",
23
- "ac": "❄️",
24
- "kitchen": "🍳",
25
- "security": "πŸ”’",
26
- "elevator": "πŸ›—",
27
- "laundry": "🧺",
28
- "heating": "πŸ”₯",
29
  }
30
 
31
 
32
  def add_amenity_icons(amenities: List[str]) -> str:
33
- """Convert amenity list to icon format"""
34
  if not amenities:
35
  return ""
36
 
@@ -44,11 +31,10 @@ def add_amenity_icons(amenities: List[str]) -> str:
44
 
45
 
46
  def generate_title(state: Dict) -> str:
47
- """Generate listing title from fields"""
48
  bedrooms = state.get("bedrooms", "N/A")
49
  location = state.get("location", "N/A").title()
50
  listing_type = state.get("listing_type", "rental").title()
51
-
52
  return f"{bedrooms}-Bedroom {listing_type} in {location}"
53
 
54
 
@@ -63,58 +49,56 @@ def generate_description(state: Dict) -> str:
63
  amenities = state.get("amenities", [])
64
  requirements = state.get("requirements")
65
 
66
- # Build description
67
  parts = []
68
-
69
- # Main description
70
  parts.append(f"Spacious {bedrooms}-bedroom, {bathrooms}-bathroom property in {location}.")
71
 
72
- # Amenities
73
  if amenities:
74
  amenities_str = ", ".join(amenities)
75
  parts.append(f"Features include: {amenities_str}.")
76
 
77
- # Price
78
  parts.append(f"Price: {price:,.0f} {currency} per {price_type}.")
79
 
80
- # Requirements
81
  if requirements:
82
- parts.append(f"Requirements: {requirements}.")
 
83
 
84
  return " ".join(parts)
85
 
86
 
87
  async def draft_node(state: Dict) -> Dict:
88
- """
89
- Process draft creation and display
90
-
91
- States:
92
- - collecting β†’ asking for missing fields
93
- - checking_optional β†’ asking about amenities
94
- - draft_ready β†’ generate preview
95
- - preview_shown β†’ waiting for user action (publish/edit/discard)
96
- """
97
 
98
  current_status = state.get("status", "greeting")
99
  logger.info(f"πŸ“„ Draft Node", status=current_status)
100
 
101
- # ===== COLLECTING REQUIRED FIELDS =====
102
- if current_status == "collecting":
103
- # Ask for next missing field - reply already set in intent_node
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  return state
105
 
106
- # ===== CHECKING OPTIONAL FIELDS =====
107
- if current_status == "checking_optional":
108
- # User just answered optional fields question
109
- # Next: Generate draft preview
110
- state["status"] = "draft_ready"
111
 
112
- # Generate title and description
113
  title = generate_title(state)
114
  description = generate_description(state)
115
  amenities_with_icons = add_amenity_icons(state.get("amenities", []))
116
 
117
- # Build draft preview
118
  draft_preview = {
119
  "title": title,
120
  "description": description,
@@ -133,16 +117,13 @@ async def draft_node(state: Dict) -> Dict:
133
 
134
  state["draft_preview"] = draft_preview
135
 
136
- # Build preview message
137
- images_section = f"πŸ“· Images: {len(draft_preview['images'])} uploaded" if draft_preview['images'] else "πŸ“· No images yet"
138
 
139
  preview_text = f"""
140
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
141
- 🏠 LISTING PREVIEW
142
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
143
 
144
- **{draft_preview['title']}**
145
-
146
  πŸ“ Location: {draft_preview['location']}
147
  πŸ›οΈ Bedrooms: {draft_preview['bedrooms']}
148
  🚿 Bathrooms: {draft_preview['bathrooms']}
@@ -155,84 +136,94 @@ async def draft_node(state: Dict) -> Dict:
155
 
156
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
157
 
158
- βœ… Ready to publish? Say **"publish"** to make it live!
159
- You can also say **"edit"** to change something or **"discard"** to start over.
160
  """
161
 
162
  state["status"] = "preview_shown"
163
  state["ai_reply"] = preview_text
164
-
165
- logger.info(f"πŸ“Š Draft preview generated: {title}")
166
 
167
  # ===== PREVIEW SHOWN - WAITING FOR ACTION =====
168
  elif current_status == "preview_shown":
169
- # Check for user actions
 
170
  last_msg = state["messages"][-1]["content"].lower()
171
 
172
- if any(k in last_msg for k in ["publish", "publier", "go live", "post", "confirm", "yes", "ok"]):
173
  state["status"] = "publishing"
174
- state["ai_reply"] = "βœ… Publishing your listing now..."
175
- logger.info(f"πŸ“€ Publishing triggered")
 
 
176
 
177
- elif any(k in last_msg for k in ["edit", "change", "modifier", "update", "correction"]):
178
  state["status"] = "editing"
179
- state["ai_reply"] = "What would you like to change? (e.g., location, price, bedrooms, amenities)"
180
- logger.info(f"✏️ Edit mode triggered")
 
 
181
 
182
- elif any(k in last_msg for k in ["discard", "delete", "cancel", "annuler", "remove"]):
183
  state["status"] = "discarded"
184
- # Clear draft fields
185
- for key in ["location", "bedrooms", "bathrooms", "price", "listing_type", "price_type", "amenities", "requirements", "draft_preview"]:
186
  state.pop(key, None)
187
- state["ai_reply"] = "βœ… Draft cleared. What would you like to do next?"
188
- logger.info(f"πŸ—‘οΈ Draft discarded")
 
 
189
 
190
- else:
191
- # User asked a question or gave feedback
192
- state["ai_reply"] = "You can say **'publish'** to list it, **'edit'** to change something, or **'discard'** to start over."
193
 
194
  # ===== EDITING =====
195
  elif current_status == "editing":
196
- # User is in edit mode
 
197
  last_msg = state["messages"][-1]["content"]
198
 
199
- # Simple field detection for edits
200
  if "price" in last_msg.lower():
201
- # Extract new price
202
- import re
203
- prices = re.findall(r'(\d+)[k,.]?', last_msg.lower())
204
- if prices:
205
- new_price = float(prices[0]) * (1000 if 'k' in last_msg.lower() else 1)
206
  state["price"] = new_price
207
  state["ai_reply"] = f"βœ… Price updated to {new_price:,.0f}! Here's the updated preview:"
208
 
209
  elif "bedroom" in last_msg.lower() or "bed" in last_msg.lower():
210
- import re
211
  nums = re.findall(r'(\d+)', last_msg)
212
  if nums:
213
  state["bedrooms"] = int(nums[0])
214
  state["ai_reply"] = f"βœ… Bedrooms updated to {nums[0]}! Here's the updated preview:"
215
 
216
  elif "bathroom" in last_msg.lower() or "bath" in last_msg.lower():
217
- import re
218
  nums = re.findall(r'(\d+)', last_msg)
219
  if nums:
220
  state["bathrooms"] = int(nums[0])
221
  state["ai_reply"] = f"βœ… Bathrooms updated to {nums[0]}! Here's the updated preview:"
222
 
223
- elif "location" in last_msg.lower():
224
- # Extract location from edit message
225
- from app.ai.nodes.intent_node import extract_location
226
  new_location = extract_location(last_msg)
227
  if new_location:
228
  state["location"] = new_location
 
 
 
 
 
 
 
 
229
  state["ai_reply"] = f"βœ… Location updated to {new_location}! Here's the updated preview:"
230
 
 
 
 
 
 
 
 
231
  else:
232
- state["ai_reply"] = "I didn't understand what to change. Please say 'price', 'location', 'bedrooms', or 'bathrooms' and the new value."
233
  return state
234
 
235
- # Regenerate draft preview
236
  title = generate_title(state)
237
  description = generate_description(state)
238
  amenities_with_icons = add_amenity_icons(state.get("amenities", []))
@@ -255,16 +246,13 @@ You can also say **"edit"** to change something or **"discard"** to start over.
255
 
256
  state["draft_preview"] = draft_preview
257
 
258
- # Show updated preview
259
- images_section = f"πŸ“· Images: {len(draft_preview['images'])} uploaded" if draft_preview['images'] else "πŸ“· No images yet"
260
 
261
  preview_text = f"""
262
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
263
- 🏠 UPDATED PREVIEW
264
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
265
 
266
- **{draft_preview['title']}**
267
-
268
  πŸ“ Location: {draft_preview['location']}
269
  πŸ›οΈ Bedrooms: {draft_preview['bedrooms']}
270
  🚿 Bathrooms: {draft_preview['bathrooms']}
@@ -277,12 +265,11 @@ You can also say **"edit"** to change something or **"discard"** to start over.
277
 
278
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
279
 
280
- βœ… Ready? Say **"publish"** to make it live!
281
- Or continue editing.
282
  """
283
 
284
  state["status"] = "preview_shown"
285
  state["ai_reply"] = preview_text
286
- logger.info(f"πŸ”„ Draft updated and preview regenerated")
287
 
288
  return state
 
1
  # app/ai/nodes/draft_node.py - COMPLETE WORKING VERSION
2
+ import re
3
  from typing import Dict, List
4
  from structlog import get_logger
5
 
 
9
  # AMENITY ICONS
10
  # ============================================
11
  AMENITY_ICONS = {
12
+ "wifi": "πŸ“Ά", "parking": "πŸ…ΏοΈ", "furnished": "πŸ›‹οΈ", "washing machine": "🧼",
13
+ "washing": "🧼", "dryer": "πŸ”₯", "balcony": "πŸ–οΈ", "pool": "🏊", "gym": "πŸ’ͺ",
14
+ "garden": "🌿", "air conditioning": "❄️", "ac": "❄️", "kitchen": "🍳",
15
+ "security": "πŸ”’", "elevator": "πŸ›—", "laundry": "🧺", "heating": "πŸ”₯", "hot water": "🚿",
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
 
18
 
19
  def add_amenity_icons(amenities: List[str]) -> str:
20
+ """Convert amenity list to emoji format"""
21
  if not amenities:
22
  return ""
23
 
 
31
 
32
 
33
  def generate_title(state: Dict) -> str:
34
+ """Generate professional title"""
35
  bedrooms = state.get("bedrooms", "N/A")
36
  location = state.get("location", "N/A").title()
37
  listing_type = state.get("listing_type", "rental").title()
 
38
  return f"{bedrooms}-Bedroom {listing_type} in {location}"
39
 
40
 
 
49
  amenities = state.get("amenities", [])
50
  requirements = state.get("requirements")
51
 
 
52
  parts = []
 
 
53
  parts.append(f"Spacious {bedrooms}-bedroom, {bathrooms}-bathroom property in {location}.")
54
 
 
55
  if amenities:
56
  amenities_str = ", ".join(amenities)
57
  parts.append(f"Features include: {amenities_str}.")
58
 
 
59
  parts.append(f"Price: {price:,.0f} {currency} per {price_type}.")
60
 
 
61
  if requirements:
62
+ if "deposit" in requirements.lower() or "require" in requirements.lower():
63
+ parts.append("Special requirements apply.")
64
 
65
  return " ".join(parts)
66
 
67
 
68
  async def draft_node(state: Dict) -> Dict:
69
+ """Process draft creation, preview, edits, and actions"""
 
 
 
 
 
 
 
 
70
 
71
  current_status = state.get("status", "greeting")
72
  logger.info(f"πŸ“„ Draft Node", status=current_status)
73
 
74
+ # ===== COLLECTING / CHECKING_OPTIONAL =====
75
+ if current_status in ["collecting", "checking_optional"]:
76
+ from app.ai.nodes.intent_node import extract_amenities, get_missing_fields
77
+
78
+ new_amenities = extract_amenities(state["messages"][-1]["content"])
79
+ if new_amenities:
80
+ state["amenities"] = list(set(state.get("amenities", []) + new_amenities))
81
+
82
+ user_msg = state["messages"][-1]["content"].lower()
83
+ if any(w in user_msg for w in ["require", "deposit", "condition", "no ", "must"]):
84
+ state["requirements"] = state["messages"][-1]["content"]
85
+
86
+ missing = get_missing_fields(state)
87
+ state["missing_fields"] = missing
88
+
89
+ if not missing and current_status == "checking_optional":
90
+ state["status"] = "draft_ready"
91
+
92
  return state
93
 
94
+ # ===== GENERATE DRAFT PREVIEW =====
95
+ if current_status == "draft_ready":
96
+ logger.info("πŸ“„ Generating draft preview")
 
 
97
 
 
98
  title = generate_title(state)
99
  description = generate_description(state)
100
  amenities_with_icons = add_amenity_icons(state.get("amenities", []))
101
 
 
102
  draft_preview = {
103
  "title": title,
104
  "description": description,
 
117
 
118
  state["draft_preview"] = draft_preview
119
 
120
+ images_section = f"πŸ“· {len(draft_preview['images'])} images" if draft_preview['images'] else "πŸ“· No images"
 
121
 
122
  preview_text = f"""
123
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
124
+ 🏠 {draft_preview['title']}
125
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
126
 
 
 
127
  πŸ“ Location: {draft_preview['location']}
128
  πŸ›οΈ Bedrooms: {draft_preview['bedrooms']}
129
  🚿 Bathrooms: {draft_preview['bathrooms']}
 
136
 
137
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
138
 
139
+ Ready? Say 'publish' to make it live, 'edit' to change, or 'discard' to start over.
 
140
  """
141
 
142
  state["status"] = "preview_shown"
143
  state["ai_reply"] = preview_text
144
+ logger.info("βœ… Preview generated")
 
145
 
146
  # ===== PREVIEW SHOWN - WAITING FOR ACTION =====
147
  elif current_status == "preview_shown":
148
+ from app.ai.nodes.intent_node import translate_to_language
149
+
150
  last_msg = state["messages"][-1]["content"].lower()
151
 
152
+ if any(k in last_msg for k in ["publish", "publier", "go live", "post", "confirm", "yes", "ok", "okay"]):
153
  state["status"] = "publishing"
154
+ publish_msg = "Publishing your listing..."
155
+ state["ai_reply"] = await translate_to_language(publish_msg, state.get("user_language", "English"))
156
+ logger.info("πŸ“€ Publishing triggered")
157
+ return state
158
 
159
+ if any(k in last_msg for k in ["edit", "change", "modifier", "update", "correction", "fix"]):
160
  state["status"] = "editing"
161
+ edit_msg = "What would you like to change? (e.g., price, bedrooms, location, amenities)"
162
+ state["ai_reply"] = await translate_to_language(edit_msg, state.get("user_language", "English"))
163
+ logger.info("✏️ Edit mode triggered")
164
+ return state
165
 
166
+ if any(k in last_msg for k in ["discard", "delete", "cancel", "annuler", "remove", "start over"]):
167
  state["status"] = "discarded"
168
+ for key in ["location", "bedrooms", "bathrooms", "price", "listing_type", "price_type", "amenities", "requirements", "draft_preview", "image_urls"]:
 
169
  state.pop(key, None)
170
+ discard_msg = "βœ… Draft cleared. What would you like to do next?"
171
+ state["ai_reply"] = await translate_to_language(discard_msg, state.get("user_language", "English"))
172
+ logger.info("πŸ—‘οΈ Draft discarded")
173
+ return state
174
 
175
+ state["ai_reply"] = "You can say 'publish' to list it, 'edit' to change something, or 'discard' to start over."
 
 
176
 
177
  # ===== EDITING =====
178
  elif current_status == "editing":
179
+ from app.ai.nodes.intent_node import extract_number, extract_location
180
+
181
  last_msg = state["messages"][-1]["content"]
182
 
 
183
  if "price" in last_msg.lower():
184
+ new_price = extract_number(last_msg)
185
+ if new_price:
 
 
 
186
  state["price"] = new_price
187
  state["ai_reply"] = f"βœ… Price updated to {new_price:,.0f}! Here's the updated preview:"
188
 
189
  elif "bedroom" in last_msg.lower() or "bed" in last_msg.lower():
 
190
  nums = re.findall(r'(\d+)', last_msg)
191
  if nums:
192
  state["bedrooms"] = int(nums[0])
193
  state["ai_reply"] = f"βœ… Bedrooms updated to {nums[0]}! Here's the updated preview:"
194
 
195
  elif "bathroom" in last_msg.lower() or "bath" in last_msg.lower():
 
196
  nums = re.findall(r'(\d+)', last_msg)
197
  if nums:
198
  state["bathrooms"] = int(nums[0])
199
  state["ai_reply"] = f"βœ… Bathrooms updated to {nums[0]}! Here's the updated preview:"
200
 
201
+ elif "location" in last_msg.lower() or "city" in last_msg.lower():
 
 
202
  new_location = extract_location(last_msg)
203
  if new_location:
204
  state["location"] = new_location
205
+ try:
206
+ from app.ml.models.ml_listing_extractor import get_ml_extractor
207
+ ml = get_ml_extractor()
208
+ currency, country, city, conf = await ml.infer_currency(state)
209
+ if currency:
210
+ state["currency"] = currency
211
+ except:
212
+ pass
213
  state["ai_reply"] = f"βœ… Location updated to {new_location}! Here's the updated preview:"
214
 
215
+ elif "amenities" in last_msg.lower():
216
+ from app.ai.nodes.intent_node import extract_amenities
217
+ new_amenities = extract_amenities(last_msg)
218
+ if new_amenities:
219
+ state["amenities"] = list(set(state.get("amenities", []) + new_amenities))
220
+ state["ai_reply"] = f"βœ… Amenities updated! Here's the updated preview:"
221
+
222
  else:
223
+ state["ai_reply"] = "I didn't understand. Please say 'price', 'location', 'bedrooms', 'bathrooms', or 'amenities' and the new value."
224
  return state
225
 
226
+ # Regenerate preview
227
  title = generate_title(state)
228
  description = generate_description(state)
229
  amenities_with_icons = add_amenity_icons(state.get("amenities", []))
 
246
 
247
  state["draft_preview"] = draft_preview
248
 
249
+ images_section = f"πŸ“· {len(draft_preview['images'])} images" if draft_preview['images'] else "πŸ“· No images"
 
250
 
251
  preview_text = f"""
252
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
253
+ 🏠 {draft_preview['title']}
254
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
255
 
 
 
256
  πŸ“ Location: {draft_preview['location']}
257
  πŸ›οΈ Bedrooms: {draft_preview['bedrooms']}
258
  🚿 Bathrooms: {draft_preview['bathrooms']}
 
265
 
266
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
267
 
268
+ Ready? Say 'publish' or 'edit' again, or 'discard'.
 
269
  """
270
 
271
  state["status"] = "preview_shown"
272
  state["ai_reply"] = preview_text
273
+ logger.info("πŸ”„ Preview regenerated")
274
 
275
  return state
app/ai/nodes/intent_node.py CHANGED
@@ -1,4 +1,4 @@
1
- # app/ai/nodes/intent_node.py - COMPLETE WORKING VERSION (DeepSeek)
2
  import json
3
  import re
4
  import os
@@ -23,58 +23,123 @@ client = AsyncOpenAI(
23
  )
24
 
25
  # ============================================
26
- # LANGUAGE DETECTION
27
  # ============================================
28
- def detect_language(text: str) -> str:
29
- """
30
- Detect user's language from their message
31
- Returns: 'english', 'french', 'yoruba', 'spanish', 'portuguese'
32
- """
33
- text_lower = text.lower()
34
-
35
- # French keywords
36
- if any(w in text_lower for w in ["bonjour", "prix", "loyer", "appartement", "chambre", "franΓ§ais", "merci", "svp"]):
37
- return "french"
38
-
39
- # Spanish keywords
40
- if any(w in text_lower for w in ["hola", "precio", "alquiler", "habitaciΓ³n", "espaΓ±ol", "gracias", "por favor"]):
41
- return "spanish"
42
-
43
- # Portuguese keywords
44
- if any(w in text_lower for w in ["olΓ‘", "preΓ§o", "aluguel", "quarto", "portuguΓͺs", "obrigado", "por favor"]):
45
- return "portuguese"
46
-
47
- # Yoruba keywords
48
- if any(w in text_lower for w in ["bawo", "owo", "ile", "odun", "yoruba", "e kaaro"]):
49
- return "yoruba"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
- return "english"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  # ============================================
55
  # FIELD EXTRACTION HELPERS
56
  # ============================================
57
  def detect_listing_type(text: str) -> Optional[str]:
58
- """Auto-detect listing type from user's message"""
59
  text_lower = text.lower()
60
-
61
  if any(w in text_lower for w in ["short stay", "airbnb", "nightly", "daily", "weekly"]):
62
  return "short-stay"
63
- elif any(w in text_lower for w in ["sale", "sell", "selling", "for sale", "vendre", "Γ  vendre"]):
64
  return "sale"
65
- elif any(w in text_lower for w in ["roommate", "sharing", "flatmate", "colocataire", "coloca"]):
66
  return "roommate"
67
  else:
68
  return "rent"
69
 
70
 
71
  def detect_price_type(text: str) -> Optional[str]:
72
- """Auto-detect price type from user's message"""
73
  text_lower = text.lower()
74
-
75
- if any(w in text_lower for w in ["nightly", "night", "daily", "day", "par nuit", "nuit"]):
76
  return "nightly"
77
- elif any(w in text_lower for w in ["yearly", "year", "annually", "par an", "annuel"]):
78
  return "yearly"
79
  else:
80
  return "monthly"
@@ -94,29 +159,20 @@ def extract_number(text: str) -> Optional[float]:
94
 
95
 
96
  def extract_location(text: str) -> Optional[str]:
97
- """Extract location (city name) from text"""
98
  cities = {
99
- "lagos": "lagos",
100
- "cotonou": "cotonou",
101
- "calavi": "calavi",
102
- "paris": "paris",
103
- "london": "london",
104
- "nairobi": "nairobi",
105
- "accra": "accra",
106
- "johannesburg": "johannesburg",
107
- "lyon": "lyon",
108
- "marseille": "marseille",
109
- "kinshasa": "kinshasa",
110
- "dakar": "dakar",
111
- "kampala": "kampala",
112
- "cape town": "cape town",
113
  }
114
 
115
  text_lower = text.lower()
116
  for city_key, city_val in cities.items():
117
  if city_key in text_lower:
118
  return city_val
119
-
120
  return None
121
 
122
 
@@ -125,7 +181,7 @@ def extract_amenities(text: str) -> List[str]:
125
  amenities_list = [
126
  "wifi", "parking", "furnished", "washing machine", "dryer",
127
  "balcony", "pool", "gym", "garden", "air conditioning", "kitchen",
128
- "ac", "washer", "elevator", "security", "laundry", "heating"
129
  ]
130
 
131
  found_amenities = []
@@ -147,7 +203,7 @@ def extract_amenities(text: str) -> List[str]:
147
  # REQUIRED FIELDS CHECK
148
  # ============================================
149
  def get_missing_fields(state: Dict) -> List[str]:
150
- """Check which required fields are still missing"""
151
  required = ["location", "bedrooms", "bathrooms", "price", "listing_type", "price_type"]
152
  missing = []
153
 
@@ -159,33 +215,21 @@ def get_missing_fields(state: Dict) -> List[str]:
159
  return missing
160
 
161
 
162
- def get_next_question(missing_fields: List[str], language: str = "english") -> Optional[str]:
163
- """Generate the next question to ask user"""
164
  if not missing_fields:
165
  return None
166
 
167
  questions = {
168
- "english": {
169
- "location": "What city/area is the property in?",
170
- "bedrooms": "How many bedrooms does it have?",
171
- "bathrooms": "How many bathrooms?",
172
- "price": "What's the price?",
173
- "listing_type": "Is it for rent, short-stay, sale, or roommate?",
174
- "price_type": "Is that monthly, nightly, or yearly?",
175
- },
176
- "french": {
177
- "location": "Dans quelle ville se trouve le bien ?",
178
- "bedrooms": "Combien de chambres ?",
179
- "bathrooms": "Combien de salles de bain ?",
180
- "price": "Quel est le prix ?",
181
- "listing_type": "Est-ce pour la location, court sΓ©jour, vente ou colocation ?",
182
- "price_type": "Est-ce mensuel, nuitΓ©e ou annuel ?",
183
- },
184
  }
185
 
186
- next_field = missing_fields[0]
187
- lang_questions = questions.get(language, questions["english"])
188
- return lang_questions.get(next_field, "Tell me more about the property")
189
 
190
 
191
  # ============================================
@@ -193,37 +237,26 @@ def get_next_question(missing_fields: List[str], language: str = "english") -> O
193
  # ============================================
194
  @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5))
195
  async def intent_node(state: Dict) -> Dict:
196
- """
197
- Main intent detection & extraction node using DeepSeek
198
-
199
- Flow:
200
- 1. Detect language (once per conversation)
201
- 2. Determine intent from user message
202
- 3. Extract fields progressively
203
- 4. Check for missing required fields
204
- 5. Return next action for workflow
205
- """
206
 
207
  user_id = state.get("user_id", "unknown")
208
- user_role = state.get("user_role", "landlord")
209
  human_msg = state["messages"][-1]["content"]
210
 
211
- logger.info("πŸ€– Intent Node", user_id=user_id, msg=human_msg[:80])
212
 
213
- # ===== Step 1: Detect Language (once) =====
214
  if "user_language" not in state:
215
- state["user_language"] = detect_language(human_msg)
216
 
217
  language = state["user_language"]
218
- logger.info(f"🌍 Language detected: {language}")
219
 
220
- # ===== Step 2: Determine Intent =====
221
  last_msg_lower = human_msg.lower().strip()
222
 
223
- # Intent keywords
224
  if any(k in last_msg_lower for k in ["list", "publish", "create", "post", "sell", "rent out", "louer", "vendre"]):
225
  intent = "list"
226
- elif any(k in last_msg_lower for k in ["search", "find", "show", "look", "browse", "apartments", "houses", "chercher", "apartment", "maison"]):
227
  intent = "search"
228
  elif any(k in last_msg_lower for k in ["my listings", "my properties", "my apartments", "mes annonces"]):
229
  intent = "my_listings"
@@ -232,70 +265,116 @@ async def intent_node(state: Dict) -> Dict:
232
 
233
  logger.info(f"πŸ“ Intent: {intent}")
234
 
235
- # ===== Step 3: Route Based on Intent =====
236
-
237
  if intent == "list":
238
- # LISTING CREATION FLOW
239
  state["intent"] = "list"
240
  state["allowed"] = True
241
 
242
- # Extract fields from user message
243
- location = extract_location(human_msg)
244
- if location:
245
- state["location"] = location
246
 
247
- bedrooms = extract_number(human_msg)
248
- if bedrooms and bedrooms <= 20: # Sanity check
249
- state["bedrooms"] = int(bedrooms)
250
-
251
- bathrooms = extract_number(human_msg.split("bathroom")[-1] if "bathroom" in human_msg else human_msg)
252
- if bathrooms and bathrooms <= 20:
253
- state["bathrooms"] = int(bathrooms)
254
-
255
- price = extract_number(human_msg)
256
- if price and price > 0:
257
- state["price"] = float(price)
258
-
259
- state["listing_type"] = detect_listing_type(human_msg)
260
- state["price_type"] = detect_price_type(human_msg)
261
 
262
- # Extract amenities
263
- amenities = extract_amenities(human_msg)
264
- if amenities:
265
- state["amenities"] = list(set(state.get("amenities", []) + amenities))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
- # Determine status and next action
268
  missing = get_missing_fields(state)
269
  state["missing_fields"] = missing
270
 
271
  if missing:
272
- # Still collecting required fields
273
  state["status"] = "collecting"
274
- state["next_question"] = get_next_question(missing, language)
275
-
276
- # Build friendly reply
277
- if state.get("bedrooms") and state.get("location"):
278
- state["ai_reply"] = f"Great! {state['bedrooms']}-bed in {state['location']}. {state['next_question']}"
279
- else:
280
- state["ai_reply"] = state["next_question"]
281
  else:
282
- # All required fields present, ask about optional
283
  state["status"] = "checking_optional"
284
- if language == "french":
285
- state["ai_reply"] = "Parfait ! Des équipements ou exigences particulières ?"
286
- else:
287
- state["ai_reply"] = "Perfect! Any amenities or special requirements?"
288
 
289
- logger.info(f"πŸ“‹ Listing status: {state['status']}, missing: {missing}")
290
 
 
291
  elif intent == "search":
292
- # SEARCH FLOW
293
  state["intent"] = "search"
294
  state["status"] = "searching"
295
  state["search_query"] = human_msg
296
  state["allowed"] = True
297
 
298
- # Extract search filters
299
  location = extract_location(human_msg)
300
  if location:
301
  state["location"] = location
@@ -308,7 +387,6 @@ async def intent_node(state: Dict) -> Dict:
308
  if amenities:
309
  state["amenities"] = amenities
310
 
311
- # Extract price range
312
  prices = re.findall(r'(\d+)[k,.]?', human_msg.lower())
313
  if prices:
314
  if len(prices) >= 2:
@@ -317,38 +395,29 @@ async def intent_node(state: Dict) -> Dict:
317
  else:
318
  state["max_price"] = float(prices[0]) * (1000 if 'k' in human_msg.lower() else 1)
319
 
320
- if language == "french":
321
- state["ai_reply"] = "Recherche en cours..."
322
- else:
323
- state["ai_reply"] = "Searching for properties..."
324
-
325
- logger.info(f"πŸ” Search: {location}, bedrooms: {bedrooms}, max_price: {state.get('max_price')}")
326
 
 
327
  elif intent == "my_listings":
328
- # MY LISTINGS FLOW
329
  state["intent"] = "my_listings"
330
  state["status"] = "fetching_listings"
331
  state["allowed"] = True
332
 
333
- if language == "french":
334
- state["ai_reply"] = "RΓ©cupΓ©ration de vos annonces..."
335
- else:
336
- state["ai_reply"] = "Fetching your listings..."
337
-
338
  logger.info(f"πŸ“š My listings requested")
339
 
 
340
  else:
341
- # GREETING / CASUAL CHAT
342
  state["intent"] = "greeting"
343
  state["status"] = "greeting"
344
  state["allowed"] = True
345
 
346
- if language == "french":
347
- state["ai_reply"] = "Bonjour ! πŸ‘‹ Je suis Aida, l'assistant IA immobilier de Lojiz. Je peux vous aider Γ  lister une propriΓ©tΓ©, rechercher des biens, ou discuter de l'immobilier. Qu'aimeriez-vous faire ?"
348
- else:
349
- state["ai_reply"] = "Hello! πŸ‘‹ I'm Aida, Lojiz's real estate AI assistant. I can help you list a property, search for homes, or chat about real estate. What would you like to do?"
350
-
351
- logger.info(f"πŸ‘‹ Greeting mode")
352
 
353
  logger.info(f"βœ… Intent node complete", intent=state.get("intent"), status=state.get("status"))
354
  return state
 
1
+ # app/ai/nodes/intent_node.py - COMPLETE WORKING VERSION
2
  import json
3
  import re
4
  import os
 
23
  )
24
 
25
  # ============================================
26
+ # ML EXTRACTOR - Robust extraction
27
  # ============================================
28
+ try:
29
+ from app.ml.models.ml_listing_extractor import get_ml_extractor
30
+ ml_extractor = get_ml_extractor()
31
+ ML_AVAILABLE = True
32
+ logger.info("βœ… ML Extractor available")
33
+ except Exception as e:
34
+ ml_extractor = None
35
+ ML_AVAILABLE = False
36
+ logger.warning(f"⚠️ ML Extractor not available: {e}")
37
+
38
+
39
+ # ============================================
40
+ # LANGUAGE DETECTION - Using LLM
41
+ # ============================================
42
+ async def detect_language_with_llm(text: str) -> str:
43
+ """Detect user's language using DeepSeek LLM"""
44
+ try:
45
+ response = await client.chat.completions.create(
46
+ model=MODEL,
47
+ messages=[
48
+ {
49
+ "role": "user",
50
+ "content": f"What language is this text written in? Reply with ONLY the language name:\n\n{text[:200]}"
51
+ }
52
+ ],
53
+ temperature=0,
54
+ max_tokens=20,
55
+ )
56
+ language = response.choices[0].message.content.strip()
57
+ logger.info(f"🌍 Language detected: {language}")
58
+ return language
59
+ except Exception as e:
60
+ logger.warning(f"⚠️ Language detection failed: {e}")
61
+ return "English"
62
+
63
+
64
+ # ============================================
65
+ # TRANSLATE TEXT - Using LLM
66
+ # ============================================
67
+ async def translate_to_language(text: str, language: str) -> str:
68
+ """Translate text to user's language"""
69
+ if language.lower() == "english":
70
+ return text
71
 
72
+ try:
73
+ response = await client.chat.completions.create(
74
+ model=MODEL,
75
+ messages=[
76
+ {
77
+ "role": "user",
78
+ "content": f"Translate this to {language} language. Reply with ONLY the translation:\n\n{text}"
79
+ }
80
+ ],
81
+ temperature=0,
82
+ max_tokens=300,
83
+ )
84
+ return response.choices[0].message.content.strip()
85
+ except Exception as e:
86
+ logger.warning(f"⚠️ Translation failed: {e}")
87
+ return text
88
+
89
+
90
+ # ============================================
91
+ # GENERATE EXAMPLE - Using LLM
92
+ # ============================================
93
+ async def generate_listing_example(language: str) -> str:
94
+ """Generate property listing example in user's language"""
95
+ try:
96
+ response = await client.chat.completions.create(
97
+ model=MODEL,
98
+ messages=[
99
+ {
100
+ "role": "user",
101
+ "content": f"""Generate a helpful property listing example in {language} language.
102
+
103
+ Show them an example of what they could say when listing a property.
104
+ Include: bedrooms, bathrooms, location, price, amenities, and requirements.
105
+ Make it friendly and realistic.
106
+ End with: "Tell me about your property! 🏠"
107
+
108
+ Start with: "πŸ“ Here's how to list a property:"
109
+ """
110
+ }
111
+ ],
112
+ temperature=0.3,
113
+ max_tokens=400,
114
+ )
115
+ return response.choices[0].message.content.strip()
116
+ except Exception as e:
117
+ logger.warning(f"⚠️ Example generation failed: {e}")
118
+ return f"πŸ“ Tell me about the property you want to list!"
119
 
120
 
121
  # ============================================
122
  # FIELD EXTRACTION HELPERS
123
  # ============================================
124
  def detect_listing_type(text: str) -> Optional[str]:
125
+ """Auto-detect listing type"""
126
  text_lower = text.lower()
 
127
  if any(w in text_lower for w in ["short stay", "airbnb", "nightly", "daily", "weekly"]):
128
  return "short-stay"
129
+ elif any(w in text_lower for w in ["sale", "sell", "selling", "for sale", "vendre"]):
130
  return "sale"
131
+ elif any(w in text_lower for w in ["roommate", "sharing", "flatmate", "colocataire"]):
132
  return "roommate"
133
  else:
134
  return "rent"
135
 
136
 
137
  def detect_price_type(text: str) -> Optional[str]:
138
+ """Auto-detect price type"""
139
  text_lower = text.lower()
140
+ if any(w in text_lower for w in ["nightly", "night", "daily", "day", "par nuit"]):
 
141
  return "nightly"
142
+ elif any(w in text_lower for w in ["yearly", "year", "annually", "par an"]):
143
  return "yearly"
144
  else:
145
  return "monthly"
 
159
 
160
 
161
  def extract_location(text: str) -> Optional[str]:
162
+ """Extract location (city name)"""
163
  cities = {
164
+ "lagos": "lagos", "cotonou": "cotonou", "calavi": "calavi",
165
+ "paris": "paris", "london": "london", "lyon": "lyon",
166
+ "marseille": "marseille", "nairobi": "nairobi", "accra": "accra",
167
+ "johannesburg": "johannesburg", "kinshasa": "kinshasa", "dakar": "dakar",
168
+ "kampala": "kampala", "cape town": "cape town", "madrid": "madrid",
169
+ "barcelona": "barcelona", "lisbon": "lisbon",
 
 
 
 
 
 
 
 
170
  }
171
 
172
  text_lower = text.lower()
173
  for city_key, city_val in cities.items():
174
  if city_key in text_lower:
175
  return city_val
 
176
  return None
177
 
178
 
 
181
  amenities_list = [
182
  "wifi", "parking", "furnished", "washing machine", "dryer",
183
  "balcony", "pool", "gym", "garden", "air conditioning", "kitchen",
184
+ "ac", "washer", "elevator", "security", "laundry", "heating", "hot water"
185
  ]
186
 
187
  found_amenities = []
 
203
  # REQUIRED FIELDS CHECK
204
  # ============================================
205
  def get_missing_fields(state: Dict) -> List[str]:
206
+ """Check which required fields are missing"""
207
  required = ["location", "bedrooms", "bathrooms", "price", "listing_type", "price_type"]
208
  missing = []
209
 
 
215
  return missing
216
 
217
 
218
+ def get_next_question_en(missing_fields: List[str]) -> Optional[str]:
219
+ """Get next question in English"""
220
  if not missing_fields:
221
  return None
222
 
223
  questions = {
224
+ "location": "What city/area is the property in?",
225
+ "bedrooms": "How many bedrooms does it have?",
226
+ "bathrooms": "How many bathrooms?",
227
+ "price": "What's the price?",
228
+ "listing_type": "Is it for rent, short-stay, sale, or roommate?",
229
+ "price_type": "Is that monthly, nightly, or yearly?",
 
 
 
 
 
 
 
 
 
 
230
  }
231
 
232
+ return questions.get(missing_fields[0], "Tell me more about the property")
 
 
233
 
234
 
235
  # ============================================
 
237
  # ============================================
238
  @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5))
239
  async def intent_node(state: Dict) -> Dict:
240
+ """Main intent detection & extraction node"""
 
 
 
 
 
 
 
 
 
241
 
242
  user_id = state.get("user_id", "unknown")
 
243
  human_msg = state["messages"][-1]["content"]
244
 
245
+ logger.info(f"πŸ€– Intent Node", user_id=user_id, msg=human_msg[:80])
246
 
247
+ # ===== DETECT LANGUAGE =====
248
  if "user_language" not in state:
249
+ state["user_language"] = await detect_language_with_llm(human_msg)
250
 
251
  language = state["user_language"]
252
+ logger.info(f"🌍 Language: {language}")
253
 
254
+ # ===== DETERMINE INTENT =====
255
  last_msg_lower = human_msg.lower().strip()
256
 
 
257
  if any(k in last_msg_lower for k in ["list", "publish", "create", "post", "sell", "rent out", "louer", "vendre"]):
258
  intent = "list"
259
+ elif any(k in last_msg_lower for k in ["search", "find", "show", "look", "browse", "apartments", "houses"]):
260
  intent = "search"
261
  elif any(k in last_msg_lower for k in ["my listings", "my properties", "my apartments", "mes annonces"]):
262
  intent = "my_listings"
 
265
 
266
  logger.info(f"πŸ“ Intent: {intent}")
267
 
268
+ # ===== LIST INTENT =====
 
269
  if intent == "list":
 
270
  state["intent"] = "list"
271
  state["allowed"] = True
272
 
273
+ # First time - show example
274
+ is_first_list = not state.get("location") and not state.get("bedrooms")
 
 
275
 
276
+ if is_first_list:
277
+ example_msg = await generate_listing_example(language)
278
+ state["status"] = "collecting"
279
+ state["ai_reply"] = example_msg
280
+ logger.info("πŸ“ Showing example")
281
+ return state
 
 
 
 
 
 
 
 
282
 
283
+ # Extract fields using ML
284
+ if ML_AVAILABLE and ml_extractor:
285
+ try:
286
+ location = extract_location(human_msg)
287
+ if location:
288
+ state["location"] = location
289
+
290
+ bedrooms_match = re.search(r'(\d+)\s*(?:bed|bedroom|chambre)', human_msg.lower())
291
+ if bedrooms_match:
292
+ state["bedrooms"] = int(bedrooms_match.group(1))
293
+
294
+ bathrooms_match = re.search(r'(\d+)\s*(?:bath|bathroom|salle de bain)', human_msg.lower())
295
+ if bathrooms_match:
296
+ state["bathrooms"] = int(bathrooms_match.group(1))
297
+
298
+ price = extract_number(human_msg)
299
+ if price and price > 0:
300
+ state["price"] = float(price)
301
+
302
+ state["listing_type"] = detect_listing_type(human_msg)
303
+ state["price_type"] = detect_price_type(human_msg)
304
+
305
+ amenities = extract_amenities(human_msg)
306
+ if amenities:
307
+ state["amenities"] = list(set(state.get("amenities", []) + amenities))
308
+
309
+ if any(w in human_msg.lower() for w in ["require", "deposit", "condition", "need"]):
310
+ state["requirements"] = human_msg
311
+
312
+ # Infer currency
313
+ if state.get("location") and not state.get("currency"):
314
+ try:
315
+ currency, country, city, conf = await ml_extractor.infer_currency(state)
316
+ if currency:
317
+ state["currency"] = currency
318
+ except:
319
+ state["currency"] = "XOF"
320
+
321
+ except Exception as e:
322
+ logger.warning(f"⚠️ ML extraction error: {e}")
323
+ location = extract_location(human_msg)
324
+ if location:
325
+ state["location"] = location
326
+ bedrooms = extract_number(human_msg)
327
+ if bedrooms and bedrooms <= 20:
328
+ state["bedrooms"] = int(bedrooms)
329
+ price = extract_number(human_msg)
330
+ if price and price > 0:
331
+ state["price"] = float(price)
332
+ state["listing_type"] = detect_listing_type(human_msg)
333
+ state["price_type"] = detect_price_type(human_msg)
334
+ amenities = extract_amenities(human_msg)
335
+ if amenities:
336
+ state["amenities"] = list(set(state.get("amenities", []) + amenities))
337
+ state["currency"] = "XOF"
338
+ else:
339
+ location = extract_location(human_msg)
340
+ if location:
341
+ state["location"] = location
342
+ bedrooms = extract_number(human_msg)
343
+ if bedrooms and bedrooms <= 20:
344
+ state["bedrooms"] = int(bedrooms)
345
+ price = extract_number(human_msg)
346
+ if price and price > 0:
347
+ state["price"] = float(price)
348
+ state["listing_type"] = detect_listing_type(human_msg)
349
+ state["price_type"] = detect_price_type(human_msg)
350
+ amenities = extract_amenities(human_msg)
351
+ if amenities:
352
+ state["amenities"] = list(set(state.get("amenities", []) + amenities))
353
+ state["currency"] = "XOF"
354
 
355
+ # Check missing fields
356
  missing = get_missing_fields(state)
357
  state["missing_fields"] = missing
358
 
359
  if missing:
 
360
  state["status"] = "collecting"
361
+ question_en = get_next_question_en(missing)
362
+ state["next_question"] = question_en
363
+ state["ai_reply"] = await translate_to_language(question_en, language)
 
 
 
 
364
  else:
 
365
  state["status"] = "checking_optional"
366
+ optional_q = "Perfect! Any amenities or special requirements?"
367
+ state["ai_reply"] = await translate_to_language(optional_q, language)
 
 
368
 
369
+ logger.info(f"πŸ“‹ Listing: status={state['status']}, missing={missing}")
370
 
371
+ # ===== SEARCH INTENT =====
372
  elif intent == "search":
 
373
  state["intent"] = "search"
374
  state["status"] = "searching"
375
  state["search_query"] = human_msg
376
  state["allowed"] = True
377
 
 
378
  location = extract_location(human_msg)
379
  if location:
380
  state["location"] = location
 
387
  if amenities:
388
  state["amenities"] = amenities
389
 
 
390
  prices = re.findall(r'(\d+)[k,.]?', human_msg.lower())
391
  if prices:
392
  if len(prices) >= 2:
 
395
  else:
396
  state["max_price"] = float(prices[0]) * (1000 if 'k' in human_msg.lower() else 1)
397
 
398
+ searching_msg = "Searching for properties..."
399
+ state["ai_reply"] = await translate_to_language(searching_msg, language)
400
+ logger.info(f"πŸ” Search initiated")
 
 
 
401
 
402
+ # ===== MY LISTINGS INTENT =====
403
  elif intent == "my_listings":
 
404
  state["intent"] = "my_listings"
405
  state["status"] = "fetching_listings"
406
  state["allowed"] = True
407
 
408
+ fetching_msg = "Fetching your listings..."
409
+ state["ai_reply"] = await translate_to_language(fetching_msg, language)
 
 
 
410
  logger.info(f"πŸ“š My listings requested")
411
 
412
+ # ===== GREETING =====
413
  else:
 
414
  state["intent"] = "greeting"
415
  state["status"] = "greeting"
416
  state["allowed"] = True
417
 
418
+ greeting_msg = "Hello! πŸ‘‹ I'm Aida, Lojiz's real estate AI assistant. I can help you list a property, search for homes, or chat about real estate. What would you like to do?"
419
+ state["ai_reply"] = await translate_to_language(greeting_msg, language)
420
+ logger.info(f"πŸ‘‹ Greeting in {language}")
 
 
 
421
 
422
  logger.info(f"βœ… Intent node complete", intent=state.get("intent"), status=state.get("status"))
423
  return state
app/ai/routes/chat.py CHANGED
@@ -1,4 +1,4 @@
1
- # app/routes/chat.py - COMPLETE WORKING API ROUTE
2
  from fastapi import APIRouter, Depends, HTTPException
3
  from fastapi.security import HTTPBearer
4
  from pydantic import BaseModel
@@ -17,14 +17,8 @@ security = HTTPBearer()
17
  # ============================================
18
  # REQUEST/RESPONSE MODELS
19
  # ============================================
20
- class MessageHistory(BaseModel):
21
- """Chat message"""
22
- role: str # 'user' or 'assistant'
23
- content: str
24
-
25
-
26
  class AskBody(BaseModel):
27
- """Chat request"""
28
  message: str
29
  thread_id: Optional[str] = None
30
 
@@ -32,7 +26,7 @@ class AskBody(BaseModel):
32
  class AskResponse(BaseModel):
33
  """Chat response"""
34
  text: str # Main AI reply
35
- intent: Optional[str] = None # 'list' | 'search' | 'greeting'
36
  status: Optional[str] = None # Current flow state
37
  draft_preview: Optional[Dict[str, Any]] = None # For listings
38
  search_results: Optional[List[Dict]] = None # For searches
 
1
+ # app/routes/chat.py - COMPLETE AIDA CHAT ROUTES
2
  from fastapi import APIRouter, Depends, HTTPException
3
  from fastapi.security import HTTPBearer
4
  from pydantic import BaseModel
 
17
  # ============================================
18
  # REQUEST/RESPONSE MODELS
19
  # ============================================
 
 
 
 
 
 
20
  class AskBody(BaseModel):
21
+ """Chat request body"""
22
  message: str
23
  thread_id: Optional[str] = None
24
 
 
26
  class AskResponse(BaseModel):
27
  """Chat response"""
28
  text: str # Main AI reply
29
+ intent: Optional[str] = None # 'list' | 'search' | 'greeting' | 'my_listings'
30
  status: Optional[str] = None # Current flow state
31
  draft_preview: Optional[Dict[str, Any]] = None # For listings
32
  search_results: Optional[List[Dict]] = None # For searches
app/ai/service.py CHANGED
@@ -1,4 +1,4 @@
1
- # app/ai/service.py - COMPLETE WORKING SERVICE
2
  import json
3
  from typing import Dict, Any
4
  from structlog import get_logger
@@ -51,7 +51,6 @@ async def aida_chat_sync(
51
  "amenities": [],
52
  "image_urls": [],
53
  "search_results": [],
54
- "user_corrections": {},
55
  }
56
 
57
  logger.info(f"πŸ“Š Initial state created")
 
1
+ # app/ai/service.py - COMPLETE SERVICE LAYER
2
  import json
3
  from typing import Dict, Any
4
  from structlog import get_logger
 
51
  "amenities": [],
52
  "image_urls": [],
53
  "search_results": [],
 
54
  }
55
 
56
  logger.info(f"πŸ“Š Initial state created")
app/ai/state.py CHANGED
@@ -1,45 +1,41 @@
1
- # app/ai/state.py - Complete State Definition
2
  from typing import TypedDict, List, Optional, Dict, Any
3
- from datetime import datetime
4
 
5
  class ChatState(TypedDict, total=False):
6
  """Complete state for Aida AI conversation"""
7
 
8
  # ============ User Info ============
9
- user_id: str # Current user
10
  user_role: str # 'landlord' or 'renter'
11
- user_language: str # Detected language (english, french, yoruba, etc)
12
 
13
  # ============ Conversation ============
14
  messages: List[Dict[str, str]] # Chat history: [{"role": "user"/"assistant", "content": "..."}]
15
 
16
  # ============ Intent & Status ============
17
- intent: str # 'list' | 'search' | 'greeting' | 'my_listings' | 'edit_listing'
18
- status: str # Current flow state (see below)
19
  allowed: bool # Permission check passed?
20
 
21
  # ============ Listing Creation Fields ============
22
- location: Optional[str]
23
- bedrooms: Optional[int]
24
- bathrooms: Optional[int]
25
- price: Optional[float]
26
  price_type: Optional[str] # 'monthly' | 'nightly' | 'yearly'
27
  listing_type: Optional[str] # 'rent' | 'short-stay' | 'sale' | 'roommate'
28
- amenities: List[str]
29
- requirements: Optional[str]
30
- currency: Optional[str]
31
 
32
  # ============ Search Fields ============
33
- search_query: Optional[str]
34
- min_price: Optional[float]
35
- max_price: Optional[float]
36
 
37
  # ============ Collection Flow ============
38
  missing_fields: List[str] # Fields still needed
39
- next_question: Optional[str] # What to ask user next
40
-
41
- # ============ Corrections (IMPORTANT) ============
42
- user_corrections: Dict[str, Any] # Track user-corrected fields {field: value}
43
 
44
  # ============ Draft & Preview ============
45
  draft_preview: Optional[Dict[str, Any]] # Formatted draft shown to user
@@ -48,55 +44,10 @@ class ChatState(TypedDict, total=False):
48
  mongo_id: Optional[str] # MongoDB ID after publish
49
 
50
  # ============ Images ============
51
- image_urls: List[str]
52
 
53
  # ============ Results ============
54
- search_results: List[Dict[str, Any]]
55
 
56
  # ============ AI Response ============
57
- ai_reply: str # What Aida says to user
58
-
59
-
60
- # ============ Status Flow Definition ============
61
- """
62
- LISTING CREATION FLOW:
63
- 'greeting'
64
- ↓ (user says "list")
65
- 'collecting' β†’ asking for missing required fields one-by-one
66
- ↓ (all required fields complete)
67
- 'checking_optional' β†’ asking about amenities/requirements
68
- ↓ (optional fields handled)
69
- 'draft_ready' β†’ draft generated, ready to show
70
- ↓ (show preview to user)
71
- 'preview_shown' β†’ user sees draft, can edit/publish/discard
72
- ↓ (user says "publish")
73
- 'publishing' β†’ saving to MongoDB
74
- ↓
75
- 'published' β†’ success
76
- ↓ (user says "edit")
77
- 'editing' β†’ user provides new value
78
- ↓
79
- 'preview_shown' β†’ updated draft shown (loop back)
80
- ↓ (user says "discard")
81
- 'discarded' β†’ draft cleared, back to greeting
82
-
83
- SEARCH FLOW:
84
- 'greeting'
85
- ↓ (user says "search")
86
- 'collecting' β†’ extracting all search filters
87
- ↓
88
- 'searching' β†’ querying Qdrant
89
- ↓
90
- 'results_shown' β†’ displaying results
91
- ↓
92
- 'greeting' β†’ ready for next action
93
-
94
- MY LISTINGS FLOW:
95
- 'greeting'
96
- ↓ (user says "show my listings")
97
- 'fetching_listings' β†’ getting user's listings from MongoDB
98
- ↓
99
- 'listings_shown' β†’ displaying list
100
- ↓
101
- 'greeting' β†’ ready for next action
102
- """
 
1
+ # app/ai/state.py - Complete State Definition for LangGraph
2
  from typing import TypedDict, List, Optional, Dict, Any
 
3
 
4
  class ChatState(TypedDict, total=False):
5
  """Complete state for Aida AI conversation"""
6
 
7
  # ============ User Info ============
8
+ user_id: str # Current user ID
9
  user_role: str # 'landlord' or 'renter'
10
+ user_language: str # Detected language (English, French, Yoruba, etc)
11
 
12
  # ============ Conversation ============
13
  messages: List[Dict[str, str]] # Chat history: [{"role": "user"/"assistant", "content": "..."}]
14
 
15
  # ============ Intent & Status ============
16
+ intent: str # 'list' | 'search' | 'greeting' | 'my_listings'
17
+ status: str # Current flow state
18
  allowed: bool # Permission check passed?
19
 
20
  # ============ Listing Creation Fields ============
21
+ location: Optional[str] # City name
22
+ bedrooms: Optional[int] # Number of bedrooms
23
+ bathrooms: Optional[int] # Number of bathrooms
24
+ price: Optional[float] # Price amount
25
  price_type: Optional[str] # 'monthly' | 'nightly' | 'yearly'
26
  listing_type: Optional[str] # 'rent' | 'short-stay' | 'sale' | 'roommate'
27
+ amenities: List[str] # List of amenities
28
+ requirements: Optional[str] # Special requirements
29
+ currency: Optional[str] # Currency code (XOF, NGN, EUR, etc)
30
 
31
  # ============ Search Fields ============
32
+ search_query: Optional[str] # User's search query
33
+ min_price: Optional[float] # Minimum price filter
34
+ max_price: Optional[float] # Maximum price filter
35
 
36
  # ============ Collection Flow ============
37
  missing_fields: List[str] # Fields still needed
38
+ next_question: Optional[str] # Next question to ask user
 
 
 
39
 
40
  # ============ Draft & Preview ============
41
  draft_preview: Optional[Dict[str, Any]] # Formatted draft shown to user
 
44
  mongo_id: Optional[str] # MongoDB ID after publish
45
 
46
  # ============ Images ============
47
+ image_urls: List[str] # Cloudflare image URLs
48
 
49
  # ============ Results ============
50
+ search_results: List[Dict[str, Any]] # Search results
51
 
52
  # ============ AI Response ============
53
+ ai_reply: str # What Aida says to user