# core/stylometrics.py # Stylometric feature extraction — model-agnostic AI detection signals. # # WHY this matters: # BERT learned GPT-5 Nano patterns. These features catch ALL LLMs # because they measure structural writing habits, not learned tokens. import re import math import nltk from nltk.tokenize import sent_tokenize, word_tokenize nltk.download("punkt", quiet=True) nltk.download("punkt_tab", quiet=True) def avg_sentence_length(text: str) -> float: """ Average words per sentence. AI text tends to have suspiciously uniform, medium-length sentences. Human text varies wildly — some very short, some very long. """ sentences = sent_tokenize(text) if not sentences: return 0.0 lengths = [len(word_tokenize(s)) for s in sentences] return sum(lengths) / len(lengths) def burstiness(text: str) -> float: """ Burstiness = coefficient of variation of sentence lengths. High burstiness → human (varied rhythm) Low burstiness → AI (robotic uniformity) Formula: std_dev / mean of sentence lengths A value near 0 means all sentences are the same length (AI signal). A value > 0.5 is typical human writing. """ sentences = sent_tokenize(text) if len(sentences) < 3: return 0.5 # Not enough data — return neutral lengths = [len(word_tokenize(s)) for s in sentences] mean = sum(lengths) / len(lengths) if mean == 0: return 0.0 variance = sum((l - mean) ** 2 for l in lengths) / len(lengths) std_dev = math.sqrt(variance) return std_dev / mean def type_token_ratio(text: str) -> float: """ TTR = unique words / total words. Low TTR → repetitive vocabulary (AI signal — LLMs reuse safe words) High TTR → diverse vocabulary (human signal) We use a windowed version (first 200 words) to avoid length bias. """ words = word_tokenize(text.lower()) # Use first 200 words to normalize for text length words = words[:200] if not words: return 0.0 unique = set(words) return len(unique) / len(words) def punctuation_diversity(text: str) -> float: """ Ratio of punctuation variety to text length. AI text tends to use periods and commas almost exclusively. Humans use dashes, ellipses, semicolons, exclamation marks more freely. """ diverse_punct = re.findall(r"[;:—–…!?]", text) total_chars = len(text) if total_chars == 0: return 0.0 return len(diverse_punct) / total_chars * 100 def repetition_score(text: str) -> float: """ Detects repeated phrases (3+ word n-grams that appear more than once). AI text often repeats phrases like "in the context of", "it is worth noting", "plays a crucial role". """ words = word_tokenize(text.lower()) if len(words) < 6: return 0.0 # Build trigrams trigrams = [ " ".join(words[i:i+3]) for i in range(len(words) - 2) ] # Count how many trigrams appear more than once seen = {} for tg in trigrams: seen[tg] = seen.get(tg, 0) + 1 repeated = sum(1 for count in seen.values() if count > 1) return repeated / len(trigrams) if trigrams else 0.0 def compute_stylometric_score(text: str) -> dict: """ Combines all features into a single AI-likelihood score (0–1) plus a breakdown of each signal. Scoring logic: - Low burstiness → more AI-like - Low TTR → more AI-like - Low punctuation diversity → more AI-like - High repetition → more AI-like Each feature is normalized and combined with empirically tuned weights. These weights are NOT magic — they're starting points. The evaluation phase will show whether they need adjustment. """ burst = burstiness(text) ttr = type_token_ratio(text) punct = punctuation_diversity(text) rep = repetition_score(text) avg_len = avg_sentence_length(text) # --- Normalize each feature to 0-1 AI likelihood --- # Burstiness: human ~0.4-0.8, AI ~0.1-0.3 # Lower burstiness = higher AI score burst_score = max(0, min(1, 1 - (burst / 0.6))) # TTR: only meaningful for texts with 150+ words. # Short texts have artificially high TTR (every word is unique), # which produces a false "human" signal and dilutes the score. words = word_tokenize(text.lower()) if len(words) >= 150: ttr_score = max(0, min(1, 1 - (ttr / 0.7))) else: ttr_score = 0.5 # Not enough text — use neutral value # Punctuation diversity: human > AI # Lower diversity = higher AI score punct_score = max(0, min(1, 1 - (punct / 0.5))) # Repetition: AI > human # Higher repetition = higher AI score rep_score = min(1.0, rep * 5) # Weighted combination # BERT is our primary signal — stylometrics is secondary/supporting weights = { "burstiness": 0.35, "ttr": 0.30, "punctuation": 0.20, "repetition": 0.15, } combined = ( burst_score * weights["burstiness"] + ttr_score * weights["ttr"] + punct_score * weights["punctuation"] + rep_score * weights["repetition"] ) return { "stylometric_ai_score": round(combined, 4), "features": { "burstiness": round(burst, 4), "avg_sentence_length": round(avg_len, 2), "type_token_ratio": round(ttr, 4), "punctuation_diversity": round(punct, 4), "repetition_score": round(rep, 4), }, "feature_scores": { "burstiness_ai_signal": round(burst_score, 4), "ttr_ai_signal": round(ttr_score, 4), "punctuation_ai_signal": round(punct_score, 4), "repetition_ai_signal": round(rep_score, 4), } }