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.
Files changed (41) hide show
  1. aegis/__init__.py +30 -0
  2. aegis/__main__.py +3 -0
  3. aegis/api/__init__.py +0 -0
  4. aegis/api/app.py +433 -0
  5. aegis/api/static/index.html +606 -0
  6. aegis/cli.py +638 -0
  7. aegis/core/__init__.py +0 -0
  8. aegis/core/document.py +449 -0
  9. aegis/core/pipeline.py +790 -0
  10. aegis/core/preprocessor.py +144 -0
  11. aegis/corpus/__init__.py +0 -0
  12. aegis/corpus/indexer.py +299 -0
  13. aegis/detectors/__init__.py +0 -0
  14. aegis/detectors/ai_detector.py +461 -0
  15. aegis/detectors/batch_analyzer.py +269 -0
  16. aegis/detectors/citation.py +626 -0
  17. aegis/detectors/citation_network.py +333 -0
  18. aegis/detectors/coherence_analyzer.py +302 -0
  19. aegis/detectors/grammar.py +397 -0
  20. aegis/detectors/math_formula.py +400 -0
  21. aegis/detectors/ngram.py +212 -0
  22. aegis/detectors/publisher_registry.py +209 -0
  23. aegis/detectors/self_plagiarism.py +374 -0
  24. aegis/detectors/semantic.py +180 -0
  25. aegis/detectors/stylometric.py +318 -0
  26. aegis/detectors/venue_verification.py +295 -0
  27. aegis/detectors/watermark_detector.py +371 -0
  28. aegis/doctor.py +218 -0
  29. aegis/guidelines/__init__.py +28 -0
  30. aegis/guidelines/checker.py +418 -0
  31. aegis/guidelines/profiles.py +282 -0
  32. aegis/mcp_server.py +260 -0
  33. aegis/paths.py +60 -0
  34. aegis/report/__init__.py +0 -0
  35. aegis/report/generator.py +1036 -0
  36. aegis_integrity-3.2.0.dist-info/METADATA +1133 -0
  37. aegis_integrity-3.2.0.dist-info/RECORD +41 -0
  38. aegis_integrity-3.2.0.dist-info/WHEEL +5 -0
  39. aegis_integrity-3.2.0.dist-info/entry_points.txt +3 -0
  40. aegis_integrity-3.2.0.dist-info/licenses/LICENSE +21 -0
  41. 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
@@ -0,0 +1,3 @@
1
+ from aegis.cli import cli
2
+
3
+ cli()
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)