mokshad commited on
Commit
e945892
·
0 Parent(s):

Initial deploy — AI text detector API

Browse files
.dockerignore ADDED
File without changes
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .env
6
+ *.egg-info/
README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AIdetector
3
+ emoji: 🏃
4
+ colorFrom: red
5
+ colorTo: pink
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ ---
10
+
11
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
api/__init__.py ADDED
File without changes
api/main.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # api/main.py
2
+ # Application entry point.
3
+ # Handles startup (model loading), CORS, and router registration.
4
+
5
+ import logging
6
+ from contextlib import asynccontextmanager
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from api.routes import router
10
+ from core.bert_scorer import bert_scorer
11
+ from config.settings import settings
12
+ from core.perplexity_scorer import perplexity_scorer
13
+
14
+ # Configure logging format
15
+ logging.basicConfig(
16
+ level=logging.INFO,
17
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
18
+ )
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @asynccontextmanager
23
+ async def lifespan(app: FastAPI):
24
+ """
25
+ Lifespan context manager — runs setup before the app starts
26
+ accepting requests, and cleanup when it shuts down.
27
+
28
+ We load the model HERE, not on first request, so the first
29
+ user doesn't wait 10 seconds for a cold start.
30
+ """
31
+ logger.info("Starting up — loading model...")
32
+ bert_scorer.load()
33
+ perplexity_scorer.load()
34
+ logger.info("Model ready. API is live.")
35
+
36
+ yield # App runs here
37
+
38
+ # Shutdown cleanup (if needed in future)
39
+ logger.info("Shutting down.")
40
+
41
+
42
+ # Create FastAPI app
43
+ app = FastAPI(
44
+ title="AI Text Detector API",
45
+ description=(
46
+ "Detects AI-generated text using a hybrid approach: "
47
+ "BERT-based neural classification + stylometric feature analysis. "
48
+ "Output is a calibrated probability — NOT a definitive verdict."
49
+ ),
50
+ version="1.0.0",
51
+ lifespan=lifespan,
52
+ )
53
+
54
+ # CORS — allow frontend to call this API
55
+ app.add_middleware(
56
+ CORSMiddleware,
57
+ allow_origins=settings.ALLOWED_ORIGINS,
58
+ allow_credentials=True,
59
+ allow_methods=["*"],
60
+ allow_headers=["*"],
61
+ )
62
+
63
+ # Register routes under /api prefix
64
+ app.include_router(router, prefix="/api")
65
+
66
+
67
+ # Root endpoint
68
+ @app.get("/")
69
+ async def root():
70
+ return {
71
+ "name": "AI Text Detector API",
72
+ "version": "1.0.0",
73
+ "docs": "/docs",
74
+ "health": "/api/health",
75
+ }
api/routes.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # api/routes.py
2
+ # API endpoints — thin layer that validates input, calls core logic,
3
+ # returns structured responses. No business logic lives here.
4
+
5
+ import logging
6
+ from fastapi import APIRouter, HTTPException
7
+ from api.schemas import (
8
+ AnalyzeRequest, AnalyzeResponse,
9
+ BatchAnalyzeRequest, BatchAnalyzeResponse,
10
+ HealthResponse,
11
+ )
12
+ from core.preprocessor import preprocess, split_sentences
13
+ from core.bert_scorer import bert_scorer
14
+ from core.aggregator import aggregate_scores
15
+ from config.settings import settings
16
+
17
+ # Set up logging — every request is logged for debugging
18
+ logger = logging.getLogger(__name__)
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ @router.get("/health", response_model=HealthResponse)
24
+ async def health_check():
25
+ """
26
+ Health check endpoint.
27
+ Render/Railway ping this to know if the service is alive.
28
+ """
29
+ return HealthResponse(
30
+ status="ok",
31
+ model_loaded=bert_scorer._pipeline is not None,
32
+ model_id=settings.MODEL_ID,
33
+ environment=settings.ENVIRONMENT,
34
+ )
35
+
36
+
37
+ @router.post("/analyze", response_model=AnalyzeResponse)
38
+ async def analyze_text(request: AnalyzeRequest):
39
+ """
40
+ Main endpoint — analyze a single text for AI authorship.
41
+
42
+ Steps:
43
+ 1. Clean and validate input
44
+ 2. Score full text with BERT
45
+ 3. Score each sentence individually
46
+ 4. Compute stylometric features
47
+ 5. Aggregate into final score + explanation
48
+ """
49
+ try:
50
+ # Step 1: Clean text
51
+ cleaned_text = preprocess(request.text)
52
+ logger.info(f"Analyzing text of length {len(cleaned_text)}")
53
+
54
+ # Step 2: BERT score on full text
55
+ bert_result = bert_scorer.score_text(cleaned_text)
56
+
57
+ # Step 3: Sentence-level scores (for highlighting)
58
+ sentences = split_sentences(
59
+ cleaned_text,
60
+ min_length=settings.MIN_SENTENCE_LENGTH,
61
+ )
62
+ # Cap sentences to avoid slow responses on very long texts
63
+ sentences = sentences[:settings.MAX_SENTENCES]
64
+ sentence_scores = bert_scorer.score_sentences(sentences)
65
+
66
+ # Step 4 + 5: Aggregate everything
67
+ result = aggregate_scores(
68
+ bert_result=bert_result,
69
+ text=cleaned_text,
70
+ sentence_scores=sentence_scores,
71
+ )
72
+
73
+ # Add metadata
74
+ result["word_count"] = len(cleaned_text.split())
75
+ result["character_count"] = len(cleaned_text)
76
+
77
+ return AnalyzeResponse(**result)
78
+
79
+ except ValueError as e:
80
+ # Input validation errors → 400
81
+ raise HTTPException(status_code=400, detail=str(e))
82
+
83
+ except Exception as e:
84
+ # Unexpected errors → 500 with logging
85
+ logger.error(f"Analysis failed: {e}", exc_info=True)
86
+ raise HTTPException(
87
+ status_code=500,
88
+ detail="Analysis failed. Please try again."
89
+ )
90
+
91
+
92
+ @router.post("/batch", response_model=BatchAnalyzeResponse)
93
+ async def batch_analyze(request: BatchAnalyzeRequest):
94
+ """
95
+ Batch endpoint — analyze multiple texts in one call.
96
+ Max 20 texts per request to prevent timeout on free tier hosting.
97
+ """
98
+ results = []
99
+
100
+ for text in request.texts:
101
+ try:
102
+ cleaned = preprocess(text)
103
+ bert_result = bert_scorer.score_text(cleaned)
104
+ sentences = split_sentences(cleaned, min_length=settings.MIN_SENTENCE_LENGTH)
105
+ sentences = sentences[:settings.MAX_SENTENCES]
106
+ sentence_scores = bert_scorer.score_sentences(sentences)
107
+ result = aggregate_scores(
108
+ bert_result=bert_result,
109
+ text=cleaned,
110
+ sentence_scores=sentence_scores,
111
+ )
112
+ result["word_count"] = len(cleaned.split())
113
+ result["character_count"] = len(cleaned)
114
+ results.append(AnalyzeResponse(**result))
115
+
116
+ except Exception as e:
117
+ logger.error(f"Batch item failed: {e}")
118
+ # Skip failed items — don't crash entire batch
119
+ continue
120
+
121
+ return BatchAnalyzeResponse(results=results, total=len(results))
api/schemas.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # api/schemas.py
2
+ # Pydantic models define the exact shape of every request and response.
3
+ # This gives us automatic validation + auto-generated API docs for free.
4
+
5
+ from pydantic import BaseModel, Field, field_validator
6
+ from typing import Optional
7
+
8
+
9
+ class AnalyzeRequest(BaseModel):
10
+ text: str = Field(
11
+ ...,
12
+ min_length=20,
13
+ max_length=50000,
14
+ description="Text to analyze for AI authorship",
15
+ )
16
+
17
+ @field_validator("text")
18
+ @classmethod
19
+ def text_must_not_be_empty(cls, v):
20
+ if not v.strip():
21
+ raise ValueError("Text cannot be empty or whitespace only.")
22
+ return v
23
+
24
+
25
+ class BatchAnalyzeRequest(BaseModel):
26
+ texts: list[str] = Field(
27
+ ...,
28
+ min_length=1,
29
+ max_length=20,
30
+ description="List of texts to analyze (max 20)",
31
+ )
32
+
33
+
34
+ class SentenceScore(BaseModel):
35
+ sentence: str
36
+ ai_probability: float
37
+ reliable: bool
38
+
39
+
40
+ class ExplanationReason(BaseModel):
41
+ signal: str
42
+ detail: str
43
+ weight: str # "high", "medium", "low"
44
+
45
+
46
+ class Explanation(BaseModel):
47
+ verdict: str
48
+ ai_probability: float
49
+ reasons: list[ExplanationReason]
50
+ top_suspicious_sentences: list[SentenceScore]
51
+ uncertainty_note: str
52
+
53
+
54
+ class ScoreBreakdown(BaseModel):
55
+ bert_score: float
56
+ stylometric_score: float
57
+ bert_weight: float
58
+ stylometric_weight: float
59
+
60
+
61
+ class AnalyzeResponse(BaseModel):
62
+ ai_probability: float = Field(
63
+ description="Final calibrated probability (0-1) that text is AI-generated"
64
+ )
65
+ is_ai: bool = Field(
66
+ description="True if probability exceeds confidence threshold"
67
+ )
68
+ verdict: str = Field(
69
+ description="Human-readable classification label"
70
+ )
71
+ confidence_threshold: float
72
+ scores: ScoreBreakdown
73
+ stylometric_features: dict
74
+ sentence_scores: list[SentenceScore]
75
+ explanation: Explanation
76
+ chunks_analyzed: int
77
+ word_count: int
78
+ character_count: int
79
+
80
+
81
+ class BatchAnalyzeResponse(BaseModel):
82
+ results: list[AnalyzeResponse]
83
+ total: int
84
+
85
+
86
+ class HealthResponse(BaseModel):
87
+ model_config = {"protected_namespaces": ()} # ← add this line
88
+
89
+ status: str
90
+ model_loaded: bool
91
+ model_id: str
92
+ environment: str
config/__init__.py ADDED
File without changes
config/settings.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from dotenv import load_dotenv
3
+ import os
4
+
5
+ load_dotenv(Path(__file__).parent.parent / ".env")
6
+
7
+ class Settings:
8
+ MODEL_ID: str = os.getenv("MODEL_ID", "gouwsxander/slop-detector-bert")
9
+ MAX_TOKENS: int = int(os.getenv("MAX_TOKENS", 500))
10
+ CONFIDENCE_THRESHOLD: float = 0.68
11
+ MIN_SENTENCE_LENGTH: int = 20
12
+ MAX_SENTENCES: int = 40
13
+ TEMPERATURE: float = 2.5 # ← make sure this is 2.5
14
+ ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
15
+ ALLOWED_ORIGINS: list = [
16
+ "http://localhost:3000",
17
+ "http://localhost:5500",
18
+ "http://127.0.0.1:5500",
19
+ "http://localhost:5501",
20
+ "http://127.0.0.1:5501",
21
+ "https://*.vercel.app",
22
+ "https://*.hf.space",
23
+ "https://huggingface.co",
24
+ ]
25
+ settings = Settings()
core/__init__.py ADDED
File without changes
core/aggregator.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/aggregator.py
2
+ # Combines BERT score + stylometric score into a final probability.
3
+
4
+ from core.stylometrics import compute_stylometric_score
5
+ from core.explainer import find_ai_phrases, generate_explanation
6
+ from config.settings import settings
7
+
8
+
9
+ def aggregate_scores(
10
+ bert_result: dict,
11
+ text: str,
12
+ sentence_scores: list[dict],
13
+ ) -> dict:
14
+ """
15
+ Two-signal hybrid:
16
+ - BERT (70%): neural, trained on Wikipedia pairs
17
+ - Stylometrics (30%): model-agnostic structural features
18
+ """
19
+ stylo_result = compute_stylometric_score(text)
20
+ stylo_score = stylo_result["stylometric_ai_score"]
21
+
22
+ bert_prob = bert_result["ai_probability"]
23
+
24
+ bert_weight = 0.70
25
+ stylo_weight = 0.30
26
+
27
+ final_probability = (
28
+ bert_prob * bert_weight +
29
+ stylo_score * stylo_weight
30
+ )
31
+
32
+ final_probability = round(max(0.0, min(1.0, final_probability)), 4)
33
+
34
+ ai_phrases = find_ai_phrases(text)
35
+
36
+ explanation = generate_explanation(
37
+ ai_probability=final_probability,
38
+ stylometric_data=stylo_result,
39
+ sentence_scores=sentence_scores,
40
+ ai_phrases=ai_phrases,
41
+ )
42
+
43
+ is_ai = final_probability >= settings.CONFIDENCE_THRESHOLD
44
+
45
+ return {
46
+ "ai_probability": final_probability,
47
+ "is_ai": is_ai,
48
+ "verdict": explanation["verdict"],
49
+ "confidence_threshold": settings.CONFIDENCE_THRESHOLD,
50
+ "scores": {
51
+ "bert_score": bert_prob,
52
+ "stylometric_score": round(stylo_score, 4),
53
+ "bert_weight": bert_weight,
54
+ "stylometric_weight": stylo_weight,
55
+ },
56
+ "stylometric_features": stylo_result["features"],
57
+ "sentence_scores": sentence_scores,
58
+ "explanation": explanation,
59
+ "chunks_analyzed": bert_result.get("chunks_analyzed", 1),
60
+ }
core/bert_scorer.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/bert_scorer.py
2
+ # Loads slop-detector-bert by merging LoRA weights directly into BERT.
3
+ # Key findings from adapter_config.json:
4
+ # - lora_alpha=16, r=16, scaling=1.0
5
+ # - modules_to_save=["classifier"] — saved with different key format
6
+ # - LABEL_0 = AI, LABEL_1 = Human (inverted from model card)
7
+
8
+ import numpy as np
9
+ import torch
10
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
11
+ from huggingface_hub import hf_hub_download
12
+ from safetensors.torch import load_file
13
+ from config.settings import settings
14
+ from core.preprocessor import chunk_for_bert
15
+
16
+
17
+ class BertScorer:
18
+
19
+ def __init__(self):
20
+ self.model_id = settings.MODEL_ID
21
+ self.max_tokens = settings.MAX_TOKENS
22
+ self.temperature = settings.TEMPERATURE
23
+ self._model = None
24
+ self._tokenizer = None
25
+
26
+ def load(self):
27
+ print(f"Loading model: {self.model_id}")
28
+
29
+ # Load tokenizer from base model
30
+ self._tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
31
+
32
+ # Load base BERT with classification head
33
+ self._model = AutoModelForSequenceClassification.from_pretrained(
34
+ "bert-base-cased",
35
+ num_labels=2,
36
+ ignore_mismatched_sizes=True,
37
+ )
38
+
39
+ # Download and apply LoRA adapter weights
40
+ try:
41
+ adapter_path = hf_hub_download(
42
+ repo_id=self.model_id,
43
+ filename="adapter_model.safetensors",
44
+ )
45
+ print(f"Adapter downloaded: {adapter_path}")
46
+ self._apply_lora_weights(adapter_path)
47
+ except Exception as e:
48
+ print(f"Warning: Could not load adapter weights: {e}")
49
+ print("Running with base BERT — scores will be less accurate.")
50
+
51
+ self._model.eval()
52
+ print("Model loaded successfully.")
53
+
54
+ def _apply_lora_weights(self, adapter_path: str):
55
+ """
56
+ Merge LoRA weights into base model.
57
+
58
+ Key format in safetensors:
59
+ LoRA: base_model.model.bert.encoder.layer.X....lora_A.weight
60
+ Classifier: base_model.model.classifier.modules_to_save.default.weight
61
+ base_model.model.classifier.modules_to_save.default.bias
62
+
63
+ Scaling = lora_alpha / r = 16 / 16 = 1.0
64
+
65
+ LABEL_0 = AI-generated
66
+ LABEL_1 = Human-written
67
+ (confirmed by empirical testing — inverted from model card)
68
+ """
69
+ lora_weights = load_file(adapter_path)
70
+
71
+ # Print all keys for debugging
72
+ classifier_keys = [k for k in lora_weights.keys() if "classifier" in k]
73
+ print(f"Classifier keys found: {classifier_keys}")
74
+
75
+ # Collect lora_A and lora_B tensors
76
+ lora_A = {}
77
+ lora_B = {}
78
+
79
+ for key, tensor in lora_weights.items():
80
+ if key.endswith("lora_A.weight"):
81
+ base = key.replace("base_model.model.", "").replace(".lora_A.weight", "")
82
+ lora_A[base] = tensor
83
+ elif key.endswith("lora_B.weight"):
84
+ base = key.replace("base_model.model.", "").replace(".lora_B.weight", "")
85
+ lora_B[base] = tensor
86
+
87
+ # scaling = lora_alpha / r = 16 / 16 = 1.0
88
+ scaling = 16 / 16
89
+
90
+ # Get current model state dict
91
+ state_dict = self._model.state_dict()
92
+ merged = 0
93
+
94
+ # Merge: W = W_base + scaling * (B @ A)
95
+ for base_key in lora_A:
96
+ if base_key in lora_B:
97
+ weight_key = base_key + ".weight"
98
+ if weight_key in state_dict:
99
+ A = lora_A[base_key].float()
100
+ B = lora_B[base_key].float()
101
+ delta = scaling * (B @ A)
102
+ state_dict[weight_key] = state_dict[weight_key].float() + delta
103
+ merged += 1
104
+
105
+ print(f"Merged {merged} LoRA layers into base model.")
106
+
107
+ # Load classifier weights
108
+ # Format: base_model.model.classifier.modules_to_save.default.weight/bias
109
+ for key, tensor in lora_weights.items():
110
+ if "classifier" in key and "modules_to_save" in key:
111
+ # Map to model's classifier.weight / classifier.bias
112
+ if key.endswith(".weight"):
113
+ state_dict["classifier.weight"] = tensor.float()
114
+ print("Loaded classifier.weight")
115
+ elif key.endswith(".bias"):
116
+ state_dict["classifier.bias"] = tensor.float()
117
+ print("Loaded classifier.bias")
118
+
119
+ self._model.load_state_dict(state_dict)
120
+
121
+ def _temperature_scale(self, prob: float) -> float:
122
+ """
123
+ Calibrate raw softmax probability.
124
+ T=1.0 = no change. T>1.0 softens overconfident predictions.
125
+ """
126
+ p = max(min(prob, 0.9999), 0.0001)
127
+ raw_logit = np.log(p / (1 - p))
128
+ scaled_logit = raw_logit / self.temperature
129
+ return float(1 / (1 + np.exp(-scaled_logit)))
130
+
131
+ def _predict(self, text: str) -> float:
132
+ """
133
+ Run single inference pass.
134
+ Returns probability that text is AI-generated.
135
+ LABEL_0 = AI, LABEL_1 = Human
136
+ So we return probs[0][0] for AI probability.
137
+ """
138
+ inputs = self._tokenizer(
139
+ text,
140
+ return_tensors="pt",
141
+ truncation=True,
142
+ max_length=self.max_tokens,
143
+ padding=True,
144
+ )
145
+
146
+ with torch.no_grad():
147
+ outputs = self._model(**inputs)
148
+
149
+ probs = torch.softmax(outputs.logits, dim=-1)
150
+
151
+ # LABEL_0 = AI probability
152
+ ai_prob = probs[0][0].item()
153
+ return ai_prob
154
+
155
+ def score_text(self, text: str) -> dict:
156
+ """
157
+ Score full text. Chunks if longer than max_tokens.
158
+ Returns calibrated AI probability.
159
+ """
160
+ if self._model is None:
161
+ raise RuntimeError("Model not loaded. Call load() first.")
162
+
163
+ chunks = chunk_for_bert(text, self._tokenizer, self.max_tokens)
164
+ chunk_scores = []
165
+
166
+ for chunk_text in chunks:
167
+ raw_prob = self._predict(chunk_text)
168
+ chunk_scores.append(raw_prob)
169
+
170
+ raw_ai_probability = float(np.mean(chunk_scores))
171
+ calibrated_probability = self._temperature_scale(raw_ai_probability)
172
+
173
+ return {
174
+ "raw_ai_probability": round(raw_ai_probability, 4),
175
+ "ai_probability": round(calibrated_probability, 4),
176
+ "chunks_analyzed": len(chunks),
177
+ }
178
+
179
+ def score_sentences(self, sentences: list[str]) -> list[dict]:
180
+ """
181
+ Score each sentence individually for frontend highlighting.
182
+ """
183
+ if self._model is None:
184
+ raise RuntimeError("Model not loaded. Call load() first.")
185
+
186
+ results = []
187
+
188
+ for sentence in sentences:
189
+ if len(sentence) < settings.MIN_SENTENCE_LENGTH:
190
+ results.append({
191
+ "sentence": sentence,
192
+ "ai_probability": 0.5,
193
+ "reliable": False,
194
+ })
195
+ continue
196
+
197
+ try:
198
+ raw_prob = self._predict(sentence)
199
+ calibrated = self._temperature_scale(raw_prob)
200
+ results.append({
201
+ "sentence": sentence,
202
+ "ai_probability": round(calibrated, 4),
203
+ "reliable": True,
204
+ })
205
+ except Exception:
206
+ results.append({
207
+ "sentence": sentence,
208
+ "ai_probability": 0.5,
209
+ "reliable": False,
210
+ })
211
+
212
+ return results
213
+
214
+
215
+ # Module-level singleton — imported by routes
216
+ bert_scorer = BertScorer()# core/bert_scorer.py
217
+ # Loads slop-detector-bert by merging LoRA weights directly into BERT.
218
+ # Key findings from adapter_config.json:
219
+ # - lora_alpha=16, r=16, scaling=1.0
220
+ # - modules_to_save=["classifier"] — saved with different key format
221
+ # - LABEL_0 = AI, LABEL_1 = Human (inverted from model card)
222
+
223
+ import numpy as np
224
+ import torch
225
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
226
+ from huggingface_hub import hf_hub_download
227
+ from safetensors.torch import load_file
228
+ from config.settings import settings
229
+ from core.preprocessor import chunk_for_bert
230
+
231
+
232
+ class BertScorer:
233
+
234
+ def __init__(self):
235
+ self.model_id = settings.MODEL_ID
236
+ self.max_tokens = settings.MAX_TOKENS
237
+ self.temperature = settings.TEMPERATURE
238
+ self._model = None
239
+ self._tokenizer = None
240
+
241
+ def load(self):
242
+ print(f"Loading model: {self.model_id}")
243
+
244
+ # Load tokenizer from base model
245
+ self._tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
246
+
247
+ # Load base BERT with classification head
248
+ self._model = AutoModelForSequenceClassification.from_pretrained(
249
+ "bert-base-cased",
250
+ num_labels=2,
251
+ ignore_mismatched_sizes=True,
252
+ )
253
+
254
+ # Download and apply LoRA adapter weights
255
+ try:
256
+ adapter_path = hf_hub_download(
257
+ repo_id=self.model_id,
258
+ filename="adapter_model.safetensors",
259
+ )
260
+ print(f"Adapter downloaded: {adapter_path}")
261
+ self._apply_lora_weights(adapter_path)
262
+ except Exception as e:
263
+ print(f"Warning: Could not load adapter weights: {e}")
264
+ print("Running with base BERT — scores will be less accurate.")
265
+
266
+ self._model.eval()
267
+ print("Model loaded successfully.")
268
+
269
+ def _apply_lora_weights(self, adapter_path: str):
270
+ """
271
+ Merge LoRA weights into base model.
272
+
273
+ Key format in safetensors:
274
+ LoRA: base_model.model.bert.encoder.layer.X....lora_A.weight
275
+ Classifier: base_model.model.classifier.modules_to_save.default.weight
276
+ base_model.model.classifier.modules_to_save.default.bias
277
+
278
+ Scaling = lora_alpha / r = 16 / 16 = 1.0
279
+
280
+ LABEL_0 = AI-generated
281
+ LABEL_1 = Human-written
282
+ (confirmed by empirical testing — inverted from model card)
283
+ """
284
+ lora_weights = load_file(adapter_path)
285
+
286
+ # Print all keys for debugging
287
+ classifier_keys = [k for k in lora_weights.keys() if "classifier" in k]
288
+ print(f"Classifier keys found: {classifier_keys}")
289
+
290
+ # Collect lora_A and lora_B tensors
291
+ lora_A = {}
292
+ lora_B = {}
293
+
294
+ for key, tensor in lora_weights.items():
295
+ if key.endswith("lora_A.weight"):
296
+ base = key.replace("base_model.model.", "").replace(".lora_A.weight", "")
297
+ lora_A[base] = tensor
298
+ elif key.endswith("lora_B.weight"):
299
+ base = key.replace("base_model.model.", "").replace(".lora_B.weight", "")
300
+ lora_B[base] = tensor
301
+
302
+ # scaling = lora_alpha / r = 16 / 16 = 1.0
303
+ scaling = 16 / 16
304
+
305
+ # Get current model state dict
306
+ state_dict = self._model.state_dict()
307
+ merged = 0
308
+
309
+ # Merge: W = W_base + scaling * (B @ A)
310
+ for base_key in lora_A:
311
+ if base_key in lora_B:
312
+ weight_key = base_key + ".weight"
313
+ if weight_key in state_dict:
314
+ A = lora_A[base_key].float()
315
+ B = lora_B[base_key].float()
316
+ delta = scaling * (B @ A)
317
+ state_dict[weight_key] = state_dict[weight_key].float() + delta
318
+ merged += 1
319
+
320
+ print(f"Merged {merged} LoRA layers into base model.")
321
+
322
+ # Load classifier weights
323
+ # Format: base_model.model.classifier.weight / bias
324
+ for key, tensor in lora_weights.items():
325
+ if "classifier" in key:
326
+ clean_key = key.replace("base_model.model.", "")
327
+ if clean_key in state_dict:
328
+ state_dict[clean_key] = tensor.float()
329
+ print(f"Loaded {clean_key} {tensor.shape}")
330
+
331
+ self._model.load_state_dict(state_dict)
332
+
333
+ def _temperature_scale(self, prob: float) -> float:
334
+ """
335
+ Calibrate raw softmax probability.
336
+ T=1.0 = no change. T>1.0 softens overconfident predictions.
337
+ """
338
+ p = max(min(prob, 0.9999), 0.0001)
339
+ raw_logit = np.log(p / (1 - p))
340
+ scaled_logit = raw_logit / self.temperature
341
+ return float(1 / (1 + np.exp(-scaled_logit)))
342
+
343
+ def _predict(self, text: str) -> float:
344
+ """
345
+ Run single inference pass.
346
+ Returns probability that text is AI-generated.
347
+ LABEL_0 = AI, LABEL_1 = Human
348
+ So we return probs[0][0] for AI probability.
349
+ """
350
+ inputs = self._tokenizer(
351
+ text,
352
+ return_tensors="pt",
353
+ truncation=True,
354
+ max_length=self.max_tokens,
355
+ padding=True,
356
+ )
357
+
358
+ with torch.no_grad():
359
+ outputs = self._model(**inputs)
360
+
361
+ probs = torch.softmax(outputs.logits, dim=-1)
362
+
363
+ # LABEL_0 = AI probability
364
+ ai_prob = probs[0][0].item()
365
+ return ai_prob
366
+
367
+ def score_text(self, text: str) -> dict:
368
+ """
369
+ Score full text. Chunks if longer than max_tokens.
370
+ Returns calibrated AI probability.
371
+ """
372
+ if self._model is None:
373
+ raise RuntimeError("Model not loaded. Call load() first.")
374
+
375
+ chunks = chunk_for_bert(text, self._tokenizer, self.max_tokens)
376
+ chunk_scores = []
377
+
378
+ for chunk_text in chunks:
379
+ raw_prob = self._predict(chunk_text)
380
+ chunk_scores.append(raw_prob)
381
+
382
+ raw_ai_probability = float(np.mean(chunk_scores))
383
+ calibrated_probability = self._temperature_scale(raw_ai_probability)
384
+
385
+ return {
386
+ "raw_ai_probability": round(raw_ai_probability, 4),
387
+ "ai_probability": round(calibrated_probability, 4),
388
+ "chunks_analyzed": len(chunks),
389
+ }
390
+
391
+ def score_sentences(self, sentences: list[str]) -> list[dict]:
392
+ """
393
+ Score each sentence individually for frontend highlighting.
394
+ """
395
+ if self._model is None:
396
+ raise RuntimeError("Model not loaded. Call load() first.")
397
+
398
+ results = []
399
+
400
+ for sentence in sentences:
401
+ if len(sentence) < settings.MIN_SENTENCE_LENGTH:
402
+ results.append({
403
+ "sentence": sentence,
404
+ "ai_probability": 0.5,
405
+ "reliable": False,
406
+ })
407
+ continue
408
+
409
+ try:
410
+ raw_prob = self._predict(sentence)
411
+ calibrated = self._temperature_scale(raw_prob)
412
+ results.append({
413
+ "sentence": sentence,
414
+ "ai_probability": round(calibrated, 4),
415
+ "reliable": True,
416
+ })
417
+ except Exception:
418
+ results.append({
419
+ "sentence": sentence,
420
+ "ai_probability": 0.5,
421
+ "reliable": False,
422
+ })
423
+
424
+ return results
425
+
426
+
427
+ # Module-level singleton — imported by routes
428
+ bert_scorer = BertScorer()
core/explainer.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/explainer.py
2
+ # Generates human-readable explanations for WHY text was flagged.
3
+ # This is what separates a production tool from a tutorial project.
4
+
5
+ from config.settings import settings
6
+
7
+
8
+ # AI writing markers — phrases that appear disproportionately in LLM output.
9
+ # These were identified by analyzing the dataset manually.
10
+ AI_PHRASES = [
11
+ "it is worth noting",
12
+ "it is important to note",
13
+ "in the context of",
14
+ "plays a crucial role",
15
+ "plays an important role",
16
+ "serves as a",
17
+ "as a result of",
18
+ "in order to",
19
+ "due to the fact that",
20
+ "with respect to",
21
+ "in terms of",
22
+ "as mentioned",
23
+ "furthermore",
24
+ "moreover",
25
+ "additionally",
26
+ "consequently",
27
+ "nevertheless",
28
+ "it should be noted",
29
+ "it can be seen",
30
+ "in conclusion",
31
+ "to summarize",
32
+ "overall,",
33
+ "notably,",
34
+ "significantly,",
35
+ "importantly,",
36
+ "reflects",
37
+ "highlights",
38
+ "underscores",
39
+ "illustrates",
40
+ ]
41
+
42
+
43
+ def find_ai_phrases(text: str) -> list[str]:
44
+ """Find known AI marker phrases in the text."""
45
+ text_lower = text.lower()
46
+ found = [phrase for phrase in AI_PHRASES if phrase in text_lower]
47
+ return found
48
+
49
+
50
+ def generate_explanation(
51
+ ai_probability: float,
52
+ stylometric_data: dict,
53
+ sentence_scores: list[dict],
54
+ ai_phrases: list[str],
55
+ ) -> dict:
56
+ """
57
+ Builds a structured explanation object that the frontend renders.
58
+
59
+ Design principle: always explain uncertainty.
60
+ We never say "this IS AI" — we say "these signals suggest AI".
61
+ """
62
+
63
+ reasons = []
64
+ confidence_label = _confidence_label(ai_probability)
65
+ features = stylometric_data.get("features", {})
66
+ feature_scores = stylometric_data.get("feature_scores", {})
67
+
68
+ # --- Reason 1: BERT model signal ---
69
+ if ai_probability > 0.75:
70
+ reasons.append({
71
+ "signal": "Neural classifier",
72
+ "detail": f"The AI detection model assigned a {ai_probability:.0%} probability of AI authorship.",
73
+ "weight": "high",
74
+ })
75
+ elif ai_probability > 0.55:
76
+ reasons.append({
77
+ "signal": "Neural classifier",
78
+ "detail": f"The AI detection model found moderate signals of AI authorship ({ai_probability:.0%}).",
79
+ "weight": "medium",
80
+ })
81
+
82
+ # --- Reason 2: Burstiness (sentence length variation) ---
83
+ burst = features.get("burstiness", 0.5)
84
+ if burst < 0.25:
85
+ reasons.append({
86
+ "signal": "Uniform sentence rhythm",
87
+ "detail": f"Sentence lengths are unusually uniform (burstiness: {burst:.2f}). Human writing varies more.",
88
+ "weight": "high",
89
+ })
90
+ elif burst < 0.35:
91
+ reasons.append({
92
+ "signal": "Low sentence variation",
93
+ "detail": f"Sentence lengths show limited variation (burstiness: {burst:.2f}).",
94
+ "weight": "medium",
95
+ })
96
+
97
+ # --- Reason 3: Type-token ratio (vocabulary diversity) ---
98
+ ttr = features.get("type_token_ratio", 0.7)
99
+ if ttr < 0.5:
100
+ reasons.append({
101
+ "signal": "Limited vocabulary diversity",
102
+ "detail": f"The text reuses words frequently (TTR: {ttr:.2f}). AI text tends to be lexically repetitive.",
103
+ "weight": "medium",
104
+ })
105
+
106
+ # --- Reason 4: AI marker phrases ---
107
+ if len(ai_phrases) >= 3:
108
+ phrase_list = ", ".join(f'"{p}"' for p in ai_phrases[:4])
109
+ reasons.append({
110
+ "signal": "AI marker phrases",
111
+ "detail": f"Found {len(ai_phrases)} common AI phrases: {phrase_list}.",
112
+ "weight": "medium",
113
+ })
114
+ elif len(ai_phrases) >= 1:
115
+ phrase_list = ", ".join(f'"{p}"' for p in ai_phrases[:2])
116
+ reasons.append({
117
+ "signal": "AI transition words",
118
+ "detail": f"Found phrases common in AI text: {phrase_list}.",
119
+ "weight": "low",
120
+ })
121
+
122
+ # --- Reason 5: Repetition ---
123
+ rep = features.get("repetition_score", 0)
124
+ if rep > 0.1:
125
+ reasons.append({
126
+ "signal": "Phrase repetition",
127
+ "detail": f"Repeated phrases detected (score: {rep:.2f}). LLMs often loop back to the same constructions.",
128
+ "weight": "medium",
129
+ })
130
+
131
+ # --- Highlight the most suspicious sentences ---
132
+ suspicious = [
133
+ s for s in sentence_scores
134
+ if s.get("ai_probability", 0) > 0.7 and s.get("reliable", False)
135
+ ]
136
+ suspicious.sort(key=lambda x: x["ai_probability"], reverse=True)
137
+ top_suspicious = suspicious[:3]
138
+
139
+ return {
140
+ "verdict": confidence_label,
141
+ "ai_probability": ai_probability,
142
+ "reasons": reasons,
143
+ "top_suspicious_sentences": top_suspicious,
144
+ "uncertainty_note": _uncertainty_note(ai_probability),
145
+ }
146
+
147
+
148
+ def _confidence_label(prob: float) -> str:
149
+ """Convert probability to human-readable verdict."""
150
+ if prob >= 0.85:
151
+ return "Very likely AI-generated"
152
+ elif prob >= 0.70:
153
+ return "Likely AI-generated"
154
+ elif prob >= 0.55:
155
+ return "Possibly AI-generated"
156
+ elif prob >= 0.45:
157
+ return "Uncertain — could be either"
158
+ elif prob >= 0.30:
159
+ return "Possibly human-written"
160
+ else:
161
+ return "Likely human-written"
162
+
163
+
164
+ def _uncertainty_note(prob: float) -> str:
165
+ """
166
+ Always include an uncertainty note.
167
+ This is non-negotiable in any honest AI detection system.
168
+ """
169
+ if 0.4 <= prob <= 0.6:
170
+ return (
171
+ "This text falls in the uncertain range. "
172
+ "The model cannot confidently distinguish AI from human authorship here. "
173
+ "Do not use this result for any consequential decision."
174
+ )
175
+ elif prob > 0.8:
176
+ return (
177
+ "While the model is fairly confident, no AI detector is perfect. "
178
+ "Heavily edited AI text and formal human writing can both score high."
179
+ )
180
+ else:
181
+ return (
182
+ "AI detectors have meaningful false-positive and false-negative rates. "
183
+ "Treat this as one signal among many, not a definitive verdict."
184
+ )
core/perplexity_scorer.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/perplexity_scorer.py
2
+ # Perplexity-based AI detection using GPT-2.
3
+ #
4
+ # Key insight: AI text is MORE predictable than human text.
5
+ # GPT-2 assigns LOWER perplexity to AI-generated text
6
+ # because LLMs generate high-probability token sequences.
7
+ #
8
+ # This signal is MODEL-AGNOSTIC — works regardless of which
9
+ # AI wrote the text, unlike our BERT model which learned
10
+ # GPT-5 Nano patterns specifically.
11
+
12
+ import torch
13
+ import math
14
+ import numpy as np
15
+ from transformers import GPT2LMHeadModel, GPT2TokenizerFast
16
+
17
+
18
+ class PerplexityScorer:
19
+ """
20
+ Scores text using GPT-2 perplexity.
21
+ Lower perplexity = more predictable = more likely AI.
22
+ """
23
+
24
+ def __init__(self):
25
+ self._model = None
26
+ self._tokenizer = None
27
+ self._loaded = False
28
+
29
+ def load(self):
30
+ print("Loading GPT-2 for perplexity scoring...")
31
+ self._tokenizer = GPT2TokenizerFast.from_pretrained("gpt2")
32
+ self._model = GPT2LMHeadModel.from_pretrained("gpt2")
33
+ self._model.eval()
34
+ print("GPT-2 loaded.")
35
+ self._loaded = True
36
+
37
+ def get_perplexity(self, text: str) -> float:
38
+ """
39
+ Compute perplexity of text under GPT-2.
40
+ Lower = more predictable = more AI-like.
41
+ Typical ranges:
42
+ AI text: 30 - 80
43
+ Human text: 80 - 200+
44
+ """
45
+ if not self._loaded:
46
+ return None
47
+
48
+ encodings = self._tokenizer(
49
+ text,
50
+ return_tensors="pt",
51
+ truncation=True,
52
+ max_length=512,
53
+ )
54
+
55
+ input_ids = encodings.input_ids
56
+
57
+ with torch.no_grad():
58
+ outputs = self._model(input_ids, labels=input_ids)
59
+ loss = outputs.loss
60
+
61
+ return math.exp(loss.item())
62
+
63
+ def perplexity_to_ai_score(self, perplexity: float) -> float:
64
+ """
65
+ Convert perplexity to 0-1 AI probability.
66
+ Lower perplexity = higher AI score.
67
+
68
+ Calibrated ranges:
69
+ perplexity < 50 → score > 0.8 (very likely AI)
70
+ perplexity 50-100 → score 0.5-0.8
71
+ perplexity > 150 → score < 0.3 (likely human)
72
+ """
73
+ if perplexity is None:
74
+ return 0.5
75
+
76
+ # Sigmoid-like mapping
77
+ # Anchor: perplexity=50 → score=0.75, perplexity=150 → score=0.25
78
+ score = 1 / (1 + (perplexity / 80) ** 1.5)
79
+ return round(float(max(0.0, min(1.0, score))), 4)
80
+
81
+
82
+ # Singleton
83
+ perplexity_scorer = PerplexityScorer()
core/preprocessor.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/preprocessor.py
2
+ # Handles all text cleaning and sentence splitting.
3
+ # Kept separate so it can be tested and improved independently.
4
+
5
+ import re
6
+ import nltk
7
+ from nltk.tokenize import sent_tokenize
8
+
9
+ # Download the sentence tokenizer model on first run
10
+ # This is a one-time ~400KB download
11
+ nltk.download("punkt", quiet=True)
12
+ nltk.download("punkt_tab", quiet=True)
13
+
14
+
15
+ def clean_text(text: str) -> str:
16
+ """
17
+ Light cleaning — removes junk but preserves writing style.
18
+ We intentionally DON'T remove punctuation or normalize case
19
+ because those stylometric signals matter for detection.
20
+ """
21
+ if not text or not text.strip():
22
+ raise ValueError("Input text is empty.")
23
+
24
+ # Collapse multiple spaces/newlines into single space
25
+ text = re.sub(r"\s+", " ", text)
26
+
27
+ # Remove invisible unicode characters (zero-width spaces etc.)
28
+ text = re.sub(r"[\u200b\u200c\u200d\ufeff]", "", text)
29
+
30
+ # Strip leading/trailing whitespace
31
+ text = text.strip()
32
+
33
+ if len(text) < 20:
34
+ raise ValueError("Text too short to analyze (minimum 20 characters).")
35
+
36
+ return text
37
+
38
+
39
+ def split_sentences(text: str, min_length: int = 20) -> list[str]:
40
+ """
41
+ Split text into sentences using NLTK's Punkt tokenizer.
42
+ Filters out sentences that are too short to be meaningful.
43
+
44
+ Why NLTK over simple split(".")?
45
+ → Handles abbreviations (U.S.A., Dr., etc.)
46
+ → Handles quoted speech
47
+ → More accurate on real-world text
48
+ """
49
+ sentences = sent_tokenize(text)
50
+
51
+ # Filter trivially short fragments
52
+ sentences = [s.strip() for s in sentences if len(s.strip()) >= min_length]
53
+
54
+ return sentences
55
+
56
+
57
+ def chunk_for_bert(text: str, tokenizer, max_tokens: int = 500) -> list[str]:
58
+ """
59
+ BERT has a hard 512-token limit. For long texts we split into
60
+ overlapping chunks so no content is silently dropped.
61
+
62
+ Overlap of 50 tokens ensures sentence boundaries aren't cut mid-thought.
63
+ We score each chunk separately, then average the scores.
64
+ """
65
+ tokens = tokenizer.encode(text, add_special_tokens=False)
66
+
67
+ if len(tokens) <= max_tokens:
68
+ # Short enough — no chunking needed
69
+ return [text]
70
+
71
+ # Split token IDs into overlapping windows
72
+ chunks = []
73
+ stride = max_tokens - 50 # 50-token overlap between chunks
74
+
75
+ for start in range(0, len(tokens), stride):
76
+ end = start + max_tokens
77
+ chunk_tokens = tokens[start:end]
78
+
79
+ # Decode back to text
80
+ chunk_text = tokenizer.decode(chunk_tokens, skip_special_tokens=True)
81
+ chunks.append(chunk_text)
82
+
83
+ if end >= len(tokens):
84
+ break
85
+
86
+ return chunks
87
+
88
+
89
+ def preprocess(text: str) -> str:
90
+ """
91
+ Main entry point — clean and validate.
92
+ Returns cleaned text ready for scoring.
93
+ """
94
+ return clean_text(text)
core/stylometrics.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/stylometrics.py
2
+ # Stylometric feature extraction — model-agnostic AI detection signals.
3
+ #
4
+ # WHY this matters:
5
+ # BERT learned GPT-5 Nano patterns. These features catch ALL LLMs
6
+ # because they measure structural writing habits, not learned tokens.
7
+
8
+ import re
9
+ import math
10
+ import nltk
11
+ from nltk.tokenize import sent_tokenize, word_tokenize
12
+
13
+ nltk.download("punkt", quiet=True)
14
+ nltk.download("punkt_tab", quiet=True)
15
+
16
+
17
+ def avg_sentence_length(text: str) -> float:
18
+ """
19
+ Average words per sentence.
20
+ AI text tends to have suspiciously uniform, medium-length sentences.
21
+ Human text varies wildly — some very short, some very long.
22
+ """
23
+ sentences = sent_tokenize(text)
24
+ if not sentences:
25
+ return 0.0
26
+
27
+ lengths = [len(word_tokenize(s)) for s in sentences]
28
+ return sum(lengths) / len(lengths)
29
+
30
+
31
+ def burstiness(text: str) -> float:
32
+ """
33
+ Burstiness = coefficient of variation of sentence lengths.
34
+
35
+ High burstiness → human (varied rhythm)
36
+ Low burstiness → AI (robotic uniformity)
37
+
38
+ Formula: std_dev / mean of sentence lengths
39
+ A value near 0 means all sentences are the same length (AI signal).
40
+ A value > 0.5 is typical human writing.
41
+ """
42
+ sentences = sent_tokenize(text)
43
+ if len(sentences) < 3:
44
+ return 0.5 # Not enough data — return neutral
45
+
46
+ lengths = [len(word_tokenize(s)) for s in sentences]
47
+ mean = sum(lengths) / len(lengths)
48
+
49
+ if mean == 0:
50
+ return 0.0
51
+
52
+ variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)
53
+ std_dev = math.sqrt(variance)
54
+
55
+ return std_dev / mean
56
+
57
+
58
+ def type_token_ratio(text: str) -> float:
59
+ """
60
+ TTR = unique words / total words.
61
+
62
+ Low TTR → repetitive vocabulary (AI signal — LLMs reuse safe words)
63
+ High TTR → diverse vocabulary (human signal)
64
+
65
+ We use a windowed version (first 200 words) to avoid length bias.
66
+ """
67
+ words = word_tokenize(text.lower())
68
+
69
+ # Use first 200 words to normalize for text length
70
+ words = words[:200]
71
+
72
+ if not words:
73
+ return 0.0
74
+
75
+ unique = set(words)
76
+ return len(unique) / len(words)
77
+
78
+
79
+ def punctuation_diversity(text: str) -> float:
80
+ """
81
+ Ratio of punctuation variety to text length.
82
+
83
+ AI text tends to use periods and commas almost exclusively.
84
+ Humans use dashes, ellipses, semicolons, exclamation marks more freely.
85
+ """
86
+ diverse_punct = re.findall(r"[;:—–…!?]", text)
87
+ total_chars = len(text)
88
+
89
+ if total_chars == 0:
90
+ return 0.0
91
+
92
+ return len(diverse_punct) / total_chars * 100
93
+
94
+
95
+ def repetition_score(text: str) -> float:
96
+ """
97
+ Detects repeated phrases (3+ word n-grams that appear more than once).
98
+
99
+ AI text often repeats phrases like "in the context of",
100
+ "it is worth noting", "plays a crucial role".
101
+ """
102
+ words = word_tokenize(text.lower())
103
+
104
+ if len(words) < 6:
105
+ return 0.0
106
+
107
+ # Build trigrams
108
+ trigrams = [
109
+ " ".join(words[i:i+3])
110
+ for i in range(len(words) - 2)
111
+ ]
112
+
113
+ # Count how many trigrams appear more than once
114
+ seen = {}
115
+ for tg in trigrams:
116
+ seen[tg] = seen.get(tg, 0) + 1
117
+
118
+ repeated = sum(1 for count in seen.values() if count > 1)
119
+
120
+ return repeated / len(trigrams) if trigrams else 0.0
121
+
122
+
123
+ def compute_stylometric_score(text: str) -> dict:
124
+ """
125
+ Combines all features into a single AI-likelihood score (0–1)
126
+ plus a breakdown of each signal.
127
+
128
+ Scoring logic:
129
+ - Low burstiness → more AI-like
130
+ - Low TTR → more AI-like
131
+ - Low punctuation diversity → more AI-like
132
+ - High repetition → more AI-like
133
+
134
+ Each feature is normalized and combined with empirically tuned weights.
135
+ These weights are NOT magic — they're starting points. The evaluation
136
+ phase will show whether they need adjustment.
137
+ """
138
+ burst = burstiness(text)
139
+ ttr = type_token_ratio(text)
140
+ punct = punctuation_diversity(text)
141
+ rep = repetition_score(text)
142
+ avg_len = avg_sentence_length(text)
143
+
144
+ # --- Normalize each feature to 0-1 AI likelihood ---
145
+
146
+ # Burstiness: human ~0.4-0.8, AI ~0.1-0.3
147
+ # Lower burstiness = higher AI score
148
+ burst_score = max(0, min(1, 1 - (burst / 0.6)))
149
+
150
+ # TTR: human ~0.6-0.8, AI ~0.4-0.6
151
+ # Lower TTR = higher AI score
152
+ ttr_score = max(0, min(1, 1 - (ttr / 0.7)))
153
+
154
+ # Punctuation diversity: human > AI
155
+ # Lower diversity = higher AI score
156
+ punct_score = max(0, min(1, 1 - (punct / 0.5)))
157
+
158
+ # Repetition: AI > human
159
+ # Higher repetition = higher AI score
160
+ rep_score = min(1.0, rep * 5)
161
+
162
+ # Weighted combination
163
+ # BERT is our primary signal — stylometrics is secondary/supporting
164
+ weights = {
165
+ "burstiness": 0.35,
166
+ "ttr": 0.30,
167
+ "punctuation": 0.20,
168
+ "repetition": 0.15,
169
+ }
170
+
171
+ combined = (
172
+ burst_score * weights["burstiness"] +
173
+ ttr_score * weights["ttr"] +
174
+ punct_score * weights["punctuation"] +
175
+ rep_score * weights["repetition"]
176
+ )
177
+
178
+ return {
179
+ "stylometric_ai_score": round(combined, 4),
180
+ "features": {
181
+ "burstiness": round(burst, 4),
182
+ "avg_sentence_length": round(avg_len, 2),
183
+ "type_token_ratio": round(ttr, 4),
184
+ "punctuation_diversity": round(punct, 4),
185
+ "repetition_score": round(rep, 4),
186
+ },
187
+ "feature_scores": {
188
+ "burstiness_ai_signal": round(burst_score, 4),
189
+ "ttr_ai_signal": round(ttr_score, 4),
190
+ "punctuation_ai_signal": round(punct_score, 4),
191
+ "repetition_ai_signal": round(rep_score, 4),
192
+ }
193
+ }
dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Install system dependencies
7
+ RUN apt-get update && apt-get install -y \
8
+ gcc \
9
+ g++ \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Copy requirements first (for Docker layer caching)
13
+ COPY requirements.txt .
14
+
15
+ # Install Python dependencies
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # Copy all backend code
19
+ COPY . .
20
+
21
+ # Set Python path so imports resolve correctly
22
+ ENV PYTHONPATH=/app
23
+
24
+ # Download NLTK data at build time
25
+ RUN python -c "import nltk; nltk.download('punkt'); nltk.download('punkt_tab')"
26
+
27
+ # HuggingFace Spaces uses port 7860
28
+ EXPOSE 7860
29
+
30
+ # Start the API
31
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ transformers==4.46.0
4
+ torch==2.11.0
5
+ peft==0.13.0
6
+ sentencepiece==0.2.0
7
+ nltk==3.9.1
8
+ numpy==1.26.4
9
+ scipy==1.13.1
10
+ pydantic==2.8.2
11
+ python-multipart==0.0.9
12
+ httpx==0.27.2
13
+ python-dotenv==1.0.1
14
+ accelerate==1.1.0
run.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+
4
+ # This must happen before any other import
5
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
6
+ os.environ["PYTHONPATH"] = os.path.dirname(os.path.abspath(__file__))
7
+
8
+ import uvicorn
9
+
10
+ if __name__ == "__main__":
11
+ uvicorn.run(
12
+ "api.main:app",
13
+ host="0.0.0.0",
14
+ port=8000,
15
+ reload=False, # Disabled — solves the subprocess path issue
16
+ )
17
+