Spaces:
Sleeping
Sleeping
File size: 9,760 Bytes
cf796c5 8d73fdc cf796c5 8d73fdc cf796c5 8d73fdc b6ae869 8d73fdc 7c82b46 8d73fdc 7c82b46 ad89bd6 7c82b46 ad89bd6 7c82b46 ad89bd6 7c82b46 ad89bd6 7c82b46 cf796c5 8d73fdc cf796c5 8d73fdc cf796c5 d2c5868 cf796c5 8d73fdc cf796c5 8d73fdc cf796c5 d2c5868 b6ae869 d2c5868 b6ae869 d2c5868 b6ae869 d2c5868 cf796c5 3d2e5a4 ad89bd6 d2c5868 3d2e5a4 d2c5868 b6ae869 7c82b46 d2c5868 3d2e5a4 b6ae869 cf796c5 d2c5868 cf796c5 b6ae869 8d73fdc cf796c5 2e83d00 cf796c5 b6ae869 cf796c5 8d73fdc b6ae869 8d73fdc cf796c5 b6ae869 2e83d00 cf796c5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | from dotenv import load_dotenv
import os
import sys
# Add project root to Python path
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if project_root not in sys.path:
sys.path.insert(0, project_root)
# Load environment variables first
load_dotenv()
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from src.graph import graph
from src.state import init_state
from src.utils.semantic_cache import get_cached_response, store_in_cache, get_cache_stats
import uuid
from collections import defaultdict
from datetime import datetime, timedelta
import threading
import asyncio
from functools import partial
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: Pre-warm LLM and models to avoid first-request timeout"""
from src.utils.llm_factory import get_llm
from langchain_core.messages import SystemMessage, HumanMessage
try:
print("Warming up LLM connection...")
llm = get_llm(temperature=0.0)
loop = asyncio.get_running_loop()
# Use proper message format for llama-3.1
await loop.run_in_executor(
None,
lambda: llm.invoke([
SystemMessage(content="You are a helpful assistant."),
HumanMessage(content="Hello")
])
)
print("LLM warmed up successfully.")
except Exception as e:
print(f"LLM warm-up failed: {e}")
print("LLM will initialize on first request.")
yield
app = FastAPI(title="Olist Intelligence Layer", version="1.0", lifespan=lifespan)
# Simple in-memory rate limiter (for production, use Redis)
rate_limit_store = defaultdict(list)
rate_limit_lock = threading.Lock()
RATE_LIMIT_REQUESTS = 20 # requests per window
RATE_LIMIT_WINDOW = 60 # seconds
def check_rate_limit(client_ip: str) -> bool:
"""Check if client has exceeded rate limit"""
with rate_limit_lock:
now = datetime.now()
cutoff = now - timedelta(seconds=RATE_LIMIT_WINDOW)
# Clean old entries
rate_limit_store[client_ip] = [
timestamp for timestamp in rate_limit_store[client_ip]
if timestamp > cutoff
]
# Check limit
if len(rate_limit_store[client_ip]) >= RATE_LIMIT_REQUESTS:
return False
# Add current request
rate_limit_store[client_ip].append(now)
return True
class ChatRequest(BaseModel):
message: str = Field(..., min_length=1, max_length=2000)
session_id: str = None
class ChatResponse(BaseModel):
response: str
intent: str = "unknown" # Default value if intent not set
session_id: str
debug: dict = {}
@app.post("/chat", response_model=ChatResponse)
async def chat(req: ChatRequest, request: Request):
"""Process customer query through the intelligence layer"""
# Rate limiting
client_ip = request.client.host
if not check_rate_limit(client_ip):
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded. Maximum {RATE_LIMIT_REQUESTS} requests per {RATE_LIMIT_WINDOW} seconds."
)
session_id = req.session_id or str(uuid.uuid4())
config = {"configurable": {"thread_id": session_id}}
# Restore prior state from checkpoint to maintain conversation history
try:
prior_state = graph.get_state(config).values
prior_messages = prior_state.get("messages", [])
prior_intent = prior_state.get("intent") # Get prior intent for cache check
prior_context = {
"last_order_id": prior_state.get("last_order_id"),
"last_product_id": prior_state.get("last_product_id"),
"last_seller_id": prior_state.get("last_seller_id"),
"last_category": prior_state.get("last_category"),
"session_context": prior_state.get("session_context", {}),
"compensation_offered": prior_state.get("compensation_offered"),
"compensation_tier": prior_state.get("compensation_tier"),
"case_id": prior_state.get("case_id"),
"escalation_required": prior_state.get("escalation_required"),
}
except Exception as e:
print(f"[DEBUG] No prior state found for session {session_id[:8]}: {e}")
prior_messages = []
prior_intent = None
prior_context = {}
# Check semantic cache BEFORE graph execution (only for cacheable intents)
# Skip cache if user has order context (personalized queries)
has_order_context = prior_context.get("last_order_id") is not None
if not has_order_context:
cached_response = get_cached_response(req.message, prior_intent)
if cached_response:
print(f"[API] Returning cached response for session {session_id[:8]}")
return ChatResponse(
response=cached_response,
intent=prior_intent or "informational",
session_id=session_id,
debug={"cache_hit": True} if request.query_params.get("debug") == "true" else {}
)
# Initialize state with current message and restore history
input_state = init_state(req.message)
input_state["messages"] = prior_messages # Restore conversation history
input_state.update(prior_context) # Restore entity context
try:
# Run blocking graph.invoke in executor with timeout to prevent hung threads
loop = asyncio.get_running_loop()
print(f"[DEBUG] Starting graph execution for session {session_id[:8]}: {req.message[:50]}...")
result = await asyncio.wait_for(
loop.run_in_executor(None, partial(graph.invoke, input_state, config=config)),
timeout=45.0 # Circuit breaker: 3 retries × (1+2+4)s backoff + ~15s LLM = ~37s max
)
print(f"[DEBUG] Graph execution completed. Intent: {result.get('intent')}")
except asyncio.TimeoutError:
raise HTTPException(
status_code=504,
detail="Request timed out after 45 seconds. This may be due to high load or an LLM provider issue. Please try again in a moment."
)
except Exception as e:
# Log the full error for debugging
import traceback
error_trace = traceback.format_exc()
print(f"Graph execution error: {error_trace}")
raise HTTPException(status_code=500, detail=f"Processing error: {str(e)}")
# Store in semantic cache AFTER successful execution (only for cacheable intents)
final_response = result.get("final_response", "I'm sorry, I couldn't process that request.")
result_intent = result.get("intent")
if not has_order_context and result_intent:
store_in_cache(req.message, final_response, result_intent)
# Only include debug info if explicitly requested (security)
debug_info = {}
if request.query_params.get("debug") == "true":
debug_info = {
"sql_query": result.get("sql_query"),
"rag_score": result.get("rag_score"),
"frustration_score": result.get("frustration_score"),
"is_late_delivery": result.get("is_late_delivery"),
"compensation_offered": result.get("compensation_offered"),
"escalation_required": result.get("escalation_required"),
"escalation_summary": result.get("escalation_summary"),
"error_log": result.get("error_log", []),
"cache_hit": False,
}
return ChatResponse(
response=final_response,
intent=result_intent or "unknown",
session_id=session_id,
debug=debug_info
)
@app.get("/health")
def health():
"""Health check endpoint"""
return {"status": "ok", "service": "olist-intelligence-layer"}
@app.get("/cache/stats")
def cache_stats():
"""Return semantic cache statistics for monitoring"""
return get_cache_stats()
@app.get("/escalations/{case_id}")
def get_escalation_summary(case_id: str):
"""Retrieve escalation summary by case ID"""
import os
import json
json_path = f"logs/escalations/{case_id}.json"
if not os.path.exists(json_path):
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading case file: {str(e)}")
@app.get("/escalations")
def list_escalations():
"""List all escalation case IDs"""
import os
import json
from pathlib import Path
log_dir = "logs/escalations"
if not os.path.exists(log_dir):
return {"cases": [], "total": 0}
cases = []
for filename in os.listdir(log_dir):
if filename.endswith(".json"):
try:
with open(os.path.join(log_dir, filename), "r", encoding="utf-8") as f:
data = json.load(f)
cases.append({
"case_id": data["case_id"],
"timestamp": data["timestamp"],
"urgency": data["summary"].get("urgency", "unknown"),
"customer_issue": data["summary"].get("customer_issue", "")[:100]
})
except Exception as e:
print(f"Error reading {filename}: {e}")
continue
# Sort by timestamp descending (newest first)
cases.sort(key=lambda x: x["timestamp"], reverse=True)
return {"cases": cases, "total": len(cases)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
|