sentiment-scope / backend /tests /conftest.py
melkholy's picture
Ship AI text detection: enable 5 models, bake detectors, docs
8392b7d verified
Raw
History Blame Contribute Delete
3.72 kB
"""Shared test fixtures.
SKIP_MODEL_LOAD must be set BEFORE importing the app: the FastAPI lifespan
reads it to decide whether to download/load the ~500MB transformer. Unit
tests never touch the real model β€” that's what keeps CI fast and free of
torch as a dependency.
"""
import os
os.environ["SKIP_MODEL_LOAD"] = "1"
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
# TestClient used as a context manager so the lifespan (startup/shutdown) runs.
with TestClient(app) as c:
yield c
class FakeModel:
"""Stands in for SentimentModel in unit tests. Same interface, canned
output β€” API tests check wiring/validation, not ML quality (the
integration suite covers that)."""
is_loaded = True
device = "cpu"
labels = ["negative", "neutral", "positive"]
def predict(self, texts):
return [
{"label": "positive", "scores": {"negative": 0.05, "neutral": 0.15, "positive": 0.8}}
for _ in texts
]
def explain(self, text):
return {
"label": "positive",
"scores": {"negative": 0.05, "neutral": 0.15, "positive": 0.8},
"tokens": [
{"token": "I", "attribution": 0.01},
{"token": " love", "attribution": 0.92},
{"token": " this", "attribution": 0.05},
],
}
@pytest.fixture
def client_with_model():
from app.routes import get_model
app.dependency_overrides[get_model] = lambda: FakeModel()
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
class FakeBinaryModel:
"""Binary sentiment stand-in (DistilBERT-SST2 shape): negative/positive
only, no neutral. Lets compare tests prove that a 2-class model emits just
its own label keys β€” never a faked neutral score."""
is_loaded = True
device = "cpu"
labels = ["negative", "positive"]
def predict(self, texts):
return [
{"label": "negative", "scores": {"negative": 0.7, "positive": 0.3}}
for _ in texts
]
@pytest.fixture
def client_with_compare_models(client):
"""Seed the lazy cache with fakes so /api/compare runs without real weights.
get_or_load_model checks app.state.model_cache first, so a pre-seeded entry
short-circuits the ~500MB load β€” no torch, still exercises the cache path."""
client.app.state.model_cache["twitter-roberta"] = FakeModel()
client.app.state.model_cache["distilbert-sst2"] = FakeBinaryModel()
return client
class FakeDetector:
"""AI-detector stand-in: canonical {human, ai} scores, canned label. Lets
the detector API tests prove wiring/validation/disagreement without torch β€”
real label mapping and probabilities are covered by the integration suite."""
is_loaded = True
device = "cpu"
labels = ["human", "ai"]
def __init__(self, label: str = "ai", scores: dict | None = None) -> None:
self._label = label
self._scores = scores or {"human": 0.1, "ai": 0.9}
def predict(self, texts):
return [{"label": self._label, "scores": self._scores} for _ in texts]
@pytest.fixture
def client_with_detectors(client):
"""Seed the cache with three fake detectors. desklib + fakespot call it AI,
oxidane calls it human β€” so the default (all-detector) compare disagrees."""
cache = client.app.state.model_cache
cache["desklib-ai-detector"] = FakeDetector("ai", {"human": 0.05, "ai": 0.95})
cache["fakespot-ai-detector"] = FakeDetector("ai", {"human": 0.2, "ai": 0.8})
cache["oxidane-ai-detector"] = FakeDetector("human", {"human": 0.7, "ai": 0.3})
return client