mihir-apte's picture
deploy: fix footer note positioning
542ac35
Raw
History Blame Contribute Delete
7.88 kB
"""
src/api/main.py
---------------
FastAPI backend for the Cognitive Distortion Detector.
Endpoints:
GET / - serve the frontend (index.html)
POST /analyse - run inference + LIME + guidance on user text
GET /health - quick liveness check
Usage (from project root):
uvicorn src.api.main:app --reload --port 8000
# then open http://localhost:8000
The DistilBERT model and LIME explainer are loaded once at startup.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Allow `python src/api/main.py` without installing the package
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel, field_validator
# Resolve frontend path relative to this file - works from any working directory
_FRONTEND = Path(__file__).parent.parent.parent / "frontend" / "index.html"
from src.explain import Explainer
from src.guidance import get_guidance
from src.config import DISTORTION_LABELS
# ── Constants ──────────────────────────────────────────────────────────────────
WORD_LIMIT = 150
THRESHOLD = 0.40 # label predicted positive if prob >= this
NUM_FEATURES = 6 # top LIME words per label
NUM_SAMPLES = 300 # LIME perturbations - reduced for API latency
# Single-char tokens and subword artifacts to strip from LIME keyword output
# (DistilBERT tokenises "I'm" β†’ ["I", "m"] - "m" is meaningless as a keyword)
_NOISE_TOKENS = {
"m", "s", "t", "re", "ve", "ll", "d", # subword artifacts from I'm, it's, etc.
"and", "the", "a", "an", "of", "to", "in", # stopwords that carry no distortion signal
"is", "it", "be", "or", "at", "by", "but",
}
# ── Global model (loaded once at startup) ─────────────────────────────────────
_explainer: Explainer | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Load model on startup, release on shutdown."""
global _explainer
print("Loading DistilBERT model …")
_explainer = Explainer()
print("Model ready.")
yield
_explainer = None
print("Model released.")
# ── App ────────────────────────────────────────────────────────────────────────
app = FastAPI(
title="Cognitive Distortion Detector API",
description="Multi-label CBT distortion classifier with LIME explanations.",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # tightened at deployment if needed
allow_methods=["*"],
allow_headers=["*"],
)
# ── Schemas ────────────────────────────────────────────────────────────────────
class AnalyseRequest(BaseModel):
text: str
@field_validator("text")
@classmethod
def check_word_limit(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Text cannot be empty.")
word_count = len(v.split())
if word_count > WORD_LIMIT:
raise ValueError(
f"Input exceeds {WORD_LIMIT}-word limit "
f"({word_count} words). Please shorten your text."
)
return v
class WordWeight(BaseModel):
word: str
weight: float
class GuidanceOut(BaseModel):
explanation: str
technique: str
challenge: str
reframe: str
lime_hint: str
class DistortionResult(BaseModel):
label: str
probability: float # 0.0 – 1.0
top_words: list[WordWeight]
guidance: GuidanceOut
class AnalyseResponse(BaseModel):
detected: list[DistortionResult] # only labels above threshold
word_count: int
threshold: float
class HealthResponse(BaseModel):
status: str
model_loaded: bool
# ── Endpoints ──────────────────────────────────────────────────────────────────
@app.get("/", include_in_schema=False)
def root():
"""Serve the single-page frontend."""
return FileResponse(str(_FRONTEND), media_type="text/html")
@app.get("/health", response_model=HealthResponse)
def health():
return HealthResponse(
status="ok",
model_loaded=_explainer is not None,
)
@app.post("/analyse", response_model=AnalyseResponse)
def analyse(req: AnalyseRequest) -> AnalyseResponse:
"""
Three-step pipeline:
1. DistilBERT inference β†’ probabilities for all 10 labels
2. LIME β†’ top keywords per detected label
3. Guidance lookup β†’ CBT technique + Socratic question + reframe
"""
if _explainer is None:
raise HTTPException(status_code=503, detail="Model not loaded yet. Try again shortly.")
text = req.text
word_count = len(text.split())
# ── Step 1: Inference ──────────────────────────────────────────────────────
probs = _explainer.predict_proba([text])[0] # shape (10,)
detected_indices = [i for i, p in enumerate(probs) if p >= THRESHOLD]
if not detected_indices:
# Return empty detected list - no distortions above threshold
return AnalyseResponse(
detected=[],
word_count=word_count,
threshold=THRESHOLD,
)
# ── Steps 2 & 3: LIME + Guidance per detected label ───────────────────────
results: list[DistortionResult] = []
for idx in detected_indices:
label = DISTORTION_LABELS[idx]
prob = float(probs[idx])
# LIME explanation
lime_result = _explainer.explain(
text,
label_idx=idx,
num_features=NUM_FEATURES,
num_samples=NUM_SAMPLES,
)
top_words = [
WordWeight(word=w, weight=round(float(wt), 4))
for w, wt in lime_result["top_words"]
if len(w) > 1 and w.lower() not in _NOISE_TOKENS
]
# Guidance lookup
g = get_guidance(label)
guidance = GuidanceOut(
explanation=g["explanation"],
technique=g["technique"],
challenge=g["challenge"],
reframe=g["reframe"],
lime_hint=g["lime_hint"],
)
results.append(DistortionResult(
label=label,
probability=round(prob, 4),
top_words=top_words,
guidance=guidance,
))
# Sort by probability descending
results.sort(key=lambda r: r.probability, reverse=True)
return AnalyseResponse(
detected=results,
word_count=word_count,
threshold=THRESHOLD,
)
# ── Dev runner ─────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
# Local dev: http://localhost:8000 | HuggingFace Spaces: port 7860 via Dockerfile
uvicorn.run("src.api.main:app", host="0.0.0.0", port=8000, reload=True)