destinyebuka commited on
Commit
56fed02
Β·
1 Parent(s): d699ce3
app/ai/agent/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from app.ai.agent.state import AgentState, FlowState
2
+ from app.ai.agent.graph import get_aida_graph
3
+
4
+ __all__ = ["AgentState", "FlowState", "get_aida_graph"]
app/ai/agent/graph.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/graph.py
2
+ """
3
+ Main agent graph orchestrator - UPDATED with all 6 flow nodes.
4
+ This is the deterministic state machine that routes all requests.
5
+ """
6
+
7
+ from structlog import get_logger
8
+ from typing import Optional, Dict, Any
9
+ from datetime import datetime
10
+
11
+ from app.ai.agent.state import AgentState, FlowState
12
+ from app.ai.agent.schemas import AgentResponse, UserMessage
13
+ from app.ai.agent.nodes.authenticate import authenticate
14
+ from app.ai.agent.nodes.classify_intent import classify_intent
15
+ from app.ai.agent.nodes.greeting import greeting_handler
16
+ from app.ai.agent.nodes.listing_collect import listing_collect_handler
17
+ from app.ai.agent.nodes.listing_validate import listing_validate_handler
18
+ from app.ai.agent.nodes.listing_publish import listing_publish_handler
19
+ from app.ai.agent.nodes.search_query import search_query_handler
20
+ from app.ai.agent.nodes.casual_chat import casual_chat_handler
21
+ from app.ai.agent.nodes.validate_output import validate_output_node
22
+ from app.ai.agent.nodes.respond import respond_to_user
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ class AidaGraph:
28
+ """
29
+ Main AIDA agent graph.
30
+ Executes nodes in sequence with validated transitions.
31
+
32
+ Flow:
33
+ 1. Authenticate
34
+ 2. Classify Intent
35
+ 3. Route to specific handler (greeting, listing, search, casual_chat)
36
+ 4. Validate Output
37
+ 5. Respond to User
38
+ """
39
+
40
+ def __init__(self):
41
+ """Initialize agent graph"""
42
+ logger.info("Initializing AIDA Graph with all flow nodes")
43
+
44
+ async def execute(
45
+ self,
46
+ user_id: str,
47
+ session_id: str,
48
+ user_role: str,
49
+ message: str,
50
+ token: str,
51
+ ) -> AgentResponse:
52
+ """
53
+ Execute agent graph for user message.
54
+
55
+ This is the main entry point. Orchestrates all nodes in sequence.
56
+
57
+ Args:
58
+ user_id: User ID
59
+ session_id: Session ID
60
+ user_role: "landlord" or "renter"
61
+ message: User's message
62
+ token: JWT token
63
+
64
+ Returns:
65
+ AgentResponse to send to client
66
+ """
67
+
68
+ logger.info(
69
+ "πŸš€ Graph execution started",
70
+ user_id=user_id,
71
+ session_id=session_id,
72
+ message_len=len(message),
73
+ )
74
+
75
+ try:
76
+ # ============================================================
77
+ # STEP 1: Initialize state
78
+ # ============================================================
79
+ state = AgentState(
80
+ user_id=user_id,
81
+ session_id=session_id,
82
+ user_role=user_role,
83
+ )
84
+
85
+ # Add user message to history
86
+ state.add_message("user", message)
87
+ state.last_user_message = message
88
+
89
+ logger.info("βœ… State initialized", state_summary=state.get_summary())
90
+
91
+ # ============================================================
92
+ # STEP 2: Node 1 - Authenticate
93
+ # ============================================================
94
+ logger.info("πŸ“ Executing AUTHENTICATE node")
95
+ state = await authenticate(state, token)
96
+ if state.current_flow == FlowState.ERROR:
97
+ logger.error("❌ Authentication failed")
98
+ return self._error_response(state, "Authentication failed")
99
+
100
+ # ============================================================
101
+ # STEP 3: Node 2 - Classify Intent
102
+ # ============================================================
103
+ logger.info("πŸ“ Executing CLASSIFY_INTENT node")
104
+ state = await classify_intent(state)
105
+ if state.current_flow == FlowState.ERROR:
106
+ logger.error("❌ Intent classification failed")
107
+ return self._error_response(state, "Could not understand your intent")
108
+
109
+ logger.info(
110
+ "βœ… Intent classified",
111
+ intent=state.intent_type,
112
+ confidence=state.intent_confidence
113
+ )
114
+
115
+ # ============================================================
116
+ # STEP 4: Node 3-N - Route to specific flow handlers
117
+ # ============================================================
118
+
119
+ logger.info(
120
+ "πŸ“ Routing to flow handler",
121
+ intent=state.intent_type,
122
+ flow=state.current_flow.value
123
+ )
124
+
125
+ if state.current_flow == FlowState.GREETING:
126
+ logger.info("πŸ“ Executing GREETING handler")
127
+ state = await greeting_handler(state)
128
+
129
+ elif state.current_flow == FlowState.LISTING_COLLECT:
130
+ logger.info("πŸ“ Executing LISTING_COLLECT handler")
131
+ state = await listing_collect_handler(state)
132
+
133
+ elif state.current_flow == FlowState.LISTING_VALIDATE:
134
+ logger.info("πŸ“ Executing LISTING_VALIDATE handler")
135
+ state = await listing_validate_handler(state)
136
+
137
+ elif state.current_flow == FlowState.LISTING_PUBLISH:
138
+ logger.info("πŸ“ Executing LISTING_PUBLISH handler")
139
+ state = await listing_publish_handler(state)
140
+
141
+ elif state.current_flow == FlowState.SEARCH_QUERY:
142
+ logger.info("πŸ“ Executing SEARCH_QUERY handler")
143
+ state = await search_query_handler(state)
144
+
145
+ elif state.current_flow == FlowState.CASUAL_CHAT:
146
+ logger.info("πŸ“ Executing CASUAL_CHAT handler")
147
+ state = await casual_chat_handler(state)
148
+
149
+ else:
150
+ logger.warning("⚠️ Unknown flow state")
151
+ state.set_error(f"Unknown flow: {state.current_flow}", should_retry=False)
152
+ return self._error_response(state, "Unknown flow state")
153
+
154
+ logger.info("βœ… Flow handler completed", flow=state.current_flow.value)
155
+
156
+ # ============================================================
157
+ # STEP 5: Node N-2 - Validate Output
158
+ # ============================================================
159
+ logger.info("πŸ“ Executing VALIDATE_OUTPUT node (CRITICAL)")
160
+ state = await validate_output_node(state)
161
+ if state.last_error:
162
+ logger.warning("⚠️ Output validation had errors", error=state.last_error)
163
+ else:
164
+ logger.info("βœ… Output validation passed")
165
+
166
+ # ============================================================
167
+ # STEP 6: Node N-1 - Respond
168
+ # ============================================================
169
+ logger.info("πŸ“ Executing RESPOND node")
170
+ state, response = await respond_to_user(state)
171
+
172
+ logger.info(
173
+ "βœ… Graph execution completed",
174
+ user_id=user_id,
175
+ flow=state.current_flow.value,
176
+ steps=state.steps_taken,
177
+ success=response.success,
178
+ )
179
+
180
+ return response
181
+
182
+ except Exception as e:
183
+ logger.error("❌ Graph execution error", exc_info=e)
184
+ return AgentResponse(
185
+ success=False,
186
+ text="An unexpected error occurred. Please try again.",
187
+ action="error",
188
+ error=str(e),
189
+ )
190
+
191
+ def _error_response(self, state: AgentState, message: str) -> AgentResponse:
192
+ """Generate error response"""
193
+ return AgentResponse(
194
+ success=False,
195
+ text=message,
196
+ action="error",
197
+ state={"flow": state.current_flow.value},
198
+ error=state.last_error,
199
+ )
200
+
201
+
202
+ # Singleton instance
203
+ _graph = None
204
+
205
+
206
+ def get_aida_graph() -> AidaGraph:
207
+ """Get or create global AIDA graph instance"""
208
+ global _graph
209
+ if _graph is None:
210
+ _graph = AidaGraph()
211
+ logger.info("βœ… AIDA Graph singleton created")
212
+ return _graph
app/ai/agent/nodes/__init__.py ADDED
File without changes
app/ai/agent/nodes/authenticate.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/authenticate.py
2
+ """
3
+ Node 1: Authenticate user.
4
+ Validates JWT token and ensures user has permission to use agent.
5
+ """
6
+
7
+ from structlog import get_logger
8
+ from app.ai.agent.state import AgentState, FlowState
9
+ from app.guards.jwt_guard import decode_access_token
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ async def authenticate(
15
+ state: AgentState,
16
+ token_credentials: str,
17
+ ) -> AgentState:
18
+ """
19
+ Authenticate user by validating JWT token.
20
+
21
+ Args:
22
+ state: Current agent state
23
+ token_credentials: JWT token string
24
+
25
+ Returns:
26
+ Updated agent state
27
+
28
+ Transitions to:
29
+ - CLASSIFY_INTENT if authenticated
30
+ - ERROR if authentication fails
31
+ """
32
+
33
+ logger.info("Authenticating user", user_id=state.user_id, session_id=state.session_id)
34
+
35
+ try:
36
+ # Validate token
37
+ payload = decode_access_token(token_credentials)
38
+
39
+ if not payload:
40
+ error_msg = "Invalid or expired token"
41
+ logger.warning("Authentication failed", error=error_msg)
42
+ state.set_error(error_msg, should_retry=False)
43
+ state.transition_to(FlowState.ERROR, reason="Authentication failed")
44
+ return state
45
+
46
+ # Verify user_id matches
47
+ token_user_id = payload.get("user_id")
48
+ if token_user_id != state.user_id:
49
+ error_msg = "Token does not match user_id"
50
+ logger.warning("Authentication failed", error=error_msg, token_user_id=token_user_id)
51
+ state.set_error(error_msg, should_retry=False)
52
+ state.transition_to(FlowState.ERROR, reason="User ID mismatch")
53
+ return state
54
+
55
+ # Verify user role
56
+ token_role = payload.get("role")
57
+ if token_role not in ["landlord", "renter"]:
58
+ error_msg = f"Invalid user role: {token_role}"
59
+ logger.warning("Authentication failed", error=error_msg)
60
+ state.set_error(error_msg, should_retry=False)
61
+ state.transition_to(FlowState.ERROR, reason="Invalid role")
62
+ return state
63
+
64
+ # Update state with verified info
65
+ state.user_role = token_role
66
+
67
+ logger.info("Authentication successful", user_id=state.user_id, user_role=state.user_role)
68
+
69
+ # Transition to next state
70
+ success, error = state.transition_to(FlowState.CLASSIFY_INTENT, reason="User authenticated")
71
+ if not success:
72
+ state.set_error(error, should_retry=False)
73
+ return state
74
+
75
+ return state
76
+
77
+ except Exception as e:
78
+ logger.error("Authentication error", exc_info=e)
79
+ error_msg = f"Authentication error: {str(e)}"
80
+ state.set_error(error_msg, should_retry=False)
81
+ state.transition_to(FlowState.ERROR, reason="Authentication exception")
82
+ return state
app/ai/agent/nodes/casual_chat.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/casual_chat.py
2
+ """
3
+ Node: Handle casual conversation.
4
+ For topics that don't fit listing or search - general questions, chat, etc.
5
+ """
6
+
7
+ from structlog import get_logger
8
+ from langchain_openai import ChatOpenAI
9
+ from langchain_core.messages import SystemMessage, HumanMessage
10
+
11
+ from app.ai.agent.state import AgentState, FlowState
12
+ from app.ai.agent.validators import ResponseValidator
13
+ from app.ai.prompts.system_prompt import get_system_prompt
14
+ from app.config import settings
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ # Initialize LLM for casual conversation
19
+ llm = ChatOpenAI(
20
+ api_key=settings.DEEPSEEK_API_KEY,
21
+ base_url=settings.DEEPSEEK_BASE_URL,
22
+ model="deepseek-chat",
23
+ temperature=0.8, # Higher temp for more natural conversation
24
+ )
25
+
26
+
27
+ def build_conversation_context(state: AgentState) -> str:
28
+ """
29
+ Build conversation context from history.
30
+
31
+ Args:
32
+ state: Agent state with conversation history
33
+
34
+ Returns:
35
+ Formatted conversation history string
36
+ """
37
+
38
+ messages = state.conversation_history[-6:] # Last 6 messages for context
39
+
40
+ if not messages:
41
+ return "(New conversation)"
42
+
43
+ formatted = []
44
+ for msg in messages:
45
+ role = "User" if msg["role"] == "user" else "Aida"
46
+ content = msg["content"]
47
+ formatted.append(f"{role}: {content}")
48
+
49
+ return "\n".join(formatted)
50
+
51
+
52
+ async def casual_chat_handler(state: AgentState) -> AgentState:
53
+ """
54
+ Handle casual conversation.
55
+
56
+ Flow:
57
+ 1. Build conversation context
58
+ 2. Get system prompt
59
+ 3. Call LLM for natural response
60
+ 4. Validate response
61
+ 5. Store and return
62
+ 6. Transition to COMPLETE
63
+
64
+ Args:
65
+ state: Agent state
66
+
67
+ Returns:
68
+ Updated state
69
+ """
70
+
71
+ logger.info(
72
+ "Handling casual chat",
73
+ user_id=state.user_id,
74
+ message=state.last_user_message[:50]
75
+ )
76
+
77
+ try:
78
+ # ============================================================
79
+ # STEP 1: Build conversation context
80
+ # ============================================================
81
+
82
+ conv_context = build_conversation_context(state)
83
+
84
+ logger.info("Conversation context built", context_len=len(conv_context))
85
+
86
+ # ============================================================
87
+ # STEP 2: Get system prompt
88
+ # ============================================================
89
+
90
+ system_prompt = get_system_prompt(user_role=state.user_role)
91
+
92
+ logger.info("System prompt loaded", user_role=state.user_role)
93
+
94
+ # ============================================================
95
+ # STEP 3: Build chat prompt with context
96
+ # ============================================================
97
+
98
+ chat_prompt = f"""{system_prompt}
99
+
100
+ CONVERSATION HISTORY:
101
+ {conv_context}
102
+
103
+ CURRENT USER MESSAGE: {state.last_user_message}
104
+
105
+ Respond naturally and helpfully. Keep your response conversational and friendly (2-3 sentences max)."""
106
+
107
+ # ============================================================
108
+ # STEP 4: Call LLM for response
109
+ # ============================================================
110
+
111
+ response = await llm.ainvoke([
112
+ SystemMessage(content="You are AIDA, a warm and helpful real estate AI assistant. Respond naturally to user questions."),
113
+ HumanMessage(content=chat_prompt)
114
+ ])
115
+
116
+ response_text = response.content if hasattr(response, 'content') else str(response)
117
+
118
+ logger.info("LLM response generated", response_len=len(response_text))
119
+
120
+ # ============================================================
121
+ # STEP 5: Validate response
122
+ # ============================================================
123
+
124
+ is_valid, cleaned_text, error = ResponseValidator.validate_response_text(response_text)
125
+
126
+ if not is_valid:
127
+ logger.warning("Response validation failed", error=error)
128
+ # Use fallback
129
+ cleaned_text = "I'm here to help with real estate questions. What would you like to know?"
130
+ else:
131
+ # Sanitize
132
+ cleaned_text = ResponseValidator.sanitize_response(cleaned_text)
133
+
134
+ logger.info("Response validated", text_len=len(cleaned_text))
135
+
136
+ # ============================================================
137
+ # STEP 6: Store in state
138
+ # ============================================================
139
+
140
+ state.temp_data["response_text"] = cleaned_text
141
+ state.temp_data["action"] = "casual_chat"
142
+
143
+ logger.info("Response stored in state", user_id=state.user_id)
144
+
145
+ # ============================================================
146
+ # STEP 7: Transition to COMPLETE
147
+ # ============================================================
148
+
149
+ success, error = state.transition_to(FlowState.COMPLETE, reason="Casual chat completed")
150
+
151
+ if not success:
152
+ logger.error("Transition to COMPLETE failed", error=error)
153
+ state.set_error(error, should_retry=False)
154
+ return state
155
+
156
+ logger.info(
157
+ "Casual chat completed",
158
+ user_id=state.user_id,
159
+ steps=state.steps_taken
160
+ )
161
+
162
+ return state
163
+
164
+ except Exception as e:
165
+ logger.error("Casual chat error", exc_info=e)
166
+ error_msg = f"Chat error: {str(e)}"
167
+
168
+ # Set fallback response
169
+ state.temp_data["response_text"] = "Sorry, I had a moment there! What were you saying?"
170
+ state.temp_data["action"] = "casual_chat"
171
+
172
+ # Try to recover
173
+ if state.set_error(error_msg, should_retry=True):
174
+ state.transition_to(FlowState.COMPLETE, reason="Chat with error recovery")
175
+ else:
176
+ state.transition_to(FlowState.ERROR, reason="Casual chat error")
177
+
178
+ return state
app/ai/agent/nodes/classify_intent.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
13
+
14
+ from app.config import settings
15
+ from app.ai.agent.state import AgentState, FlowState
16
+ from app.ai.agent.validators import JSONValidator, IntentValidator
17
+ from app.ai.agent.schemas import Intent
18
+
19
+ logger = get_logger(__name__)
20
+
21
+ # Initialize LLM for classification
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, # 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.
33
+
34
+ User message: "{user_message}"
35
+
36
+ Classify into ONE of these intents:
37
+ 1. "greeting" - Pure greeting (Hello, Hi, Good morning, etc.)
38
+ 2. "listing" - User wants to create/list a property
39
+ 3. "search" - User wants to search/find properties
40
+ 4. "casual_chat" - Other conversation
41
+ 5. "unknown" - You don't understand
42
+
43
+ Return ONLY valid JSON (no markdown, no extra text):
44
+ {{
45
+ "type": "greeting|listing|search|casual_chat|unknown",
46
+ "confidence": 0.0-1.0,
47
+ "reasoning": "Why you chose this intent",
48
+ "requires_auth": true/false,
49
+ "next_action": "What Aida should do next"
50
+ }}
51
+
52
+ Examples:
53
+ - "Hello!" β†’ {{"type": "greeting", "confidence": 0.95, "reasoning": "Pure greeting", "requires_auth": false, "next_action": "respond_warmly"}}
54
+ - "List my apartment" β†’ {{"type": "listing", "confidence": 0.90, "reasoning": "User wants to create listing", "requires_auth": true, "next_action": "start_listing_flow"}}
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:
77
+ logger.error("No user message to classify")
78
+ state.set_error("No user message provided", should_retry=False)
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,
85
+ message=state.last_user_message[:50]
86
+ )
87
+
88
+ try:
89
+ # Build prompt
90
+ prompt = CLASSIFICATION_PROMPT.format(user_message=state.last_user_message)
91
+
92
+ # Call LLM
93
+ response = await llm.ainvoke([
94
+ SystemMessage(content="You are an intent classifier. Return ONLY valid JSON."),
95
+ HumanMessage(content=prompt)
96
+ ])
97
+
98
+ response_text = response.content if hasattr(response, 'content') else str(response)
99
+ logger.info("LLM response received", response_len=len(response_text))
100
+
101
+ # βœ… VALIDATE: Extract and validate JSON against Intent schema
102
+ validation = JSONValidator.extract_and_validate(response_text, Intent)
103
+
104
+ if not validation.is_valid:
105
+ logger.warning("JSON validation failed", errors=validation.errors)
106
+ # Retry with error recovery - default to casual_chat
107
+ if state.set_error(f"Invalid classification response: {validation.errors[0]}", should_retry=True):
108
+ state.intent_type = "casual_chat"
109
+ state.intent_confidence = 0.5
110
+ else:
111
+ state.transition_to(FlowState.ERROR, reason="Intent classification failed")
112
+ return state
113
+ else:
114
+ intent_data = validation.data
115
+ state.intent_type = intent_data.type
116
+ state.intent_confidence = intent_data.confidence
117
+
118
+ logger.info(
119
+ "Intent classified",
120
+ intent_type=state.intent_type,
121
+ confidence=state.intent_confidence,
122
+ user_id=state.user_id
123
+ )
124
+
125
+ # βœ… ROUTE: Based on intent type, transition to next node
126
+ intent_to_flow = {
127
+ "greeting": FlowState.GREETING,
128
+ "listing": FlowState.LISTING_COLLECT,
129
+ "search": FlowState.SEARCH_QUERY,
130
+ "casual_chat": FlowState.CASUAL_CHAT,
131
+ "unknown": FlowState.CASUAL_CHAT, # Default to casual chat
132
+ }
133
+
134
+ next_flow = intent_to_flow.get(state.intent_type, FlowState.CASUAL_CHAT)
135
+
136
+ # Transition with validation
137
+ success, error = state.transition_to(next_flow, reason=f"Intent: {state.intent_type}")
138
+ if not success:
139
+ state.set_error(error, should_retry=False)
140
+ state.transition_to(FlowState.ERROR, reason="Invalid transition")
141
+ return state
142
+
143
+ return state
144
+
145
+ except Exception as e:
146
+ logger.error("Intent classification error", exc_info=e)
147
+ error_msg = f"Classification error: {str(e)}"
148
+
149
+ # Try to recover by defaulting to casual chat
150
+ if state.set_error(error_msg, should_retry=True):
151
+ state.intent_type = "casual_chat"
152
+ state.intent_confidence = 0.3
153
+ success, error = state.transition_to(FlowState.CASUAL_CHAT, reason="Error recovery")
154
+ if not success:
155
+ state.transition_to(FlowState.ERROR, reason="Error recovery failed")
156
+ else:
157
+ state.transition_to(FlowState.ERROR, reason="Intent classification exception")
158
+
159
+ return state
app/ai/agent/nodes/greeting.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/greeting.py
2
+ """
3
+ Node: Handle greeting flow.
4
+ User says hello/hi/greetings β†’ respond warmly β†’ end conversation.
5
+ """
6
+
7
+ from structlog import get_logger
8
+ from langchain_openai import ChatOpenAI
9
+ from langchain_core.messages import SystemMessage, HumanMessage
10
+
11
+ from app.ai.agent.state import AgentState, FlowState
12
+ from app.ai.agent.validators import ResponseValidator
13
+ from app.config import settings
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ # Initialize LLM for warm greeting responses
18
+ llm = ChatOpenAI(
19
+ api_key=settings.DEEPSEEK_API_KEY,
20
+ base_url=settings.DEEPSEEK_BASE_URL,
21
+ model="deepseek-chat",
22
+ temperature=0.7, # Warm, friendly tone
23
+ )
24
+
25
+
26
+ GREETING_PROMPT = """You are AIDA, a warm and friendly real estate AI assistant for Lojiz platform.
27
+
28
+ User greeted you with: "{user_message}"
29
+
30
+ Your task: Respond to their greeting warmly and naturally.
31
+
32
+ Requirements:
33
+ 1. Match their tone (formal/casual/friendly)
34
+ 2. Respond in the SAME language they used
35
+ 3. Keep response SHORT (2-3 sentences max)
36
+ 4. Ask how you can help with real estate
37
+ 5. Be genuine and human-like
38
+
39
+ Examples:
40
+ - User: "Hello!" β†’ "Hello! πŸ‘‹ I'm AIDA, your real estate assistant. How can I help you find or list a property today?"
41
+ - User: "Hi there" β†’ "Hi! 😊 Great to see you! What can I do for you regarding real estate?"
42
+ - User: "Bonjour!" β†’ "Bonjour! πŸ‘‹ Je suis AIDA, votre assistant immobilier. Comment puis-je vous aider?"
43
+
44
+ Now respond to their greeting naturally (2-3 sentences only):"""
45
+
46
+
47
+ async def greeting_handler(state: AgentState) -> AgentState:
48
+ """
49
+ Handle greeting flow.
50
+
51
+ Args:
52
+ state: Agent state with user's greeting message
53
+
54
+ Returns:
55
+ Updated state with greeting response
56
+
57
+ Transitions to:
58
+ - COMPLETE (greeting handled, conversation ends)
59
+ """
60
+
61
+ logger.info(
62
+ "Handling greeting flow",
63
+ user_id=state.user_id,
64
+ message=state.last_user_message[:50]
65
+ )
66
+
67
+ try:
68
+ # ============================================================
69
+ # STEP 1: Generate warm greeting response
70
+ # ============================================================
71
+
72
+ prompt = GREETING_PROMPT.format(user_message=state.last_user_message)
73
+
74
+ # Call LLM for natural greeting response
75
+ response = await llm.ainvoke([
76
+ SystemMessage(content="You are AIDA, a warm and friendly real estate AI assistant. Respond naturally to greetings in the user's language."),
77
+ HumanMessage(content=prompt)
78
+ ])
79
+
80
+ response_text = response.content if hasattr(response, 'content') else str(response)
81
+
82
+ logger.info("LLM greeting response generated", response_len=len(response_text))
83
+
84
+ # ============================================================
85
+ # STEP 2: Validate response
86
+ # ============================================================
87
+
88
+ is_valid, cleaned_text, error = ResponseValidator.validate_response_text(response_text)
89
+
90
+ if not is_valid:
91
+ logger.warning("Response validation failed", error=error)
92
+ # Use fallback response
93
+ cleaned_text = "Hello! πŸ‘‹ I'm AIDA, your real estate assistant. How can I help you today?"
94
+ else:
95
+ # Sanitize response
96
+ cleaned_text = ResponseValidator.sanitize_response(cleaned_text)
97
+
98
+ logger.info("Greeting response validated", text_len=len(cleaned_text))
99
+
100
+ # ============================================================
101
+ # STEP 3: Store in state
102
+ # ============================================================
103
+
104
+ state.temp_data["response_text"] = cleaned_text
105
+ state.temp_data["action"] = "greeting"
106
+
107
+ logger.info("Greeting response stored in state", user_id=state.user_id)
108
+
109
+ # ============================================================
110
+ # STEP 4: Transition to COMPLETE
111
+ # ============================================================
112
+
113
+ success, error = state.transition_to(FlowState.COMPLETE, reason="Greeting handled")
114
+
115
+ if not success:
116
+ logger.error("Transition to COMPLETE failed", error=error)
117
+ state.set_error(error, should_retry=False)
118
+ return state
119
+
120
+ logger.info(
121
+ "Greeting flow completed",
122
+ user_id=state.user_id,
123
+ steps=state.steps_taken
124
+ )
125
+
126
+ return state
127
+
128
+ except Exception as e:
129
+ logger.error("Greeting flow error", exc_info=e)
130
+ error_msg = f"Greeting processing error: {str(e)}"
131
+
132
+ # Set fallback response
133
+ state.temp_data["response_text"] = "Hello! How can I help you with real estate?"
134
+ state.temp_data["action"] = "greeting"
135
+
136
+ # Try to continue despite error
137
+ if state.set_error(error_msg, should_retry=True):
138
+ state.transition_to(FlowState.COMPLETE, reason="Greeting with error recovery")
139
+ else:
140
+ state.transition_to(FlowState.ERROR, reason="Greeting error")
141
+
142
+ return state
app/ai/agent/nodes/listing_collect.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
285
+
286
+ return state
app/ai/agent/nodes/listing_publish.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/listing_publish.py
2
+ """
3
+ Node: Publish listing to MongoDB.
4
+ Saves validated listing to database, returns success message.
5
+ """
6
+
7
+ from structlog import get_logger
8
+ from datetime import datetime
9
+
10
+ from app.ai.agent.state import AgentState, FlowState
11
+ from app.database import get_db
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ async def listing_publish_handler(state: AgentState) -> AgentState:
17
+ """
18
+ Publish listing to MongoDB.
19
+
20
+ Flow:
21
+ 1. Check for publish confirmation in message
22
+ 2. Get listing draft from state
23
+ 3. Validate draft still valid
24
+ 4. Convert to MongoDB document
25
+ 5. Insert into database
26
+ 6. Validate insert result
27
+ 7. Generate success message
28
+ 8. Transition to COMPLETE
29
+
30
+ Args:
31
+ state: Agent state with listing_draft
32
+
33
+ Returns:
34
+ Updated state
35
+ """
36
+
37
+ logger.info(
38
+ "Handling listing publishing",
39
+ user_id=state.user_id,
40
+ has_draft=state.listing_draft is not None
41
+ )
42
+
43
+ try:
44
+ # ============================================================
45
+ # STEP 1: Verify we have a draft to publish
46
+ # ============================================================
47
+
48
+ if not state.listing_draft:
49
+ logger.error("No listing draft found in state")
50
+ error_msg = "No listing to publish. Please create a listing first."
51
+ state.set_error(error_msg, should_retry=False)
52
+ state.temp_data["response_text"] = error_msg
53
+ state.temp_data["action"] = "error"
54
+ return state
55
+
56
+ draft = state.listing_draft
57
+ logger.info("Draft found, preparing to publish", title=draft.title)
58
+
59
+ # ============================================================
60
+ # STEP 2: Convert draft to MongoDB document
61
+ # ============================================================
62
+
63
+ listing_document = {
64
+ "user_id": draft.user_id,
65
+ "user_role": draft.user_role,
66
+ "title": draft.title,
67
+ "description": draft.description,
68
+ "location": draft.location,
69
+ "bedrooms": draft.bedrooms,
70
+ "bathrooms": draft.bathrooms,
71
+ "price": draft.price,
72
+ "price_type": draft.price_type,
73
+ "currency": draft.currency,
74
+ "listing_type": draft.listing_type,
75
+ "amenities": draft.amenities,
76
+ "requirements": draft.requirements,
77
+ "images": draft.images,
78
+ "status": "active",
79
+ "created_at": datetime.utcnow(),
80
+ "updated_at": datetime.utcnow(),
81
+ "view_count": 0,
82
+ "favorite_count": 0,
83
+ }
84
+
85
+ logger.info(
86
+ "Document prepared for insertion",
87
+ document_keys=list(listing_document.keys())
88
+ )
89
+
90
+ # ============================================================
91
+ # STEP 3: Insert into MongoDB
92
+ # ============================================================
93
+
94
+ try:
95
+ db = await get_db()
96
+ result = await db.listings.insert_one(listing_document)
97
+
98
+ if not result.inserted_id:
99
+ raise ValueError("Insert returned no ID")
100
+
101
+ listing_id = str(result.inserted_id)
102
+ logger.info("Listing inserted successfully", listing_id=listing_id)
103
+
104
+ except Exception as e:
105
+ logger.error("MongoDB insert failed", exc_info=e)
106
+ error_msg = f"Failed to save listing: {str(e)}"
107
+
108
+ if state.set_error(error_msg, should_retry=True):
109
+ # Retry possible
110
+ state.temp_data["response_text"] = "Let me try saving again..."
111
+ state.temp_data["action"] = "retry_publish"
112
+ return state
113
+ else:
114
+ # Max retries exceeded
115
+ state.transition_to(FlowState.ERROR, reason="MongoDB insert failed after retries")
116
+ state.temp_data["response_text"] = f"Sorry, couldn't save your listing: {error_msg}"
117
+ state.temp_data["action"] = "error"
118
+ return state
119
+
120
+ # ============================================================
121
+ # STEP 4: Generate success message
122
+ # ============================================================
123
+
124
+ success_message = f"""πŸŽ‰ **Listing Published Successfully!**
125
+
126
+ Your listing **"{draft.title}"** is now live on Lojiz!
127
+
128
+ πŸ“Š **Listing Details:**
129
+ - πŸ“ Location: {draft.location}
130
+ - πŸ›οΈ Bedrooms: {draft.bedrooms}
131
+ - 🚿 Bathrooms: {draft.bathrooms}
132
+ - πŸ’° Price: {draft.price} {draft.currency}/{draft.price_type}
133
+
134
+ πŸ”— **Listing ID:** `{listing_id}`
135
+
136
+ ✨ Your property is now visible to potential renters/buyers. They can:
137
+ - View your listing details
138
+ - See all {len(draft.images)} images
139
+ - Contact you about the property
140
+
141
+ πŸ“± You can:
142
+ - Edit the listing anytime
143
+ - View interested users
144
+ - Manage inquiries
145
+
146
+ Good luck with your listing! πŸš€"""
147
+
148
+ state.temp_data["response_text"] = success_message
149
+ state.temp_data["action"] = "published"
150
+ state.temp_data["listing_id"] = listing_id
151
+
152
+ logger.info(
153
+ "Success message generated",
154
+ listing_id=listing_id,
155
+ title=draft.title
156
+ )
157
+
158
+ # ============================================================
159
+ # STEP 5: Clear draft from state and transition to COMPLETE
160
+ # ============================================================
161
+
162
+ # Clear listing state for next operation
163
+ state.listing_draft = None
164
+ state.provided_fields.clear()
165
+ state.missing_required_fields.clear()
166
+ state.current_asking_for = None
167
+
168
+ # Transition to complete
169
+ success, error = state.transition_to(
170
+ FlowState.COMPLETE,
171
+ reason=f"Listing published: {listing_id}"
172
+ )
173
+
174
+ if not success:
175
+ logger.error("Transition to COMPLETE failed", error=error)
176
+ # But listing is already saved, so just continue
177
+
178
+ logger.info(
179
+ "Listing publishing completed",
180
+ user_id=state.user_id,
181
+ listing_id=listing_id,
182
+ steps=state.steps_taken
183
+ )
184
+
185
+ return state
186
+
187
+ except Exception as e:
188
+ logger.error("Listing publishing error", exc_info=e)
189
+ error_msg = f"Publishing error: {str(e)}"
190
+
191
+ # Set error but provide helpful message
192
+ state.set_error(error_msg, should_retry=True)
193
+ state.temp_data["response_text"] = (
194
+ "Something went wrong while publishing your listing. "
195
+ "Please try again, or contact support if the problem persists."
196
+ )
197
+ state.temp_data["action"] = "error"
198
+
199
+ return state
app/ai/agent/nodes/listing_validate.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/listing_validate.py
2
+ """
3
+ Node: Validate and preview listing before publishing.
4
+ Builds ListingDraft, auto-generates title and description, shows preview.
5
+ """
6
+
7
+ from structlog import get_logger
8
+ from typing import Tuple
9
+
10
+ from app.ai.agent.state import AgentState, FlowState
11
+ from app.ai.agent.validators import ListingValidator
12
+ from app.ai.agent.schemas import ListingDraft
13
+ from app.ai.tools.listing_tool import (
14
+ auto_detect_listing_type,
15
+ get_currency_for_location,
16
+ generate_title_and_description,
17
+ )
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ def build_draft_ui(draft: ListingDraft) -> dict:
23
+ """
24
+ Build UI preview component for listing draft.
25
+
26
+ Args:
27
+ draft: ListingDraft object
28
+
29
+ Returns:
30
+ Dict with UI component structure
31
+ """
32
+
33
+ # Amenity icons
34
+ amenity_icons = {
35
+ "wifi": "πŸ“Ά",
36
+ "parking": "πŸ…ΏοΈ",
37
+ "furnished": "πŸ›‹οΈ",
38
+ "washing machine": "🧼",
39
+ "dryer": "πŸŒͺ️",
40
+ "ac": "❄️",
41
+ "air conditioning": "❄️",
42
+ "balcony": "🏠",
43
+ "pool": "🏊",
44
+ "gym": "πŸ’ͺ",
45
+ "garden": "🌳",
46
+ "kitchen": "🍳",
47
+ }
48
+
49
+ amenities = draft.amenities or []
50
+ amenities_display = []
51
+
52
+ for amenity in amenities:
53
+ icon = amenity_icons.get(amenity.lower(), "βœ“")
54
+ amenities_display.append(f"{icon} {amenity.capitalize()}")
55
+
56
+ ui_component = {
57
+ "component_type": "listing_draft_preview",
58
+ "title": draft.title,
59
+ "description": draft.description,
60
+ "details": {
61
+ "location": draft.location,
62
+ "bedrooms": draft.bedrooms,
63
+ "bathrooms": draft.bathrooms,
64
+ "price": f"{draft.price} {draft.currency}",
65
+ "price_type": draft.price_type,
66
+ "listing_type": draft.listing_type.capitalize(),
67
+ },
68
+ "amenities": amenities_display if amenities_display else ["No amenities listed"],
69
+ "requirements": draft.requirements or "No special requirements",
70
+ "images_count": len(draft.images),
71
+ "images": draft.images[:5], # Show first 5
72
+ "status": "ready_for_review",
73
+ "actions": ["publish", "edit", "discard"],
74
+ }
75
+
76
+ return ui_component
77
+
78
+
79
+ async def listing_validate_handler(state: AgentState) -> AgentState:
80
+ """
81
+ Validate listing and show preview.
82
+
83
+ Flow:
84
+ 1. Build ListingDraft from provided fields
85
+ 2. Auto-detect listing_type
86
+ 3. Auto-detect currency
87
+ 4. Auto-generate title & description
88
+ 5. Validate schema
89
+ 6. Build UI preview
90
+ 7. Show to user, wait for confirmation
91
+
92
+ Args:
93
+ state: Agent state with provided_fields
94
+
95
+ Returns:
96
+ Updated state
97
+ """
98
+
99
+ logger.info(
100
+ "Handling listing validation",
101
+ user_id=state.user_id,
102
+ provided_fields=list(state.provided_fields.keys())
103
+ )
104
+
105
+ try:
106
+ # ============================================================
107
+ # STEP 1: Extract provided fields
108
+ # ============================================================
109
+
110
+ location = state.provided_fields.get("location")
111
+ bedrooms = state.provided_fields.get("bedrooms")
112
+ bathrooms = state.provided_fields.get("bathrooms")
113
+ price = state.provided_fields.get("price")
114
+ price_type = state.provided_fields.get("price_type")
115
+ amenities = state.provided_fields.get("amenities", [])
116
+ requirements = state.provided_fields.get("requirements")
117
+ images = state.provided_fields.get("images", [])
118
+
119
+ logger.info(
120
+ "Fields extracted for validation",
121
+ location=location,
122
+ bedrooms=bedrooms,
123
+ price=price,
124
+ images_count=len(images)
125
+ )
126
+
127
+ # ============================================================
128
+ # STEP 2: Auto-detect listing_type
129
+ # ============================================================
130
+
131
+ listing_type = await auto_detect_listing_type(
132
+ price_type=price_type,
133
+ user_role=state.user_role,
134
+ user_message=state.last_user_message
135
+ )
136
+
137
+ logger.info("Listing type auto-detected", listing_type=listing_type)
138
+
139
+ # ============================================================
140
+ # STEP 3: Auto-detect currency
141
+ # ============================================================
142
+
143
+ currency = await get_currency_for_location(location)
144
+
145
+ logger.info("Currency auto-detected", location=location, currency=currency)
146
+
147
+ # ============================================================
148
+ # STEP 4: Auto-generate title & description
149
+ # ============================================================
150
+
151
+ draft_data = {
152
+ "title": "Generating...",
153
+ "location": location,
154
+ "bedrooms": bedrooms,
155
+ "bathrooms": bathrooms,
156
+ "price": price,
157
+ "listing_type": listing_type,
158
+ "currency": currency,
159
+ "amenities": amenities,
160
+ }
161
+
162
+ title, description = await generate_title_and_description(draft_data, state.user_role)
163
+
164
+ logger.info("Title and description generated", title=title)
165
+
166
+ # ============================================================
167
+ # STEP 5: Build ListingDraft
168
+ # ============================================================
169
+
170
+ draft_dict = {
171
+ "user_id": state.user_id,
172
+ "user_role": state.user_role,
173
+ "title": title,
174
+ "description": description,
175
+ "location": location,
176
+ "bedrooms": int(bedrooms),
177
+ "bathrooms": int(bathrooms),
178
+ "price": float(price),
179
+ "price_type": price_type,
180
+ "currency": currency,
181
+ "listing_type": listing_type,
182
+ "amenities": amenities,
183
+ "requirements": requirements,
184
+ "images": images,
185
+ }
186
+
187
+ # βœ… VALIDATE against schema
188
+ draft = ListingDraft(**draft_dict)
189
+
190
+ logger.info(
191
+ "ListingDraft built and validated",
192
+ title=draft.title,
193
+ location=draft.location
194
+ )
195
+
196
+ # ============================================================
197
+ # STEP 6: Build UI preview
198
+ # ============================================================
199
+
200
+ draft_ui = build_draft_ui(draft)
201
+
202
+ logger.info("Draft UI preview built")
203
+
204
+ # ============================================================
205
+ # STEP 7: Store in state and show preview
206
+ # ============================================================
207
+
208
+ state.listing_draft = draft
209
+ state.temp_data["draft"] = draft
210
+ state.temp_data["draft_ui"] = draft_ui
211
+ state.temp_data["action"] = "show_draft"
212
+
213
+ # Build preview response
214
+ preview_text = f"""πŸ“‹ **Your Listing Preview**
215
+
216
+ **{draft.title}**
217
+
218
+ {draft.description}
219
+
220
+ πŸ“ **Location:** {draft.location}
221
+ πŸ›οΈ **Bedrooms:** {draft.bedrooms}
222
+ 🚿 **Bathrooms:** {draft.bathrooms}
223
+ πŸ’° **Price:** {draft.price} {draft.currency} per {draft.price_type}
224
+ 🏷️ **Type:** {draft.listing_type.capitalize()}
225
+
226
+ ✨ **Amenities:** {', '.join(draft.amenities) if draft.amenities else 'None listed'}
227
+ πŸ“Œ **Requirements:** {draft.requirements if draft.requirements else 'None'}
228
+ πŸ“· **Images:** {len(draft.images)} uploaded
229
+
230
+ ---
231
+ Ready to publish? Say **"publish"**, or **"edit [field]"** to change something, or **"discard"** to cancel."""
232
+
233
+ state.temp_data["response_text"] = preview_text
234
+
235
+ logger.info(
236
+ "Listing preview ready",
237
+ user_id=state.user_id,
238
+ title=draft.title
239
+ )
240
+
241
+ return state
242
+
243
+ except ValueError as e:
244
+ # Schema validation error
245
+ logger.error("ListingDraft validation failed", error=str(e))
246
+ error_msg = f"Invalid listing data: {str(e)}"
247
+
248
+ state.set_error(error_msg, should_retry=True)
249
+ state.temp_data["response_text"] = f"There's an issue with your listing: {error_msg}\n\nLet's fix it. What would you like to change?"
250
+ state.temp_data["action"] = "validation_error"
251
+
252
+ return state
253
+
254
+ except Exception as e:
255
+ logger.error("Listing validation error", exc_info=e)
256
+ error_msg = f"Error validating listing: {str(e)}"
257
+
258
+ if state.set_error(error_msg, should_retry=True):
259
+ state.temp_data["response_text"] = "Let me regenerate your listing preview..."
260
+ state.temp_data["action"] = "retry_validation"
261
+ else:
262
+ state.transition_to(FlowState.ERROR, reason="Listing validation error")
263
+
264
+ return state
app/ai/agent/nodes/respond.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/respond.py
2
+ """
3
+ Node: Generate and return response to user.
4
+ Final node before returning to client.
5
+ """
6
+
7
+ from structlog import get_logger
8
+ from typing import Dict, Any, Optional
9
+
10
+ from app.ai.agent.state import AgentState, FlowState
11
+ from app.ai.agent.schemas import AgentResponse
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ async def respond_to_user(state: AgentState) -> tuple[AgentState, AgentResponse]:
17
+ """
18
+ Generate final response to send to user.
19
+
20
+ Args:
21
+ state: Current agent state
22
+
23
+ Returns:
24
+ Tuple of (updated_state, AgentResponse)
25
+ """
26
+
27
+ logger.info("Generating response", user_id=state.user_id, flow=state.current_flow.value)
28
+
29
+ try:
30
+ # Get response data from state
31
+ response_text = state.temp_data.get("response_text", "")
32
+ draft = state.temp_data.get("draft")
33
+ draft_ui = state.temp_data.get("draft_ui")
34
+ tool_result = state.temp_data.get("tool_result")
35
+ action = state.temp_data.get("action", state.current_flow.value)
36
+
37
+ # Ensure we have response text
38
+ if not response_text:
39
+ response_text = _get_fallback_response(state)
40
+
41
+ # Build response
42
+ response = AgentResponse(
43
+ success=state.last_error is None,
44
+ text=response_text,
45
+ action=action,
46
+ state={
47
+ "flow": state.current_flow.value,
48
+ "steps": state.steps_taken,
49
+ "errors": state.error_count,
50
+ },
51
+ draft=draft,
52
+ draft_ui=draft_ui,
53
+ tool_result=tool_result,
54
+ error=state.last_error,
55
+ metadata={
56
+ "intent": state.intent_type,
57
+ "intent_confidence": state.intent_confidence,
58
+ "language": state.language_detected,
59
+ "messages_in_session": len(state.conversation_history),
60
+ }
61
+ )
62
+
63
+ # Add message to history
64
+ state.add_message("assistant", response_text)
65
+
66
+ # Determine next state for UI
67
+ if state.current_flow == FlowState.COMPLETE:
68
+ # Reset for next conversation
69
+ state.transition_to(FlowState.IDLE, reason="Conversation complete")
70
+ elif state.current_flow == FlowState.ERROR:
71
+ # Keep in error state for user to see
72
+ pass
73
+
74
+ logger.info(
75
+ "Response generated",
76
+ user_id=state.user_id,
77
+ response_len=len(response_text),
78
+ has_draft=draft is not None,
79
+ )
80
+
81
+ return state, response
82
+
83
+ except Exception as e:
84
+ logger.error("Response generation error", exc_info=e)
85
+
86
+ # Fallback response
87
+ fallback_text = "I encountered an error processing your request. Please try again."
88
+ fallback_response = AgentResponse(
89
+ success=False,
90
+ text=fallback_text,
91
+ action="error",
92
+ state={"flow": state.current_flow.value},
93
+ error=str(e),
94
+ )
95
+
96
+ return state, fallback_response
97
+
98
+
99
+ def _get_fallback_response(state: AgentState) -> str:
100
+ """
101
+ Generate fallback response based on current flow.
102
+
103
+ Used when no response was generated or error occurred.
104
+ """
105
+ fallback_responses = {
106
+ FlowState.GREETING: "Hello! How can I help you with real estate today?",
107
+ FlowState.LISTING_COLLECT: "Tell me more about the property you want to list.",
108
+ FlowState.SEARCH_QUERY: "What properties are you looking for?",
109
+ FlowState.CASUAL_CHAT: "I'm here to help! What would you like to know?",
110
+ FlowState.ERROR: f"Sorry, I encountered an error: {state.last_error or 'Unknown error'}",
111
+ }
112
+
113
+ return fallback_responses.get(state.current_flow, "How can I assist you?")
app/ai/agent/nodes/search_query.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/search_query.py
2
+ """
3
+ Node: Process search queries and return matching listings.
4
+ Extracts search criteria, queries MongoDB, formats results.
5
+ """
6
+
7
+ import json
8
+ import re
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.database import get_db
16
+ from app.config import settings
17
+
18
+ logger = get_logger(__name__)
19
+
20
+ # Initialize LLM for search parameter extraction
21
+ llm = ChatOpenAI(
22
+ api_key=settings.DEEPSEEK_API_KEY,
23
+ base_url=settings.DEEPSEEK_BASE_URL,
24
+ model="deepseek-chat",
25
+ temperature=0.3,
26
+ )
27
+
28
+ SEARCH_EXTRACTION_PROMPT = """Extract search criteria from user's query.
29
+
30
+ User message: "{user_message}"
31
+
32
+ Extract search parameters (set to null if not mentioned):
33
+ - location: City/area name (e.g., "Lagos", "Cotonou") or null
34
+ - min_price: Minimum price or null
35
+ - max_price: Maximum price or null
36
+ - bedrooms: Number of bedrooms or null
37
+ - bathrooms: Number of bathrooms or null
38
+ - listing_type: Type of listing (rent, short-stay, sale, roommate) or null
39
+ - amenities: List of desired amenities or []
40
+
41
+ Be smart about:
42
+ - Understanding "under 50k" as max_price: 50000
43
+ - "3+ bedrooms" as bedrooms: 3
44
+ - "furnished apartment" as listing_type: rent
45
+
46
+ Return ONLY valid JSON:
47
+ {{
48
+ "location": string or null,
49
+ "min_price": number or null,
50
+ "max_price": number or null,
51
+ "bedrooms": integer or null,
52
+ "bathrooms": integer or null,
53
+ "listing_type": string or null,
54
+ "amenities": []
55
+ }}"""
56
+
57
+
58
+ async def extract_search_params(user_message: str) -> dict:
59
+ """
60
+ Extract search parameters from user message.
61
+
62
+ Args:
63
+ user_message: What user searched for
64
+
65
+ Returns:
66
+ Dict with search parameters
67
+ """
68
+
69
+ logger.info("Extracting search parameters", message_len=len(user_message))
70
+
71
+ try:
72
+ prompt = SEARCH_EXTRACTION_PROMPT.format(user_message=user_message)
73
+
74
+ response = await llm.ainvoke([
75
+ SystemMessage(content="Extract search parameters from user query. Return ONLY valid JSON."),
76
+ HumanMessage(content=prompt)
77
+ ])
78
+
79
+ response_text = response.content if hasattr(response, 'content') else str(response)
80
+
81
+ # βœ… Validate JSON
82
+ validation = JSONValidator.extract_and_validate(response_text)
83
+
84
+ if not validation.is_valid:
85
+ logger.warning("Search parameter validation failed")
86
+ return {}
87
+
88
+ logger.info("Search parameters extracted", keys=list(validation.data.keys()))
89
+ return validation.data
90
+
91
+ except Exception as e:
92
+ logger.error("Search extraction error", exc_info=e)
93
+ return {}
94
+
95
+
96
+ async def search_listings(search_params: dict) -> list:
97
+ """
98
+ Query MongoDB for listings matching search criteria.
99
+
100
+ Args:
101
+ search_params: Dict with location, price, bedrooms, etc.
102
+
103
+ Returns:
104
+ List of matching listings
105
+ """
106
+
107
+ logger.info("Searching listings", params_keys=list(search_params.keys()))
108
+
109
+ try:
110
+ db = await get_db()
111
+
112
+ # Build MongoDB query
113
+ query = {"status": "active"}
114
+
115
+ # Location filter
116
+ if search_params.get("location"):
117
+ # Case-insensitive location search
118
+ location = search_params["location"]
119
+ query["location"] = {"$regex": location, "$options": "i"}
120
+
121
+ # Price filters
122
+ if search_params.get("min_price"):
123
+ query["price"] = {"$gte": search_params["min_price"]}
124
+ if search_params.get("max_price"):
125
+ if "price" in query:
126
+ query["price"]["$lte"] = search_params["max_price"]
127
+ else:
128
+ query["price"] = {"$lte": search_params["max_price"]}
129
+
130
+ # Bedrooms
131
+ if search_params.get("bedrooms"):
132
+ query["bedrooms"] = {"$gte": search_params["bedrooms"]}
133
+
134
+ # Bathrooms
135
+ if search_params.get("bathrooms"):
136
+ query["bathrooms"] = {"$gte": search_params["bathrooms"]}
137
+
138
+ # Listing type
139
+ if search_params.get("listing_type"):
140
+ query["listing_type"] = search_params["listing_type"].lower()
141
+
142
+ # Amenities
143
+ if search_params.get("amenities"):
144
+ amenities = [a.lower() for a in search_params["amenities"]]
145
+ query["amenities"] = {"$in": amenities}
146
+
147
+ logger.info("MongoDB query built", query=query)
148
+
149
+ # Execute query with limit
150
+ results = await db.listings.find(query).limit(10).to_list(10)
151
+
152
+ logger.info("Search completed", results_count=len(results))
153
+
154
+ return results
155
+
156
+ except Exception as e:
157
+ logger.error("Listing search error", exc_info=e)
158
+ return []
159
+
160
+
161
+ def format_search_results(listings: list, search_params: dict) -> str:
162
+ """
163
+ Format search results for display.
164
+
165
+ Args:
166
+ listings: List of matching listings
167
+ search_params: Original search parameters (for context)
168
+
169
+ Returns:
170
+ Formatted string with results
171
+ """
172
+
173
+ if not listings:
174
+ location = search_params.get("location", "that location")
175
+ return (
176
+ f"πŸ˜• No listings found matching your criteria in {location}.\n\n"
177
+ "Try:\n"
178
+ "- Searching in a different area\n"
179
+ "- Adjusting your price range\n"
180
+ "- Reducing bedroom/bathroom requirements\n"
181
+ "- Searching for a different listing type (rent, sale, short-stay, roommate)"
182
+ )
183
+
184
+ results_text = f"🏠 Found **{len(listings)}** matching listing{'s' if len(listings) != 1 else ''}:\n\n"
185
+
186
+ for i, listing in enumerate(listings, 1):
187
+ title = listing.get("title", "Untitled")
188
+ location = listing.get("location", "Unknown")
189
+ price = listing.get("price", "N/A")
190
+ currency = listing.get("currency", "")
191
+ price_type = listing.get("price_type", "")
192
+ bedrooms = listing.get("bedrooms", "?")
193
+ bathrooms = listing.get("bathrooms", "?")
194
+ listing_type = listing.get("listing_type", "").capitalize()
195
+ images_count = len(listing.get("images", []))
196
+
197
+ results_text += f"""**{i}. {title}**
198
+ πŸ“ {location} | πŸ›οΈ {bedrooms}bd {bathrooms}ba
199
+ πŸ’° {price} {currency}/{price_type} | 🏷️ {listing_type}
200
+ πŸ“· {images_count} image{'s' if images_count != 1 else ''}
201
+
202
+ """
203
+
204
+ results_text += (
205
+ "\nπŸ’¬ Would you like more details about any of these listings? "
206
+ "Just ask!\n\n"
207
+ "πŸ“ Or if you'd like to list your own property, I can help with that too!"
208
+ )
209
+
210
+ return results_text
211
+
212
+
213
+ async def search_query_handler(state: AgentState) -> AgentState:
214
+ """
215
+ Handle search flow.
216
+
217
+ Flow:
218
+ 1. Extract search criteria from message
219
+ 2. Query MongoDB
220
+ 3. Format results
221
+ 4. Show to user
222
+ 5. Transition to COMPLETE
223
+
224
+ Args:
225
+ state: Agent state
226
+
227
+ Returns:
228
+ Updated state
229
+ """
230
+
231
+ logger.info(
232
+ "Handling search query",
233
+ user_id=state.user_id,
234
+ message=state.last_user_message[:50]
235
+ )
236
+
237
+ try:
238
+ # ============================================================
239
+ # STEP 1: Extract search parameters
240
+ # ============================================================
241
+
242
+ search_params = await extract_search_params(state.last_user_message)
243
+
244
+ if not search_params:
245
+ logger.warning("No search parameters extracted")
246
+ state.temp_data["response_text"] = (
247
+ "I couldn't understand your search. Try asking:\n"
248
+ "- \"2-bedroom apartments in Lagos\"\n"
249
+ "- \"Properties under 50k per month\"\n"
250
+ "- \"Short-stay rentals with wifi\""
251
+ )
252
+ state.temp_data["action"] = "search_invalid"
253
+ return state
254
+
255
+ logger.info("Search parameters extracted", params=search_params)
256
+
257
+ # ============================================================
258
+ # STEP 2: Search MongoDB
259
+ # ============================================================
260
+
261
+ results = await search_listings(search_params)
262
+
263
+ logger.info("Search results retrieved", count=len(results))
264
+
265
+ # ============================================================
266
+ # STEP 3: Format results
267
+ # ============================================================
268
+
269
+ formatted_results = format_search_results(results, search_params)
270
+
271
+ logger.info("Results formatted", length=len(formatted_results))
272
+
273
+ # ============================================================
274
+ # STEP 4: Store in state
275
+ # ============================================================
276
+
277
+ state.search_results = results
278
+ state.temp_data["response_text"] = formatted_results
279
+ state.temp_data["action"] = "search_results"
280
+
281
+ # ============================================================
282
+ # STEP 5: Transition to COMPLETE
283
+ # ============================================================
284
+
285
+ success, error = state.transition_to(FlowState.COMPLETE, reason="Search completed")
286
+
287
+ if not success:
288
+ logger.error("Transition to COMPLETE failed", error=error)
289
+ state.set_error(error, should_retry=False)
290
+
291
+ logger.info(
292
+ "Search flow completed",
293
+ user_id=state.user_id,
294
+ results_count=len(results)
295
+ )
296
+
297
+ return state
298
+
299
+ except Exception as e:
300
+ logger.error("Search query error", exc_info=e)
301
+ error_msg = f"Search error: {str(e)}"
302
+
303
+ state.set_error(error_msg, should_retry=True)
304
+ state.temp_data["response_text"] = (
305
+ "I had trouble searching for listings. Please try again with a simpler query."
306
+ )
307
+ state.temp_data["action"] = "search_error"
308
+
309
+ return state
app/ai/agent/nodes/validate_output.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/nodes/validate_output.py
2
+ """
3
+ Node: Validate output before sending to user.
4
+ This is THE KEY node that ensures reliability.
5
+ Checks all data, schemas, safety, and validity.
6
+ """
7
+
8
+ from structlog import get_logger
9
+ from typing import Dict, Any, Optional, List
10
+
11
+ from app.ai.agent.state import AgentState
12
+ from app.ai.agent.validators import ResponseValidator, ListingValidator
13
+ from app.ai.agent.schemas import ListingDraft, ValidationResult
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class OutputValidator:
19
+ """Comprehensive output validation"""
20
+
21
+ @staticmethod
22
+ async def validate_response(
23
+ text: str,
24
+ draft: Optional[Dict[str, Any]] = None,
25
+ state: Optional[AgentState] = None,
26
+ ) -> Dict[str, Any]:
27
+ """
28
+ Validate response text and draft before sending to user.
29
+
30
+ Returns:
31
+ {
32
+ "is_valid": bool,
33
+ "text": str,
34
+ "draft": Dict or None,
35
+ "errors": [str],
36
+ "warnings": [str],
37
+ }
38
+ """
39
+
40
+ errors: List[str] = []
41
+ warnings: List[str] = []
42
+
43
+ # 1️⃣ Validate response text
44
+ is_valid_text, cleaned_text, text_error = ResponseValidator.validate_response_text(text)
45
+ if not is_valid_text:
46
+ errors.append(text_error or "Invalid response text")
47
+
48
+ # 2️⃣ Sanitize response
49
+ if is_valid_text:
50
+ cleaned_text = ResponseValidator.sanitize_response(cleaned_text)
51
+
52
+ # 3️⃣ Validate listing draft if present
53
+ if draft:
54
+ draft_validation = await OutputValidator._validate_listing_draft(draft)
55
+ if not draft_validation["is_valid"]:
56
+ errors.extend(draft_validation["errors"])
57
+ if draft_validation["warnings"]:
58
+ warnings.extend(draft_validation["warnings"])
59
+
60
+ # 4️⃣ Check state consistency if provided
61
+ if state:
62
+ state_warnings = OutputValidator._check_state_consistency(state)
63
+ if state_warnings:
64
+ warnings.extend(state_warnings)
65
+
66
+ return {
67
+ "is_valid": len(errors) == 0,
68
+ "text": cleaned_text if is_valid_text else "",
69
+ "draft": draft if draft and not draft_validation.get("has_errors", False) else None,
70
+ "errors": errors,
71
+ "warnings": warnings,
72
+ }
73
+
74
+ @staticmethod
75
+ async def _validate_listing_draft(draft: Dict[str, Any]) -> Dict[str, Any]:
76
+ """
77
+ Validate listing draft against schema.
78
+
79
+ Returns:
80
+ {
81
+ "is_valid": bool,
82
+ "errors": [str],
83
+ "warnings": [str],
84
+ "has_errors": bool,
85
+ }
86
+ """
87
+
88
+ errors: List[str] = []
89
+ warnings: List[str] = []
90
+
91
+ try:
92
+ # Use ListingValidator to check schema
93
+ validation = ListingValidator.validate_draft(draft)
94
+
95
+ if not validation.is_valid:
96
+ errors.extend(validation.errors)
97
+ return {
98
+ "is_valid": False,
99
+ "errors": errors,
100
+ "warnings": warnings,
101
+ "has_errors": True,
102
+ }
103
+
104
+ # Additional checks
105
+
106
+ # Check required images
107
+ images = draft.get("images", [])
108
+ if not images or len(images) == 0:
109
+ errors.append("At least one image is required")
110
+ elif len(images) > 10:
111
+ warnings.append(f"More than 10 images ({len(images)}). Consider removing some.")
112
+
113
+ # Check title length
114
+ title = draft.get("title", "")
115
+ if len(title) < 5:
116
+ errors.append("Title too short (min 5 chars)")
117
+ elif len(title) > 100:
118
+ warnings.append("Title is quite long, consider shortening")
119
+
120
+ # Check description quality
121
+ description = draft.get("description", "")
122
+ if len(description) < 10:
123
+ errors.append("Description too short (min 10 chars)")
124
+ elif len(description) > 1500:
125
+ warnings.append("Description is very long")
126
+
127
+ # Check price is positive
128
+ price = draft.get("price")
129
+ if isinstance(price, (int, float)) and price <= 0:
130
+ errors.append("Price must be greater than 0")
131
+
132
+ # Check bedrooms/bathrooms reasonable
133
+ bedrooms = draft.get("bedrooms", 0)
134
+ if bedrooms > 20:
135
+ warnings.append(f"Unusual number of bedrooms: {bedrooms}")
136
+
137
+ return {
138
+ "is_valid": len(errors) == 0,
139
+ "errors": errors,
140
+ "warnings": warnings,
141
+ "has_errors": len(errors) > 0,
142
+ }
143
+
144
+ except Exception as e:
145
+ logger.error("Draft validation error", exc_info=e)
146
+ return {
147
+ "is_valid": False,
148
+ "errors": [f"Validation error: {str(e)}"],
149
+ "warnings": [],
150
+ "has_errors": True,
151
+ }
152
+
153
+ @staticmethod
154
+ def _check_state_consistency(state: AgentState) -> List[str]:
155
+ """
156
+ Check for state inconsistencies that might indicate bugs.
157
+
158
+ Returns:
159
+ List of warnings
160
+ """
161
+ warnings: List[str] = []
162
+
163
+ # Check if state matches conversation history
164
+ if state.conversation_history:
165
+ last_msg = state.conversation_history[-1]
166
+ if last_msg["role"] == "assistant" and state.last_ai_message != last_msg["content"]:
167
+ warnings.append("State AI message doesn't match history")
168
+
169
+ # Check listing state consistency
170
+ if state.listing_draft and state.provided_fields:
171
+ draft_location = state.listing_draft.get("location")
172
+ provided_location = state.provided_fields.get("location")
173
+ if draft_location and provided_location and draft_location != provided_location:
174
+ warnings.append("Location mismatch between draft and provided fields")
175
+
176
+ return warnings
177
+
178
+
179
+ async def validate_output_node(state: AgentState) -> AgentState:
180
+ """
181
+ Node: Validate all output before returning to user.
182
+
183
+ This is the CRITICAL reliability node.
184
+ Every response goes through here.
185
+
186
+ Args:
187
+ state: Agent state with draft and response
188
+
189
+ Returns:
190
+ Updated state with validation results
191
+ """
192
+
193
+ logger.info("Validating output", user_id=state.user_id, flow=state.current_flow.value)
194
+
195
+ try:
196
+ # Get response text from temp_data (set by previous node)
197
+ response_text = state.temp_data.get("response_text", "")
198
+ draft = state.temp_data.get("draft")
199
+
200
+ if not response_text:
201
+ logger.warning("No response text to validate")
202
+ state.set_error("No response text generated", should_retry=True)
203
+ return state
204
+
205
+ # βœ… VALIDATE
206
+ validation_result = await OutputValidator.validate_response(
207
+ text=response_text,
208
+ draft=draft,
209
+ state=state,
210
+ )
211
+
212
+ # βœ… CHECK ERRORS
213
+ if not validation_result["is_valid"]:
214
+ logger.warning("Output validation failed", errors=validation_result["errors"])
215
+ error_msg = f"Output validation failed: {validation_result['errors'][0]}"
216
+
217
+ if state.set_error(error_msg, should_retry=True):
218
+ logger.info("Retrying validation", retry_count=state.retries)
219
+ # Could regenerate response here
220
+ else:
221
+ logger.error("Max retries exceeded for output validation")
222
+
223
+ return state
224
+
225
+ # βœ… CHECK WARNINGS
226
+ if validation_result["warnings"]:
227
+ logger.warning("Output validation warnings", warnings=validation_result["warnings"])
228
+ state.temp_data["validation_warnings"] = validation_result["warnings"]
229
+
230
+ # βœ… STORE VALIDATED DATA
231
+ state.temp_data["response_text"] = validation_result["text"]
232
+ state.temp_data["draft"] = validation_result["draft"]
233
+ state.temp_data["is_validated"] = True
234
+
235
+ logger.info("Output validation passed", user_id=state.user_id)
236
+
237
+ return state
238
+
239
+ except Exception as e:
240
+ logger.error("Output validation exception", exc_info=e)
241
+ error_msg = f"Validation exception: {str(e)}"
242
+
243
+ if not state.set_error(error_msg, should_retry=True):
244
+ logger.error("Validation failed with no retries remaining")
245
+
246
+ return state
app/ai/agent/schemas.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/schemas.py
2
+ """
3
+ Strict schema definitions for AIDA agent.
4
+ Every input/output must validate against these schemas.
5
+ """
6
+
7
+ from pydantic import BaseModel, Field, validator
8
+ from typing import Optional, List, Literal, Dict, Any
9
+ from datetime import datetime
10
+
11
+ # ============================================================
12
+ # INPUT SCHEMAS
13
+ # ============================================================
14
+
15
+ class UserMessage(BaseModel):
16
+ """User input validation"""
17
+ message: str = Field(..., min_length=1, max_length=2000)
18
+ session_id: str = Field(..., min_length=1, max_length=100)
19
+ user_id: str = Field(..., min_length=1, max_length=100)
20
+ user_role: Literal["landlord", "renter"]
21
+
22
+ @validator("message")
23
+ def message_not_empty(cls, v):
24
+ if not v.strip():
25
+ raise ValueError("Message cannot be empty")
26
+ return v.strip()
27
+
28
+
29
+ # ============================================================
30
+ # INTENT SCHEMAS
31
+ # ============================================================
32
+
33
+ class Intent(BaseModel):
34
+ """LLM classification output"""
35
+ type: Literal["greeting", "listing", "search", "casual_chat", "unknown"]
36
+ confidence: float = Field(..., ge=0.0, le=1.0)
37
+ reasoning: str = Field(..., min_length=1, max_length=500)
38
+ requires_auth: bool = False
39
+ next_action: str
40
+
41
+ @validator("confidence")
42
+ def valid_confidence(cls, v):
43
+ if v < 0.3:
44
+ raise ValueError("Confidence below threshold")
45
+ return v
46
+
47
+
48
+ # ============================================================
49
+ # LISTING SCHEMAS
50
+ # ============================================================
51
+
52
+ class ListingField(BaseModel):
53
+ """Individual listing field"""
54
+ field_name: str
55
+ value: Any
56
+ is_required: bool
57
+ is_valid: bool
58
+ error: Optional[str] = None
59
+
60
+
61
+ class ListingDraft(BaseModel):
62
+ """Complete listing draft - must be valid before publishing"""
63
+ user_id: str
64
+ user_role: str
65
+ title: str = Field(..., min_length=5, max_length=100)
66
+ description: str = Field(..., min_length=10, max_length=1500)
67
+ location: str = Field(..., min_length=2, max_length=100)
68
+ bedrooms: int = Field(..., ge=0, le=20)
69
+ bathrooms: int = Field(..., ge=0, le=20)
70
+ price: float = Field(..., gt=0)
71
+ price_type: Literal["monthly", "yearly", "weekly", "daily", "nightly"]
72
+ currency: str = Field(..., min_length=3, max_length=3)
73
+ listing_type: Literal["rent", "short-stay", "sale", "roommate"]
74
+ amenities: List[str] = Field(default_factory=list)
75
+ requirements: Optional[str] = None
76
+ images: List[str] = Field(default_factory=list)
77
+
78
+ @validator("images")
79
+ def validate_image_urls(cls, v):
80
+ """Ensure all image URLs are valid"""
81
+ for url in v:
82
+ if not isinstance(url, str) or not url.startswith(("http://", "https://")):
83
+ raise ValueError(f"Invalid image URL: {url}")
84
+ return v
85
+
86
+ @validator("images")
87
+ def require_at_least_one_image(cls, v):
88
+ """At least one image required"""
89
+ if len(v) < 1:
90
+ raise ValueError("At least one image is required")
91
+ return v
92
+
93
+
94
+ class ListingExtracted(BaseModel):
95
+ """Fields extracted from user message"""
96
+ location: Optional[str] = None
97
+ bedrooms: Optional[int] = None
98
+ bathrooms: Optional[int] = None
99
+ price: Optional[float] = None
100
+ price_type: Optional[str] = None
101
+ amenities: List[str] = Field(default_factory=list)
102
+ requirements: Optional[str] = None
103
+ images: List[str] = Field(default_factory=list)
104
+
105
+
106
+ # ============================================================
107
+ # SEARCH SCHEMAS
108
+ # ============================================================
109
+
110
+ class SearchQuery(BaseModel):
111
+ """Search parameters"""
112
+ location: Optional[str] = None
113
+ min_price: Optional[float] = None
114
+ max_price: Optional[float] = None
115
+ bedrooms: Optional[int] = None
116
+ bathrooms: Optional[int] = None
117
+ listing_type: Optional[Literal["rent", "short-stay", "sale", "roommate"]] = None
118
+ amenities: List[str] = Field(default_factory=list)
119
+ price_type: Optional[str] = None
120
+
121
+
122
+ class SearchResult(BaseModel):
123
+ """Individual search result"""
124
+ listing_id: str
125
+ title: str
126
+ location: str
127
+ price: float
128
+ currency: str
129
+ bedrooms: int
130
+ bathrooms: int
131
+ image: Optional[str] = None
132
+ listing_type: str
133
+
134
+
135
+ # ============================================================
136
+ # TOOL CALL SCHEMAS
137
+ # ============================================================
138
+
139
+ class ToolCall(BaseModel):
140
+ """Structured tool invocation"""
141
+ tool_name: str = Field(..., min_length=1)
142
+ parameters: Dict[str, Any]
143
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
144
+
145
+
146
+ class ToolResult(BaseModel):
147
+ """Result from tool execution"""
148
+ tool_name: str
149
+ success: bool
150
+ result: Any
151
+ error: Optional[str] = None
152
+ execution_time_ms: float
153
+
154
+
155
+ # ============================================================
156
+ # RESPONSE SCHEMAS
157
+ # ============================================================
158
+
159
+ class AgentResponse(BaseModel):
160
+ """Standard response from agent"""
161
+ success: bool
162
+ text: str = Field(..., min_length=1, max_length=2000)
163
+ action: str
164
+ state: Dict[str, Any] = Field(default_factory=dict)
165
+ draft: Optional[ListingDraft] = None
166
+ draft_ui: Optional[Dict[str, Any]] = None
167
+ tool_result: Optional[ToolResult] = None
168
+ error: Optional[str] = None
169
+ metadata: Dict[str, Any] = Field(default_factory=dict)
170
+
171
+
172
+ # ============================================================
173
+ # VALIDATION RESULT SCHEMAS
174
+ # ============================================================
175
+
176
+ class ValidationResult(BaseModel):
177
+ """Result of validation"""
178
+ is_valid: bool
179
+ data: Optional[Any] = None
180
+ errors: List[str] = Field(default_factory=list)
181
+ warnings: List[str] = Field(default_factory=list)
182
+
183
+ def __bool__(self):
184
+ """Allow: if validation_result:"""
185
+ return self.is_valid
186
+
187
+
188
+ class FieldValidationResult(BaseModel):
189
+ """Validation result for a single field"""
190
+ field_name: str
191
+ is_valid: bool
192
+ value: Optional[Any] = None
193
+ error: Optional[str] = None
194
+ suggestion: Optional[str] = None
195
+
196
+
197
+ # ============================================================
198
+ # STATE SCHEMAS
199
+ # ============================================================
200
+
201
+ class ConversationContext(BaseModel):
202
+ """Context for the current conversation"""
203
+ user_id: str
204
+ session_id: str
205
+ user_role: str
206
+ last_message_at: datetime = Field(default_factory=datetime.utcnow)
207
+ total_messages: int = 0
208
+ language: str = "en"
209
+ current_intent: Optional[str] = None
210
+ flow_state: str = "idle"
211
+ temporary_data: Dict[str, Any] = Field(default_factory=dict)
212
+
213
+
214
+ # ============================================================
215
+ # ERROR SCHEMAS
216
+ # ============================================================
217
+
218
+ class ErrorResponse(BaseModel):
219
+ """Standard error response"""
220
+ success: bool = False
221
+ error_code: str
222
+ message: str
223
+ details: Optional[Dict[str, Any]] = None
224
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
app/ai/agent/state.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/state.py
2
+ """
3
+ State machine for AIDA agent.
4
+ Defines valid states, transitions, and enforces state consistency.
5
+ """
6
+
7
+ from enum import Enum
8
+ from typing import Dict, Any, Optional, Tuple, List
9
+ from pydantic import BaseModel, Field
10
+ from datetime import datetime
11
+ from structlog import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class FlowState(str, Enum):
17
+ """Valid states in the agent flow"""
18
+
19
+ # Core states
20
+ AUTHENTICATE = "authenticate"
21
+ CLASSIFY_INTENT = "classify_intent"
22
+
23
+ # Listing flow
24
+ LISTING_COLLECT = "listing_collect"
25
+ LISTING_VALIDATE = "listing_validate"
26
+ LISTING_PUBLISH = "listing_publish"
27
+
28
+ # Search flow
29
+ SEARCH_QUERY = "search_query"
30
+ SEARCH_RESULTS = "search_results"
31
+
32
+ # Other flows
33
+ GREETING = "greeting"
34
+ CASUAL_CHAT = "casual_chat"
35
+
36
+ # Terminal states
37
+ ERROR = "error"
38
+ COMPLETE = "complete"
39
+ IDLE = "idle"
40
+
41
+
42
+ class AgentState(BaseModel):
43
+ """
44
+ Single source of truth for agent state.
45
+ Every part of the agent reads/writes only to this.
46
+ """
47
+
48
+ # User info
49
+ user_id: str
50
+ session_id: str
51
+ user_role: str
52
+
53
+ # Current flow tracking
54
+ current_flow: FlowState = FlowState.IDLE
55
+ previous_flow: Optional[FlowState] = None
56
+ intent_type: Optional[str] = None
57
+ intent_confidence: float = 0.0
58
+
59
+ # Listing flow data
60
+ listing_draft: Optional[Dict[str, Any]] = None
61
+ provided_fields: Dict[str, Any] = Field(default_factory=dict)
62
+ missing_required_fields: List[str] = Field(default_factory=list)
63
+ current_asking_for: Optional[str] = None # Which field we're asking about
64
+
65
+ # Search flow data
66
+ search_query: Optional[str] = None
67
+ search_results: List[Dict[str, Any]] = Field(default_factory=list)
68
+
69
+ # Conversation context
70
+ conversation_history: List[Dict[str, str]] = Field(default_factory=list)
71
+ language_detected: str = "en"
72
+ last_user_message: Optional[str] = None
73
+ last_ai_message: Optional[str] = None
74
+
75
+ # Metadata
76
+ created_at: datetime = Field(default_factory=datetime.utcnow)
77
+ last_updated_at: datetime = Field(default_factory=datetime.utcnow)
78
+ steps_taken: int = 0
79
+ last_error: Optional[str] = None
80
+ error_count: int = 0
81
+ retries: int = 0
82
+ max_retries: int = 3
83
+
84
+ # Temporary data for current operation
85
+ temp_data: Dict[str, Any] = Field(default_factory=dict)
86
+
87
+ def can_transition_to(self, new_flow: FlowState) -> Tuple[bool, Optional[str]]:
88
+ """
89
+ Validate if transition is allowed.
90
+
91
+ Returns:
92
+ Tuple[is_allowed, error_message]
93
+ """
94
+ valid_transitions = {
95
+ FlowState.IDLE: [
96
+ FlowState.AUTHENTICATE,
97
+ FlowState.CLASSIFY_INTENT,
98
+ ],
99
+ FlowState.AUTHENTICATE: [
100
+ FlowState.CLASSIFY_INTENT,
101
+ FlowState.ERROR,
102
+ ],
103
+ FlowState.CLASSIFY_INTENT: [
104
+ FlowState.GREETING,
105
+ FlowState.LISTING_COLLECT,
106
+ FlowState.SEARCH_QUERY,
107
+ FlowState.CASUAL_CHAT,
108
+ FlowState.ERROR,
109
+ ],
110
+ FlowState.GREETING: [
111
+ FlowState.IDLE,
112
+ FlowState.CLASSIFY_INTENT,
113
+ FlowState.ERROR,
114
+ ],
115
+ FlowState.LISTING_COLLECT: [
116
+ FlowState.LISTING_VALIDATE,
117
+ FlowState.ERROR,
118
+ ],
119
+ FlowState.LISTING_VALIDATE: [
120
+ FlowState.LISTING_PUBLISH,
121
+ FlowState.LISTING_COLLECT, # Go back if missing fields
122
+ FlowState.ERROR,
123
+ ],
124
+ FlowState.LISTING_PUBLISH: [
125
+ FlowState.COMPLETE,
126
+ FlowState.ERROR,
127
+ ],
128
+ FlowState.SEARCH_QUERY: [
129
+ FlowState.SEARCH_RESULTS,
130
+ FlowState.ERROR,
131
+ ],
132
+ FlowState.SEARCH_RESULTS: [
133
+ FlowState.IDLE,
134
+ FlowState.CLASSIFY_INTENT,
135
+ FlowState.ERROR,
136
+ ],
137
+ FlowState.CASUAL_CHAT: [
138
+ FlowState.IDLE,
139
+ FlowState.CLASSIFY_INTENT,
140
+ FlowState.ERROR,
141
+ ],
142
+ FlowState.ERROR: [
143
+ FlowState.IDLE,
144
+ FlowState.CLASSIFY_INTENT,
145
+ ],
146
+ FlowState.COMPLETE: [
147
+ FlowState.IDLE,
148
+ ],
149
+ }
150
+
151
+ if self.current_flow not in valid_transitions:
152
+ error = f"Unknown current flow: {self.current_flow}"
153
+ logger.error("Invalid current flow", flow=self.current_flow)
154
+ return False, error
155
+
156
+ if new_flow not in valid_transitions[self.current_flow]:
157
+ error = (
158
+ f"Cannot transition from {self.current_flow.value} "
159
+ f"to {new_flow.value}. Valid transitions: "
160
+ f"{[f.value for f in valid_transitions[self.current_flow]]}"
161
+ )
162
+ logger.warning("Invalid transition", from_flow=self.current_flow, to_flow=new_flow)
163
+ return False, error
164
+
165
+ logger.info("Transition valid", from_flow=self.current_flow, to_flow=new_flow)
166
+ return True, None
167
+
168
+ def transition_to(self, new_flow: FlowState, reason: str = "") -> Tuple[bool, Optional[str]]:
169
+ """
170
+ Perform state transition with validation.
171
+
172
+ Returns:
173
+ Tuple[success, error_message]
174
+ """
175
+ is_valid, error = self.can_transition_to(new_flow)
176
+
177
+ if not is_valid:
178
+ return False, error
179
+
180
+ # Track transition
181
+ self.previous_flow = self.current_flow
182
+ self.current_flow = new_flow
183
+ self.last_updated_at = datetime.utcnow()
184
+ self.steps_taken += 1
185
+
186
+ logger.info(
187
+ "State transitioned",
188
+ from_flow=self.previous_flow.value,
189
+ to_flow=self.current_flow.value,
190
+ reason=reason,
191
+ total_steps=self.steps_taken,
192
+ )
193
+
194
+ return True, None
195
+
196
+ def set_error(self, error_msg: str, should_retry: bool = True) -> bool:
197
+ """
198
+ Set error state.
199
+
200
+ Returns:
201
+ bool: True if should retry, False if max retries exceeded
202
+ """
203
+ self.last_error = error_msg
204
+ self.error_count += 1
205
+
206
+ if should_retry and self.retries < self.max_retries:
207
+ self.retries += 1
208
+ logger.warning(
209
+ "Error occurred, will retry",
210
+ error=error_msg,
211
+ retry_count=self.retries,
212
+ max_retries=self.max_retries,
213
+ )
214
+ return True
215
+
216
+ # Transition to error state
217
+ self.transition_to(FlowState.ERROR, reason=f"Error: {error_msg}")
218
+ logger.error("Error state entered, no more retries", error=error_msg)
219
+ return False
220
+
221
+ def clear_error(self):
222
+ """Clear error state and reset retry counter"""
223
+ self.last_error = None
224
+ self.retries = 0
225
+ logger.info("Error state cleared")
226
+
227
+ def update_listing_progress(self, field_name: str, value: Any) -> None:
228
+ """
229
+ Update listing field progress.
230
+
231
+ Args:
232
+ field_name: Name of the field being filled
233
+ value: Value provided
234
+ """
235
+ self.provided_fields[field_name] = value
236
+
237
+ # Update missing fields
238
+ required = ["location", "bedrooms", "bathrooms", "price", "price_type"]
239
+ self.missing_required_fields = [
240
+ f for f in required
241
+ if f not in self.provided_fields
242
+ or self.provided_fields[f] is None
243
+ ]
244
+
245
+ logger.info(
246
+ "Listing progress updated",
247
+ field=field_name,
248
+ missing_fields=self.missing_required_fields,
249
+ )
250
+
251
+ def get_listing_progress(self) -> Dict[str, Any]:
252
+ """Get progress summary for listing flow"""
253
+ required = ["location", "bedrooms", "bathrooms", "price", "price_type"]
254
+ completed = sum(1 for f in required if f in self.provided_fields and self.provided_fields[f] is not None)
255
+ total = len(required)
256
+
257
+ return {
258
+ "progress_percent": int((completed / total) * 100),
259
+ "completed_fields": completed,
260
+ "total_required_fields": total,
261
+ "provided_fields": self.provided_fields,
262
+ "missing_fields": self.missing_required_fields,
263
+ }
264
+
265
+ def add_message(self, role: str, content: str) -> None:
266
+ """
267
+ Add message to conversation history.
268
+
269
+ Args:
270
+ role: "user" or "assistant"
271
+ content: Message text
272
+ """
273
+ self.conversation_history.append({
274
+ "role": role,
275
+ "content": content,
276
+ "timestamp": datetime.utcnow().isoformat(),
277
+ })
278
+
279
+ if role == "user":
280
+ self.last_user_message = content
281
+ else:
282
+ self.last_ai_message = content
283
+
284
+ self.last_updated_at = datetime.utcnow()
285
+
286
+ def reset_for_new_flow(self, new_flow_type: str) -> None:
287
+ """
288
+ Reset state for a new flow.
289
+
290
+ Clears temporary data while preserving conversation history.
291
+ """
292
+ logger.info("Resetting state for new flow", flow_type=new_flow_type)
293
+
294
+ self.listing_draft = None
295
+ self.provided_fields.clear()
296
+ self.missing_required_fields.clear()
297
+ self.search_query = None
298
+ self.search_results.clear()
299
+ self.temp_data.clear()
300
+ self.last_error = None
301
+ self.retries = 0
302
+
303
+ def get_summary(self) -> Dict[str, Any]:
304
+ """Get human-readable state summary"""
305
+ return {
306
+ "user_id": self.user_id,
307
+ "session_id": self.session_id,
308
+ "current_flow": self.current_flow.value,
309
+ "steps_taken": self.steps_taken,
310
+ "messages_in_history": len(self.conversation_history),
311
+ "errors": self.error_count,
312
+ "last_error": self.last_error,
313
+ "listing_progress": self.get_listing_progress() if self.current_flow == FlowState.LISTING_COLLECT else None,
314
+ }
app/ai/agent/validators.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/agent/validators.py
2
+ """
3
+ Validation layer for AIDA agent.
4
+ Validates all LLM outputs, tool inputs/outputs, and state changes.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from typing import Any, Type, Tuple, List, Dict, Optional
10
+ from pydantic import BaseModel, ValidationError
11
+ from structlog import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ from app.ai.agent.schemas import (
16
+ ValidationResult,
17
+ FieldValidationResult,
18
+ Intent,
19
+ ListingDraft,
20
+ ListingExtracted,
21
+ )
22
+
23
+
24
+ class SchemaValidator:
25
+ """Validates data against Pydantic schemas"""
26
+
27
+ @staticmethod
28
+ def validate(data: Any, schema: Type[BaseModel]) -> ValidationResult:
29
+ """
30
+ Validate data against a Pydantic schema.
31
+
32
+ Args:
33
+ data: Data to validate (dict or object)
34
+ schema: Pydantic model class
35
+
36
+ Returns:
37
+ ValidationResult with is_valid, data, and errors
38
+ """
39
+ try:
40
+ if isinstance(data, dict):
41
+ validated = schema(**data)
42
+ else:
43
+ validated = schema(data)
44
+
45
+ logger.info("Schema validation passed", schema=schema.__name__)
46
+ return ValidationResult(is_valid=True, data=validated)
47
+
48
+ except ValidationError as e:
49
+ errors = [f"{field}: {msg}" for field, msg in
50
+ [(err["loc"][0], err["msg"]) for err in e.errors()]]
51
+ logger.warning("Schema validation failed", schema=schema.__name__, errors=errors)
52
+ return ValidationResult(is_valid=False, errors=errors)
53
+
54
+
55
+ class JSONValidator:
56
+ """Validates and extracts JSON from text responses"""
57
+
58
+ @staticmethod
59
+ def extract_and_validate(response_text: str, schema: Optional[Type[BaseModel]] = None) -> ValidationResult:
60
+ """
61
+ Extract JSON from LLM response and validate against schema.
62
+
63
+ Args:
64
+ response_text: LLM response text
65
+ schema: Optional Pydantic schema to validate against
66
+
67
+ Returns:
68
+ ValidationResult with extracted JSON
69
+ """
70
+ try:
71
+ # Try to extract JSON
72
+ match = re.search(r'\{.*\}', response_text, re.DOTALL)
73
+ if not match:
74
+ logger.error("No JSON found in response")
75
+ return ValidationResult(is_valid=False, errors=["No JSON found in response"])
76
+
77
+ json_str = match.group()
78
+ data = json.loads(json_str)
79
+ logger.info("JSON extracted successfully", keys=list(data.keys()))
80
+
81
+ # Validate against schema if provided
82
+ if schema:
83
+ return SchemaValidator.validate(data, schema)
84
+
85
+ return ValidationResult(is_valid=True, data=data)
86
+
87
+ except json.JSONDecodeError as e:
88
+ logger.error("JSON decode error", error=str(e))
89
+ return ValidationResult(is_valid=False, errors=[f"Invalid JSON: {str(e)}"])
90
+ except Exception as e:
91
+ logger.error("JSON extraction error", exc_info=e)
92
+ return ValidationResult(is_valid=False, errors=[f"Extraction error: {str(e)}"])
93
+
94
+
95
+ class IntentValidator:
96
+ """Validates intent classification"""
97
+
98
+ @staticmethod
99
+ def validate_intent(data: Dict[str, Any]) -> ValidationResult:
100
+ """Validate intent data"""
101
+ return SchemaValidator.validate(data, Intent)
102
+
103
+ @staticmethod
104
+ def validate_confidence(confidence: float, threshold: float = 0.3) -> bool:
105
+ """
106
+ Check if confidence meets threshold.
107
+
108
+ Returns:
109
+ bool: True if confidence >= threshold
110
+ """
111
+ return confidence >= threshold
112
+
113
+
114
+ class ListingValidator:
115
+ """Validates listing data at different stages"""
116
+
117
+ @staticmethod
118
+ def validate_extracted_fields(data: Dict[str, Any]) -> ValidationResult:
119
+ """Validate extracted listing fields"""
120
+ return SchemaValidator.validate(data, ListingExtracted)
121
+
122
+ @staticmethod
123
+ def validate_draft(data: Dict[str, Any]) -> ValidationResult:
124
+ """Validate complete listing draft"""
125
+ return SchemaValidator.validate(data, ListingDraft)
126
+
127
+ @staticmethod
128
+ def validate_required_fields_present(provided_fields: Dict[str, Any]) -> ValidationResult:
129
+ """
130
+ Check if all required listing fields are present.
131
+
132
+ Args:
133
+ provided_fields: Fields provided by user
134
+
135
+ Returns:
136
+ ValidationResult with missing fields if any
137
+ """
138
+ required = ["location", "bedrooms", "bathrooms", "price", "price_type"]
139
+ missing = [f for f in required if f not in provided_fields or provided_fields[f] is None]
140
+
141
+ if missing:
142
+ logger.warning("Missing required fields", missing=missing)
143
+ return ValidationResult(
144
+ is_valid=False,
145
+ errors=[f"Missing required field: {f}" for f in missing]
146
+ )
147
+
148
+ logger.info("All required fields present", fields=list(provided_fields.keys()))
149
+ return ValidationResult(is_valid=True, data=provided_fields)
150
+
151
+ @staticmethod
152
+ def validate_field_value(field_name: str, value: Any) -> FieldValidationResult:
153
+ """
154
+ Validate a single field value.
155
+
156
+ Args:
157
+ field_name: Name of the field
158
+ value: Value to validate
159
+
160
+ Returns:
161
+ FieldValidationResult with validation details
162
+ """
163
+ validators = {
164
+ "location": lambda v: isinstance(v, str) and len(v) >= 2,
165
+ "bedrooms": lambda v: isinstance(v, (int, float)) and 0 <= v <= 20,
166
+ "bathrooms": lambda v: isinstance(v, (int, float)) and 0 <= v <= 20,
167
+ "price": lambda v: isinstance(v, (int, float)) and v > 0,
168
+ "price_type": lambda v: v in ["monthly", "yearly", "weekly", "daily", "nightly"],
169
+ "currency": lambda v: isinstance(v, str) and len(v) == 3,
170
+ }
171
+
172
+ if field_name not in validators:
173
+ return FieldValidationResult(field_name=field_name, is_valid=True, value=value)
174
+
175
+ is_valid = validators[field_name](value)
176
+
177
+ if is_valid:
178
+ return FieldValidationResult(field_name=field_name, is_valid=True, value=value)
179
+ else:
180
+ error = f"Invalid value for {field_name}: {value}"
181
+ logger.warning("Field validation failed", field=field_name, error=error)
182
+ return FieldValidationResult(
183
+ field_name=field_name,
184
+ is_valid=False,
185
+ error=error,
186
+ value=value
187
+ )
188
+
189
+
190
+ class ToolCallValidator:
191
+ """Validates tool calls before execution"""
192
+
193
+ @staticmethod
194
+ def validate_tool_call(tool_name: str, parameters: Dict[str, Any]) -> ValidationResult:
195
+ """
196
+ Validate tool call parameters.
197
+
198
+ Args:
199
+ tool_name: Name of the tool
200
+ parameters: Parameters to pass to tool
201
+
202
+ Returns:
203
+ ValidationResult
204
+ """
205
+ # Define required parameters per tool
206
+ required_params = {
207
+ "publish_listing": ["listing_id", "title", "description"],
208
+ "search_listings": ["query"],
209
+ "send_email": ["to", "subject", "body"],
210
+ }
211
+
212
+ if tool_name not in required_params:
213
+ logger.warning("Unknown tool", tool=tool_name)
214
+ return ValidationResult(is_valid=False, errors=[f"Unknown tool: {tool_name}"])
215
+
216
+ missing = [p for p in required_params[tool_name] if p not in parameters]
217
+
218
+ if missing:
219
+ logger.warning("Missing tool parameters", tool=tool_name, missing=missing)
220
+ return ValidationResult(
221
+ is_valid=False,
222
+ errors=[f"Missing parameter: {p}" for p in missing]
223
+ )
224
+
225
+ logger.info("Tool call validated", tool=tool_name)
226
+ return ValidationResult(is_valid=True, data=parameters)
227
+
228
+
229
+ class StateValidator:
230
+ """Validates state transitions"""
231
+
232
+ VALID_TRANSITIONS = {
233
+ "idle": ["greeting", "listing", "search", "casual_chat"],
234
+ "listing": ["listing_collecting", "listing_validating"],
235
+ "listing_collecting": ["listing_validating"],
236
+ "listing_validating": ["listing_publishing", "listing_collecting"],
237
+ "listing_publishing": ["idle"],
238
+ "search": ["search_results"],
239
+ "search_results": ["idle"],
240
+ "greeting": ["idle"],
241
+ "casual_chat": ["idle"],
242
+ }
243
+
244
+ @classmethod
245
+ def can_transition(cls, current_state: str, new_state: str) -> Tuple[bool, Optional[str]]:
246
+ """
247
+ Check if state transition is valid.
248
+
249
+ Args:
250
+ current_state: Current state
251
+ new_state: Desired next state
252
+
253
+ Returns:
254
+ Tuple[is_valid, error_message]
255
+ """
256
+ if current_state not in cls.VALID_TRANSITIONS:
257
+ error = f"Unknown current state: {current_state}"
258
+ logger.warning("Invalid state", state=current_state)
259
+ return False, error
260
+
261
+ if new_state not in cls.VALID_TRANSITIONS[current_state]:
262
+ error = f"Cannot transition from {current_state} to {new_state}"
263
+ logger.warning("Invalid transition", from_state=current_state, to_state=new_state)
264
+ return False, error
265
+
266
+ logger.info("State transition valid", from_state=current_state, to_state=new_state)
267
+ return True, None
268
+
269
+
270
+ class ResponseValidator:
271
+ """Validates agent responses before sending to client"""
272
+
273
+ @staticmethod
274
+ def validate_response_text(text: str) -> Tuple[bool, str, Optional[str]]:
275
+ """
276
+ Validate response text.
277
+
278
+ Args:
279
+ text: Response text
280
+
281
+ Returns:
282
+ Tuple[is_valid, cleaned_text, error]
283
+ """
284
+ if not text or not text.strip():
285
+ return False, "", "Response text cannot be empty"
286
+
287
+ if len(text) > 2000:
288
+ text = text[:2000]
289
+ logger.warning("Response text truncated to 2000 chars")
290
+
291
+ return True, text.strip(), None
292
+
293
+ @staticmethod
294
+ def sanitize_response(text: str) -> str:
295
+ """
296
+ Remove any potentially harmful content from response.
297
+
298
+ Args:
299
+ text: Response text
300
+
301
+ Returns:
302
+ Sanitized text
303
+ """
304
+ # Remove multiple newlines
305
+ text = re.sub(r'\n{3,}', '\n\n', text)
306
+
307
+ # Remove extra whitespace
308
+ text = re.sub(r' {2,}', ' ', text)
309
+
310
+ return text.strip()
app/ai/routes/chat_refactored.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/ai/routes/chat_refactored.py
2
+ """
3
+ REFACTORED chat endpoint using new graph architecture.
4
+ This replaces the old chat.py.
5
+
6
+ Much cleaner, more reliable, easier to debug.
7
+ """
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException
10
+ from fastapi.security import HTTPBearer
11
+ from pydantic import BaseModel
12
+ from typing import Optional
13
+ from structlog import get_logger
14
+
15
+ from app.guards.jwt_guard import decode_access_token
16
+ from app.ai.agent.graph import get_aida_graph
17
+ from app.ai.agent.schemas import AgentResponse, UserMessage
18
+
19
+ logger = get_logger(__name__)
20
+
21
+ router = APIRouter()
22
+ security = HTTPBearer()
23
+
24
+
25
+ # ============================================================
26
+ # REQUEST/RESPONSE MODELS
27
+ # ============================================================
28
+
29
+ class AskBody(BaseModel):
30
+ """Request body for /ask endpoint"""
31
+ message: str
32
+ session_id: Optional[str] = None
33
+ start_new_session: Optional[bool] = False
34
+
35
+
36
+ # ============================================================
37
+ # MAIN CHAT ENDPOINT
38
+ # ============================================================
39
+
40
+ @router.post("/ask", response_model=AgentResponse)
41
+ async def ask_ai(
42
+ body: AskBody,
43
+ token: str = Depends(security),
44
+ ) -> AgentResponse:
45
+ """
46
+ Main chat endpoint using new graph architecture.
47
+
48
+ Flow:
49
+ 1. Validate input
50
+ 2. Authenticate user
51
+ 3. Execute agent graph
52
+ 4. Return response
53
+
54
+ Args:
55
+ body: AskBody with message
56
+ token: JWT token
57
+
58
+ Returns:
59
+ AgentResponse with reply and state
60
+ """
61
+
62
+ logger.info("Chat request received", message_len=len(body.message))
63
+
64
+ try:
65
+ # ============================================================
66
+ # VALIDATE INPUT
67
+ # ============================================================
68
+
69
+ if not body.message or not body.message.strip():
70
+ logger.warning("Empty message")
71
+ raise HTTPException(status_code=400, detail="Message cannot be empty")
72
+
73
+ message = body.message.strip()
74
+ session_id = body.session_id or "default"
75
+
76
+ # ============================================================
77
+ # AUTHENTICATE
78
+ # ============================================================
79
+
80
+ payload = decode_access_token(token.credentials)
81
+ if not payload:
82
+ logger.warning("Invalid token")
83
+ raise HTTPException(status_code=401, detail="Invalid token")
84
+
85
+ user_id = payload.get("user_id")
86
+ user_role = payload.get("role", "renter")
87
+
88
+ if not user_id:
89
+ logger.warning("No user_id in token")
90
+ raise HTTPException(status_code=401, detail="Invalid token")
91
+
92
+ logger.info(
93
+ "User authenticated",
94
+ user_id=user_id,
95
+ user_role=user_role,
96
+ session_id=session_id,
97
+ )
98
+
99
+ # ============================================================
100
+ # EXECUTE AGENT GRAPH
101
+ # ============================================================
102
+
103
+ graph = get_aida_graph()
104
+
105
+ response = await graph.execute(
106
+ user_id=user_id,
107
+ session_id=session_id,
108
+ user_role=user_role,
109
+ message=message,
110
+ token=token.credentials,
111
+ )
112
+
113
+ logger.info(
114
+ "Chat response generated",
115
+ user_id=user_id,
116
+ action=response.action,
117
+ success=response.success,
118
+ )
119
+
120
+ return response
121
+
122
+ except HTTPException:
123
+ raise
124
+ except Exception as e:
125
+ logger.error("Chat endpoint error", exc_info=e)
126
+ raise HTTPException(
127
+ status_code=500,
128
+ detail="Internal server error"
129
+ )
130
+
131
+
132
+ # ============================================================
133
+ # HEALTH CHECK
134
+ # ============================================================
135
+
136
+ @router.get("/health")
137
+ async def health_check():
138
+ """Health check for chat service"""
139
+ return {
140
+ "status": "healthy",
141
+ "service": "AIDA Chat (Graph Architecture)",
142
+ "architecture": "State Machine + Validated Nodes",
143
+ "version": "2.0.0",
144
+ }
145
+
146
+
147
+ # ============================================================
148
+ # HISTORY ENDPOINT
149
+ # ============================================================
150
+
151
+ @router.get("/history/{session_id}")
152
+ async def get_history(
153
+ session_id: str,
154
+ token: str = Depends(security),
155
+ ):
156
+ """Get conversation history for a session"""
157
+
158
+ try:
159
+ payload = decode_access_token(token.credentials)
160
+ if not payload:
161
+ raise HTTPException(status_code=401, detail="Invalid token")
162
+
163
+ user_id = payload.get("user_id")
164
+
165
+ # TODO: Fetch from memory/database
166
+ logger.info("Retrieved history", user_id=user_id, session_id=session_id)
167
+
168
+ return {
169
+ "success": True,
170
+ "user_id": user_id,
171
+ "session_id": session_id,
172
+ "messages": [], # TODO: Get from storage
173
+ }
174
+
175
+ except HTTPException:
176
+ raise
177
+ except Exception as e:
178
+ logger.error("History retrieval error", exc_info=e)
179
+ raise HTTPException(status_code=500, detail="Error retrieving history")
180
+
181
+
182
+ # ============================================================
183
+ # SESSION MANAGEMENT
184
+ # ============================================================
185
+
186
+ @router.post("/reset-session/{session_id}")
187
+ async def reset_session(
188
+ session_id: str,
189
+ token: str = Depends(security),
190
+ ):
191
+ """Reset a session to fresh state"""
192
+
193
+ try:
194
+ payload = decode_access_token(token.credentials)
195
+ if not payload:
196
+ raise HTTPException(status_code=401, detail="Invalid token")
197
+
198
+ user_id = payload.get("user_id")
199
+
200
+ # TODO: Clear session from memory
201
+ logger.info("Session reset", user_id=user_id, session_id=session_id)
202
+
203
+ return {
204
+ "success": True,
205
+ "message": "Session reset to fresh state",
206
+ }
207
+
208
+ except HTTPException:
209
+ raise
210
+ except Exception as e:
211
+ logger.error("Session reset error", exc_info=e)
212
+ raise HTTPException(status_code=500, detail="Error resetting session")
main.py CHANGED
@@ -1,5 +1,5 @@
1
  # app/main.py
2
- # Lojiz Platform + Aida AI - Modular Architecture
3
 
4
  from fastapi import FastAPI, Request
5
  from fastapi.middleware.cors import CORSMiddleware
@@ -26,20 +26,37 @@ try:
26
  except ImportError:
27
  AuthException = Exception
28
 
29
- # AI IMPORTS (image upload router REMOVED)
30
- from app.ai.routes.chat import router as ai_chat_router
31
- from app.ai.config import (
32
- validate_ai_startup,
33
- check_redis_health,
34
- check_qdrant_health,
35
- redis_client,
36
- qdrant_client,
37
- )
38
- from app.ai.memory.redis_context_memory import get_memory_manager
39
- from app.ml.models.ml_listing_extractor import get_ml_extractor
40
- from app.models.listing import ensure_listing_indexes
 
 
 
 
 
 
 
41
 
42
- logger = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
43
 
44
  # ENVIRONMENT
45
  environment = os.getenv("ENVIRONMENT", "development")
@@ -50,7 +67,10 @@ is_production = environment == "production"
50
  async def lifespan(app: FastAPI):
51
  """Application lifespan - startup and shutdown"""
52
  logger.info("=" * 70)
53
- logger.info("Starting Lojiz Platform + Aida AI (Modular Architecture)")
 
 
 
54
  logger.info("=" * 70)
55
 
56
  # STARTUP
@@ -59,89 +79,89 @@ async def lifespan(app: FastAPI):
59
  await connect_db()
60
  await ensure_auth_indexes()
61
  await ensure_listing_indexes()
62
- logger.info("MongoDB connected and indexed")
63
  except Exception as e:
64
- logger.critical(f"MongoDB connection failed - aborting startup: {e}")
65
  raise
66
 
67
  try:
68
  logger.info("Connecting to Redis...")
69
  if redis_client:
70
  await redis_client.ping()
71
- logger.info("Redis connected")
72
  else:
73
- logger.warning("Redis not available (optional)")
74
  except Exception as e:
75
- logger.warning(f"Redis connection failed (continuing without): {e}")
76
 
77
  try:
78
  logger.info("Connecting to Qdrant...")
79
  if qdrant_client:
80
  await qdrant_client.get_collections()
81
- logger.info("Qdrant connected")
82
  else:
83
- logger.warning("Qdrant not available (optional)")
84
  except Exception as e:
85
- logger.warning(f"Qdrant connection failed (continuing without): {e}")
86
 
87
  try:
88
  logger.info("Validating AI components...")
89
  ai_checks = await validate_ai_startup()
90
- logger.info("AI components validated")
91
  except Exception as e:
92
- logger.warning(f"AI validation failed: {e}")
93
 
94
  try:
95
  logger.info("Initializing ML Extractor...")
96
  ml = get_ml_extractor()
97
- logger.info("ML Extractor ready")
98
  except Exception as e:
99
- logger.warning(f"ML Extractor initialization failed: {e}")
100
 
101
  try:
102
  logger.info("Initializing Memory Manager...")
103
  manager = get_memory_manager()
104
- logger.info("Memory Manager ready")
105
  except Exception as e:
106
- logger.warning(f"Memory Manager initialization failed: {e}")
107
 
108
  logger.info("=" * 70)
109
- logger.info("APPLICATION READY - All systems operational!")
110
  logger.info("=" * 70)
111
 
112
  yield
113
 
114
  # SHUTDOWN
115
  logger.info("=" * 70)
116
- logger.info("Shutting down Lojiz Platform + Aida AI")
117
  logger.info("=" * 70)
118
 
119
  try:
120
  try:
121
  ml = get_ml_extractor()
122
  ml.currency_mgr.clear_cache()
123
- logger.info("ML caches cleared")
124
  except:
125
  pass
126
 
127
  from app.database import disconnect_db
128
  await disconnect_db()
129
- logger.info("MongoDB disconnected")
130
 
131
  if redis_client:
132
  await redis_client.close()
133
- logger.info("Redis closed")
134
 
135
- logger.info("Shutdown complete")
136
  except Exception as e:
137
- logger.warning(f"Shutdown warning: {e}")
138
 
139
 
140
  # FASTAPI SETUP
141
  app = FastAPI(
142
  title="Lojiz Platform + Aida AI",
143
- description="Real-estate platform with conversational AI assistant",
144
- version="1.0.0",
145
  lifespan=lifespan,
146
  )
147
 
@@ -199,12 +219,42 @@ async def auth_exception_handler(request: Request, exc: AuthException): # type:
199
 
200
 
201
  # ROUTERS
 
202
  logger.info("Registering routers...")
 
203
 
 
204
  app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
205
- app.include_router(ai_chat_router, prefix="/ai", tags=["Aida AI Chat"])
206
 
207
- # ---------- LISTING ROUTERS ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  from app.routes.listing import router as listing_router
209
  from app.routes.user_public import router as user_public_router
210
  from app.routes.websocket_listings import router as ws_router
@@ -213,7 +263,9 @@ app.include_router(listing_router, prefix="/api/listings", tags=["Listings"])
213
  app.include_router(user_public_router, prefix="/api/users", tags=["Users"])
214
  app.include_router(ws_router, tags=["WebSocket"])
215
 
216
- logger.info("All routers registered")
 
 
217
 
218
 
219
  # ENDPOINTS
@@ -247,8 +299,13 @@ async def health_check():
247
  return {
248
  "status": "healthy",
249
  "service": "Lojiz Platform + Aida AI",
250
- "version": "1.0.0",
 
251
  "environment": environment,
 
 
 
 
252
  "components": {
253
  "mongodb": "connected",
254
  "redis": "connected" if redis_ok else "disconnected",
@@ -272,8 +329,13 @@ async def root():
272
  "docs": "/docs",
273
  "health": "/health",
274
  "environment": environment,
275
- "version": "1.0.0",
 
276
  "description": "Real-estate platform with conversational AI assistant (Aida)",
 
 
 
 
277
  }
278
 
279
 
 
1
  # app/main.py
2
+ # Lojiz Platform + Aida AI - Graph-Based Architecture (v1 Primary)
3
 
4
  from fastapi import FastAPI, Request
5
  from fastapi.middleware.cors import CORSMiddleware
 
26
  except ImportError:
27
  AuthException = Exception
28
 
29
+ # ============================================================
30
+ # AI IMPORTS - GRAPH-BASED ARCHITECTURE (PRIMARY - v1)
31
+ # ============================================================
32
+ try:
33
+ from app.ai.routes.chat_refactored import router as ai_chat_router_v1
34
+ from app.ai.config import (
35
+ validate_ai_startup,
36
+ check_redis_health,
37
+ check_qdrant_health,
38
+ redis_client,
39
+ qdrant_client,
40
+ )
41
+ from app.ai.memory.redis_context_memory import get_memory_manager
42
+ from app.ml.models.ml_listing_extractor import get_ml_extractor
43
+ logger.info("βœ… Graph-based AI architecture loaded (PRIMARY)")
44
+ except ImportError as e:
45
+ logger.error(f"Graph-based AI import error: {e}")
46
+ ai_chat_router_v1 = None
47
+ raise
48
 
49
+ # ============================================================
50
+ # AI IMPORTS - LEGACY CHAT (FALLBACK - v2)
51
+ # ============================================================
52
+ try:
53
+ from app.ai.routes.chat import router as ai_chat_router_legacy
54
+ logger.info("βœ… Legacy chat endpoint available (FALLBACK)")
55
+ except ImportError as e:
56
+ logger.warning(f"Legacy chat import warning (optional): {e}")
57
+ ai_chat_router_legacy = None
58
+
59
+ from app.models.listing import ensure_listing_indexes
60
 
61
  # ENVIRONMENT
62
  environment = os.getenv("ENVIRONMENT", "development")
 
67
  async def lifespan(app: FastAPI):
68
  """Application lifespan - startup and shutdown"""
69
  logger.info("=" * 70)
70
+ logger.info("πŸš€ Starting Lojiz Platform + Aida AI")
71
+ logger.info(" Architecture: Graph-Based (State Machine + Validation)")
72
+ logger.info(" Primary Endpoint: /ai/v1 (Graph-Based)")
73
+ logger.info(" Fallback Endpoint: /ai/v2 (Legacy)")
74
  logger.info("=" * 70)
75
 
76
  # STARTUP
 
79
  await connect_db()
80
  await ensure_auth_indexes()
81
  await ensure_listing_indexes()
82
+ logger.info("βœ… MongoDB connected and indexed")
83
  except Exception as e:
84
+ logger.critical(f"❌ MongoDB connection failed - aborting startup: {e}")
85
  raise
86
 
87
  try:
88
  logger.info("Connecting to Redis...")
89
  if redis_client:
90
  await redis_client.ping()
91
+ logger.info("βœ… Redis connected")
92
  else:
93
+ logger.warning("⚠️ Redis not available (optional)")
94
  except Exception as e:
95
+ logger.warning(f"⚠️ Redis connection failed (continuing without): {e}")
96
 
97
  try:
98
  logger.info("Connecting to Qdrant...")
99
  if qdrant_client:
100
  await qdrant_client.get_collections()
101
+ logger.info("βœ… Qdrant connected")
102
  else:
103
+ logger.warning("⚠️ Qdrant not available (optional)")
104
  except Exception as e:
105
+ logger.warning(f"⚠️ Qdrant connection failed (continuing without): {e}")
106
 
107
  try:
108
  logger.info("Validating AI components...")
109
  ai_checks = await validate_ai_startup()
110
+ logger.info("βœ… AI components validated")
111
  except Exception as e:
112
+ logger.warning(f"⚠️ AI validation warning: {e}")
113
 
114
  try:
115
  logger.info("Initializing ML Extractor...")
116
  ml = get_ml_extractor()
117
+ logger.info("βœ… ML Extractor ready")
118
  except Exception as e:
119
+ logger.warning(f"⚠️ ML Extractor initialization warning: {e}")
120
 
121
  try:
122
  logger.info("Initializing Memory Manager...")
123
  manager = get_memory_manager()
124
+ logger.info("βœ… Memory Manager ready")
125
  except Exception as e:
126
+ logger.warning(f"⚠️ Memory Manager initialization warning: {e}")
127
 
128
  logger.info("=" * 70)
129
+ logger.info("βœ… APPLICATION READY - Graph-Based Architecture Active!")
130
  logger.info("=" * 70)
131
 
132
  yield
133
 
134
  # SHUTDOWN
135
  logger.info("=" * 70)
136
+ logger.info("πŸ›‘ Shutting down Lojiz Platform + Aida AI")
137
  logger.info("=" * 70)
138
 
139
  try:
140
  try:
141
  ml = get_ml_extractor()
142
  ml.currency_mgr.clear_cache()
143
+ logger.info("βœ… ML caches cleared")
144
  except:
145
  pass
146
 
147
  from app.database import disconnect_db
148
  await disconnect_db()
149
+ logger.info("βœ… MongoDB disconnected")
150
 
151
  if redis_client:
152
  await redis_client.close()
153
+ logger.info("βœ… Redis closed")
154
 
155
+ logger.info("βœ… Shutdown complete")
156
  except Exception as e:
157
+ logger.warning(f"⚠️ Shutdown warning: {e}")
158
 
159
 
160
  # FASTAPI SETUP
161
  app = FastAPI(
162
  title="Lojiz Platform + Aida AI",
163
+ description="Real-estate platform with conversational AI assistant (Graph-Based Architecture)",
164
+ version="2.0.0",
165
  lifespan=lifespan,
166
  )
167
 
 
219
 
220
 
221
  # ROUTERS
222
+ logger.info("=" * 70)
223
  logger.info("Registering routers...")
224
+ logger.info("=" * 70)
225
 
226
+ # Authentication
227
  app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
 
228
 
229
+ # ============================================================
230
+ # AI CHAT ROUTERS
231
+ # ============================================================
232
+
233
+ # PRIMARY: Graph-Based Architecture (v1)
234
+ if ai_chat_router_v1:
235
+ app.include_router(
236
+ ai_chat_router_v1,
237
+ prefix="/ai/v1",
238
+ tags=["Aida AI Chat (Graph-Based - PRIMARY ⭐)"]
239
+ )
240
+ logger.info("βœ… Graph-Based AI Chat registered at /ai/v1 (PRIMARY)")
241
+ else:
242
+ logger.error("❌ Graph-Based AI Chat failed to load!")
243
+
244
+ # FALLBACK: Legacy Chat (v2)
245
+ if ai_chat_router_legacy:
246
+ app.include_router(
247
+ ai_chat_router_legacy,
248
+ prefix="/ai/v2",
249
+ tags=["Aida AI Chat (Legacy - FALLBACK)"]
250
+ )
251
+ logger.info("βœ… Legacy AI Chat registered at /ai/v2 (FALLBACK)")
252
+ else:
253
+ logger.warning("⚠️ Legacy AI Chat not available")
254
+
255
+ # ============================================================
256
+ # LISTING ROUTERS
257
+ # ============================================================
258
  from app.routes.listing import router as listing_router
259
  from app.routes.user_public import router as user_public_router
260
  from app.routes.websocket_listings import router as ws_router
 
263
  app.include_router(user_public_router, prefix="/api/users", tags=["Users"])
264
  app.include_router(ws_router, tags=["WebSocket"])
265
 
266
+ logger.info("=" * 70)
267
+ logger.info("βœ… All routers registered successfully")
268
+ logger.info("=" * 70)
269
 
270
 
271
  # ENDPOINTS
 
299
  return {
300
  "status": "healthy",
301
  "service": "Lojiz Platform + Aida AI",
302
+ "version": "2.0.0",
303
+ "architecture": "Graph-Based (State Machine + Validation)",
304
  "environment": environment,
305
+ "ai_endpoints": {
306
+ "primary": "/ai/v1 (Graph-Based)",
307
+ "fallback": "/ai/v2 (Legacy)",
308
+ },
309
  "components": {
310
  "mongodb": "connected",
311
  "redis": "connected" if redis_ok else "disconnected",
 
329
  "docs": "/docs",
330
  "health": "/health",
331
  "environment": environment,
332
+ "version": "2.0.0",
333
+ "architecture": "Graph-Based (State Machine + Validation)",
334
  "description": "Real-estate platform with conversational AI assistant (Aida)",
335
+ "ai_chat": {
336
+ "primary": "/ai/v1/ask (Graph-Based - 95% reliable)",
337
+ "fallback": "/ai/v2/ask (Legacy - for emergency use)",
338
+ },
339
  }
340
 
341