File size: 8,851 Bytes
8392b7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
019b6f4
8392b7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
019b6f4
 
 
 
 
 
 
 
 
 
 
8392b7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""AI text detection endpoints (Task 19).

Unit tests (SKIP_MODEL_LOAD=1, no torch): the detector routes read models from
app.state.model_cache via get_or_load_model, so client_with_detectors pre-seeds
that cache with FakeDetector instances. Wiring, validation, and disagreement are
checked over HTTP only.

Integration tests (@pytest.mark.integration, real weights, `ai` conda env)
exercise the two output adapters end to end: desklib's custom single-logit
sigmoid head and the softmax detectors' canonical label mapping.
"""

import pytest

from app.model_registry import ModelTask, models_for_task
from app.routes import DETECTOR_WARNING

# The exact warning the product promises on every detector response. A literal
# copy here guards against an accidental reword drifting from the spec.
EXPECTED_WARNING = (
    "AI detectors are probabilistic and can be wrong, especially on short, "
    "edited, non-native, highly formal, or mixed-authorship text. "
    "Do not use this as proof of authorship."
)


def test_warning_constant_matches_spec():
    # Guard the source constant itself, not just the response, against a reword.
    assert DETECTOR_WARNING == EXPECTED_WARNING


# --- /api/ai-detect -------------------------------------------------------


def test_ai_detect_returns_result_and_warning(client_with_detectors):
    resp = client_with_detectors.post("/api/ai-detect", json={"text": "some text"})
    assert resp.status_code == 200
    body = resp.json()
    result = body["result"]
    assert result["model_id"] == "desklib-ai-detector"
    assert result["label"] in {"human", "ai"}
    assert set(result["scores"]) == {"human", "ai"}
    assert result["confidence"] == max(result["scores"].values())
    assert body["warning"] == EXPECTED_WARNING


def test_ai_detect_defaults_to_desklib(client_with_detectors):
    resp = client_with_detectors.post("/api/ai-detect", json={"text": "x"})
    assert resp.json()["result"]["model_id"] == "desklib-ai-detector"


def test_ai_detect_honors_explicit_detector(client_with_detectors):
    resp = client_with_detectors.post(
        "/api/ai-detect", json={"text": "x", "model_ids": ["fakespot-ai-detector"]}
    )
    assert resp.json()["result"]["model_id"] == "fakespot-ai-detector"


def test_ai_detect_row_carries_registry_metadata(client_with_detectors):
    result = client_with_detectors.post("/api/ai-detect", json={"text": "x"}).json()["result"]
    assert result["name"] == "desklib/ai-text-detector-v1.01"
    assert result["domain"]
    assert result["note"]
    assert isinstance(result["latency_ms"], (int, float))
    assert result["latency_ms"] >= 0


def test_ai_detect_rejects_sentiment_model(client_with_detectors):
    resp = client_with_detectors.post(
        "/api/ai-detect", json={"text": "x", "model_ids": ["twitter-roberta"]}
    )
    assert resp.status_code == 400
    assert "sentiment" in resp.json()["detail"].lower()


def test_ai_detect_rejects_unknown_model(client_with_detectors):
    resp = client_with_detectors.post(
        "/api/ai-detect", json={"text": "x", "model_ids": ["not-a-real-model"]}
    )
    assert resp.status_code == 400
    assert "not-a-real-model" in resp.json()["detail"]


def test_ai_detect_rejects_disabled_model(monkeypatch, client):
    # ENABLED_MODELS is the public-deploy allowlist. The guard runs BEFORE
    # get_or_load_model, so no seeded cache entry is needed.
    monkeypatch.setenv("ENABLED_MODELS", "oxidane-ai-detector")
    resp = client.post(
        "/api/ai-detect", json={"text": "x", "model_ids": ["desklib-ai-detector"]}
    )
    assert resp.status_code == 403
    assert "disabled" in resp.json()["detail"].lower()


# --- /api/ai-detect/compare ----------------------------------------------


def test_compare_defaults_to_all_detectors(client_with_detectors):
    resp = client_with_detectors.post("/api/ai-detect/compare", json={"text": "x"})
    assert resp.status_code == 200
    ids = {r["model_id"] for r in resp.json()["results"]}
    assert ids == set(models_for_task(ModelTask.AI_TEXT_DETECTION))


def test_compare_disagreement_true_when_labels_differ(client_with_detectors):
    # Seeded fakes: desklib+fakespot say "ai", oxidane says "human" → disagree.
    body = client_with_detectors.post("/api/ai-detect/compare", json={"text": "x"}).json()
    assert body["disagreement"] is True
    assert body["warning"] == EXPECTED_WARNING


def test_compare_disagreement_false_when_labels_agree(client_with_detectors):
    body = client_with_detectors.post(
        "/api/ai-detect/compare",
        json={"text": "x", "model_ids": ["desklib-ai-detector", "fakespot-ai-detector"]},
    ).json()
    assert [r["label"] for r in body["results"]] == ["ai", "ai"]
    assert body["disagreement"] is False


def test_compare_honors_explicit_model_ids(client_with_detectors):
    resp = client_with_detectors.post(
        "/api/ai-detect/compare",
        json={"text": "x", "model_ids": ["oxidane-ai-detector"]},
    )
    assert [r["model_id"] for r in resp.json()["results"]] == ["oxidane-ai-detector"]


def test_compare_rejects_sentiment_model(client_with_detectors):
    resp = client_with_detectors.post(
        "/api/ai-detect/compare", json={"text": "x", "model_ids": ["finbert"]}
    )
    assert resp.status_code == 400
    assert "sentiment" in resp.json()["detail"].lower()


def test_compare_dedupes_duplicate_detector_ids(client_with_detectors):
    # Same dedupe guard as /api/compare: a repeated detector id collapses to one
    # row, so one request can't queue the same detector many times over.
    resp = client_with_detectors.post(
        "/api/ai-detect/compare",
        json={"text": "x", "model_ids": ["desklib-ai-detector", "desklib-ai-detector"]},
    )
    assert resp.status_code == 200
    assert [r["model_id"] for r in resp.json()["results"]] == ["desklib-ai-detector"]


# --- cross-task rejection: detectors are refused by /api/compare ----------


def test_sentiment_compare_rejects_detector_model(client_with_detectors):
    resp = client_with_detectors.post(
        "/api/compare", json={"text": "x", "model_ids": ["desklib-ai-detector"]}
    )
    assert resp.status_code == 400
    assert "sentiment" in resp.json()["detail"].lower()


# --- integration: real weights, real adapters ----------------------------

# From the desklib model card — an obviously AI-ish paragraph vs a terser,
# human-ish note. Labels on borderline text are noisy, so integration tests
# assert shape (keys, sum≈1, valid label), not a specific verdict.
AI_TEXT = (
    "AI detection refers to the process of identifying whether a given piece of "
    "content, such as text, images, or audio, has been generated by artificial "
    "intelligence. This is achieved using various machine learning techniques, "
    "including perplexity analysis, entropy measurements, linguistic pattern "
    "recognition, and neural network classifiers trained on human and AI-generated "
    "data. Advanced AI detection tools assess writing style, coherence, and "
    "statistical properties to determine the likelihood of AI involvement."
)
HUMAN_TEXT = (
    "It is estimated that a major part of the content in the internet will be "
    "generated by AI / LLMs by 2025. This leads to a lot of misinformation and "
    "credibility related issues. That is why if is important to have accurate "
    "tools to identify if a content is AI generated or human written"
)


def _assert_detector_output(row: dict) -> None:
    assert set(row["scores"]) == {"human", "ai"}
    assert abs(sum(row["scores"].values()) - 1.0) < 0.01
    assert row["label"] in {"human", "ai"}


@pytest.mark.integration
def test_desklib_loads_via_custom_class_and_scores(capsys):
    from app.model import DetectorModel
    from app.model_registry import get_model_config

    m = DetectorModel(get_model_config("desklib-ai-detector"))
    m.load()
    out = m.predict([AI_TEXT, HUMAN_TEXT])
    for row in out:
        _assert_detector_output(row)
    with capsys.disabled():
        print(f"\n[desklib] P(ai) ai_text={out[0]['scores']['ai']} "
              f"human_text={out[1]['scores']['ai']}")


@pytest.mark.integration
@pytest.mark.parametrize("model_id", ["fakespot-ai-detector", "oxidane-ai-detector"])
def test_softmax_detectors_canonical_labels(model_id, capsys):
    from app.model import DetectorModel
    from app.model_registry import get_model_config

    m = DetectorModel(get_model_config(model_id))
    m.load()
    # Canonical labels were derived from the checkpoint's own id2label.
    assert set(m._softmax_labels) == {"human", "ai"}
    out = m.predict([AI_TEXT, HUMAN_TEXT])
    for row in out:
        _assert_detector_output(row)
    with capsys.disabled():
        print(f"\n[{model_id}] P(ai) ai_text={out[0]['scores']['ai']} "
              f"human_text={out[1]['scores']['ai']}")