File size: 7,883 Bytes
542ac35 | 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 | """
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)
|