melkholy commited on
Commit
019b6f4
·
verified ·
1 Parent(s): 8392b7d

Ship hardening wave: off-loop CSV inference, model_ids cap/dedupe, real detector Hub ids, comment fixes

Browse files
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. The registry gives detectors display names ("desklib-ai-text-detector-
54
- # v1.01"), not valid Hub IDs, so resolve_model_source() can only reach them via
55
- # their local_path; the Hub-name fallback that works for sentiment would 404
56
- # here. So snapshot_download each real detector repo into the exact path
57
- # resolve_model_source() computes at runtime (models/ sibling of the app dir).
 
 
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, _rate_limit_exceeded_handler)
 
 
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-ai-text-detector-v1.01",
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="oxidane-tmr-ai-text-detector",
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
- raw = await file.read()
 
 
 
 
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
- results = model.predict(texts)
 
 
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
- # Public-deployment allowlist first: a disabled model is rejected before
272
- # get_or_load_model can ever spend memory/bandwidth on its weights.
273
- for model_id in model_ids:
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
- # perf_counter is monotonic — the correct clock for measuring a duration.
 
 
 
 
299
  start = time.perf_counter()
300
- prediction = model.predict([req.text])[0]
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: allowlist (403) → known id (400) → detector-only
325
- (400), ALL before any weights load. Sentiment ids are refused here — mixing
326
- the two task families is the one thing this endpoint exists to prevent.
 
 
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([text])[0]
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 CompareRequest(AnalyzeRequest):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  # Omit model_ids to compare the default lineup (see routes.DEFAULT_COMPARE_MODELS).
93
- model_ids: list[str] | None = None
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(AnalyzeRequest):
118
  # Omit model_ids: /api/ai-detect uses the default detector; /api/ai-detect/compare
119
  # uses every detector (see routes).
120
- model_ids: list[str] | None = None
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-ai-text-detector-v1.01"
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-ai-text-detector-v1.01
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` — oxidane-tmr-ai-text-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
- # React + TypeScript + Vite
2
 
3
- This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
 
4
 
5
- Currently, two official plugins are available:
6
 
7
- - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
- - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
-
10
- ## React Compiler
11
-
12
- The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
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
- See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
 
 
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-ai-text-detector-v1.01",
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-ai-text-detector-v1.01",
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-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
 
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)}