# core/explainer.py # Generates human-readable explanations for WHY text was flagged. # This is what separates a production tool from a tutorial project. from config.settings import settings # AI writing markers — phrases that appear disproportionately in LLM output. # These were identified by analyzing the dataset manually. AI_PHRASES = [ "it is worth noting", "it is important to note", "in the context of", "plays a crucial role", "plays an important role", "serves as a", "as a result of", "in order to", "due to the fact that", "with respect to", "in terms of", "as mentioned", "furthermore", "moreover", "additionally", "consequently", "nevertheless", "it should be noted", "it can be seen", "in conclusion", "to summarize", "overall,", "notably,", "significantly,", "importantly,", "reflects", "highlights", "underscores", "illustrates", ] def find_ai_phrases(text: str) -> list[str]: """Find known AI marker phrases in the text.""" text_lower = text.lower() found = [phrase for phrase in AI_PHRASES if phrase in text_lower] return found def generate_explanation( ai_probability: float, stylometric_data: dict, sentence_scores: list[dict], ai_phrases: list[str], ) -> dict: """ Builds a structured explanation object that the frontend renders. Design principle: always explain uncertainty. We never say "this IS AI" — we say "these signals suggest AI". """ reasons = [] confidence_label = _confidence_label(ai_probability) features = stylometric_data.get("features", {}) feature_scores = stylometric_data.get("feature_scores", {}) # --- Reason 1: BERT model signal --- if ai_probability > 0.75: reasons.append({ "signal": "Neural classifier", "detail": f"The AI detection model assigned a {ai_probability:.0%} probability of AI authorship.", "weight": "high", }) elif ai_probability > 0.55: reasons.append({ "signal": "Neural classifier", "detail": f"The AI detection model found moderate signals of AI authorship ({ai_probability:.0%}).", "weight": "medium", }) # --- Reason 2: Burstiness (sentence length variation) --- burst = features.get("burstiness", 0.5) if burst < 0.25: reasons.append({ "signal": "Uniform sentence rhythm", "detail": f"Sentence lengths are unusually uniform (burstiness: {burst:.2f}). Human writing varies more.", "weight": "high", }) elif burst < 0.35: reasons.append({ "signal": "Low sentence variation", "detail": f"Sentence lengths show limited variation (burstiness: {burst:.2f}).", "weight": "medium", }) # --- Reason 3: Type-token ratio (vocabulary diversity) --- ttr = features.get("type_token_ratio", 0.7) if ttr < 0.5: reasons.append({ "signal": "Limited vocabulary diversity", "detail": f"The text reuses words frequently (TTR: {ttr:.2f}). AI text tends to be lexically repetitive.", "weight": "medium", }) # --- Reason 4: AI marker phrases --- if len(ai_phrases) >= 3: phrase_list = ", ".join(f'"{p}"' for p in ai_phrases[:4]) reasons.append({ "signal": "AI marker phrases", "detail": f"Found {len(ai_phrases)} common AI phrases: {phrase_list}.", "weight": "medium", }) elif len(ai_phrases) >= 1: phrase_list = ", ".join(f'"{p}"' for p in ai_phrases[:2]) reasons.append({ "signal": "AI transition words", "detail": f"Found phrases common in AI text: {phrase_list}.", "weight": "low", }) # --- Reason 5: Repetition --- rep = features.get("repetition_score", 0) if rep > 0.1: reasons.append({ "signal": "Phrase repetition", "detail": f"Repeated phrases detected (score: {rep:.2f}). LLMs often loop back to the same constructions.", "weight": "medium", }) # --- Highlight the most suspicious sentences --- suspicious = [ s for s in sentence_scores if s.get("ai_probability", 0) > 0.7 and s.get("reliable", False) ] suspicious.sort(key=lambda x: x["ai_probability"], reverse=True) top_suspicious = suspicious[:3] return { "verdict": confidence_label, "ai_probability": ai_probability, "reasons": reasons, "top_suspicious_sentences": top_suspicious, "uncertainty_note": _uncertainty_note(ai_probability), } def _confidence_label(prob: float) -> str: """Convert probability to human-readable verdict.""" if prob >= 0.85: return "Very likely AI-generated" elif prob >= 0.70: return "Likely AI-generated" elif prob >= 0.55: return "Possibly AI-generated" elif prob >= 0.45: return "Uncertain — could be either" elif prob >= 0.30: return "Possibly human-written" else: return "Likely human-written" def _uncertainty_note(prob: float) -> str: """ Always include an uncertainty note. This is non-negotiable in any honest AI detection system. """ if 0.4 <= prob <= 0.6: return ( "This text falls in the uncertain range. " "The model cannot confidently distinguish AI from human authorship here. " "Do not use this result for any consequential decision." ) elif prob > 0.8: return ( "While the model is fairly confident, no AI detector is perfect. " "Heavily edited AI text and formal human writing can both score high." ) else: return ( "AI detectors have meaningful false-positive and false-negative rates. " "Treat this as one signal among many, not a definitive verdict." )