# core/aggregator.py # Combines BERT score + stylometric score + perplexity into a final probability. from core.stylometrics import compute_stylometric_score from core.explainer import find_ai_phrases, generate_explanation from core.perplexity_scorer import perplexity_scorer from config.settings import settings def aggregate_scores( bert_result: dict, text: str, sentence_scores: list[dict], ) -> dict: """ Three-signal hybrid: - BERT (60%): neural classifier fine-tuned on AI vs human text - Perplexity (20%): GPT-2 perplexity — model-agnostic, works for any LLM - Stylometrics (20%): structural writing features Perplexity replaces pure BERT weight because it catches AI text that slop-detector-bert wasn't trained on (modern GPT-4/Claude output). """ stylo_result = compute_stylometric_score(text) stylo_score = stylo_result["stylometric_ai_score"] bert_prob = bert_result["ai_probability"] # Perplexity signal — lower perplexity = more predictable = more AI-like raw_perplexity = perplexity_scorer.get_perplexity(text) perplexity_score = perplexity_scorer.perplexity_to_ai_score(raw_perplexity) bert_weight = 0.60 perplexity_weight = 0.20 stylo_weight = 0.20 final_probability = ( bert_prob * bert_weight + perplexity_score * perplexity_weight + stylo_score * stylo_weight ) final_probability = round(max(0.0, min(1.0, final_probability)), 4) ai_phrases = find_ai_phrases(text) explanation = generate_explanation( ai_probability=final_probability, stylometric_data=stylo_result, sentence_scores=sentence_scores, ai_phrases=ai_phrases, perplexity=raw_perplexity, ) is_ai = final_probability >= settings.CONFIDENCE_THRESHOLD return { "ai_probability": final_probability, "is_ai": is_ai, "verdict": explanation["verdict"], "confidence_threshold": settings.CONFIDENCE_THRESHOLD, "scores": { "bert_score": bert_prob, "stylometric_score": round(stylo_score, 4), "bert_weight": bert_weight, "stylometric_weight": stylo_weight, }, "stylometric_features": stylo_result["features"], "sentence_scores": sentence_scores, "explanation": explanation, "chunks_analyzed": bert_result.get("chunks_analyzed", 1), "perplexity": round(raw_perplexity, 2) if raw_perplexity is not None else None, "perplexity_score": round(perplexity_score, 4), }