Spaces:
Sleeping
Sleeping
Ship hardening wave: off-loop CSV inference, model_ids cap/dedupe, real detector Hub ids, comment fixes
Browse files- Dockerfile +7 -5
- backend/app/main.py +3 -1
- backend/app/model_registry.py +3 -3
- backend/app/routes.py +37 -18
- backend/app/schemas.py +27 -4
- backend/tests/test_ai_detect.py +12 -1
- backend/tests/test_compare.py +34 -0
- backend/tests/test_csv.py +10 -0
- evals/ai_detection_report.md +3 -3
- frontend/README.md +12 -27
- frontend/nginx.conf +3 -0
- frontend/src/App.test.tsx +1 -0
- frontend/src/api.ts +1 -8
- frontend/src/components/AiTextDetector.test.tsx +4 -4
- frontend/src/components/CompareModels.test.tsx +3 -0
- frontend/src/components/CompareModels.tsx +1 -0
Dockerfile
CHANGED
|
@@ -50,11 +50,13 @@ names = ['cardiffnlp/twitter-roberta-base-sentiment-latest', \
|
|
| 50 |
[(AutoTokenizer.from_pretrained(n), AutoModelForSequenceClassification.from_pretrained(n)) for n in names]"
|
| 51 |
|
| 52 |
# Detectors are baked differently — into local model directories, not the HF
|
| 53 |
-
# cache.
|
| 54 |
-
#
|
| 55 |
-
#
|
| 56 |
-
#
|
| 57 |
-
#
|
|
|
|
|
|
|
| 58 |
#
|
| 59 |
# All THREE detectors are baked, not lazy-loaded: the AI Detector tab's default
|
| 60 |
# action runs /api/ai-detect/compare with no model_ids, which scores every
|
|
|
|
| 50 |
[(AutoTokenizer.from_pretrained(n), AutoModelForSequenceClassification.from_pretrained(n)) for n in names]"
|
| 51 |
|
| 52 |
# Detectors are baked differently — into local model directories, not the HF
|
| 53 |
+
# cache. Each registry detector sets a local_path, and resolve_model_source()
|
| 54 |
+
# checks that on-disk dir FIRST (falling back to the Hub name only if it is
|
| 55 |
+
# absent), so runtime resolves detectors from local weights. snapshot_download
|
| 56 |
+
# each real detector repo into the exact path resolve_model_source() computes at
|
| 57 |
+
# runtime (models/ sibling of the app dir). The registry names are the real Hub
|
| 58 |
+
# repos too (the same ids used as the snapshot_download source), so the Hub-name
|
| 59 |
+
# fallback would resolve as well — exactly like the sentiment models above.
|
| 60 |
#
|
| 61 |
# All THREE detectors are baked, not lazy-loaded: the AI Detector tab's default
|
| 62 |
# action runs /api/ai-detect/compare with no model_ids, which scores every
|
backend/app/main.py
CHANGED
|
@@ -71,7 +71,9 @@ if os.getenv("PUBLIC_DEPLOY") == "1":
|
|
| 71 |
# this protects.
|
| 72 |
limiter = Limiter(key_func=client_ip, application_limits=["30/minute"])
|
| 73 |
app.state.limiter = limiter
|
| 74 |
-
app.add_exception_handler(RateLimitExceeded,
|
|
|
|
|
|
|
| 75 |
|
| 76 |
# NOT slowapi's SlowAPIMiddleware: it resolves the handler by scanning
|
| 77 |
# app.routes for an `endpoint` attribute, but FastAPI 0.139 wraps included
|
|
|
|
| 71 |
# this protects.
|
| 72 |
limiter = Limiter(key_func=client_ip, application_limits=["30/minute"])
|
| 73 |
app.state.limiter = limiter
|
| 74 |
+
# No app.add_exception_handler(RateLimitExceeded, ...): the middleware below
|
| 75 |
+
# catches RateLimitExceeded and returns the 429 itself, so the exception
|
| 76 |
+
# never propagates to an app-level handler — registering one would be dead code.
|
| 77 |
|
| 78 |
# NOT slowapi's SlowAPIMiddleware: it resolves the handler by scanning
|
| 79 |
# app.routes for an `endpoint` attribute, but FastAPI 0.139 wraps included
|
backend/app/model_registry.py
CHANGED
|
@@ -76,7 +76,7 @@ MODEL_REGISTRY: dict[str, ModelConfig] = {
|
|
| 76 |
# AI text detector models
|
| 77 |
"desklib-ai-detector": ModelConfig(
|
| 78 |
id="desklib-ai-detector",
|
| 79 |
-
name="desklib
|
| 80 |
task=ModelTask.AI_TEXT_DETECTION,
|
| 81 |
labels=("human", "ai"),
|
| 82 |
domain="general AI-written text detection",
|
|
@@ -91,7 +91,7 @@ MODEL_REGISTRY: dict[str, ModelConfig] = {
|
|
| 91 |
),
|
| 92 |
"fakespot-ai-detector": ModelConfig(
|
| 93 |
id="fakespot-ai-detector",
|
| 94 |
-
name="fakespot-roberta-base-ai-text-detection-v1",
|
| 95 |
task=ModelTask.AI_TEXT_DETECTION,
|
| 96 |
labels=("human", "ai"),
|
| 97 |
domain="AI-generated review/text detection",
|
|
@@ -100,7 +100,7 @@ MODEL_REGISTRY: dict[str, ModelConfig] = {
|
|
| 100 |
),
|
| 101 |
"oxidane-ai-detector": ModelConfig(
|
| 102 |
id="oxidane-ai-detector",
|
| 103 |
-
name="
|
| 104 |
task=ModelTask.AI_TEXT_DETECTION,
|
| 105 |
labels=("human", "ai"),
|
| 106 |
domain="general AI text detection",
|
|
|
|
| 76 |
# AI text detector models
|
| 77 |
"desklib-ai-detector": ModelConfig(
|
| 78 |
id="desklib-ai-detector",
|
| 79 |
+
name="desklib/ai-text-detector-v1.01",
|
| 80 |
task=ModelTask.AI_TEXT_DETECTION,
|
| 81 |
labels=("human", "ai"),
|
| 82 |
domain="general AI-written text detection",
|
|
|
|
| 91 |
),
|
| 92 |
"fakespot-ai-detector": ModelConfig(
|
| 93 |
id="fakespot-ai-detector",
|
| 94 |
+
name="fakespot-ai/roberta-base-ai-text-detection-v1",
|
| 95 |
task=ModelTask.AI_TEXT_DETECTION,
|
| 96 |
labels=("human", "ai"),
|
| 97 |
domain="AI-generated review/text detection",
|
|
|
|
| 100 |
),
|
| 101 |
"oxidane-ai-detector": ModelConfig(
|
| 102 |
id="oxidane-ai-detector",
|
| 103 |
+
name="Oxidane/tmr-ai-text-detector",
|
| 104 |
task=ModelTask.AI_TEXT_DETECTION,
|
| 105 |
labels=("human", "ai"),
|
| 106 |
domain="general AI text detection",
|
backend/app/routes.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""API routes. Endpoints stay thin: validation lives in schemas (Pydantic),
|
| 2 |
ML logic lives in SentimentModel — routes just wire the two together."""
|
| 3 |
|
|
|
|
| 4 |
import csv
|
| 5 |
import io
|
| 6 |
import os
|
|
@@ -198,7 +199,11 @@ async def analyze_csv(
|
|
| 198 |
JSON, so each failure mode gets its own explicit 400 — the caller should
|
| 199 |
always learn WHY their file was rejected."""
|
| 200 |
reject_non_default_model_id(model_id)
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
if len(raw) > MAX_CSV_BYTES:
|
| 203 |
# 413 Payload Too Large, not 400: the upload isn't malformed — it's
|
| 204 |
# simply too big, which is exactly what 413 means. Checked before any
|
|
@@ -236,7 +241,9 @@ async def analyze_csv(
|
|
| 236 |
if not texts:
|
| 237 |
raise HTTPException(status_code=400, detail="No non-empty rows found")
|
| 238 |
|
| 239 |
-
|
|
|
|
|
|
|
| 240 |
items = [{"text": t, **r} for t, r in zip(texts, results)]
|
| 241 |
return {"results": items, "aggregates": aggregate(results)}
|
| 242 |
|
|
@@ -268,13 +275,9 @@ async def compare(req: CompareRequest, request: Request):
|
|
| 268 |
"""
|
| 269 |
model_ids = req.model_ids or DEFAULT_COMPARE_MODELS
|
| 270 |
|
| 271 |
-
#
|
| 272 |
-
#
|
| 273 |
-
|
| 274 |
-
reject_disabled_model(model_id)
|
| 275 |
-
|
| 276 |
-
# Validate every id BEFORE loading any weights — fail fast at the boundary
|
| 277 |
-
# so a bad id never costs a multi-hundred-MB load first.
|
| 278 |
configs = []
|
| 279 |
for model_id in model_ids:
|
| 280 |
try:
|
|
@@ -292,12 +295,21 @@ async def compare(req: CompareRequest, request: Request):
|
|
| 292 |
)
|
| 293 |
configs.append((model_id, cfg))
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
results = []
|
| 296 |
for model_id, cfg in configs:
|
| 297 |
model = await get_or_load_model(request.app, model_id)
|
| 298 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
start = time.perf_counter()
|
| 300 |
-
prediction = model.predict
|
| 301 |
latency_ms = (time.perf_counter() - start) * 1000
|
| 302 |
scores = prediction["scores"]
|
| 303 |
results.append(
|
|
@@ -321,13 +333,12 @@ async def _score_detectors(request: Request, text: str, model_ids: list[str]) ->
|
|
| 321 |
"""Validate, load, and score a set of AI text detectors on one input.
|
| 322 |
|
| 323 |
Shared by /api/ai-detect and /api/ai-detect/compare so both apply the same
|
| 324 |
-
guards in the same order:
|
| 325 |
-
(
|
| 326 |
-
|
|
|
|
|
|
|
| 327 |
"""
|
| 328 |
-
for model_id in model_ids:
|
| 329 |
-
reject_disabled_model(model_id)
|
| 330 |
-
|
| 331 |
configs = []
|
| 332 |
for model_id in model_ids:
|
| 333 |
try:
|
|
@@ -346,11 +357,19 @@ async def _score_detectors(request: Request, text: str, model_ids: list[str]) ->
|
|
| 346 |
)
|
| 347 |
configs.append((model_id, cfg))
|
| 348 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
rows = []
|
| 350 |
for model_id, cfg in configs:
|
| 351 |
model = await get_or_load_model(request.app, model_id)
|
|
|
|
|
|
|
|
|
|
| 352 |
start = time.perf_counter()
|
| 353 |
-
prediction = model.predict
|
| 354 |
latency_ms = (time.perf_counter() - start) * 1000
|
| 355 |
scores = prediction["scores"]
|
| 356 |
rows.append(
|
|
|
|
| 1 |
"""API routes. Endpoints stay thin: validation lives in schemas (Pydantic),
|
| 2 |
ML logic lives in SentimentModel — routes just wire the two together."""
|
| 3 |
|
| 4 |
+
import asyncio
|
| 5 |
import csv
|
| 6 |
import io
|
| 7 |
import os
|
|
|
|
| 199 |
JSON, so each failure mode gets its own explicit 400 — the caller should
|
| 200 |
always learn WHY their file was rejected."""
|
| 201 |
reject_non_default_model_id(model_id)
|
| 202 |
+
# Bounded read: pull at most MAX_CSV_BYTES + 1 bytes off the wire, never the
|
| 203 |
+
# whole upload. The +1 byte is all the size check below needs to fire, so the
|
| 204 |
+
# guard can't be defeated by a body larger than RAM — an attacker streaming a
|
| 205 |
+
# multi-GB file never gets more than ~5MB buffered here.
|
| 206 |
+
raw = await file.read(MAX_CSV_BYTES + 1)
|
| 207 |
if len(raw) > MAX_CSV_BYTES:
|
| 208 |
# 413 Payload Too Large, not 400: the upload isn't malformed — it's
|
| 209 |
# simply too big, which is exactly what 413 means. Checked before any
|
|
|
|
| 241 |
if not texts:
|
| 242 |
raise HTTPException(status_code=400, detail="No non-empty rows found")
|
| 243 |
|
| 244 |
+
# Inference is CPU-bound and blocking; run it off the event loop so
|
| 245 |
+
# concurrent requests stay responsive.
|
| 246 |
+
results = await asyncio.to_thread(model.predict, texts)
|
| 247 |
items = [{"text": t, **r} for t, r in zip(texts, results)]
|
| 248 |
return {"results": items, "aggregates": aggregate(results)}
|
| 249 |
|
|
|
|
| 275 |
"""
|
| 276 |
model_ids = req.model_ids or DEFAULT_COMPARE_MODELS
|
| 277 |
|
| 278 |
+
# Validate identity BEFORE the allowlist: an unknown id must return a
|
| 279 |
+
# truthful 400, not masquerade as a 403 "disabled". Every id is checked
|
| 280 |
+
# BEFORE loading any weights, so a bad id never costs a multi-hundred-MB load.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
configs = []
|
| 282 |
for model_id in model_ids:
|
| 283 |
try:
|
|
|
|
| 295 |
)
|
| 296 |
configs.append((model_id, cfg))
|
| 297 |
|
| 298 |
+
# Public-deployment allowlist next: a known-but-disabled model is rejected
|
| 299 |
+
# before get_or_load_model can ever spend memory/bandwidth on its weights.
|
| 300 |
+
for model_id, _ in configs:
|
| 301 |
+
reject_disabled_model(model_id)
|
| 302 |
+
|
| 303 |
results = []
|
| 304 |
for model_id, cfg in configs:
|
| 305 |
model = await get_or_load_model(request.app, model_id)
|
| 306 |
+
# Inference is CPU-bound and blocking; run it off the event loop (like
|
| 307 |
+
# the lazy load() in get_or_load_model) so concurrent requests stay
|
| 308 |
+
# responsive. perf_counter is monotonic — the right clock for a duration
|
| 309 |
+
# — and times the predict call itself (the thread hand-off is sub-ms
|
| 310 |
+
# noise; the expensive one-time load already happened before this loop).
|
| 311 |
start = time.perf_counter()
|
| 312 |
+
prediction = (await asyncio.to_thread(model.predict, [req.text]))[0]
|
| 313 |
latency_ms = (time.perf_counter() - start) * 1000
|
| 314 |
scores = prediction["scores"]
|
| 315 |
results.append(
|
|
|
|
| 333 |
"""Validate, load, and score a set of AI text detectors on one input.
|
| 334 |
|
| 335 |
Shared by /api/ai-detect and /api/ai-detect/compare so both apply the same
|
| 336 |
+
guards in the same order: known id (400) → detector-only (400) → allowlist
|
| 337 |
+
(403), ALL before any weights load. Identity is checked before the allowlist
|
| 338 |
+
so an unknown id returns a truthful 400 instead of masquerading as a 403
|
| 339 |
+
"disabled". Sentiment ids are refused here — mixing the two task families is
|
| 340 |
+
the one thing this endpoint exists to prevent.
|
| 341 |
"""
|
|
|
|
|
|
|
|
|
|
| 342 |
configs = []
|
| 343 |
for model_id in model_ids:
|
| 344 |
try:
|
|
|
|
| 357 |
)
|
| 358 |
configs.append((model_id, cfg))
|
| 359 |
|
| 360 |
+
# Public-deployment allowlist last: a known-but-disabled detector is rejected
|
| 361 |
+
# before get_or_load_model can ever spend memory/bandwidth on its weights.
|
| 362 |
+
for model_id, _ in configs:
|
| 363 |
+
reject_disabled_model(model_id)
|
| 364 |
+
|
| 365 |
rows = []
|
| 366 |
for model_id, cfg in configs:
|
| 367 |
model = await get_or_load_model(request.app, model_id)
|
| 368 |
+
# Inference is CPU-bound and blocking; run it off the event loop so
|
| 369 |
+
# concurrent requests stay responsive. Latency times the predict call
|
| 370 |
+
# (thread hand-off is sub-ms noise; the one-time load already happened).
|
| 371 |
start = time.perf_counter()
|
| 372 |
+
prediction = (await asyncio.to_thread(model.predict, [text]))[0]
|
| 373 |
latency_ms = (time.perf_counter() - start) * 1000
|
| 374 |
scores = prediction["scores"]
|
| 375 |
rows.append(
|
backend/app/schemas.py
CHANGED
|
@@ -7,11 +7,17 @@ assume clean input, which keeps it simple.
|
|
| 7 |
|
| 8 |
from pydantic import BaseModel, Field, field_validator
|
| 9 |
|
|
|
|
|
|
|
| 10 |
# Character cap is a cheap pre-tokenization guard. The tokenizer still
|
| 11 |
# truncates to 512 tokens, but rejecting huge payloads early protects the
|
| 12 |
# server from pathological requests.
|
| 13 |
MAX_CHARS = 2000
|
| 14 |
MAX_BATCH = 500
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class AnalyzeRequest(BaseModel):
|
|
@@ -88,9 +94,26 @@ class DynamicAnalyzeResponse(BaseModel):
|
|
| 88 |
scores: dict[str, float]
|
| 89 |
|
| 90 |
|
| 91 |
-
class
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
# Omit model_ids to compare the default lineup (see routes.DEFAULT_COMPARE_MODELS).
|
| 93 |
-
|
| 94 |
|
| 95 |
|
| 96 |
class CompareItem(DynamicAnalyzeResponse):
|
|
@@ -114,10 +137,10 @@ class CompareResponse(BaseModel):
|
|
| 114 |
# the softmax detectors (fakespot/oxidane) all funnel into the same shape.
|
| 115 |
|
| 116 |
|
| 117 |
-
class AiDetectRequest(
|
| 118 |
# Omit model_ids: /api/ai-detect uses the default detector; /api/ai-detect/compare
|
| 119 |
# uses every detector (see routes).
|
| 120 |
-
|
| 121 |
|
| 122 |
|
| 123 |
class AiDetectItem(DynamicAnalyzeResponse):
|
|
|
|
| 7 |
|
| 8 |
from pydantic import BaseModel, Field, field_validator
|
| 9 |
|
| 10 |
+
from app.model_registry import MODEL_REGISTRY
|
| 11 |
+
|
| 12 |
# Character cap is a cheap pre-tokenization guard. The tokenizer still
|
| 13 |
# truncates to 512 tokens, but rejecting huge payloads early protects the
|
| 14 |
# server from pathological requests.
|
| 15 |
MAX_CHARS = 2000
|
| 16 |
MAX_BATCH = 500
|
| 17 |
+
# A multi-model request can name at most every registry model once. Capping at
|
| 18 |
+
# the registry size (and de-duplicating below) stops one anonymous request from
|
| 19 |
+
# queueing thousands of inferences by padding or repeating model_ids.
|
| 20 |
+
MAX_MODEL_IDS = len(MODEL_REGISTRY)
|
| 21 |
|
| 22 |
|
| 23 |
class AnalyzeRequest(BaseModel):
|
|
|
|
| 94 |
scores: dict[str, float]
|
| 95 |
|
| 96 |
|
| 97 |
+
class _MultiModelRequest(AnalyzeRequest):
|
| 98 |
+
"""Shared contract for endpoints that fan one input across several named
|
| 99 |
+
models. model_ids is capped at the registry size and de-duplicated
|
| 100 |
+
(order-preserving) at the boundary, so one anonymous request can never
|
| 101 |
+
queue thousands of inferences by repeating or padding the list."""
|
| 102 |
+
|
| 103 |
+
model_ids: list[str] | None = Field(default=None, max_length=MAX_MODEL_IDS)
|
| 104 |
+
|
| 105 |
+
@field_validator("model_ids")
|
| 106 |
+
@classmethod
|
| 107 |
+
def dedupe_model_ids(cls, v: list[str] | None) -> list[str] | None:
|
| 108 |
+
if v is None:
|
| 109 |
+
return v
|
| 110 |
+
# dict.fromkeys keeps the first occurrence of each id and preserves order.
|
| 111 |
+
return list(dict.fromkeys(v))
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class CompareRequest(_MultiModelRequest):
|
| 115 |
# Omit model_ids to compare the default lineup (see routes.DEFAULT_COMPARE_MODELS).
|
| 116 |
+
pass
|
| 117 |
|
| 118 |
|
| 119 |
class CompareItem(DynamicAnalyzeResponse):
|
|
|
|
| 137 |
# the softmax detectors (fakespot/oxidane) all funnel into the same shape.
|
| 138 |
|
| 139 |
|
| 140 |
+
class AiDetectRequest(_MultiModelRequest):
|
| 141 |
# Omit model_ids: /api/ai-detect uses the default detector; /api/ai-detect/compare
|
| 142 |
# uses every detector (see routes).
|
| 143 |
+
pass
|
| 144 |
|
| 145 |
|
| 146 |
class AiDetectItem(DynamicAnalyzeResponse):
|
backend/tests/test_ai_detect.py
CHANGED
|
@@ -58,7 +58,7 @@ def test_ai_detect_honors_explicit_detector(client_with_detectors):
|
|
| 58 |
|
| 59 |
def test_ai_detect_row_carries_registry_metadata(client_with_detectors):
|
| 60 |
result = client_with_detectors.post("/api/ai-detect", json={"text": "x"}).json()["result"]
|
| 61 |
-
assert result["name"] == "desklib
|
| 62 |
assert result["domain"]
|
| 63 |
assert result["note"]
|
| 64 |
assert isinstance(result["latency_ms"], (int, float))
|
|
@@ -134,6 +134,17 @@ def test_compare_rejects_sentiment_model(client_with_detectors):
|
|
| 134 |
assert "sentiment" in resp.json()["detail"].lower()
|
| 135 |
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
# --- cross-task rejection: detectors are refused by /api/compare ----------
|
| 138 |
|
| 139 |
|
|
|
|
| 58 |
|
| 59 |
def test_ai_detect_row_carries_registry_metadata(client_with_detectors):
|
| 60 |
result = client_with_detectors.post("/api/ai-detect", json={"text": "x"}).json()["result"]
|
| 61 |
+
assert result["name"] == "desklib/ai-text-detector-v1.01"
|
| 62 |
assert result["domain"]
|
| 63 |
assert result["note"]
|
| 64 |
assert isinstance(result["latency_ms"], (int, float))
|
|
|
|
| 134 |
assert "sentiment" in resp.json()["detail"].lower()
|
| 135 |
|
| 136 |
|
| 137 |
+
def test_compare_dedupes_duplicate_detector_ids(client_with_detectors):
|
| 138 |
+
# Same dedupe guard as /api/compare: a repeated detector id collapses to one
|
| 139 |
+
# row, so one request can't queue the same detector many times over.
|
| 140 |
+
resp = client_with_detectors.post(
|
| 141 |
+
"/api/ai-detect/compare",
|
| 142 |
+
json={"text": "x", "model_ids": ["desklib-ai-detector", "desklib-ai-detector"]},
|
| 143 |
+
)
|
| 144 |
+
assert resp.status_code == 200
|
| 145 |
+
assert [r["model_id"] for r in resp.json()["results"]] == ["desklib-ai-detector"]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
# --- cross-task rejection: detectors are refused by /api/compare ----------
|
| 149 |
|
| 150 |
|
backend/tests/test_compare.py
CHANGED
|
@@ -91,6 +91,40 @@ def test_compare_allows_models_on_the_allowlist(monkeypatch, client_with_compare
|
|
| 91 |
assert len(resp.json()["results"]) == 2
|
| 92 |
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
def test_lifespan_seeds_default_model_into_cache(monkeypatch):
|
| 95 |
"""The startup-loaded default must be seeded into model_cache so /api/compare
|
| 96 |
reuses that one copy instead of loading a second ~500MB set of weights."""
|
|
|
|
| 91 |
assert len(resp.json()["results"]) == 2
|
| 92 |
|
| 93 |
|
| 94 |
+
def test_compare_dedupes_duplicate_model_ids(client_with_compare_models):
|
| 95 |
+
# A repeated id must collapse to ONE inference/row — one anonymous request
|
| 96 |
+
# can't queue the same model dozens of times by padding the list.
|
| 97 |
+
resp = client_with_compare_models.post(
|
| 98 |
+
"/api/compare",
|
| 99 |
+
json={"text": "good", "model_ids": ["twitter-roberta", "twitter-roberta"]},
|
| 100 |
+
)
|
| 101 |
+
assert resp.status_code == 200
|
| 102 |
+
assert [r["model_id"] for r in resp.json()["results"]] == ["twitter-roberta"]
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_compare_rejects_over_cap_model_ids(client_with_compare_models):
|
| 106 |
+
# More ids than the registry holds is rejected at the boundary (422) before
|
| 107 |
+
# any weights load — a hard cap on how many inferences one request can queue.
|
| 108 |
+
from app.schemas import MAX_MODEL_IDS
|
| 109 |
+
|
| 110 |
+
too_many = [f"model-{i}" for i in range(MAX_MODEL_IDS + 1)]
|
| 111 |
+
resp = client_with_compare_models.post(
|
| 112 |
+
"/api/compare", json={"text": "good", "model_ids": too_many}
|
| 113 |
+
)
|
| 114 |
+
assert resp.status_code == 422
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_compare_unknown_id_is_400_even_under_allowlist(monkeypatch, client_with_compare_models):
|
| 118 |
+
# Guard order: an unknown id returns a truthful 400, NOT a 403 masquerading
|
| 119 |
+
# as "disabled", even when the ENABLED_MODELS allowlist is active.
|
| 120 |
+
monkeypatch.setenv("ENABLED_MODELS", "twitter-roberta")
|
| 121 |
+
resp = client_with_compare_models.post(
|
| 122 |
+
"/api/compare", json={"text": "good", "model_ids": ["not-a-real-model"]}
|
| 123 |
+
)
|
| 124 |
+
assert resp.status_code == 400
|
| 125 |
+
assert "not-a-real-model" in resp.json()["detail"]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
def test_lifespan_seeds_default_model_into_cache(monkeypatch):
|
| 129 |
"""The startup-loaded default must be seeded into model_cache so /api/compare
|
| 130 |
reuses that one copy instead of loading a second ~500MB set of weights."""
|
backend/tests/test_csv.py
CHANGED
|
@@ -70,3 +70,13 @@ def test_csv_over_max_bytes_is_413(client_with_model):
|
|
| 70 |
resp = _upload(client_with_model, csv_bytes)
|
| 71 |
assert resp.status_code == 413
|
| 72 |
assert "5MB" in resp.json()["detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
resp = _upload(client_with_model, csv_bytes)
|
| 71 |
assert resp.status_code == 413
|
| 72 |
assert "5MB" in resp.json()["detail"]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_csv_far_over_max_bytes_still_413(client_with_model):
|
| 76 |
+
# A body many times the limit still 413s: the endpoint uses a BOUNDED read
|
| 77 |
+
# (file.read(MAX_CSV_BYTES + 1)), so the guard fires on the first MAX+1 bytes
|
| 78 |
+
# and can't be defeated by a body larger than RAM. Behavior is all we can
|
| 79 |
+
# assert here; the bounded read is what keeps that behavior cheap.
|
| 80 |
+
csv_bytes = b"text\n" + b"x" * (MAX_CSV_BYTES * 3)
|
| 81 |
+
resp = _upload(client_with_model, csv_bytes)
|
| 82 |
+
assert resp.status_code == 413
|
evals/ai_detection_report.md
CHANGED
|
@@ -71,7 +71,7 @@ Accuracy and macro F1 against the seed labels, plus single-text latency (p50 = t
|
|
| 71 |
| fakespot-ai-detector | 0.567 | 0.562 | 8.5 | 32.9 |
|
| 72 |
| oxidane-ai-detector | 0.600 | 0.524 | 6.5 | 8.5 |
|
| 73 |
|
| 74 |
-
### `desklib-ai-detector` — desklib
|
| 75 |
|
| 76 |
Per-class precision / recall / F1:
|
| 77 |
|
|
@@ -107,7 +107,7 @@ Misclassified (15 of 30) — P(ai) shows how confidently it was wrong:
|
|
| 107 |
| There are many factors to consider, and ultimately the right choice depends on your own needs. | ambiguous | human | ai | 1.00 |
|
| 108 |
| Overall, it was a memorable trip that offered both challenges and a few lessons I won't forget. | ambiguous | human | ai | 1.00 |
|
| 109 |
|
| 110 |
-
### `fakespot-ai-detector` — fakespot-roberta-base-ai-text-detection-v1
|
| 111 |
|
| 112 |
Per-class precision / recall / F1:
|
| 113 |
|
|
@@ -141,7 +141,7 @@ Misclassified (13 of 30) — P(ai) shows how confidently it was wrong:
|
|
| 141 |
| A thorough strategy such as this secures the best results and encourages steady expansion in every area. | ai_paraphrased | ai | human | 0.19 |
|
| 142 |
| my two cents plus the draft: The proposed framework offers a robust and scalable foundation for future development. | mixed_human_ai | ai | human | 0.14 |
|
| 143 |
|
| 144 |
-
### `oxidane-ai-detector` —
|
| 145 |
|
| 146 |
Per-class precision / recall / F1:
|
| 147 |
|
|
|
|
| 71 |
| fakespot-ai-detector | 0.567 | 0.562 | 8.5 | 32.9 |
|
| 72 |
| oxidane-ai-detector | 0.600 | 0.524 | 6.5 | 8.5 |
|
| 73 |
|
| 74 |
+
### `desklib-ai-detector` — desklib/ai-text-detector-v1.01
|
| 75 |
|
| 76 |
Per-class precision / recall / F1:
|
| 77 |
|
|
|
|
| 107 |
| There are many factors to consider, and ultimately the right choice depends on your own needs. | ambiguous | human | ai | 1.00 |
|
| 108 |
| Overall, it was a memorable trip that offered both challenges and a few lessons I won't forget. | ambiguous | human | ai | 1.00 |
|
| 109 |
|
| 110 |
+
### `fakespot-ai-detector` — fakespot-ai/roberta-base-ai-text-detection-v1
|
| 111 |
|
| 112 |
Per-class precision / recall / F1:
|
| 113 |
|
|
|
|
| 141 |
| A thorough strategy such as this secures the best results and encourages steady expansion in every area. | ai_paraphrased | ai | human | 0.19 |
|
| 142 |
| my two cents plus the draft: The proposed framework offers a robust and scalable foundation for future development. | mixed_human_ai | ai | human | 0.14 |
|
| 143 |
|
| 144 |
+
### `oxidane-ai-detector` — Oxidane/tmr-ai-text-detector
|
| 145 |
|
| 146 |
Per-class precision / recall / F1:
|
| 147 |
|
frontend/README.md
CHANGED
|
@@ -1,32 +1,17 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
#
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
## Expanding the Oxlint configuration
|
| 15 |
-
|
| 16 |
-
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
| 17 |
-
|
| 18 |
-
```json
|
| 19 |
-
{
|
| 20 |
-
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
| 21 |
-
"plugins": ["react", "typescript", "oxc"],
|
| 22 |
-
"options": {
|
| 23 |
-
"typeAware": true
|
| 24 |
-
},
|
| 25 |
-
"rules": {
|
| 26 |
-
"react/rules-of-hooks": "error",
|
| 27 |
-
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
| 28 |
-
}
|
| 29 |
-
}
|
| 30 |
```
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
| 1 |
+
# SentimentScope — frontend
|
| 2 |
|
| 3 |
+
React + TypeScript + Vite SPA for SentimentScope. See the [root README](../README.md)
|
| 4 |
+
for the full project overview, architecture, and deployment topologies.
|
| 5 |
|
| 6 |
+
## Dev commands
|
| 7 |
|
| 8 |
+
```bash
|
| 9 |
+
npm install # install dependencies
|
| 10 |
+
npm run dev # Vite dev server (proxies /api -> backend on :8000)
|
| 11 |
+
npm test -- --run # run the vitest suite once
|
| 12 |
+
npm run lint # oxlint
|
| 13 |
+
npm run build # type-check (tsc -b) + production build
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
```
|
| 15 |
|
| 16 |
+
The UI only ever calls relative `/api/...` paths; the Vite dev proxy, nginx
|
| 17 |
+
(compose), and FastAPI static mount (Spaces) each wire those to the backend.
|
frontend/nginx.conf
CHANGED
|
@@ -6,6 +6,9 @@ server {
|
|
| 6 |
# container. Mirrors the Vite dev proxy — the frontend build is identical
|
| 7 |
# in both environments.
|
| 8 |
location /api/ {
|
|
|
|
|
|
|
|
|
|
| 9 |
proxy_pass http://backend:8000;
|
| 10 |
}
|
| 11 |
|
|
|
|
| 6 |
# container. Mirrors the Vite dev proxy — the frontend build is identical
|
| 7 |
# in both environments.
|
| 8 |
location /api/ {
|
| 9 |
+
# Match the backend's 5MB CSV cap so oversized uploads are rejected at
|
| 10 |
+
# the edge, before they ever reach uvicorn.
|
| 11 |
+
client_max_body_size 5m;
|
| 12 |
proxy_pass http://backend:8000;
|
| 13 |
}
|
| 14 |
|
frontend/src/App.test.tsx
CHANGED
|
@@ -22,6 +22,7 @@ beforeEach(() => {
|
|
| 22 |
{
|
| 23 |
id: "twitter-roberta",
|
| 24 |
name: "cardiffnlp/twitter-roberta-base-sentiment-latest",
|
|
|
|
| 25 |
labels: ["negative", "neutral", "positive"],
|
| 26 |
domain: "social / short English text",
|
| 27 |
note: "",
|
|
|
|
| 22 |
{
|
| 23 |
id: "twitter-roberta",
|
| 24 |
name: "cardiffnlp/twitter-roberta-base-sentiment-latest",
|
| 25 |
+
task: "sentiment",
|
| 26 |
labels: ["negative", "neutral", "positive"],
|
| 27 |
domain: "social / short English text",
|
| 28 |
note: "",
|
frontend/src/api.ts
CHANGED
|
@@ -52,6 +52,7 @@ export interface ModelInfo {
|
|
| 52 |
export interface ModelSummary {
|
| 53 |
id: string;
|
| 54 |
name: string;
|
|
|
|
| 55 |
labels: string[];
|
| 56 |
domain: string;
|
| 57 |
note: string;
|
|
@@ -83,11 +84,6 @@ export interface AiDetectItem {
|
|
| 83 |
note: string;
|
| 84 |
}
|
| 85 |
|
| 86 |
-
export interface AiDetectResponse {
|
| 87 |
-
result: AiDetectItem;
|
| 88 |
-
warning: string;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
export interface AiDetectCompareResponse {
|
| 92 |
results: AiDetectItem[];
|
| 93 |
disagreement: boolean;
|
|
@@ -133,8 +129,5 @@ export const getModels = (task?: "sentiment" | "ai_text_detection") =>
|
|
| 133 |
export const compareModels = (text: string, model_ids?: string[]) =>
|
| 134 |
request<{ results: CompareItem[] }>("/api/compare", postJson({ text, model_ids }));
|
| 135 |
|
| 136 |
-
export const detectAiText = (text: string) =>
|
| 137 |
-
request<AiDetectResponse>("/api/ai-detect", postJson({ text }));
|
| 138 |
-
|
| 139 |
export const compareAiDetectors = (text: string, model_ids?: string[]) =>
|
| 140 |
request<AiDetectCompareResponse>("/api/ai-detect/compare", postJson({ text, model_ids }));
|
|
|
|
| 52 |
export interface ModelSummary {
|
| 53 |
id: string;
|
| 54 |
name: string;
|
| 55 |
+
task: string;
|
| 56 |
labels: string[];
|
| 57 |
domain: string;
|
| 58 |
note: string;
|
|
|
|
| 84 |
note: string;
|
| 85 |
}
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
export interface AiDetectCompareResponse {
|
| 88 |
results: AiDetectItem[];
|
| 89 |
disagreement: boolean;
|
|
|
|
| 129 |
export const compareModels = (text: string, model_ids?: string[]) =>
|
| 130 |
request<{ results: CompareItem[] }>("/api/compare", postJson({ text, model_ids }));
|
| 131 |
|
|
|
|
|
|
|
|
|
|
| 132 |
export const compareAiDetectors = (text: string, model_ids?: string[]) =>
|
| 133 |
request<AiDetectCompareResponse>("/api/ai-detect/compare", postJson({ text, model_ids }));
|
frontend/src/components/AiTextDetector.test.tsx
CHANGED
|
@@ -16,7 +16,7 @@ const WARNING =
|
|
| 16 |
|
| 17 |
const item = (over: Partial<AiDetectItem> = {}): AiDetectItem => ({
|
| 18 |
model_id: "desklib-ai-detector",
|
| 19 |
-
name: "desklib
|
| 20 |
domain: "general AI-written text detection",
|
| 21 |
label: "ai",
|
| 22 |
scores: { human: 0.1, ai: 0.9 },
|
|
@@ -57,7 +57,7 @@ it("renders a readout per detector with its verdict, scores, latency, and note",
|
|
| 57 |
results: [
|
| 58 |
item({
|
| 59 |
model_id: "desklib-ai-detector",
|
| 60 |
-
name: "desklib
|
| 61 |
label: "ai",
|
| 62 |
scores: { human: 0.02, ai: 0.98 },
|
| 63 |
confidence: 0.98,
|
|
@@ -66,7 +66,7 @@ it("renders a readout per detector with its verdict, scores, latency, and note",
|
|
| 66 |
}),
|
| 67 |
item({
|
| 68 |
model_id: "fakespot-ai-detector",
|
| 69 |
-
name: "fakespot-roberta-base-ai-text-detection-v1",
|
| 70 |
label: "human",
|
| 71 |
scores: { human: 0.7, ai: 0.3 },
|
| 72 |
confidence: 0.7,
|
|
@@ -85,7 +85,7 @@ it("renders a readout per detector with its verdict, scores, latency, and note",
|
|
| 85 |
const desklib = await screen.findByRole("group", { name: "desklib-ai-detector" });
|
| 86 |
const fakespot = screen.getByRole("group", { name: "fakespot-ai-detector" });
|
| 87 |
|
| 88 |
-
expect(within(desklib).getByText("desklib
|
| 89 |
expect(within(desklib).getByText("general AI-written text detection")).toBeInTheDocument();
|
| 90 |
expect(within(desklib).getByText("2.0%")).toBeInTheDocument(); // P(human)
|
| 91 |
expect(within(desklib).getAllByText("98.0%").length).toBeGreaterThan(0); // P(ai) + confidence
|
|
|
|
| 16 |
|
| 17 |
const item = (over: Partial<AiDetectItem> = {}): AiDetectItem => ({
|
| 18 |
model_id: "desklib-ai-detector",
|
| 19 |
+
name: "desklib/ai-text-detector-v1.01",
|
| 20 |
domain: "general AI-written text detection",
|
| 21 |
label: "ai",
|
| 22 |
scores: { human: 0.1, ai: 0.9 },
|
|
|
|
| 57 |
results: [
|
| 58 |
item({
|
| 59 |
model_id: "desklib-ai-detector",
|
| 60 |
+
name: "desklib/ai-text-detector-v1.01",
|
| 61 |
label: "ai",
|
| 62 |
scores: { human: 0.02, ai: 0.98 },
|
| 63 |
confidence: 0.98,
|
|
|
|
| 66 |
}),
|
| 67 |
item({
|
| 68 |
model_id: "fakespot-ai-detector",
|
| 69 |
+
name: "fakespot-ai/roberta-base-ai-text-detection-v1",
|
| 70 |
label: "human",
|
| 71 |
scores: { human: 0.7, ai: 0.3 },
|
| 72 |
confidence: 0.7,
|
|
|
|
| 85 |
const desklib = await screen.findByRole("group", { name: "desklib-ai-detector" });
|
| 86 |
const fakespot = screen.getByRole("group", { name: "fakespot-ai-detector" });
|
| 87 |
|
| 88 |
+
expect(within(desklib).getByText("desklib/ai-text-detector-v1.01")).toBeInTheDocument();
|
| 89 |
expect(within(desklib).getByText("general AI-written text detection")).toBeInTheDocument();
|
| 90 |
expect(within(desklib).getByText("2.0%")).toBeInTheDocument(); // P(human)
|
| 91 |
expect(within(desklib).getAllByText("98.0%").length).toBeGreaterThan(0); // P(ai) + confidence
|
frontend/src/components/CompareModels.test.tsx
CHANGED
|
@@ -15,6 +15,7 @@ const registry = {
|
|
| 15 |
{
|
| 16 |
id: "twitter-roberta",
|
| 17 |
name: "cardiffnlp/twitter-roberta-base-sentiment-latest",
|
|
|
|
| 18 |
labels: ["negative", "neutral", "positive"],
|
| 19 |
domain: "social / short English text",
|
| 20 |
note: "",
|
|
@@ -24,6 +25,7 @@ const registry = {
|
|
| 24 |
{
|
| 25 |
id: "distilbert-sst2",
|
| 26 |
name: "distilbert-base-uncased-finetuned-sst-2-english",
|
|
|
|
| 27 |
labels: ["negative", "positive"],
|
| 28 |
domain: "general binary sentiment",
|
| 29 |
note: "",
|
|
@@ -33,6 +35,7 @@ const registry = {
|
|
| 33 |
{
|
| 34 |
id: "finbert",
|
| 35 |
name: "ProsusAI/finbert",
|
|
|
|
| 36 |
labels: ["negative", "neutral", "positive"],
|
| 37 |
domain: "financial text",
|
| 38 |
note: "Useful for finance/news sentences; misleading outside that domain.",
|
|
|
|
| 15 |
{
|
| 16 |
id: "twitter-roberta",
|
| 17 |
name: "cardiffnlp/twitter-roberta-base-sentiment-latest",
|
| 18 |
+
task: "sentiment",
|
| 19 |
labels: ["negative", "neutral", "positive"],
|
| 20 |
domain: "social / short English text",
|
| 21 |
note: "",
|
|
|
|
| 25 |
{
|
| 26 |
id: "distilbert-sst2",
|
| 27 |
name: "distilbert-base-uncased-finetuned-sst-2-english",
|
| 28 |
+
task: "sentiment",
|
| 29 |
labels: ["negative", "positive"],
|
| 30 |
domain: "general binary sentiment",
|
| 31 |
note: "",
|
|
|
|
| 35 |
{
|
| 36 |
id: "finbert",
|
| 37 |
name: "ProsusAI/finbert",
|
| 38 |
+
task: "sentiment",
|
| 39 |
labels: ["negative", "neutral", "positive"],
|
| 40 |
domain: "financial text",
|
| 41 |
note: "Useful for finance/news sentences; misleading outside that domain.",
|
frontend/src/components/CompareModels.tsx
CHANGED
|
@@ -74,6 +74,7 @@ function CompareForm({ registryPromise }: { registryPromise: Promise<ModelSummar
|
|
| 74 |
<textarea
|
| 75 |
className="w-full rounded-lg border border-slate-300 bg-white p-3 focus:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
|
| 76 |
rows={3}
|
|
|
|
| 77 |
aria-label="Text to compare"
|
| 78 |
value={text}
|
| 79 |
onChange={(e) => setText(e.target.value)}
|
|
|
|
| 74 |
<textarea
|
| 75 |
className="w-full rounded-lg border border-slate-300 bg-white p-3 focus:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
|
| 76 |
rows={3}
|
| 77 |
+
maxLength={2000}
|
| 78 |
aria-label="Text to compare"
|
| 79 |
value={text}
|
| 80 |
onChange={(e) => setText(e.target.value)}
|