destinyebuka commited on
Commit
239f45c
Β·
1 Parent(s): acd0992
Files changed (2) hide show
  1. app/ai/graph.py +12 -4
  2. app/ai/nodes/draft_node.py +39 -48
app/ai/graph.py CHANGED
@@ -1,4 +1,4 @@
1
- # app/ai/graph.py – FINAL POST-DRAFT ROUTING
2
  from langgraph.graph import StateGraph, START, END
3
  from app.ai.state import ChatState
4
  from app.ai.nodes.intent_node import intent_node
@@ -44,7 +44,7 @@ workflow.add_conditional_edges("check_permissions", route_from_permissions,
44
 
45
  workflow.add_edge("search_listings", END)
46
 
47
- # ---------- POST-DRAFT DECISION (language-agnostic keywords) ----------
48
  _KEYWORDS_PUBLISH = {"publish", "publier", "go live", "post it", "list it", "confirm", "yes", "ok", "okay"}
49
  _KEYWORDS_EDIT = {"edit", "modifier", "change", "update", "correction", "fix"}
50
  _KEYWORDS_DISCARD = {"discard", "delete", "cancel", "annuler", "remove", "start over"}
@@ -57,10 +57,18 @@ def route_after_draft(state: ChatState):
57
  return "handle_edit"
58
  if any(k in last for k in _KEYWORDS_DISCARD):
59
  return "handle_discard"
60
- # treat as fresh intent
 
 
 
 
 
61
  return "parse_intent"
62
 
63
- workflow.add_conditional_edges("create_draft", route_after_draft,
 
 
 
64
  {"handle_publish": "handle_publish",
65
  "handle_edit": "handle_edit",
66
  "handle_discard": "handle_discard",
 
1
+ # app/ai/graph.py – FINAL (LangGraph only)
2
  from langgraph.graph import StateGraph, START, END
3
  from app.ai.state import ChatState
4
  from app.ai.nodes.intent_node import intent_node
 
44
 
45
  workflow.add_edge("search_listings", END)
46
 
47
+ # ---------- POST-DRAFT ROUTING (language-agnostic) ----------
48
  _KEYWORDS_PUBLISH = {"publish", "publier", "go live", "post it", "list it", "confirm", "yes", "ok", "okay"}
49
  _KEYWORDS_EDIT = {"edit", "modifier", "change", "update", "correction", "fix"}
50
  _KEYWORDS_DISCARD = {"discard", "delete", "cancel", "annuler", "remove", "start over"}
 
57
  return "handle_edit"
58
  if any(k in last for k in _KEYWORDS_DISCARD):
59
  return "handle_discard"
60
+ return "parse_intent" # fall back to normal intent
61
+
62
+ def gate_before_intent(state: ChatState):
63
+ # if draft is on screen, go straight to post-draft router
64
+ if state.get("status") == "preview_shown":
65
+ return "route_after_draft"
66
  return "parse_intent"
67
 
68
+ workflow.add_conditional_edges(START, gate_before_intent,
69
+ {"route_after_draft": "route_after_draft",
70
+ "parse_intent": "parse_intent"})
71
+ workflow.add_conditional_edges("route_after_draft", route_after_draft,
72
  {"handle_publish": "handle_publish",
73
  "handle_edit": "handle_edit",
74
  "handle_discard": "handle_discard",
app/ai/nodes/draft_node.py CHANGED
@@ -1,4 +1,4 @@
1
- # app/ai/nodes/draft_node.py - Fix location persistence + add amenity icons
2
  import datetime
3
  from typing import Dict
4
  from bson import ObjectId
@@ -14,7 +14,7 @@ logger = get_logger(__name__)
14
  client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
15
  ml_extractor = get_ml_extractor()
16
 
17
- # ========== AMENITY ICONS MAPPING ==========
18
  AMENITY_ICONS = {
19
  "wifi": "πŸ“Ά",
20
  "parking": "πŸ…ΏοΈ",
@@ -32,58 +32,49 @@ AMENITY_ICONS = {
32
  }
33
 
34
  def _add_amenity_icons(amenities: list) -> str:
35
- """Convert amenities list to string with icons."""
36
  if not amenities:
37
  return ""
38
-
39
  icons_text = []
40
  for amenity in amenities:
41
  amenity_lower = amenity.lower().strip()
42
  icon = AMENITY_ICONS.get(amenity_lower, "βœ“")
43
  icons_text.append(f"{icon} {amenity.title()}")
44
-
45
  return " | ".join(icons_text)
46
 
47
  def _generate_title(state: Dict) -> str:
48
- """Generate professional title from listing data."""
49
  bedrooms = state.get("bedrooms", "")
50
- # βœ… CRITICAL: Use current location from state, not regenerated
51
  location = state.get("location", "").title()
52
  listing_type = state.get("listing_type", "").title()
53
-
54
  if bedrooms and location:
55
  return f"{bedrooms}-Bedroom {listing_type} in {location}"
56
  return f"Property in {location}"
57
 
58
  def _generate_description(state: Dict) -> str:
59
- """Generate professional description from listing data."""
60
  bedrooms = state.get("bedrooms", "")
61
  bathrooms = state.get("bathrooms", "")
62
- # βœ… CRITICAL: Use current location from state, not regenerated
63
  location = state.get("location", "").title()
64
  amenities = state.get("amenities", [])
65
  price = state.get("price", "")
66
  price_type = state.get("price_type", "").title()
67
  listing_type = state.get("listing_type", "").title()
68
  requirements = state.get("requirements", "")
69
-
70
  desc = f"Spacious {bedrooms}-bedroom, {bathrooms}-bathroom {listing_type} "
71
  desc += f"located in {location}. "
72
-
73
  if price:
74
  desc += f"Priced at {price:,} {price_type}. "
75
-
76
  if amenities:
77
- amenities_str = ", ".join(amenities)
78
- desc += f"Fully furnished with modern amenities including {amenities_str}. "
79
-
80
  if requirements:
81
  desc += f"Requirements: {requirements}. "
82
-
83
  desc += "Perfect for large families or shared accommodation."
84
  return desc
85
 
86
- # ---------- node ----------
87
  async def draft_node(state: Dict) -> Dict:
88
  """
89
  LangGraph node:
@@ -92,106 +83,102 @@ async def draft_node(state: Dict) -> Dict:
92
  - Move to "draft_ready"
93
  - Or status == "draft_ready" β†’ show preview
94
  """
95
-
96
  # βœ… NEW: Check if amenities/requirements are missing
97
  if state.get("status") == "checking_optional":
98
  amenities = state.get("amenities", [])
99
  requirements = state.get("requirements")
100
-
101
- # Ask about missing optional fields
102
  missing_optional = []
103
  if not amenities:
104
  missing_optional.append("amenities")
105
  if not requirements:
106
  missing_optional.append("requirements")
107
-
108
  if missing_optional:
109
  state["status"] = "collecting_optional"
110
  state["missing_fields"] = missing_optional
111
-
112
  questions = []
113
  if "amenities" in missing_optional:
114
  questions.append("Any amenities? (e.g., wifi, parking, balcony, pool, furnished, kitchen, dryer, garden, etc.)")
115
  if "requirements" in missing_optional:
116
  questions.append("Any special requirements for renters?")
117
-
118
  state["ai_reply"] = "Just a couple more things...\n\n" + "\n".join([f"β€’ {q}" for q in questions])
119
  logger.info("ℹ️ Asking for optional fields", missing=missing_optional)
120
  return state
121
  else:
122
  # All optional fields provided, move to draft
123
  state["status"] = "draft_ready"
124
-
125
  # Only process if listing creation with all fields ready
126
  if state.get("intent") != "list" or state.get("status") != "draft_ready":
127
  return state
128
-
129
  user_id = state.get("user_id")
130
-
131
  # βœ… ML VALIDATION before drafting
132
  try:
133
  validation = ml_extractor.validate_all_fields(state, user_id)
134
-
135
  if not validation["all_valid"]:
136
- # Fields failed validation, go back to collecting
137
  issues_text = "\n".join([f"❌ {issue}" for issue in validation["issues"]])
138
  state["ai_reply"] = f"""I found some issues with your listing:
139
 
140
  {issues_text}
141
 
142
  Let me ask again - could you clarify these fields?"""
143
-
144
  state["status"] = "collecting"
145
- # Re-populate missing fields based on validation
146
  state["missing_fields"] = [
147
  field for field, result in validation["field_validations"].items()
148
  if not result["is_valid"]
149
  ]
150
-
151
  logger.warning("🚫 Fields failed ML validation", issues=validation["issues"])
152
  return state
153
-
154
  logger.info("βœ… All fields passed ML validation", user_id=user_id)
155
-
156
  except Exception as e:
157
  logger.error("❌ ML validation error", exc_info=e)
158
  state["ai_reply"] = "Sorry, I couldn't validate your listing. Please try again."
159
  state["status"] = "error"
160
  return state
161
-
162
  # Generate title and description
163
- # βœ… CRITICAL: These use state's current location (corrected by user)
164
  title = _generate_title(state)
165
  description = _generate_description(state)
166
  amenities_with_icons = _add_amenity_icons(state.get("amenities", []))
167
-
168
  # βœ… Get images from state (if any were uploaded)
169
  images = state.get("draft", {}).get("images", []) if isinstance(state.get("draft"), dict) else []
170
-
171
  # Build draft preview with all fields including images and icons
172
  draft_preview = {
173
  "title": title,
174
  "description": description,
175
- "location": state.get("location", "").title(), # βœ… Use current location
176
  "bedrooms": state.get("bedrooms"),
177
  "bathrooms": state.get("bathrooms"),
178
  "price": state.get("price"),
179
  "price_type": state.get("price_type"),
180
  "listing_type": state.get("listing_type"),
181
  "amenities": state.get("amenities", []),
182
- "amenities_with_icons": amenities_with_icons, # βœ… NEW: Add icons
183
  "requirements": state.get("requirements"),
184
  "currency": state.get("currency", "XOF"),
185
- "images": images, # βœ… INCLUDE IMAGES
186
  "field_confidences": validation["field_validations"],
187
  }
188
-
189
  logger.info("🎯 Draft preview generated",
190
  title=title,
191
- location=state.get("location"), # βœ… Log actual location
192
  image_count=len(images),
193
  amenities=state.get("amenities", []))
194
-
195
  # Build nice preview message for user
196
  images_section = ""
197
  if images:
@@ -200,7 +187,7 @@ Let me ask again - could you clarify these fields?"""
200
  images_section += f" {idx}. {img_url[:60]}...\n"
201
  if len(images) > 3:
202
  images_section += f" ... and {len(images) - 3} more\n"
203
-
204
  preview_text = f"""
205
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
206
  🏠 LISTING PREVIEW
@@ -219,7 +206,7 @@ Let me ask again - could you clarify these fields?"""
219
  {images_section}
220
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
221
  """
222
-
223
  # βœ… Check if images uploaded, ask if not
224
  if not images:
225
  preview_text += """
@@ -233,8 +220,12 @@ Then say **publish** to make it live!
233
  βœ… Perfect! Say **publish** to make your listing live!
234
  """
235
  state["status"] = "preview_shown" # βœ… Ready to publish
236
-
237
  state["draft_preview"] = draft_preview
238
  state["ai_reply"] = preview_text
239
-
 
 
 
 
240
  return state
 
1
+ # app/ai/nodes/draft_node.py – COMPLETE FILE (final)
2
  import datetime
3
  from typing import Dict
4
  from bson import ObjectId
 
14
  client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
15
  ml_extractor = get_ml_extractor()
16
 
17
+ # ========== AMENITY ICONS ==========
18
  AMENITY_ICONS = {
19
  "wifi": "πŸ“Ά",
20
  "parking": "πŸ…ΏοΈ",
 
32
  }
33
 
34
  def _add_amenity_icons(amenities: list) -> str:
 
35
  if not amenities:
36
  return ""
 
37
  icons_text = []
38
  for amenity in amenities:
39
  amenity_lower = amenity.lower().strip()
40
  icon = AMENITY_ICONS.get(amenity_lower, "βœ“")
41
  icons_text.append(f"{icon} {amenity.title()}")
 
42
  return " | ".join(icons_text)
43
 
44
  def _generate_title(state: Dict) -> str:
 
45
  bedrooms = state.get("bedrooms", "")
 
46
  location = state.get("location", "").title()
47
  listing_type = state.get("listing_type", "").title()
 
48
  if bedrooms and location:
49
  return f"{bedrooms}-Bedroom {listing_type} in {location}"
50
  return f"Property in {location}"
51
 
52
  def _generate_description(state: Dict) -> str:
 
53
  bedrooms = state.get("bedrooms", "")
54
  bathrooms = state.get("bathrooms", "")
 
55
  location = state.get("location", "").title()
56
  amenities = state.get("amenities", [])
57
  price = state.get("price", "")
58
  price_type = state.get("price_type", "").title()
59
  listing_type = state.get("listing_type", "").title()
60
  requirements = state.get("requirements", "")
61
+
62
  desc = f"Spacious {bedrooms}-bedroom, {bathrooms}-bathroom {listing_type} "
63
  desc += f"located in {location}. "
64
+
65
  if price:
66
  desc += f"Priced at {price:,} {price_type}. "
67
+
68
  if amenities:
69
+ desc += f"Fully furnished with modern amenities including {', '.join(amenities)}. "
70
+
 
71
  if requirements:
72
  desc += f"Requirements: {requirements}. "
73
+
74
  desc += "Perfect for large families or shared accommodation."
75
  return desc
76
 
77
+ # ========== MAIN NODE ==========
78
  async def draft_node(state: Dict) -> Dict:
79
  """
80
  LangGraph node:
 
83
  - Move to "draft_ready"
84
  - Or status == "draft_ready" β†’ show preview
85
  """
86
+
87
  # βœ… NEW: Check if amenities/requirements are missing
88
  if state.get("status") == "checking_optional":
89
  amenities = state.get("amenities", [])
90
  requirements = state.get("requirements")
91
+
 
92
  missing_optional = []
93
  if not amenities:
94
  missing_optional.append("amenities")
95
  if not requirements:
96
  missing_optional.append("requirements")
97
+
98
  if missing_optional:
99
  state["status"] = "collecting_optional"
100
  state["missing_fields"] = missing_optional
101
+
102
  questions = []
103
  if "amenities" in missing_optional:
104
  questions.append("Any amenities? (e.g., wifi, parking, balcony, pool, furnished, kitchen, dryer, garden, etc.)")
105
  if "requirements" in missing_optional:
106
  questions.append("Any special requirements for renters?")
107
+
108
  state["ai_reply"] = "Just a couple more things...\n\n" + "\n".join([f"β€’ {q}" for q in questions])
109
  logger.info("ℹ️ Asking for optional fields", missing=missing_optional)
110
  return state
111
  else:
112
  # All optional fields provided, move to draft
113
  state["status"] = "draft_ready"
114
+
115
  # Only process if listing creation with all fields ready
116
  if state.get("intent") != "list" or state.get("status") != "draft_ready":
117
  return state
118
+
119
  user_id = state.get("user_id")
120
+
121
  # βœ… ML VALIDATION before drafting
122
  try:
123
  validation = ml_extractor.validate_all_fields(state, user_id)
124
+
125
  if not validation["all_valid"]:
 
126
  issues_text = "\n".join([f"❌ {issue}" for issue in validation["issues"]])
127
  state["ai_reply"] = f"""I found some issues with your listing:
128
 
129
  {issues_text}
130
 
131
  Let me ask again - could you clarify these fields?"""
132
+
133
  state["status"] = "collecting"
 
134
  state["missing_fields"] = [
135
  field for field, result in validation["field_validations"].items()
136
  if not result["is_valid"]
137
  ]
138
+
139
  logger.warning("🚫 Fields failed ML validation", issues=validation["issues"])
140
  return state
141
+
142
  logger.info("βœ… All fields passed ML validation", user_id=user_id)
143
+
144
  except Exception as e:
145
  logger.error("❌ ML validation error", exc_info=e)
146
  state["ai_reply"] = "Sorry, I couldn't validate your listing. Please try again."
147
  state["status"] = "error"
148
  return state
149
+
150
  # Generate title and description
 
151
  title = _generate_title(state)
152
  description = _generate_description(state)
153
  amenities_with_icons = _add_amenity_icons(state.get("amenities", []))
154
+
155
  # βœ… Get images from state (if any were uploaded)
156
  images = state.get("draft", {}).get("images", []) if isinstance(state.get("draft"), dict) else []
157
+
158
  # Build draft preview with all fields including images and icons
159
  draft_preview = {
160
  "title": title,
161
  "description": description,
162
+ "location": state.get("location", "").title(),
163
  "bedrooms": state.get("bedrooms"),
164
  "bathrooms": state.get("bathrooms"),
165
  "price": state.get("price"),
166
  "price_type": state.get("price_type"),
167
  "listing_type": state.get("listing_type"),
168
  "amenities": state.get("amenities", []),
169
+ "amenities_with_icons": amenities_with_icons,
170
  "requirements": state.get("requirements"),
171
  "currency": state.get("currency", "XOF"),
172
+ "images": images,
173
  "field_confidences": validation["field_validations"],
174
  }
175
+
176
  logger.info("🎯 Draft preview generated",
177
  title=title,
178
+ location=state.get("location"),
179
  image_count=len(images),
180
  amenities=state.get("amenities", []))
181
+
182
  # Build nice preview message for user
183
  images_section = ""
184
  if images:
 
187
  images_section += f" {idx}. {img_url[:60]}...\n"
188
  if len(images) > 3:
189
  images_section += f" ... and {len(images) - 3} more\n"
190
+
191
  preview_text = f"""
192
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
193
  🏠 LISTING PREVIEW
 
206
  {images_section}
207
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
208
  """
209
+
210
  # βœ… Check if images uploaded, ask if not
211
  if not images:
212
  preview_text += """
 
220
  βœ… Perfect! Say **publish** to make your listing live!
221
  """
222
  state["status"] = "preview_shown" # βœ… Ready to publish
223
+
224
  state["draft_preview"] = draft_preview
225
  state["ai_reply"] = preview_text
226
+
227
+ # ------------------------------------------------------------------
228
+ # FINAL FLAG – triggers the gate in graph.py
229
+ # ------------------------------------------------------------------
230
+ state["status"] = "preview_shown"
231
  return state