Spaces:
Sleeping
Sleeping
Commit
Β·
56fed02
1
Parent(s):
d699ce3
fyp
Browse files- app/ai/agent/__init__.py +4 -0
- app/ai/agent/graph.py +212 -0
- app/ai/agent/nodes/__init__.py +0 -0
- app/ai/agent/nodes/authenticate.py +82 -0
- app/ai/agent/nodes/casual_chat.py +178 -0
- app/ai/agent/nodes/classify_intent.py +159 -0
- app/ai/agent/nodes/greeting.py +142 -0
- app/ai/agent/nodes/listing_collect.py +286 -0
- app/ai/agent/nodes/listing_publish.py +199 -0
- app/ai/agent/nodes/listing_validate.py +264 -0
- app/ai/agent/nodes/respond.py +113 -0
- app/ai/agent/nodes/search_query.py +309 -0
- app/ai/agent/nodes/validate_output.py +246 -0
- app/ai/agent/schemas.py +224 -0
- app/ai/agent/state.py +314 -0
- app/ai/agent/validators.py +310 -0
- app/ai/routes/chat_refactored.py +212 -0
- main.py +105 -43
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 -
|
| 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 |
-
#
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 107 |
|
| 108 |
logger.info("=" * 70)
|
| 109 |
-
logger.info("APPLICATION READY -
|
| 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="
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
|
|
|
|
|
|
| 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": "
|
|
|
|
| 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": "
|
|
|
|
| 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 |
|