destinyebuka commited on
Commit
3591b35
Β·
1 Parent(s): 3fcd87b
app/ai/agent/graph.py CHANGED
@@ -1,8 +1,6 @@
1
  # app/ai/agent/graph.py
2
  """
3
- AIDA Agent Graph - PROPER LangGraph Implementation
4
- This is the correct way to build production AI agents.
5
- Used by: OpenAI, Anthropic, Cognition, etc.
6
  """
7
 
8
  from typing import Literal
@@ -62,7 +60,14 @@ def route_after_listing_collect(state: AgentState) -> str:
62
  - If ready β†’ go to listing_validate
63
  """
64
 
65
- if state.missing_required_fields:
 
 
 
 
 
 
 
66
  logger.info("Still missing fields, staying in listing_collect")
67
  return "listing_collect"
68
  else:
@@ -163,7 +168,7 @@ def build_aida_graph():
163
  }
164
  )
165
 
166
- # listing_publish (only when user confirms) β†’ validate_output
167
  graph.add_edge("listing_publish", "validate_output")
168
 
169
  # Search β†’ validate_output
@@ -184,9 +189,12 @@ def build_aida_graph():
184
  # COMPILE (Create executable graph)
185
  # ============================================================
186
 
187
- compiled_graph = graph.compile()
 
 
 
188
 
189
- logger.info("βœ… LangGraph compiled and ready")
190
 
191
  return compiled_graph
192
 
 
1
  # app/ai/agent/graph.py
2
  """
3
+ AIDA Agent Graph - PROPER LangGraph Implementation with Recursion Limit
 
 
4
  """
5
 
6
  from typing import Literal
 
60
  - If ready β†’ go to listing_validate
61
  """
62
 
63
+ # βœ… CRITICAL FIX: Check if we have all required fields
64
+ required = ["location", "bedrooms", "bathrooms", "price", "price_type"]
65
+ has_all = all(
66
+ state.provided_fields.get(f) is not None
67
+ for f in required
68
+ )
69
+
70
+ if not has_all:
71
  logger.info("Still missing fields, staying in listing_collect")
72
  return "listing_collect"
73
  else:
 
168
  }
169
  )
170
 
171
+ # listing_publish β†’ validate_output
172
  graph.add_edge("listing_publish", "validate_output")
173
 
174
  # Search β†’ validate_output
 
189
  # COMPILE (Create executable graph)
190
  # ============================================================
191
 
192
+ # βœ… CRITICAL FIX: Set recursion_limit to prevent infinite loops
193
+ compiled_graph = graph.compile(
194
+ recursion_limit=100 # βœ… Increased from default 25 to 100
195
+ )
196
 
197
+ logger.info("βœ… LangGraph compiled and ready", recursion_limit=100)
198
 
199
  return compiled_graph
200
 
app/ai/agent/nodes/classify_intent.py CHANGED
@@ -1,12 +1,11 @@
1
  # app/ai/agent/nodes/classify_intent.py
2
  """
3
- Node 2: Classify user intent.
4
- Uses LLM to understand what the user wants to do.
5
- Validates output with strict schema.
6
  """
7
 
8
  import json
9
  import re
 
10
  from structlog import get_logger
11
  from langchain_openai import ChatOpenAI
12
  from langchain_core.messages import SystemMessage, HumanMessage
@@ -26,7 +25,6 @@ llm = ChatOpenAI(
26
  temperature=0.3, # Lower temp for classification
27
  )
28
 
29
-
30
  CLASSIFICATION_PROMPT = """You are AIDA, an intelligent intent classifier for a real estate platform.
31
 
32
  Your task: Understand what the user is trying to do.
@@ -55,22 +53,91 @@ Examples:
55
  - "Find me a 2-bed in Lagos" β†’ {{"type": "search", "confidence": 0.90, "reasoning": "User searching for properties", "requires_auth": false, "next_action": "execute_search"}}
56
  - "What's 2+2?" β†’ {{"type": "casual_chat", "confidence": 0.85, "reasoning": "General question", "requires_auth": false, "next_action": "respond_naturally"}}"""
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
- async def classify_intent(
60
- state: AgentState,
61
- ) -> AgentState:
62
  """
63
- Classify user intent using LLM.
64
 
65
- Args:
66
- state: Current agent state (must have last_user_message)
67
 
68
- Returns:
69
- Updated agent state
70
 
71
- Transitions to:
72
- - GREETING, LISTING_COLLECT, SEARCH_QUERY, CASUAL_CHAT (based on intent)
73
- - ERROR if classification fails
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  """
75
 
76
  if not state.last_user_message:
@@ -79,6 +146,25 @@ async def classify_intent(
79
  state.transition_to(FlowState.ERROR, reason="Missing user message")
80
  return state
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  logger.info(
83
  "Classifying intent",
84
  user_id=state.user_id,
 
1
  # app/ai/agent/nodes/classify_intent.py
2
  """
3
+ Enhanced intent classification with resume detection and smart switching
 
 
4
  """
5
 
6
  import json
7
  import re
8
+ from typing import Dict
9
  from structlog import get_logger
10
  from langchain_openai import ChatOpenAI
11
  from langchain_core.messages import SystemMessage, HumanMessage
 
25
  temperature=0.3, # Lower temp for classification
26
  )
27
 
 
28
  CLASSIFICATION_PROMPT = """You are AIDA, an intelligent intent classifier for a real estate platform.
29
 
30
  Your task: Understand what the user is trying to do.
 
53
  - "Find me a 2-bed in Lagos" β†’ {{"type": "search", "confidence": 0.90, "reasoning": "User searching for properties", "requires_auth": false, "next_action": "execute_search"}}
54
  - "What's 2+2?" β†’ {{"type": "casual_chat", "confidence": 0.85, "reasoning": "General question", "requires_auth": false, "next_action": "respond_naturally"}}"""
55
 
56
+ def _has_saved_listing_progress(state: AgentState) -> bool:
57
+ """Check if user has saved listing progress to resume"""
58
+ saved = state.temp_data.get("saved_listing_progress", {})
59
+ return bool(saved.get("provided_fields"))
60
+
61
+ def _format_saved_fields(provided_fields: Dict) -> str:
62
+ """Format saved fields for resume message"""
63
+ parts = []
64
+ if provided_fields.get("location"):
65
+ parts.append(f"πŸ“ {provided_fields['location']}")
66
+ if provided_fields.get("bedrooms"):
67
+ parts.append(f"πŸ›οΈ {provided_fields['bedrooms']} bed")
68
+ if provided_fields.get("bathrooms"):
69
+ parts.append(f"🚿 {provided_fields['bathrooms']} bath")
70
+ if provided_fields.get("price"):
71
+ parts.append(f"πŸ’° {provided_fields['price']}")
72
+ if provided_fields.get("price_type"):
73
+ parts.append(f"πŸ“… {provided_fields['price_type']}")
74
+
75
+ return " | ".join(parts) if parts else "No details saved"
76
 
77
+ async def check_for_listing_resume(state: AgentState) -> Dict:
78
+ """
79
+ Check if user wants to resume a previous listing session
80
  """
 
81
 
82
+ if not _has_saved_listing_progress(state):
83
+ return {"should_resume": False}
84
 
85
+ saved_progress = state.temp_data["saved_listing_progress"]
86
+ user_message = state.last_user_message
87
 
88
+ prompt = f"""User has saved listing progress: {json.dumps(saved_progress, indent=2)}
89
+
90
+ User said: "{user_message}"
91
+
92
+ Determine if they want to:
93
+ 1. Continue their previous listing
94
+ 2. Start a fresh listing
95
+ 3. Do something else entirely
96
+
97
+ Look for resume keywords: continue, resume, back, finish, complete
98
+ Look for fresh start: new, fresh, start over, different
99
+ Look for other intents: search, find, look for, etc.
100
+
101
+ Return JSON:
102
+ {{
103
+ "action": "continue|fresh|other",
104
+ "intent": "listing|search|greeting|casual_chat",
105
+ "confidence": 0.0-1.0
106
+ }}"""
107
+
108
+ try:
109
+ response = await llm.ainvoke([
110
+ SystemMessage(content="Detect if user wants to resume previous listing."),
111
+ HumanMessage(content=prompt)
112
+ ])
113
+
114
+ # Extract JSON
115
+ json_match = re.search(r'\{.*\}', response.content, re.DOTALL)
116
+ if json_match:
117
+ result = json.loads(json_match.group())
118
+
119
+ if result["action"] == "continue":
120
+ return {
121
+ "should_resume": True,
122
+ "intent": "listing",
123
+ "confidence": result["confidence"]
124
+ }
125
+
126
+ return {
127
+ "should_resume": False,
128
+ "intent": result["intent"],
129
+ "confidence": result["confidence"]
130
+ }
131
+
132
+ return {"should_resume": False}
133
+
134
+ except Exception as e:
135
+ logger.error("Resume detection failed", exc_info=e)
136
+ return {"should_resume": False}
137
+
138
+ async def classify_intent(state: AgentState) -> AgentState:
139
+ """
140
+ Enhanced intent classification with resume detection and smart switching
141
  """
142
 
143
  if not state.last_user_message:
 
146
  state.transition_to(FlowState.ERROR, reason="Missing user message")
147
  return state
148
 
149
+ # πŸ”„ CHECK FOR RESUME first
150
+ resume_check = await check_for_listing_resume(state)
151
+ if resume_check["should_resume"] and resume_check["confidence"] > 0.7:
152
+ logger.info("Detected listing resume", user_id=state.user_id)
153
+
154
+ # Restore saved progress
155
+ saved = state.temp_data["saved_listing_progress"]
156
+ state.provided_fields.update(saved["provided_fields"])
157
+ state.missing_required_fields = saved["missing_fields"]
158
+ state.current_asking_for = saved["last_asking_for"]
159
+
160
+ # Generate welcome back message
161
+ fields_summary = _format_saved_fields(saved["provided_fields"])
162
+ state.temp_data["response_text"] = f"πŸ“‹ Welcome back! You were listing:\n{fields_summary}\n\nContinue where you left off?"
163
+ state.temp_data["action"] = "resume_listing_choice"
164
+
165
+ state.transition_to(FlowState.LISTING_COLLECT, reason="Resuming listing")
166
+ return state
167
+
168
  logger.info(
169
  "Classifying intent",
170
  user_id=state.user_id,
app/ai/agent/nodes/listing_collect.py CHANGED
@@ -1,284 +1,425 @@
1
  # app/ai/agent/nodes/listing_collect.py
2
  """
3
- Node: Collect listing fields from user step by step.
4
- Extracts: location, bedrooms, bathrooms, price, price_type
5
- Optional: amenities, requirements, images
6
  """
7
 
8
  import json
9
  import re
 
10
  from structlog import get_logger
11
  from langchain_openai import ChatOpenAI
12
  from langchain_core.messages import SystemMessage, HumanMessage
13
 
14
  from app.ai.agent.state import AgentState, FlowState
15
- from app.ai.agent.validators import JSONValidator, ListingValidator
16
  from app.ai.agent.schemas import ListingExtracted
17
  from app.config import settings
18
 
19
  logger = get_logger(__name__)
20
 
21
- # Initialize LLM for field extraction
22
  llm = ChatOpenAI(
23
  api_key=settings.DEEPSEEK_API_KEY,
24
  base_url=settings.DEEPSEEK_BASE_URL,
25
  model="deepseek-chat",
26
- temperature=0.3, # Low temp for extraction accuracy
27
  )
28
 
29
- EXTRACTION_PROMPT = """Extract property listing fields from this user message.
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- User role: {user_role}
32
- User message: "{user_message}"
 
 
 
33
 
34
- Extract these fields (set to null if not mentioned):
35
- - location: City/area name (e.g., "Lagos", "Cotonou", "Paris")
36
- - bedrooms: Number of bedrooms (integer or null)
37
- - bathrooms: Number of bathrooms (integer or null)
38
- - price: Price amount (number or null)
39
- - price_type: How often to pay (monthly, yearly, weekly, daily, nightly) or null
40
- - amenities: List of amenities (wifi, parking, furnished, ac, washing machine, etc.) or []
41
- - requirements: Any special requirements (deposit, credit check, no pets, etc.) or null
42
- - images: List of image URLs already uploaded or []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  Be smart about:
45
- - Understanding informal language and typos
46
- - Extracting numbers from various formats (50k, 50,000, $50000)
47
- - Identifying amenities even if spelled differently
48
- - Detecting price_type from context
49
 
50
- Return ONLY valid JSON (no markdown, no preamble):
51
  {{
52
- "location": string or null,
53
- "bedrooms": integer or null,
54
- "bathrooms": integer or null,
55
- "price": number or null,
56
- "price_type": string or null,
57
- "amenities": [],
58
- "requirements": string or null,
59
- "images": []
60
  }}"""
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
- async def extract_listing_fields(user_message: str, user_role: str) -> dict:
64
  """
65
- Extract listing fields from user message using LLM.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
- Args:
68
- user_message: What user said
69
- user_role: User's role (landlord or renter)
 
 
 
 
70
 
71
- Returns:
72
- Dict with extracted fields
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  """
74
 
75
- logger.info("Extracting listing fields", message_len=len(user_message))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
  try:
78
- prompt = EXTRACTION_PROMPT.format(
79
- user_role=user_role,
80
- user_message=user_message
81
- )
82
-
83
- # Call LLM
84
  response = await llm.ainvoke([
85
- SystemMessage(content="You are a field extraction expert. Extract ONLY the fields requested. Return ONLY valid JSON."),
86
  HumanMessage(content=prompt)
87
  ])
88
 
89
- response_text = response.content if hasattr(response, 'content') else str(response)
90
- logger.info("LLM extraction response received", response_len=len(response_text))
91
-
92
- # βœ… VALIDATE JSON
93
- validation = JSONValidator.extract_and_validate(response_text)
 
94
 
95
- if not validation.is_valid:
96
- logger.warning("Extraction JSON validation failed", errors=validation.errors)
97
- return {}
98
-
99
- extracted = validation.data
100
- logger.info("Fields extracted successfully", fields=list(extracted.keys()))
101
 
102
- return extracted
103
-
104
  except Exception as e:
105
- logger.error("Field extraction error", exc_info=e)
106
  return {}
107
 
108
-
109
- async def listing_collect_handler(state: AgentState) -> AgentState:
110
  """
111
- Collect listing fields from user step by step.
112
-
113
- Flow:
114
- 1. Extract fields from message
115
- 2. Update progress
116
- 3. Check what's still missing
117
- 4. Ask for next missing field
118
- 5. When all required present β†’ transition to LISTING_VALIDATE
119
-
120
- Args:
121
- state: Agent state
122
-
123
- Returns:
124
- Updated state
125
  """
126
 
127
- logger.info(
128
- "Handling listing collection",
129
- user_id=state.user_id,
130
- current_progress=state.get_listing_progress()
131
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  try:
134
- # ============================================================
135
- # STEP 1: Extract fields from user message
136
- # ============================================================
137
-
138
- extracted = await extract_listing_fields(
139
- state.last_user_message,
140
- state.user_role
141
- )
142
-
143
- if not extracted:
144
- logger.warning("No fields extracted")
145
- state.set_error("Could not understand your message", should_retry=True)
146
- return state
147
-
148
- logger.info("Fields extracted", extracted_keys=list(extracted.keys()))
149
-
150
- # ============================================================
151
- # STEP 2: Update state with provided fields
152
- # ============================================================
153
-
154
- for field, value in extracted.items():
155
- if value is not None and value != [] and value != "":
156
- state.update_listing_progress(field, value)
157
- logger.info("Field updated", field=field, value=str(value)[:50])
158
-
159
- # ============================================================
160
- # STEP 3: Check missing required fields
161
- # ============================================================
162
-
163
- required_fields = ["location", "bedrooms", "bathrooms", "price", "price_type"]
164
- missing = [
165
- f for f in required_fields
166
- if f not in state.provided_fields or state.provided_fields[f] is None
167
- ]
168
 
169
- logger.info(
170
- "Missing fields check",
171
- missing=missing,
172
- provided=list(state.provided_fields.keys())
173
- )
 
 
 
 
 
174
 
175
- # ============================================================
176
- # STEP 4: If missing required fields, ask for next one
177
- # ============================================================
 
 
 
 
 
 
 
 
 
 
 
 
178
 
 
 
 
179
  if missing:
180
- next_field = missing[0]
181
-
182
- # Generate question for missing field
183
- field_questions = {
184
- "location": "What city or area is your property in?",
185
- "bedrooms": "How many bedrooms does it have?",
186
- "bathrooms": "How many bathrooms?",
187
- "price": "What's the price?",
188
- "price_type": "Is that monthly, yearly, weekly, daily, or nightly?",
189
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
- question = field_questions.get(next_field, f"What is the {next_field}?")
192
-
193
- state.temp_data["response_text"] = question
194
- state.temp_data["action"] = "asking_field"
195
- state.current_asking_for = next_field
196
-
197
- logger.info(
198
- "Asking for missing field",
199
- field=next_field,
200
- question=question,
201
- remaining_fields=len(missing)
 
 
202
  )
203
-
204
- return state
205
 
206
- # ============================================================
207
- # STEP 5: All required fields present - ask about optional fields
208
- # ============================================================
 
 
 
209
 
210
- logger.info("All required fields collected, checking optional fields")
 
211
 
212
- has_amenities = "amenities" in state.provided_fields and state.provided_fields.get("amenities")
213
- has_requirements = "requirements" in state.provided_fields and state.provided_fields.get("requirements")
 
214
 
215
- if not has_amenities and not has_requirements:
216
- # Ask about optional fields
217
- state.temp_data["response_text"] = (
218
- "Great! Now, does your property have any amenities like wifi, parking, furnished, AC, etc.? "
219
- "And any special requirements like deposit, credit check, no pets? (Say 'none' or skip if there are none)"
220
- )
221
- state.temp_data["action"] = "asking_optional"
222
- state.current_asking_for = "optional_fields"
223
-
224
- logger.info("Asking about optional fields")
225
 
226
- return state
227
-
228
- # ============================================================
229
- # STEP 6: Ask about images
230
- # ============================================================
231
-
232
- has_images = "images" in state.provided_fields and len(state.provided_fields.get("images", [])) > 0
233
-
234
- if not has_images:
235
- state.temp_data["response_text"] = (
236
- "πŸ“· Please upload at least one image of your property. "
237
- "This helps buyers/renters see what they're getting! "
238
- "Share the image URL in your next message."
239
- )
240
- state.temp_data["action"] = "asking_images"
241
- state.current_asking_for = "images"
242
 
243
- logger.info("Asking for images")
 
 
244
 
245
- return state
246
-
247
- # ============================================================
248
- # STEP 7: All data collected - transition to LISTING_VALIDATE
249
- # ============================================================
250
-
251
- logger.info(
252
- "All listing data collected",
253
- provided_fields=list(state.provided_fields.keys()),
254
- images_count=len(state.provided_fields.get("images", []))
255
- )
256
-
257
- state.temp_data["response_text"] = "Perfect! Let me generate your listing preview..."
258
- state.temp_data["action"] = "listing_complete"
259
-
260
- # Transition to validate
261
- success, error = state.transition_to(
262
- FlowState.LISTING_VALIDATE,
263
- reason="All listing data collected"
264
- )
265
-
266
- if not success:
267
- logger.error("Transition to LISTING_VALIDATE failed", error=error)
268
- state.set_error(error, should_retry=False)
269
- return state
270
-
271
- logger.info("Transitioned to LISTING_VALIDATE", user_id=state.user_id)
272
-
273
  return state
274
-
275
  except Exception as e:
276
- logger.error("Listing collection error", exc_info=e)
277
- error_msg = f"Error collecting listing data: {str(e)}"
278
 
279
  if state.set_error(error_msg, should_retry=True):
280
- # Retry the collection
281
- state.temp_data["response_text"] = "Let me try that again. What city is your property in?"
282
  state.temp_data["action"] = "retry_collection"
283
  else:
284
  state.transition_to(FlowState.ERROR, reason="Listing collection error")
 
1
  # app/ai/agent/nodes/listing_collect.py
2
  """
3
+ Dynamic, context-aware listing collection with smart intent switching
 
 
4
  """
5
 
6
  import json
7
  import re
8
+ from typing import Dict
9
  from structlog import get_logger
10
  from langchain_openai import ChatOpenAI
11
  from langchain_core.messages import SystemMessage, HumanMessage
12
 
13
  from app.ai.agent.state import AgentState, FlowState
14
+ from app.ai.agent.validators import JSONValidator
15
  from app.ai.agent.schemas import ListingExtracted
16
  from app.config import settings
17
 
18
  logger = get_logger(__name__)
19
 
20
+ # Initialize LLM for dynamic questioning
21
  llm = ChatOpenAI(
22
  api_key=settings.DEEPSEEK_API_KEY,
23
  base_url=settings.DEEPSEEK_BASE_URL,
24
  model="deepseek-chat",
25
+ temperature=0.7, # Higher for more natural questions
26
  )
27
 
28
+ async def generate_contextual_question(state: AgentState, next_field: str = None) -> str:
29
+ """
30
+ Generate natural, contextual questions based on current conversation state
31
+ """
32
+
33
+ missing_fields = state.missing_required_fields or []
34
+ if not missing_fields and not next_field:
35
+ return "Tell me more about your property."
36
+
37
+ field_to_ask = next_field or missing_fields[0]
38
+ provided_so_far = state.provided_fields
39
+
40
+ prompt = f"""Generate a NATURAL, conversational question to get the {field_to_ask} for a property listing.
41
 
42
+ Current conversation context:
43
+ - User role: {state.user_role}
44
+ - Already provided: {json.dumps(provided_so_far, indent=2)}
45
+ - Still need: {field_to_ask}
46
+ - Last user message: "{state.last_user_message}"
47
 
48
+ Examples of natural questions:
49
+ - If they said "I have a 3-bed in Lagos" β†’ "Nice! What's the rent per month?"
50
+ - If they said "2 bedrooms" β†’ "Perfect! How many bathrooms does it have?"
51
+ - If they said nothing yet β†’ "What city is your property in?"
52
+
53
+ Generate ONE short, natural question that flows with the conversation:
54
+ """
55
+
56
+ try:
57
+ response = await llm.ainvoke([
58
+ SystemMessage(content="You are Aida, a friendly real estate assistant. Ask questions naturally, like a human would."),
59
+ HumanMessage(content=prompt)
60
+ ])
61
+
62
+ question = response.content.strip()
63
+ # Remove quotes if LLM added them
64
+ question = question.strip('"').strip("'")
65
+
66
+ logger.info("Generated contextual question",
67
+ field=field_to_ask,
68
+ question=question[:50])
69
+
70
+ return question
71
+
72
+ except Exception as e:
73
+ logger.error("Failed to generate contextual question", exc_info=e)
74
+ # Fallback to simple question
75
+ fallback_questions = {
76
+ "location": "What city or area is your property in?",
77
+ "bedrooms": "How many bedrooms does it have?",
78
+ "bathrooms": "How many bathrooms?",
79
+ "price": "What's the price?",
80
+ "price_type": "Is that monthly, yearly, weekly, daily, or nightly?"
81
+ }
82
+ return fallback_questions.get(field_to_ask, f"What's the {field_to_ask}?")
83
+
84
+ async def is_still_listing_intent(state: AgentState) -> Dict:
85
+ """
86
+ Smart check: Is user still talking about listing or changed intent?
87
+ """
88
+
89
+ user_message = state.last_user_message
90
+ current_context = state.provided_fields
91
+ missing_fields = state.missing_required_fields
92
+
93
+ prompt = f"""User is in property listing flow. Analyze their message:
94
+
95
+ User said: "{user_message}"
96
+ Current saved data: {json.dumps(current_context, indent=2)}
97
+ Missing fields: {missing_fields}
98
+
99
+ Determine:
100
+ 1. Is this still about listing their property? (providing details, asking listing questions)
101
+ 2. Or is this a different intent? (search, greeting, casual chat, etc.)
102
 
103
  Be smart about:
104
+ - Partial answers ("50k" when expecting price)
105
+ - Listing-related questions ("is 50k too much for Lagos?")
106
+ - Corrections ("actually it's 3 bedrooms")
 
107
 
108
+ Return ONLY valid JSON:
109
  {{
110
+ "is_listing_related": true/false,
111
+ "detected_intent": "listing|search|greeting|casual_chat|unknown",
112
+ "confidence": 0.0-1.0,
113
+ "reasoning": "brief explanation",
114
+ "extracted_fields": {{}} // Any fields you can extract from this message
 
 
 
115
  }}"""
116
 
117
+ try:
118
+ response = await llm.ainvoke([
119
+ SystemMessage(content="You are an intelligent conversation analyzer. Determine if user is still discussing their property listing or changed intent."),
120
+ HumanMessage(content=prompt)
121
+ ])
122
+
123
+ # Extract JSON from response
124
+ json_match = re.search(r'\{.*\}', response.content, re.DOTALL)
125
+ if json_match:
126
+ result = json.loads(json_match.group())
127
+
128
+ logger.info("Intent check completed",
129
+ is_listing=result["is_listing_related"],
130
+ intent=result["detected_intent"],
131
+ confidence=result["confidence"])
132
+
133
+ return result
134
+
135
+ # Fallback
136
+ return {
137
+ "is_listing_related": True,
138
+ "detected_intent": "listing",
139
+ "confidence": 0.5,
140
+ "reasoning": "Parse failed - assume listing",
141
+ "extracted_fields": {}
142
+ }
143
+
144
+ except Exception as e:
145
+ logger.error("Intent check failed", exc_info=e)
146
+ return {
147
+ "is_listing_related": True,
148
+ "detected_intent": "listing",
149
+ "confidence": 0.5,
150
+ "reasoning": "Exception - assume listing",
151
+ "extracted_fields": {}
152
+ }
153
 
154
+ async def handle_intent_switch(state: AgentState, new_intent: str) -> AgentState:
155
  """
156
+ Handle smooth transition to new intent while preserving listing data
157
+ """
158
+
159
+ logger.info("Handling intent switch",
160
+ from_intent="listing",
161
+ to_intent=new_intent,
162
+ user_id=state.user_id)
163
+
164
+ # Save current listing progress
165
+ state.temp_data["saved_listing_progress"] = {
166
+ "provided_fields": state.provided_fields.copy(),
167
+ "missing_fields": state.missing_required_fields.copy(),
168
+ "last_asking_for": state.current_asking_for
169
+ }
170
+
171
+ # Switch intent
172
+ state.intent_type = new_intent
173
 
174
+ # Map to flow state
175
+ intent_to_flow = {
176
+ "search": FlowState.SEARCH_QUERY,
177
+ "greeting": FlowState.GREETING,
178
+ "casual_chat": FlowState.CASUAL_CHAT,
179
+ "listing": FlowState.LISTING_COLLECT
180
+ }
181
 
182
+ next_flow = intent_to_flow.get(new_intent, FlowState.CASUAL_CHAT)
183
+
184
+ # Generate smooth transition message
185
+ transition_messages = {
186
+ "search": "πŸ”„ Switching to search mode... Let me find properties for you!",
187
+ "greeting": "Hello! πŸ‘‹ How can I help you today?",
188
+ "casual_chat": "I'm here to help! What would you like to know?",
189
+ "listing": "Back to listing! πŸ“‹"
190
+ }
191
+
192
+ state.temp_data["response_text"] = transition_messages.get(new_intent, "How can I help?")
193
+ state.temp_data["action"] = f"switched_to_{new_intent}"
194
+
195
+ # Transition with validation
196
+ success, error = state.transition_to(next_flow, reason=f"User switched to {new_intent}")
197
+ if not success:
198
+ state.set_error(error, should_retry=False)
199
+
200
+ return state
201
+
202
+ async def extract_listing_fields_smart(user_message: str, user_role: str, current_fields: Dict = None) -> Dict:
203
+ """
204
+ Smart field extraction that understands context, corrections, and partial info
205
  """
206
 
207
+ logger.info("Smart field extraction",
208
+ msg_len=len(user_message),
209
+ current_fields=list(current_fields.keys()) if current_fields else [])
210
+
211
+ context = f"\nCurrently saved: {json.dumps(current_fields, indent=2)}" if current_fields else ""
212
+
213
+ prompt = f"""Extract property information from this user message. Be smart about context and corrections.
214
+
215
+ User role: {user_role}
216
+ User message: "{user_message}"{context}
217
+
218
+ Extract these fields (set to null if not mentioned, extract corrections if present):
219
+ - location: City/area name or null
220
+ - bedrooms: Number or null (handle "3", "three", "3bed")
221
+ - bathrooms: Number or null (handle "2", "two", "2bath")
222
+ - price: Amount or null (handle "50k", "50,000", "50000")
223
+ - price_type: "monthly", "yearly", "weekly", "daily", "nightly" or null
224
+ - amenities: List or [] (wifi, parking, furnished, ac, etc.)
225
+ - requirements: Text or null
226
+
227
+ Be smart about:
228
+ - Corrections: "actually it's 3 bedrooms" β†’ update bedrooms to 3
229
+ - Partial info: "50k" when expecting price β†’ extract price: 50000
230
+ - Context: Use conversation history to understand
231
+
232
+ Return ONLY valid JSON with extracted fields."""
233
 
234
  try:
 
 
 
 
 
 
235
  response = await llm.ainvoke([
236
+ SystemMessage(content="You are a smart field extractor. Understand context and corrections."),
237
  HumanMessage(content=prompt)
238
  ])
239
 
240
+ # Extract JSON from response
241
+ json_match = re.search(r'\{.*\}', response.content, re.DOTALL)
242
+ if json_match:
243
+ result = json.loads(json_match.group())
244
+ logger.info("Smart extraction successful", extracted=list(result.keys()))
245
+ return result
246
 
247
+ return {}
 
 
 
 
 
248
 
 
 
249
  except Exception as e:
250
+ logger.error("Smart extraction failed", exc_info=e)
251
  return {}
252
 
253
+ async def decide_next_listing_action(state: AgentState) -> Dict:
 
254
  """
255
+ AI decides what to do next based on current conversation context
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  """
257
 
258
+ provided = state.provided_fields
259
+ missing = state.missing_required_fields
260
+ user_msg = state.last_user_message
261
+
262
+ prompt = f"""You are Aida managing a property listing conversation. Decide next action.
263
+
264
+ Current state:
265
+ - Provided fields: {json.dumps(provided, indent=2)}
266
+ - Missing required: {missing}
267
+ - User just said: "{user_msg}"
268
+
269
+ Available actions:
270
+ 1. "ask_missing" - Ask for next missing required field
271
+ 2. "ask_optional" - Ask about amenities/requirements (when required complete)
272
+ 3. "show_draft" - All required fields complete, show preview
273
+ 4. "acknowledge" - Acknowledge what user said, then continue
274
+ 5. "clarify" - Need clarification on what user meant
275
+
276
+ Consider:
277
+ - If missing required fields β†’ "ask_missing"
278
+ - If all required complete β†’ "ask_optional" or "show_draft"
279
+ - If user provided info β†’ "acknowledge" then continue
280
+ - If unclear β†’ "clarify"
281
+
282
+ Return ONLY valid JSON:
283
+ {{
284
+ "action": "ask_missing|ask_optional|show_draft|acknowledge|clarify",
285
+ "reasoning": "why this action",
286
+ "next_field": "field to ask about (if ask_missing)",
287
+ "acknowledgment": "what to acknowledge (if acknowledge)"
288
+ }}"""
289
 
290
  try:
291
+ response = await llm.ainvoke([
292
+ SystemMessage(content="Make smart conversation flow decisions for property listing."),
293
+ HumanMessage(content=prompt)
294
+ ])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
+ # Extract JSON
297
+ json_match = re.search(r'\{.*\}', response.content, re.DOTALL)
298
+ if json_match:
299
+ result = json.loads(json_match.group())
300
+
301
+ logger.info("AI flow decision",
302
+ action=result["action"],
303
+ reasoning=result["reasoning"])
304
+
305
+ return result
306
 
307
+ # Fallback decision
308
+ if missing:
309
+ return {
310
+ "action": "ask_missing",
311
+ "reasoning": "Fallback - ask missing field",
312
+ "next_field": missing[0],
313
+ "acknowledgment": ""
314
+ }
315
+ else:
316
+ return {
317
+ "action": "show_draft",
318
+ "reasoning": "Fallback - show draft",
319
+ "next_field": None,
320
+ "acknowledgment": ""
321
+ }
322
 
323
+ except Exception as e:
324
+ logger.error("Flow decision failed", exc_info=e)
325
+ # Safe fallback
326
  if missing:
327
+ return {
328
+ "action": "ask_missing",
329
+ "reasoning": "Exception fallback - ask missing field",
330
+ "next_field": missing[0],
331
+ "acknowledgment": ""
 
 
 
 
332
  }
333
+ else:
334
+ return {
335
+ "action": "show_draft",
336
+ "reasoning": "Exception fallback - show draft",
337
+ "next_field": None,
338
+ "acknowledgment": ""
339
+ }
340
+
341
+ async def listing_collect_handler(state: AgentState) -> AgentState:
342
+ """
343
+ Dynamic listing collection with smart intent detection and contextual questioning
344
+ """
345
+
346
+ logger.info("Dynamic listing collection",
347
+ user_id=state.user_id,
348
+ current_fields=list(state.provided_fields.keys()),
349
+ missing_fields=state.missing_required_fields)
350
+
351
+ try:
352
+ # 🧠 Step 1: Check if user changed intent
353
+ intent_check = await is_still_listing_intent(state)
354
+
355
+ if not intent_check["is_listing_related"]:
356
+ # Extract any fields before switching
357
+ if intent_check["extracted_fields"]:
358
+ for field, value in intent_check["extracted_fields"].items():
359
+ state.update_listing_progress(field, value)
360
 
361
+ # Switch to new intent
362
+ return await handle_intent_switch(state, intent_check["detected_intent"])
363
+
364
+ # πŸ“ Step 2: Extract fields from current message
365
+ if intent_check["extracted_fields"]:
366
+ # Use extracted fields from intent check
367
+ extracted = intent_check["extracted_fields"]
368
+ else:
369
+ # Smart field extraction
370
+ extracted = await extract_listing_fields_smart(
371
+ state.last_user_message,
372
+ state.user_role,
373
+ state.provided_fields # Pass current context
374
  )
 
 
375
 
376
+ # Update state with extracted fields
377
+ if extracted:
378
+ for field, value in extracted.items():
379
+ if value is not None and value != [] and value != "":
380
+ state.update_listing_progress(field, value)
381
+ logger.info("Field updated", field=field, value=str(value)[:50])
382
 
383
+ # 🎯 Step 3: AI decides next action based on context
384
+ decision = await decide_next_listing_action(state)
385
 
386
+ logger.info("AI decided next action",
387
+ action=decision["action"],
388
+ reasoning=decision["reasoning"])
389
 
390
+ # Execute the decided action
391
+ if decision["action"] == "ask_missing":
392
+ question = await generate_contextual_question(state, decision.get("next_field"))
393
+ state.temp_data["response_text"] = question
394
+ state.temp_data["action"] = "asking_field"
395
+ state.current_asking_for = decision.get("next_field")
 
 
 
 
396
 
397
+ elif decision["action"] == "ask_optional":
398
+ # Ask about amenities/requirements naturally
399
+ question = "Great! Does your property have any amenities like wifi, parking, furnished, AC, etc.? And any special requirements?"
400
+ state.temp_data["response_text"] = question
401
+ state.temp_data["action"] = "asking_optional"
 
 
 
 
 
 
 
 
 
 
 
402
 
403
+ elif decision["action"] == "show_draft":
404
+ state.temp_data["response_text"] = "Perfect! Let me create your listing preview..."
405
+ state.temp_data["action"] = "all_fields_collected"
406
 
407
+ elif decision["action"] == "acknowledge":
408
+ # Acknowledge what they said and continue
409
+ acknowledgment = decision.get("acknowledgment", "Got it!")
410
+ next_question = await generate_contextual_question(state)
411
+ state.temp_data["response_text"] = f"{acknowledgment} {next_question}"
412
+ state.temp_data["action"] = "acknowledge_continue"
413
+
414
+ # Stay in listing_collect - router will handle transition
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  return state
416
+
417
  except Exception as e:
418
+ logger.error("Dynamic listing collection error", exc_info=e)
419
+ error_msg = f"Error processing listing: {str(e)}"
420
 
421
  if state.set_error(error_msg, should_retry=True):
422
+ state.temp_data["response_text"] = "Let me try that again. Tell me about your property."
 
423
  state.temp_data["action"] = "retry_collection"
424
  else:
425
  state.transition_to(FlowState.ERROR, reason="Listing collection error")
app/ai/tools/listing_tool.py CHANGED
@@ -1,5 +1,5 @@
1
  # app/ai/tools/listing_tool.py
2
- # FINAL VERSION: Random examples + AI-powered URL extraction + FIELD PRESERVATION FIX
3
 
4
  import json
5
  import re
@@ -27,7 +27,6 @@ llm = ChatOpenAI(
27
  temperature=0.3,
28
  )
29
 
30
-
31
  # ---------- AI-POWERED URL EXTRACTION ----------
32
  async def extract_image_urls_from_message(user_message: str) -> List[str]:
33
  """AI-powered image URL extraction using LLM."""
@@ -52,7 +51,6 @@ Return ONLY valid JSON: {{"urls": ["https://..."] or []}}"""
52
  logger.error("AI URL extraction failed", exc_info=e)
53
  return []
54
 
55
-
56
  # ---------- STEP 1: SHOW RANDOM EXAMPLE ----------
57
  async def generate_listing_example(user_language: str, user_role: str) -> str:
58
  prompt_text = f"""Generate a UNIQUE, realistic property listing example as a {user_role} in {user_language}.
@@ -65,7 +63,6 @@ DIFFERENT each time. Return ONLY the example sentence(s)."""
65
  example = response.content if hasattr(response, 'content') else str(response)
66
  return example.strip()
67
 
68
-
69
  # ---------- STEP 2: EXTRACT FIELDS ----------
70
  async def extract_listing_fields(user_message: str, user_role: str) -> Dict:
71
  logger.info("Extracting listing fields", user_role=user_role, msg_len=len(user_message))
@@ -95,7 +92,6 @@ Be smart about intent (typos, informal language). IGNORE URLs. Return ONLY valid
95
  logger.error("Extraction failed", exc_info=e)
96
  return {}
97
 
98
-
99
  # ---------- STEP 3: AUTO-DETECT LISTING TYPE ----------
100
  async def auto_detect_listing_type(price_type: str, user_role: str, user_message: str = "") -> str:
101
  if user_role == "renter":
@@ -108,9 +104,12 @@ async def auto_detect_listing_type(price_type: str, user_role: str, user_message
108
  return "short-stay"
109
  return "rent"
110
 
111
-
112
  # ---------- STEP 4: AUTO-DETECT CURRENCY ----------
113
  async def get_currency_for_location(location: str) -> str:
 
 
 
 
114
  try:
115
  currency, city, confidence = await ml_extractor.infer_currency(state={"location": location})
116
  if currency:
@@ -118,6 +117,7 @@ async def get_currency_for_location(location: str) -> str:
118
  return currency
119
  except Exception as e:
120
  logger.error("ML currency inference failed", exc_info=e)
 
121
  fallback_map = {
122
  "lagos": "NGN", "lekki": "NGN", "vi": "NGN", "ikeja": "NGN",
123
  "cotonou": "XOF", "calavi": "XOF", "porto-novo": "XOF",
@@ -135,7 +135,6 @@ async def get_currency_for_location(location: str) -> str:
135
  logger.warning("Currency not detected, defaulting to NGN", location=location)
136
  return "NGN"
137
 
138
-
139
  # ---------- STEP 5: AUTO-GENERATE TITLE & DESCRIPTION ----------
140
  async def generate_title_and_description(extracted_data: Dict, user_role: str = "landlord") -> Tuple[str, str]:
141
  logger.info("Generating title and description", role=user_role)
@@ -194,7 +193,6 @@ Return ONLY valid JSON: {{"title": "string", "description": "string"}}"""
194
  description = f"Property listing in {location}."
195
  return title, description
196
 
197
-
198
  # ---------- BUILD DRAFT UI COMPONENT ----------
199
  def build_draft_ui_component(draft: Dict) -> Dict:
200
  amenities_icons = {
@@ -228,8 +226,150 @@ def build_draft_ui_component(draft: Dict) -> Dict:
228
  }
229
  return ui_component
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
- # ---------- MAIN PROCESS LISTING ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  async def process_listing(
234
  user_message: str,
235
  user_id: str,
@@ -270,7 +410,7 @@ async def process_listing(
270
  }
271
 
272
  # STEP 2: Extract fields from user message
273
- extracted_data = await extract_listing_fields(user_message, user_role)
274
 
275
  # SMART MERGE: preserve previously provided fields
276
  provided_fields = state.get("provided_fields", {})
@@ -299,14 +439,29 @@ async def process_listing(
299
  if missing_fields:
300
  logger.info("Missing required fields", missing=missing_fields, user_id=user_id)
301
  next_field = missing_fields[0]
302
- field_questions = {
303
- "location": "What city or area is your property in?",
304
- "bedrooms": "How many bedrooms?",
305
- "bathrooms": "How many bathrooms?",
306
- "price": "What's the price?",
307
- "price_type": "Is that monthly, yearly, weekly, daily, or nightly?",
308
- }
309
- question = field_questions.get(next_field, f"What's the {next_field}?")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  return {
311
  "success": True,
312
  "action": "ask_field",
@@ -327,7 +482,7 @@ async def process_listing(
327
  return {
328
  "success": True,
329
  "action": "ask_optional",
330
- "reply": "Any amenities (like wifi, parking, furnished) or special requirements (like deposit needed, credit check)? Say 'none' or skip if there are none.",
331
  "data": provided_fields,
332
  "state": {
333
  "status": "listing",
@@ -343,7 +498,7 @@ async def process_listing(
343
  return {
344
  "success": True,
345
  "action": "ask_images",
346
- "reply": "πŸ“· Please upload at least one image of your property. This helps buyers/renters see what they're getting!",
347
  "data": provided_fields,
348
  "state": {
349
  "status": "listing",
 
1
  # app/ai/tools/listing_tool.py
2
+ # FINAL VERSION: Fixed circular import issue
3
 
4
  import json
5
  import re
 
27
  temperature=0.3,
28
  )
29
 
 
30
  # ---------- AI-POWERED URL EXTRACTION ----------
31
  async def extract_image_urls_from_message(user_message: str) -> List[str]:
32
  """AI-powered image URL extraction using LLM."""
 
51
  logger.error("AI URL extraction failed", exc_info=e)
52
  return []
53
 
 
54
  # ---------- STEP 1: SHOW RANDOM EXAMPLE ----------
55
  async def generate_listing_example(user_language: str, user_role: str) -> str:
56
  prompt_text = f"""Generate a UNIQUE, realistic property listing example as a {user_role} in {user_language}.
 
63
  example = response.content if hasattr(response, 'content') else str(response)
64
  return example.strip()
65
 
 
66
  # ---------- STEP 2: EXTRACT FIELDS ----------
67
  async def extract_listing_fields(user_message: str, user_role: str) -> Dict:
68
  logger.info("Extracting listing fields", user_role=user_role, msg_len=len(user_message))
 
92
  logger.error("Extraction failed", exc_info=e)
93
  return {}
94
 
 
95
  # ---------- STEP 3: AUTO-DETECT LISTING TYPE ----------
96
  async def auto_detect_listing_type(price_type: str, user_role: str, user_message: str = "") -> str:
97
  if user_role == "renter":
 
104
  return "short-stay"
105
  return "rent"
106
 
 
107
  # ---------- STEP 4: AUTO-DETECT CURRENCY ----------
108
  async def get_currency_for_location(location: str) -> str:
109
+ if location is None:
110
+ logger.warning("Location is None, defaulting to NGN")
111
+ return "NGN"
112
+
113
  try:
114
  currency, city, confidence = await ml_extractor.infer_currency(state={"location": location})
115
  if currency:
 
117
  return currency
118
  except Exception as e:
119
  logger.error("ML currency inference failed", exc_info=e)
120
+
121
  fallback_map = {
122
  "lagos": "NGN", "lekki": "NGN", "vi": "NGN", "ikeja": "NGN",
123
  "cotonou": "XOF", "calavi": "XOF", "porto-novo": "XOF",
 
135
  logger.warning("Currency not detected, defaulting to NGN", location=location)
136
  return "NGN"
137
 
 
138
  # ---------- STEP 5: AUTO-GENERATE TITLE & DESCRIPTION ----------
139
  async def generate_title_and_description(extracted_data: Dict, user_role: str = "landlord") -> Tuple[str, str]:
140
  logger.info("Generating title and description", role=user_role)
 
193
  description = f"Property listing in {location}."
194
  return title, description
195
 
 
196
  # ---------- BUILD DRAFT UI COMPONENT ----------
197
  def build_draft_ui_component(draft: Dict) -> Dict:
198
  amenities_icons = {
 
226
  }
227
  return ui_component
228
 
229
+ # ---------- SMART FIELD EXTRACTION (NEW) ----------
230
+ async def extract_listing_fields_smart(user_message: str, user_role: str, current_fields: Dict = None) -> Dict:
231
+ """
232
+ Smart field extraction that understands context, corrections, and partial info
233
+ """
234
+
235
+ logger.info("Smart field extraction",
236
+ msg_len=len(user_message),
237
+ current_fields=list(current_fields.keys()) if current_fields else [])
238
+
239
+ context = f"\nCurrently saved: {json.dumps(current_fields, indent=2)}" if current_fields else ""
240
+
241
+ prompt = f"""Extract property information from this user message. Be smart about context and corrections.
242
+
243
+ User role: {user_role}
244
+ User message: "{user_message}"{context}
245
+
246
+ Extract these fields (set to null if not mentioned, extract corrections if present):
247
+ - location: City/area name or null
248
+ - bedrooms: Number or null (handle "3", "three", "3bed")
249
+ - bathrooms: Number or null (handle "2", "two", "2bath")
250
+ - price: Amount or null (handle "50k", "50,000", "50000")
251
+ - price_type: "monthly", "yearly", "weekly", "daily", "nightly" or null
252
+ - amenities: List or [] (wifi, parking, furnished, ac, etc.)
253
+ - requirements: Text or null
254
+
255
+ Be smart about:
256
+ - Corrections: "actually it's 3 bedrooms" β†’ update bedrooms to 3
257
+ - Partial info: "50k" when expecting price β†’ extract price: 50000
258
+ - Context: Use conversation history to understand
259
+
260
+ Return ONLY valid JSON with extracted fields."""
261
+
262
+ try:
263
+ response = await llm.ainvoke([
264
+ SystemMessage(content="You are a smart field extractor. Understand context and corrections."),
265
+ HumanMessage(content=prompt)
266
+ ])
267
+
268
+ # Extract JSON from response
269
+ json_match = re.search(r'\{.*\}', response.content, re.DOTALL)
270
+ if json_match:
271
+ result = json.loads(json_match.group())
272
+ logger.info("Smart extraction successful", extracted=list(result.keys()))
273
+ return result
274
+
275
+ return {}
276
+
277
+ except Exception as e:
278
+ logger.error("Smart extraction failed", exc_info=e)
279
+ return {}
280
 
281
+ # ---------- DECIDE NEXT ACTION (NEW) ----------
282
+ async def decide_next_listing_action(state_data: Dict) -> Dict:
283
+ """
284
+ AI decides what to do next based on current conversation context
285
+ Takes a dictionary instead of AgentState to avoid circular imports
286
+ """
287
+
288
+ provided = state_data.get("provided_fields", {})
289
+ missing = state_data.get("missing_required_fields", [])
290
+ user_msg = state_data.get("last_user_message", "")
291
+ user_role = state_data.get("user_role", "landlord")
292
+
293
+ prompt = f"""You are Aida managing a property listing conversation. Decide next action.
294
+
295
+ Current state:
296
+ - Provided fields: {json.dumps(provided, indent=2)}
297
+ - Missing required: {missing}
298
+ - User just said: "{user_msg}"
299
+
300
+ Available actions:
301
+ 1. "ask_missing" - Ask for next missing required field
302
+ 2. "ask_optional" - Ask about amenities/requirements (when required complete)
303
+ 3. "show_draft" - All required fields complete, show preview
304
+ 4. "acknowledge" - Acknowledge what user said, then continue
305
+ 5. "clarify" - Need clarification on what user meant
306
+
307
+ Consider:
308
+ - If missing required fields β†’ "ask_missing"
309
+ - If all required complete β†’ "ask_optional" or "show_draft"
310
+ - If user provided info β†’ "acknowledge" then continue
311
+ - If unclear β†’ "clarify"
312
+
313
+ Return ONLY valid JSON:
314
+ {{
315
+ "action": "ask_missing|ask_optional|show_draft|acknowledge|clarify",
316
+ "reasoning": "why this action",
317
+ "next_field": "field to ask about (if ask_missing)",
318
+ "acknowledgment": "what to acknowledge (if acknowledge)"
319
+ }}"""
320
+
321
+ try:
322
+ response = await llm.ainvoke([
323
+ SystemMessage(content="Make smart conversation flow decisions for property listing."),
324
+ HumanMessage(content=prompt)
325
+ ])
326
+
327
+ # Extract JSON
328
+ json_match = re.search(r'\{.*\}', response.content, re.DOTALL)
329
+ if json_match:
330
+ result = json.loads(json_match.group())
331
+
332
+ logger.info("AI flow decision",
333
+ action=result["action"],
334
+ reasoning=result["reasoning"])
335
+
336
+ return result
337
+
338
+ # Fallback decision
339
+ if missing:
340
+ return {
341
+ "action": "ask_missing",
342
+ "reasoning": "Fallback - ask missing field",
343
+ "next_field": missing[0] if missing else None,
344
+ "acknowledgment": ""
345
+ }
346
+ else:
347
+ return {
348
+ "action": "show_draft",
349
+ "reasoning": "Fallback - show draft",
350
+ "next_field": None,
351
+ "acknowledgment": ""
352
+ }
353
+
354
+ except Exception as e:
355
+ logger.error("Flow decision failed", exc_info=e)
356
+ # Safe fallback
357
+ if missing:
358
+ return {
359
+ "action": "ask_missing",
360
+ "reasoning": "Exception fallback - ask missing field",
361
+ "next_field": missing[0] if missing else None,
362
+ "acknowledgment": ""
363
+ }
364
+ else:
365
+ return {
366
+ "action": "show_draft",
367
+ "reasoning": "Exception fallback - show draft",
368
+ "next_field": None,
369
+ "acknowledgment": ""
370
+ }
371
+
372
+ # ---------- MAIN PROCESS LISTING (UPDATED) ----------
373
  async def process_listing(
374
  user_message: str,
375
  user_id: str,
 
410
  }
411
 
412
  # STEP 2: Extract fields from user message
413
+ extracted_data = await extract_listing_fields_smart(user_message, user_role, state.get("provided_fields", {}))
414
 
415
  # SMART MERGE: preserve previously provided fields
416
  provided_fields = state.get("provided_fields", {})
 
439
  if missing_fields:
440
  logger.info("Missing required fields", missing=missing_fields, user_id=user_id)
441
  next_field = missing_fields[0]
442
+
443
+ # Use smart decision for next question instead of hardcoded
444
+ decision = await decide_next_listing_action({
445
+ "provided_fields": provided_fields,
446
+ "missing_required_fields": missing_fields,
447
+ "last_user_message": user_message,
448
+ "user_role": user_role
449
+ })
450
+
451
+ if decision["action"] == "ask_missing":
452
+ # Generate contextual question (we'll use a simple version here)
453
+ field_questions = {
454
+ "location": "What city or area is your property in?",
455
+ "bedrooms": "How many bedrooms does it have?",
456
+ "bathrooms": "How many bathrooms?",
457
+ "price": "What's the price?",
458
+ "price_type": "Is that monthly, yearly, weekly, daily, or nightly?",
459
+ }
460
+ question = field_questions.get(next_field, f"What's the {next_field}?")
461
+ else:
462
+ # Use the decision's guidance
463
+ question = "Tell me more about your property."
464
+
465
  return {
466
  "success": True,
467
  "action": "ask_field",
 
482
  return {
483
  "success": True,
484
  "action": "ask_optional",
485
+ "reply": "Any amenities (like wifi, parking, furnished, washing machine, AC, etc.) or special requirements (like deposit needed, credit check, no pets)? Say 'none' or skip if there are none.",
486
  "data": provided_fields,
487
  "state": {
488
  "status": "listing",
 
498
  return {
499
  "success": True,
500
  "action": "ask_images",
501
+ "reply": "πŸ“· Please upload at least one image of your property. This helps buyers/renters see what they're getting! Share the image URL in your next message.",
502
  "data": provided_fields,
503
  "state": {
504
  "status": "listing",