InboxIQ / server /app.py
Viswanath129
feat: enforce strict (0, 1) score range for Phase 2 and fix UI scroll behavior
3bbd225
Raw History Blame Contribute Delete
15.4 kB
"""
InboxIQ β€” FastAPI Backend v3.0
OpenEnv-compliant REST API for the InboxIQ RL environment.
Endpoints:
POST /reset?task={id} β†’ Initial observation
POST /step β†’ (observation, reward, done, info)
GET /state β†’ Current state
GET /grader β†’ Normalized score [0.0, 1.0]
POST /classify β†’ ML email classification
GET /classifier-stats β†’ Classifier performance metrics
"""
from fastapi import FastAPI, HTTPException, Body
from fastapi.responses import RedirectResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
import os
from dotenv import load_dotenv
from pydantic import BaseModel
from typing import Dict, Optional
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
def _clamp_score(v: float) -> float:
"""Clamp to strict (0.0, 1.0) range per OpenEnv Phase 2 requirements."""
eps = 1e-4
return max(eps, min(1.0 - eps, v))
try:
from server.environment import EmailEnv
from server.models import Action, StepResult, Observation, State
from server.classifier import EmailClassifier
from server.imap_client import validate_credentials, fetch_live_emails
from server.registry import TASK_REGISTRY, Task
except ImportError:
from .environment import EmailEnv
from .models import Action, StepResult, Observation, State
from .classifier import EmailClassifier
from .imap_client import validate_credentials, fetch_live_emails
from .registry import TASK_REGISTRY, Task
# Load env vars early
load_dotenv()
app = FastAPI(
title="InboxIQ β€” Email Triage RL Environment",
description="OpenEnv-compliant reinforcement learning environment for intelligent email inbox triage",
version="3.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── RL Environment ──
env = EmailEnv()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
"""
Gracefully handles action schema hallucinations by the OpenEnv agents.
If the agent submits a malformed Action, a 0.0 reward step is recorded.
"""
error_msg = f"Action Schema Error: {str(exc)}"
# Register a faulty step with 0.0 reward (failure baseline)
env.steps += 1
env.total_reward += 0.0
env._episode_rewards.append(0.0)
# Wrap in the mandatory StepResult format
response_payload = {
"observation": {
"inbox": [e for e in env.inbox] if hasattr(env, "inbox") else [],
"steps": env.steps,
"task": getattr(env, "current_task", "train"),
"max_steps": getattr(env, "max_steps", 10),
"total_reward": round(env.total_reward, 2),
"reward": 0.0,
"done": env.steps >= env.max_steps,
"error_trace": error_msg,
"step_feedback": "Invalid action parameters provided. Learn the schema.",
},
"reward": 0.0,
"done": env.steps >= env.max_steps,
"info": {},
}
return JSONResponse(status_code=200, content=response_payload)
# ── Standalone classifier for classification endpoint ──
_dataset_path = None
for candidate in [
os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"dataset",
"spam_assassin.csv",
),
"/app/dataset/spam_assassin.csv",
"dataset/spam_assassin.csv",
]:
if os.path.exists(candidate):
_dataset_path = candidate
break
live_classifier = EmailClassifier(dataset_path=_dataset_path)
class ClassifyRequest(BaseModel):
text: str
subject: Optional[str] = ""
class FeedbackRequest(BaseModel):
predicted_spam: bool
actual_spam: bool
class AccountRequest(BaseModel):
username: str
password: str
# ── In-memory account store ──
connected_accounts: Dict[str, str] = {}
# ── Static Files & Frontend ──
frontend_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "frontend", "dist"
)
if os.path.isdir(frontend_path):
assets_dir = os.path.join(frontend_path, "assets")
if os.path.isdir(assets_dir):
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/")
def serve_frontend():
return FileResponse(os.path.join(frontend_path, "index.html"))
@app.get("/InboxIQ.png")
def serve_icon():
icon_path = os.path.join(frontend_path, "InboxIQ.png")
if os.path.exists(icon_path):
return FileResponse(icon_path, media_type="image/png")
# Fallback to root-level copy
root_icon = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "InboxIQ.png"
)
if os.path.exists(root_icon):
return FileResponse(root_icon, media_type="image/png")
raise HTTPException(status_code=404, detail="Icon not found")
@app.get("/favicon.svg")
def serve_favicon():
fav_path = os.path.join(frontend_path, "favicon.svg")
if os.path.exists(fav_path):
return FileResponse(fav_path, media_type="image/svg+xml")
raise HTTPException(status_code=404, detail="Favicon not found")
else:
@app.get("/")
def root():
return RedirectResponse(url="/docs")
# ══════════════════════════════════════════════════════════════════════════════
# OpenEnv Core Endpoints
# ══════════════════════════════════════════════════════════════════════════════
@app.post("/reset", response_model=Observation)
def reset(
task: str = "task1", seed: Optional[int] = None, episode_id: Optional[str] = None
):
"""Reset the environment and return the initial observation."""
return env.reset(task=task, seed=seed, episode_id=episode_id)
@app.post("/step")
def step(action: Action):
"""Execute one action and return (observation, reward, done, info)."""
obs = env.step(action)
return {
"observation": obs.model_dump() if hasattr(obs, "model_dump") else obs,
"reward": _clamp_score(obs.reward if hasattr(obs, "reward") else 0.0),
"done": obs.done if hasattr(obs, "done") else False,
"info": obs.info if hasattr(obs, "info") else {},
}
@app.get("/state")
def state():
"""Return the current environment state."""
return env.state()
@app.get("/metadata")
def metadata():
"""Return OpenEnv metadata for runtime validators and clients."""
return {
"name": "InboxIQ",
"description": "OpenEnv-compliant reinforcement learning environment for intelligent email inbox triage",
"version": "3.0",
"tasks": [
{
"id": task.task_id,
"name": task.name,
"difficulty": task.difficulty,
"max_steps": task.max_steps,
}
for task in TASK_REGISTRY.values()
],
}
@app.get("/tasks")
def tasks():
"""Get metadata for all available tasks."""
return list(TASK_REGISTRY.values())
@app.get("/schema")
def schema():
"""Return action/observation/state JSON schemas."""
return {
"action": Action.model_json_schema(),
"observation": Observation.model_json_schema(),
"state": State.model_json_schema(),
}
@app.post("/mcp")
def mcp(payload: Dict = Body(default_factory=dict)):
"""
Lightweight MCP compatibility endpoint.
This keeps runtime validators and MCP-aware clients from hard failing.
"""
req_id = payload.get("id")
method = payload.get("method")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "InboxIQ", "version": "3.0"},
},
}
if method == "tools/list":
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": []}}
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": "Method not found"},
}
@app.get("/health")
def health():
"""Health check endpoint for Docker and monitoring."""
return {"status": "healthy", "environment": "InboxIQ"}
@app.get("/grader")
def grader():
"""Return the normalized grader score for the current episode."""
return {
"score": _clamp_score(env.grader()),
"total_reward": env.total_reward,
"task": env.current_task,
}
# ══════════════════════════════════════════════════════════════════════════════
# ML Classification Endpoints
# ══════════════════════════════════════════════════════════════════════════════
@app.post("/classify")
def classify_email(req: ClassifyRequest):
"""Classify a single email text using the ML pipeline."""
result = live_classifier.classify(req.text, req.subject)
return result
@app.post("/feedback")
def submit_feedback(req: FeedbackRequest):
"""Submit classification feedback for online accuracy tracking."""
live_classifier.update_feedback(req.predicted_spam, req.actual_spam)
return {"status": "recorded", "stats": live_classifier.stats}
@app.get("/classifier-stats")
def classifier_stats():
"""Get classifier performance metrics."""
return {
"live_classifier": live_classifier.stats,
"env_classifier": env.classifier.stats,
}
# ══════════════════════════════════════════════════════════════════════════════
# Account & Live Email Endpoints
# ══════════════════════════════════════════════════════════════════════════════
@app.get("/accounts")
def list_accounts():
"""List all connected IMAP accounts."""
return {"accounts": list(connected_accounts.keys())}
@app.post("/accounts")
def add_account(req: AccountRequest):
"""Validate and connect a new IMAP account."""
# ── Demo Mode Bypass ──
if req.username.lower() in ["demo", "demo@inbox-iq.ai"]:
connected_accounts[req.username] = "demo-pass"
return {"status": "connected", "username": req.username, "is_demo": True}
success, msg = validate_credentials(req.username, req.password)
# Fast bypass: If network is unreachable or auth fails, we drop into Demo Mode automatically!
if not success:
connected_accounts[req.username] = "demo-pass"
return {
"status": "connected",
"username": req.username,
"is_demo": True,
"notice": "Fell back to simulation mode",
}
connected_accounts[req.username] = req.password
return {"status": "connected", "username": req.username}
@app.get("/live-inbox")
def get_live_inbox():
"""Fetch recent emails from all accounts and classify them."""
all_emails = []
import time
for user, pwd in connected_accounts.items():
if pwd == "demo-pass":
# ── Simulated 'Live' Inbox for Demo Mode (Randomized) ──
pool = [
{
"sender": "hr@corporation.com",
"subject": "Action Required: Complete your mandatory compliance training",
},
{
"sender": "alerts@aws.amazon.com",
"subject": "AWS Budget Alert: Monthly threshold exceeded",
},
{
"sender": "newsletter@marketing.io",
"subject": "10 best practices for maximizing your workflow",
},
{
"sender": "boss@company.tech",
"subject": "Can we sync up later today? Need your input on the Q3 roadmap",
},
{
"sender": "noreply@github.com",
"subject": "[Repo/Core] Urgent Security Vulnerability Detected (Dependabot)",
},
{
"sender": "promo@deal-hub.com",
"subject": "URGENT: Your free claim prize inside! Click now",
},
{
"sender": "client.contact@external.io",
"subject": "Checking in: Revisions to the updated project proposal?",
},
{
"sender": "support@cloud-platform.io",
"subject": "CRITICAL: Service degradation report β€” ticket #4512",
},
{
"sender": "lunch-club@office.net",
"subject": "Pizza in the breakroom at 12!",
},
{
"sender": "scam.alert@baddomain.biz",
"subject": "Your Bank Account is Frozen - Reset Password Now",
},
]
import random
import uuid
# Slowing down the simulation rate to feel like normal realistic mail volume
selected = random.sample(pool, 1)
raw_emails = []
for idx, e in enumerate(selected):
offset = random.randint(10000, 1500000)
raw_emails.append(
{
"id": f"demo-{uuid.uuid4().hex[:6]}",
"sender": e["sender"],
"subject": e["subject"],
"createdAt": int(time.time() * 1000) - offset,
}
)
else:
raw_emails = fetch_live_emails(user, pwd)
for email_data in raw_emails:
# Classify using the ML pipeline
classification = live_classifier.classify(email_data["subject"])
email_data.update(
{
"type": classification["type"],
"urgency": classification["urgency"],
"sentiment": classification["sentiment"],
"confidence": classification["confidence"],
"spam_score": classification["spam_score"],
}
)
all_emails.append(email_data)
return {"emails": all_emails}
def main():
import uvicorn
port = int(os.environ.get("PORT", 7860))
uvicorn.run("server.app:app", host="0.0.0.0", port=port, reload=True)
# ── Direct execution ──
if __name__ == "__main__":
main()