aegis-integrity 3.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aegis/__init__.py +30 -0
- aegis/__main__.py +3 -0
- aegis/api/__init__.py +0 -0
- aegis/api/app.py +433 -0
- aegis/api/static/index.html +606 -0
- aegis/cli.py +638 -0
- aegis/core/__init__.py +0 -0
- aegis/core/document.py +449 -0
- aegis/core/pipeline.py +790 -0
- aegis/core/preprocessor.py +144 -0
- aegis/corpus/__init__.py +0 -0
- aegis/corpus/indexer.py +299 -0
- aegis/detectors/__init__.py +0 -0
- aegis/detectors/ai_detector.py +461 -0
- aegis/detectors/batch_analyzer.py +269 -0
- aegis/detectors/citation.py +626 -0
- aegis/detectors/citation_network.py +333 -0
- aegis/detectors/coherence_analyzer.py +302 -0
- aegis/detectors/grammar.py +397 -0
- aegis/detectors/math_formula.py +400 -0
- aegis/detectors/ngram.py +212 -0
- aegis/detectors/publisher_registry.py +209 -0
- aegis/detectors/self_plagiarism.py +374 -0
- aegis/detectors/semantic.py +180 -0
- aegis/detectors/stylometric.py +318 -0
- aegis/detectors/venue_verification.py +295 -0
- aegis/detectors/watermark_detector.py +371 -0
- aegis/doctor.py +218 -0
- aegis/guidelines/__init__.py +28 -0
- aegis/guidelines/checker.py +418 -0
- aegis/guidelines/profiles.py +282 -0
- aegis/mcp_server.py +260 -0
- aegis/paths.py +60 -0
- aegis/report/__init__.py +0 -0
- aegis/report/generator.py +1036 -0
- aegis_integrity-3.2.0.dist-info/METADATA +1133 -0
- aegis_integrity-3.2.0.dist-info/RECORD +41 -0
- aegis_integrity-3.2.0.dist-info/WHEEL +5 -0
- aegis_integrity-3.2.0.dist-info/entry_points.txt +3 -0
- aegis_integrity-3.2.0.dist-info/licenses/LICENSE +21 -0
- aegis_integrity-3.2.0.dist-info/top_level.txt +1 -0
aegis/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AEGIS -- Academic Integrity Engine with Generative-AI Scrutiny
|
|
3
|
+
|
|
4
|
+
An open-source academic integrity analysis tool that fills critical gaps
|
|
5
|
+
in existing plagiarism detection tools:
|
|
6
|
+
|
|
7
|
+
1. Citation integrity validation -- DOI resolution + hallucination detection
|
|
8
|
+
2. Language-calibrated AI detection -- bias-aware, ESL-safe scoring
|
|
9
|
+
3. Paragraph-level explainable similarity with source attribution
|
|
10
|
+
4. Stylometric authorship profiling integrated with plagiarism detection
|
|
11
|
+
5. Self-plagiarism cross-corpus comparison (offline, no Turnitin required)
|
|
12
|
+
6. LaTeX source file direct parsing (no PDF conversion artefacts)
|
|
13
|
+
7. Full offline operation -- no commercial API dependency
|
|
14
|
+
8. Structured JSON + HTML explainable reports (not black-box percentages)
|
|
15
|
+
9. Target-publisher verification -- venue-claim and duplicate-submission
|
|
16
|
+
checks scoped to IEEE, ACM, Elsevier, IET, IETE, and BCS via Crossref
|
|
17
|
+
10. Mathematical formula checking -- equation numbering, dangling/orphaned
|
|
18
|
+
reference detection, and notation-convention checks (offline, no ML)
|
|
19
|
+
11. Grammar & language convention checking -- contractions, US/UK spelling
|
|
20
|
+
consistency, subject/verb agreement, usage errors (offline, no ML)
|
|
21
|
+
12. Per-venue publisher guideline compliance -- IEEE, ACM, BCS, IET, ISACA,
|
|
22
|
+
and Elsevier checked SEPARATELY against each body's own sourced style
|
|
23
|
+
guidance, not one generic merged rule set
|
|
24
|
+
|
|
25
|
+
Authors: Sunil Gentyala, Rakesh Prakash, Akhila Kasturi
|
|
26
|
+
License: MIT
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__version__ = "3.2.0"
|
|
30
|
+
__author__ = "Sunil Gentyala"
|
aegis/__main__.py
ADDED
aegis/api/__init__.py
ADDED
|
File without changes
|
aegis/api/app.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AEGIS FastAPI REST interface.
|
|
3
|
+
|
|
4
|
+
Endpoints:
|
|
5
|
+
POST /analyze -- upload a file and run full analysis
|
|
6
|
+
POST /corpus/add -- add a document to the comparison corpus
|
|
7
|
+
POST /corpus/build -- (re)build all indices after adding documents
|
|
8
|
+
GET /corpus/summary -- list indexed documents
|
|
9
|
+
POST /compare -- direct pairwise comparison (no corpus needed)
|
|
10
|
+
POST /batch -- cross-document essay-mill / classroom analysis
|
|
11
|
+
GET /health -- liveness check (no auth required)
|
|
12
|
+
GET /status -- which checks can run here, and how to enable the rest
|
|
13
|
+
GET / -- browser UI (also opened by `aegis ui`)
|
|
14
|
+
|
|
15
|
+
All file uploads are handled via multipart/form-data.
|
|
16
|
+
Results are returned as JSON (or HTML when ?format=html is appended).
|
|
17
|
+
|
|
18
|
+
Security:
|
|
19
|
+
Set AEGIS_API_KEY to require an `X-API-Key` header on every route except
|
|
20
|
+
/health. If unset, the API runs without authentication (a warning is
|
|
21
|
+
logged at startup) -- suitable only for a trusted local/private network.
|
|
22
|
+
Uploads are capped at AEGIS_MAX_UPLOAD_MB (default 50MB). Expensive
|
|
23
|
+
analysis routes are limited to AEGIS_MAX_CONCURRENT_JOBS (default 2)
|
|
24
|
+
concurrent requests; additional requests get 503 rather than piling up
|
|
25
|
+
and exhausting memory.
|
|
26
|
+
|
|
27
|
+
Run with:
|
|
28
|
+
uvicorn aegis.api.app:app --host 127.0.0.1 --port 8000
|
|
29
|
+
|
|
30
|
+
Binding to 0.0.0.0 (e.g. inside Docker) exposes the API to the network --
|
|
31
|
+
set AEGIS_API_KEY when doing so.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
import os
|
|
36
|
+
import hmac
|
|
37
|
+
import asyncio
|
|
38
|
+
import tempfile
|
|
39
|
+
import logging
|
|
40
|
+
from contextlib import asynccontextmanager
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Optional
|
|
43
|
+
|
|
44
|
+
from fastapi import FastAPI, File, UploadFile, Form, Header, HTTPException, Query, Depends
|
|
45
|
+
from fastapi.concurrency import run_in_threadpool
|
|
46
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
47
|
+
|
|
48
|
+
from aegis import __version__ as AEGIS_VERSION
|
|
49
|
+
from aegis.core.pipeline import AEGISPipeline, PipelineConfig
|
|
50
|
+
from aegis.corpus.indexer import CorpusIndexer
|
|
51
|
+
from aegis.paths import default_index_dir, default_report_dir, load_env_file
|
|
52
|
+
from aegis.report.generator import ReportGenerator
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Initialisation
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
load_env_file()
|
|
61
|
+
INDEX_DIR = str(default_index_dir())
|
|
62
|
+
REPORT_DIR = str(default_report_dir())
|
|
63
|
+
DEVICE = os.environ.get("AEGIS_DEVICE", "cpu")
|
|
64
|
+
CITATION_EMAIL = os.environ.get("AEGIS_CITATION_EMAIL", "aegis-check@example.com")
|
|
65
|
+
API_KEY = os.environ.get("AEGIS_API_KEY")
|
|
66
|
+
MAX_UPLOAD_BYTES = int(os.environ.get("AEGIS_MAX_UPLOAD_MB", "50")) * 1024 * 1024
|
|
67
|
+
MAX_CONCURRENT_JOBS = int(os.environ.get("AEGIS_MAX_CONCURRENT_JOBS", "2"))
|
|
68
|
+
|
|
69
|
+
if not API_KEY:
|
|
70
|
+
logger.warning(
|
|
71
|
+
"AEGIS_API_KEY is not set -- the API is running WITHOUT authentication. "
|
|
72
|
+
"Only expose this to a trusted network, or set AEGIS_API_KEY."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_indexer = CorpusIndexer(INDEX_DIR, device=DEVICE)
|
|
76
|
+
_pipeline: Optional[AEGISPipeline] = None
|
|
77
|
+
_reporter = ReportGenerator(REPORT_DIR)
|
|
78
|
+
_job_semaphore = asyncio.Semaphore(MAX_CONCURRENT_JOBS)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_pipeline() -> AEGISPipeline:
|
|
82
|
+
global _pipeline
|
|
83
|
+
if _pipeline is None:
|
|
84
|
+
cfg = PipelineConfig(
|
|
85
|
+
device=DEVICE,
|
|
86
|
+
citation_email=CITATION_EMAIL,
|
|
87
|
+
)
|
|
88
|
+
_pipeline = AEGISPipeline(config=cfg)
|
|
89
|
+
return _pipeline
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def require_api_key(x_api_key: Optional[str] = Header(None)) -> None:
|
|
93
|
+
"""FastAPI dependency: no-op if AEGIS_API_KEY is unset (auth disabled);
|
|
94
|
+
otherwise requires a matching X-API-Key header on every protected route.
|
|
95
|
+
|
|
96
|
+
Uses hmac.compare_digest rather than `!=` so a mismatching header takes
|
|
97
|
+
the same amount of time to reject regardless of how many leading
|
|
98
|
+
characters match, closing a timing side-channel that would otherwise
|
|
99
|
+
let an attacker recover the key one character at a time."""
|
|
100
|
+
if not API_KEY:
|
|
101
|
+
return
|
|
102
|
+
if not x_api_key or not hmac.compare_digest(x_api_key, API_KEY):
|
|
103
|
+
raise HTTPException(status_code=401, detail="Missing or invalid X-API-Key header")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@asynccontextmanager
|
|
107
|
+
async def _job_slot():
|
|
108
|
+
"""Bound the number of concurrent expensive analysis jobs so unbounded
|
|
109
|
+
parallel uploads can't exhaust memory/CPU. Returns 503 when full rather
|
|
110
|
+
than queuing indefinitely."""
|
|
111
|
+
if _job_semaphore.locked():
|
|
112
|
+
raise HTTPException(
|
|
113
|
+
status_code=503,
|
|
114
|
+
detail=f"Server busy: {MAX_CONCURRENT_JOBS} analysis job(s) already running")
|
|
115
|
+
await _job_semaphore.acquire()
|
|
116
|
+
try:
|
|
117
|
+
yield
|
|
118
|
+
finally:
|
|
119
|
+
_job_semaphore.release()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _read_upload_capped(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
|
|
123
|
+
"""Read an upload in chunks, rejecting it as soon as it exceeds max_bytes
|
|
124
|
+
instead of buffering an unbounded amount of attacker-controlled data."""
|
|
125
|
+
chunks = []
|
|
126
|
+
total = 0
|
|
127
|
+
chunk_size = 1024 * 1024
|
|
128
|
+
while True:
|
|
129
|
+
chunk = await file.read(chunk_size)
|
|
130
|
+
if not chunk:
|
|
131
|
+
break
|
|
132
|
+
total += len(chunk)
|
|
133
|
+
if total > max_bytes:
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status_code=413,
|
|
136
|
+
detail=f"Upload exceeds the {max_bytes // (1024 * 1024)}MB limit")
|
|
137
|
+
chunks.append(chunk)
|
|
138
|
+
return b"".join(chunks)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
app = FastAPI(
|
|
142
|
+
title="AEGIS Academic Integrity Checker",
|
|
143
|
+
description=(
|
|
144
|
+
"Open-source plagiarism, AI content, citation integrity, "
|
|
145
|
+
"stylometric, and self-plagiarism analysis for academic submissions."
|
|
146
|
+
),
|
|
147
|
+
version=AEGIS_VERSION,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Endpoints
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_STATIC_DIR = Path(__file__).parent / "static"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.get("/", include_in_schema=False)
|
|
159
|
+
def web_app():
|
|
160
|
+
"""The browser UI (single self-contained page, no external assets)."""
|
|
161
|
+
return FileResponse(_STATIC_DIR / "index.html", media_type="text/html")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.get("/health")
|
|
165
|
+
def health():
|
|
166
|
+
return {"status": "ok", "version": AEGIS_VERSION, "auth_required": bool(API_KEY)}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.get("/status", dependencies=[Depends(require_api_key)])
|
|
170
|
+
def status(offline: bool = Query(False, description="Skip the Crossref connectivity test")):
|
|
171
|
+
"""Which checks can run on this server, and how to enable the rest."""
|
|
172
|
+
from aegis import doctor
|
|
173
|
+
checks = doctor.run_checks(offline=offline)
|
|
174
|
+
return {"version": AEGIS_VERSION, "summary": doctor.summary_line(checks),
|
|
175
|
+
"checks": doctor.as_dicts(checks)}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.get("/corpus/summary", dependencies=[Depends(require_api_key)])
|
|
179
|
+
def corpus_summary():
|
|
180
|
+
return _indexer.corpus_summary()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.post("/corpus/add", dependencies=[Depends(require_api_key)])
|
|
184
|
+
async def corpus_add(
|
|
185
|
+
file: UploadFile = File(...),
|
|
186
|
+
label: Optional[str] = Form(None),
|
|
187
|
+
):
|
|
188
|
+
"""
|
|
189
|
+
Add a document to the comparison corpus.
|
|
190
|
+
Call /corpus/build after adding all documents to update the search indices.
|
|
191
|
+
"""
|
|
192
|
+
suffix = Path(file.filename).suffix if file.filename else ".bin"
|
|
193
|
+
content = await _read_upload_capped(file)
|
|
194
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
195
|
+
tmp.write(content)
|
|
196
|
+
tmp_path = tmp.name
|
|
197
|
+
try:
|
|
198
|
+
assigned_label = await run_in_threadpool(_indexer.add_document, tmp_path, label=label)
|
|
199
|
+
return {"status": "added", "label": assigned_label}
|
|
200
|
+
finally:
|
|
201
|
+
os.unlink(tmp_path)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.post("/corpus/build", dependencies=[Depends(require_api_key)])
|
|
205
|
+
def corpus_build(
|
|
206
|
+
num_perm: int = Query(128, description="MinHash permutations"),
|
|
207
|
+
word_threshold: float = Query(0.25),
|
|
208
|
+
char_threshold: float = Query(0.40),
|
|
209
|
+
):
|
|
210
|
+
"""Rebuild all search indices from the current corpus."""
|
|
211
|
+
_indexer.build_indices(
|
|
212
|
+
num_perm=num_perm,
|
|
213
|
+
word_threshold=word_threshold,
|
|
214
|
+
char_threshold=char_threshold,
|
|
215
|
+
)
|
|
216
|
+
# Reload pipeline so it picks up the new indices
|
|
217
|
+
global _pipeline
|
|
218
|
+
_pipeline = None
|
|
219
|
+
return {"status": "built", "summary": _indexer.corpus_summary()}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.post("/analyze", dependencies=[Depends(require_api_key)])
|
|
223
|
+
async def analyze(
|
|
224
|
+
file: UploadFile = File(...),
|
|
225
|
+
format: str = Query("json", description="Output format: json | html"),
|
|
226
|
+
run_ai: bool = Query(True),
|
|
227
|
+
run_citations: bool = Query(True),
|
|
228
|
+
run_semantic: bool = Query(True),
|
|
229
|
+
run_stylometric: bool = Query(True),
|
|
230
|
+
run_self_plagiarism: bool = Query(True),
|
|
231
|
+
run_watermark: bool = Query(True),
|
|
232
|
+
run_coherence: bool = Query(True),
|
|
233
|
+
run_venue_check: bool = Query(True),
|
|
234
|
+
run_citation_network: bool = Query(True),
|
|
235
|
+
run_math: bool = Query(True),
|
|
236
|
+
run_grammar: bool = Query(True),
|
|
237
|
+
offline: bool = Query(False, description="Make no network calls at all "
|
|
238
|
+
"(disables Crossref/OpenAlex lookups)"),
|
|
239
|
+
guidelines: str = Query("", description="Comma-separated IEEE,ACM,BCS,IET,ISACA,ELSEVIER or 'all'"),
|
|
240
|
+
include_html: bool = Query(False, description="Also return the full HTML report in the JSON"),
|
|
241
|
+
prior_works: Optional[str] = Form(
|
|
242
|
+
None,
|
|
243
|
+
description="JSON list of prior-work texts: [[label, text], ...]",
|
|
244
|
+
),
|
|
245
|
+
):
|
|
246
|
+
"""
|
|
247
|
+
Run the full AEGIS analysis on an uploaded document.
|
|
248
|
+
|
|
249
|
+
Returns JSON by default; use ?format=html for a browser-viewable report,
|
|
250
|
+
or ?include_html=true to get both in one call.
|
|
251
|
+
"""
|
|
252
|
+
import json as _json
|
|
253
|
+
from aegis.guidelines.profiles import DEFAULT_GUIDELINE_VENUES
|
|
254
|
+
|
|
255
|
+
if guidelines.strip().lower() == "all":
|
|
256
|
+
guideline_venues = tuple(DEFAULT_GUIDELINE_VENUES)
|
|
257
|
+
else:
|
|
258
|
+
guideline_venues = tuple(v.strip().upper() for v in guidelines.split(",") if v.strip())
|
|
259
|
+
|
|
260
|
+
suffix = Path(file.filename).suffix if file.filename else ".bin"
|
|
261
|
+
content = await _read_upload_capped(file)
|
|
262
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
263
|
+
tmp.write(content)
|
|
264
|
+
tmp_path = tmp.name
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
async with _job_slot():
|
|
268
|
+
cfg = PipelineConfig(
|
|
269
|
+
device=DEVICE,
|
|
270
|
+
citation_email=CITATION_EMAIL,
|
|
271
|
+
run_ai_detector=run_ai,
|
|
272
|
+
run_citation_check=run_citations,
|
|
273
|
+
run_semantic=run_semantic,
|
|
274
|
+
run_stylometric=run_stylometric,
|
|
275
|
+
run_self_plagiarism=run_self_plagiarism,
|
|
276
|
+
run_watermark_detector=run_watermark,
|
|
277
|
+
run_coherence_analyzer=run_coherence,
|
|
278
|
+
run_venue_verification=run_venue_check and not offline,
|
|
279
|
+
run_citation_network=run_citation_network,
|
|
280
|
+
run_math_check=run_math,
|
|
281
|
+
run_grammar_check=run_grammar,
|
|
282
|
+
citation_offline=offline,
|
|
283
|
+
citation_network_offline=offline,
|
|
284
|
+
venue_offline=offline,
|
|
285
|
+
guideline_venues=guideline_venues,
|
|
286
|
+
)
|
|
287
|
+
pipeline = AEGISPipeline(config=cfg)
|
|
288
|
+
|
|
289
|
+
# Load corpus from persisted indices if available
|
|
290
|
+
try:
|
|
291
|
+
ngram_det = _indexer.load_ngram_detector()
|
|
292
|
+
pipeline._ngram = ngram_det
|
|
293
|
+
pipeline._corpus_loaded = True
|
|
294
|
+
except FileNotFoundError:
|
|
295
|
+
pass # No corpus indexed yet; proceed without
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
sem_det = _indexer.load_semantic_detector()
|
|
299
|
+
pipeline._semantic = sem_det
|
|
300
|
+
except (FileNotFoundError, ImportError):
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
# Load prior works if supplied
|
|
304
|
+
if prior_works and pipeline._self_plag:
|
|
305
|
+
try:
|
|
306
|
+
works = _json.loads(prior_works)
|
|
307
|
+
pipeline.load_prior_works(works)
|
|
308
|
+
except Exception as exc:
|
|
309
|
+
logger.warning("Could not parse prior_works: %s", exc)
|
|
310
|
+
|
|
311
|
+
# The pipeline is CPU-bound and synchronous; run it off the event
|
|
312
|
+
# loop so /health and the web UI stay responsive during a scan.
|
|
313
|
+
report = await run_in_threadpool(pipeline.analyze, tmp_path)
|
|
314
|
+
report.submission_path = file.filename or report.submission_path
|
|
315
|
+
|
|
316
|
+
finally:
|
|
317
|
+
os.unlink(tmp_path)
|
|
318
|
+
|
|
319
|
+
if format == "html":
|
|
320
|
+
html_path = _reporter.generate_html(report)
|
|
321
|
+
with open(html_path, encoding="utf-8") as f:
|
|
322
|
+
return HTMLResponse(content=f.read())
|
|
323
|
+
|
|
324
|
+
data = _reporter._report_to_dict(report)
|
|
325
|
+
if include_html:
|
|
326
|
+
html_path = _reporter.generate_html(report)
|
|
327
|
+
data["report_html"] = Path(html_path).read_text(encoding="utf-8")
|
|
328
|
+
data["report_path"] = html_path
|
|
329
|
+
return JSONResponse(data)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@app.post("/compare", dependencies=[Depends(require_api_key)])
|
|
333
|
+
async def compare_pair(
|
|
334
|
+
file_a: UploadFile = File(..., description="First document"),
|
|
335
|
+
file_b: UploadFile = File(..., description="Second document (e.g. prior work)"),
|
|
336
|
+
label_a: str = Form("document_a"),
|
|
337
|
+
label_b: str = Form("document_b"),
|
|
338
|
+
):
|
|
339
|
+
"""
|
|
340
|
+
Direct pairwise comparison: detects self-plagiarism between two documents
|
|
341
|
+
without needing a pre-built corpus.
|
|
342
|
+
"""
|
|
343
|
+
from aegis.detectors.self_plagiarism import SelfPlagiarismDetector
|
|
344
|
+
from aegis.core.document import DocumentParser
|
|
345
|
+
|
|
346
|
+
parser = DocumentParser()
|
|
347
|
+
|
|
348
|
+
tmp_paths = []
|
|
349
|
+
try:
|
|
350
|
+
for upload_file in (file_a, file_b):
|
|
351
|
+
suffix = Path(upload_file.filename).suffix if upload_file.filename else ".bin"
|
|
352
|
+
content = await _read_upload_capped(upload_file)
|
|
353
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
354
|
+
tmp.write(content)
|
|
355
|
+
tmp_paths.append(tmp.name)
|
|
356
|
+
|
|
357
|
+
text_a = (await run_in_threadpool(parser.parse, tmp_paths[0])).full_text
|
|
358
|
+
text_b = (await run_in_threadpool(parser.parse, tmp_paths[1])).full_text
|
|
359
|
+
finally:
|
|
360
|
+
for p in tmp_paths:
|
|
361
|
+
if os.path.exists(p):
|
|
362
|
+
os.unlink(p)
|
|
363
|
+
|
|
364
|
+
detector = SelfPlagiarismDetector(use_sbert=False)
|
|
365
|
+
result = await run_in_threadpool(detector.compare_documents, text_a, label_a, text_b, label_b)
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
"label_a": label_a,
|
|
369
|
+
"label_b": label_b,
|
|
370
|
+
"overall_overlap_pct": result.overall_overlap_pct,
|
|
371
|
+
"risk_level": result.risk_level,
|
|
372
|
+
"cope_guidance": result.cope_guidance,
|
|
373
|
+
"flags": result.flags,
|
|
374
|
+
"top_passages": [
|
|
375
|
+
{
|
|
376
|
+
"type": p.overlap_type,
|
|
377
|
+
"char_jaccard": p.char_jaccard,
|
|
378
|
+
"word_jaccard": p.word_jaccard,
|
|
379
|
+
"text_a": p.submission_text[:200],
|
|
380
|
+
"text_b": p.source_text[:200],
|
|
381
|
+
}
|
|
382
|
+
for p in result.recycled_passages[:10]
|
|
383
|
+
],
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@app.post("/batch", dependencies=[Depends(require_api_key)])
|
|
388
|
+
async def batch(
|
|
389
|
+
files: list[UploadFile] = File(..., description="At least 2 submissions"),
|
|
390
|
+
run_ai: bool = Query(True, description="Score each document for AI content "
|
|
391
|
+
"(enables the high-AI-score cluster signal)"),
|
|
392
|
+
):
|
|
393
|
+
"""
|
|
394
|
+
Cross-document essay-mill / classroom analysis: detects near-duplicate
|
|
395
|
+
submissions, shared section structure, and AI-score clustering across
|
|
396
|
+
a set of documents that would each look fine analyzed individually.
|
|
397
|
+
"""
|
|
398
|
+
from dataclasses import asdict
|
|
399
|
+
from aegis.core.document import DocumentParser
|
|
400
|
+
from aegis.detectors.batch_analyzer import BatchAnalyzer
|
|
401
|
+
|
|
402
|
+
if len(files) < 2:
|
|
403
|
+
raise HTTPException(status_code=400, detail="Need at least 2 files for batch analysis")
|
|
404
|
+
|
|
405
|
+
parser = DocumentParser()
|
|
406
|
+
tmp_paths = []
|
|
407
|
+
doc_names, doc_texts = [], []
|
|
408
|
+
try:
|
|
409
|
+
for f in files:
|
|
410
|
+
suffix = Path(f.filename).suffix if f.filename else ".bin"
|
|
411
|
+
content = await _read_upload_capped(f)
|
|
412
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
413
|
+
tmp.write(content)
|
|
414
|
+
tmp_paths.append(tmp.name)
|
|
415
|
+
doc_names.append(Path(f.filename).stem if f.filename else Path(tmp_paths[-1]).stem)
|
|
416
|
+
doc_texts.append(parser.parse(tmp_paths[-1]).full_text)
|
|
417
|
+
finally:
|
|
418
|
+
for p in tmp_paths:
|
|
419
|
+
if os.path.exists(p):
|
|
420
|
+
os.unlink(p)
|
|
421
|
+
|
|
422
|
+
ai_scores = None
|
|
423
|
+
if run_ai:
|
|
424
|
+
try:
|
|
425
|
+
async with _job_slot():
|
|
426
|
+
from aegis.detectors.ai_detector import AIContentDetector
|
|
427
|
+
det = AIContentDetector(device=DEVICE)
|
|
428
|
+
ai_scores = [det.detect(t).document_ensemble_score for t in doc_texts]
|
|
429
|
+
except ImportError:
|
|
430
|
+
logger.warning("transformers/torch not installed; skipping AI-score clustering")
|
|
431
|
+
|
|
432
|
+
result = BatchAnalyzer().analyze(doc_names, doc_texts, ai_scores=ai_scores)
|
|
433
|
+
return asdict(result)
|