#!/usr/bin/env python3 """ Standalone heuristic humanizer / AI-tells checker. Stdlib only: no pip packages, no ML models, no network, no external files. Usage: python3 humanizer.py python3 humanizer.py --text "your draft here" python3 humanizer.py check --channel email --text "..." echo "draft" | python3 humanizer.py - Exit codes: 0 = passed, 1 = failed heuristics, 2 = usage error. """ from __future__ import annotations import json import math import re import sys from collections import Counter # --------------------------------------------------------------------------- # Banned single words (common LLM vocabulary tells) # --------------------------------------------------------------------------- BANNED_WORDS = [ "delve", "delves", "delved", "delving", "tapestry", "landscape", "realm", "realms", "testament", "pivotal", "meticulous", "meticulously", "underscore", "underscores", "underscored", "underscoring", "showcase", "showcases", "showcasing", "foster", "fosters", "fostering", "harness", "harnesses", "harnessing", "utilize", "utilizes", "utilizing", "utilisation", "utilization", "facilitate", "facilitates", "facilitating", "embark", "embarks", "embarking", "nestled", "nestling", "elevate", "elevates", "elevating", "empower", "empowers", "empowering", "empowerment", "streamline", "streamlines", "streamlining", "robust", "seamless", "seamlessly", "cutting-edge", "groundbreaking", "game-changer", "gamechanger", "leverage", "leverages", "leveraging", "synergy", "synergies", "holistic", "multifaceted", "nuanced", "intricate", "interplay", "plethora", "myriad", "plethora", "commendable", "notable", "noteworthy", "comprehensive", "crucial", "vital", "furthermore", "moreover", "consequently", "nevertheless", "thereby", "wherein", "heretofore", "aforementioned", "henceforth", "intriguing", "captivating", "compelling", "revolutionary", "transformative", "innovative", "ecosystem", "paradigm", "frameworks", "navigate", "navigating", "navigates", "unpack", "unpacks", "unpacking", "resonate", "resonates", "resonating", "bolster", "bolsters", "bolstering", "spearhead", "spearheads", "spearheading", "underscore", "illuminate", "illuminates", "elucidate", "elucidates", "paramount", "indispensable", ] # Multi-word / phrase AI tells (case-insensitive substring or regex) BANNED_PHRASES = [ r"\bin today's (fast-paced|digital|ever-changing) world\b", r"\bin the ever-evolving\b", r"\bit'?s important to (note|remember|understand|consider)\b", r"\bit is important to (note|remember|understand|consider)\b", r"\bat the end of the day\b", r"\bwhen it comes to\b", r"\ba testament to\b", r"\bplays? a (crucial|vital|pivotal|key) role\b", r"\bin conclusion\b", r"\bto summarize\b", r"\bto sum up\b", r"\bin summary\b", r"\bas previously mentioned\b", r"\bas mentioned (earlier|above|previously)\b", r"\blet'?s dive (in|into|deeper)\b", r"\bdive into\b", r"\bunlock the potential\b", r"\btake a deep dive\b", r"\bdeep dive\b", r"\bmoving forward\b", r"\bgoing forward\b", r"\bat its core\b", r"\bin a nutshell\b", r"\bthe landscape of\b", r"\brich tapestry\b", r"\bvibrant tapestry\b", r"\bdigital landscape\b", r"\bever-changing landscape\b", r"\bgame[- ]changer\b", r"\bcutting[- ]edge\b", r"\bstate[- ]of[- ]the[- ]art\b", r"\bbest[- ]in[- ]class\b", r"\bworld[- ]class\b", r"\bnot only .+ but also\b", r"\bwhether you'?re .+ or\b", r"\bfrom .+ to .+, .+ has\b", r"\bin this article,? we (will|'?ll)\b", r"\bthis comprehensive guide\b", r"\bwithout further ado\b", r"\bhope this helps\b", r"\bfeel free to\b", r"\bdon'?t hesitate to\b", r"\bi hope this (email|message) finds you\b", r"\breach out if\b", r"\btouch base\b", r"\bcircle back\b", r"\bsynerg(?:y|ies|istic)\b", r"\bleverage (our|your|their|this|these|the)\b", # GPTZero 4.1o rhetorical pivots (from detector learnings) r"\bthat is not .+, that is\b", r"\bthat'?s not .+, that'?s\b", r"\bit doesn'?t just .+[;,] it\b", r"\bmeanwhile,?\b.{0,80}\bwhile\b", r"\bcontrast that with\b", r"\bhalf of .+ is .+, and the other half\b", r"\bfighting over scraps\b", r"\bexit liquidity\b", r"\bthe math simply breaks\b", r"\bno account needed\.?\s*no human in the loop\b", r"\bno human in the loop\b", ] # Grammar / apostrophe / sloppy patterns (ported from email QA) SLOPPY_PATTERNS = [ (r"\byou buying\b", "Missing auxiliary: use 'Are you buying' instead of 'you buying'."), (r"\byou looking\b", "Missing auxiliary: use 'Are you looking' instead of 'you looking'."), (r"\bill\b", "Missing apostrophe/capitalization: use \"I'll\" instead of 'ill'."), (r"\bim\b", "Missing apostrophe/capitalization: use \"I'm\" instead of 'im'."), (r"\bi\b", "Lowercase pronoun 'i': always capitalize 'I'."), (r"\bdont\b", "Missing apostrophe: use \"don't\" instead of 'dont'."), (r"\bwont\b", "Missing apostrophe: use \"won't\" instead of 'wont'."), (r"\bcant\b", "Missing apostrophe: use \"can't\" instead of 'cant'."), (r"\bwhats\b", "Missing apostrophe: use \"what's\" instead of 'whats'."), (r"\bits\b(?=\s+(?:a|an|the|my|our|important|clear|worth)\b)", "Possible missing apostrophe: did you mean \"it's\"?"), (r"[ \t]{2,}", "Multiple consecutive spaces detected."), ] # Expected contractions for "sounds human" soft check CONTRACTION_FORMS = [ ("I am ", "I'm"), ("I will ", "I'll"), ("I would ", "I'd"), ("we are ", "we're"), ("we will ", "we'll"), ("do not ", "don't"), ("does not ", "doesn't"), ("cannot ", "can't"), ("will not ", "won't"), ("it is ", "it's"), ("that is ", "that's"), ("you are ", "you're"), ("they are ", "they're"), ] CHANNEL_WORD_LIMITS = { "whatsapp": 55, "email": 120, "dm": 80, "generic": None, } EMAIL_ADDR_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+|\n+") WORD_RE = re.compile(r"[A-Za-z0-9']+") def _sentences(text: str) -> list[str]: parts = [s.strip() for s in SENTENCE_SPLIT.split(text.strip()) if s.strip()] return parts def _words(text: str) -> list[str]: return WORD_RE.findall(text) def _paragraphs(text: str) -> list[str]: return [p.strip() for p in re.split(r"\n\s*\n", text.strip()) if p.strip()] def stddev(nums: list[float]) -> float: if len(nums) < 2: return 0.0 mean = sum(nums) / len(nums) var = sum((x - mean) ** 2 for x in nums) / len(nums) return math.sqrt(var) def check_punctuation(text: str) -> list[str]: issues = [] if "—" in text: issues.append("PUNCTUATION: Contains em-dash (—). Use a period, comma, or rewrite as two sentences.") if "–" in text: issues.append("PUNCTUATION: Contains en-dash (–). Use a hyphen only inside compound words, or rewrite.") if " - " in text: issues.append("PUNCTUATION: Contains spaced hyphen separator (' - '). Prefer a period or comma.") return issues def check_banned(text: str) -> list[str]: issues = [] lower = text.lower() # words found_words = [] for w in _words(text): if w.lower() in BANNED_WORDS: found_words.append(w.lower()) if found_words: uniq = sorted(set(found_words)) issues.append(f"BANNED WORD(S): {', '.join(uniq)}") # phrases for pat in BANNED_PHRASES: m = re.search(pat, text, flags=re.IGNORECASE | re.DOTALL) if m: snippet = m.group(0).replace("\n", " ") if len(snippet) > 60: snippet = snippet[:57] + "..." issues.append(f"BANNED PHRASE: '{snippet}'") return issues def check_grammar(text: str) -> list[str]: issues = [] for pat, msg in SLOPPY_PATTERNS: flags = re.IGNORECASE if pat != r"\bi\b" else 0 if re.search(pat, text, flags=flags): issues.append(f"GRAMMAR/STYLE: {msg}") # sentence capitalization for s in _sentences(text): s_clean = s.strip() if s_clean and s_clean[0].isalpha() and not s_clean[0].isupper(): preview = s_clean[:40] + ("..." if len(s_clean) > 40 else "") issues.append(f"CAPITALIZATION: Sentence starts lowercase: '{preview}'") # ending punctuation (allow bare name sign-off) stripped = text.strip() body_end = stripped lines = stripped.splitlines() if len(lines) > 1 and re.fullmatch(r"[A-Z][\w'.-]*(?:\s+[A-Z][\w'.-]*)?", lines[-1].strip() or ""): body_end = "\n".join(lines[:-1]).rstrip() prev = body_end.splitlines()[-1].strip() if body_end else "" if re.fullmatch( r"(best|thanks|thank you|cheers|regards|best regards|kind regards|" r"all the best|talk soon|warmly),?", prev, flags=re.IGNORECASE, ): body_end = "\n".join(body_end.splitlines()[:-1]).rstrip() or "." if body_end and body_end[-1] not in ".!?": issues.append("PUNCTUATION: Message does not end with ., ! or ?.") return issues def check_contractions(text: str) -> list[str]: """Soft flag: expanded forms that humans usually contract in casual prose.""" issues = [] hits = [] for full, short in CONTRACTION_FORMS: if full.lower() in text.lower(): hits.append(f"'{full.strip()}' -> prefer '{short}'") if hits: issues.append("CONTRACTIONS (soft): " + "; ".join(hits[:6])) return issues def check_burstiness(text: str) -> tuple[list[str], dict]: """Sentence- and paragraph-length variation stats.""" issues = [] sents = _sentences(text) lengths = [len(_words(s)) for s in sents if _words(s)] paras = _paragraphs(text) para_lens = [len(_words(p)) for p in paras] stats = { "sentence_count": len(lengths), "sentence_word_lengths": lengths, "sentence_mean": round(sum(lengths) / len(lengths), 2) if lengths else 0.0, "sentence_stddev": round(stddev([float(x) for x in lengths]), 2) if lengths else 0.0, "sentence_min": min(lengths) if lengths else 0, "sentence_max": max(lengths) if lengths else 0, "paragraph_count": len(para_lens), "paragraph_word_lengths": para_lens, "paragraph_stddev": round(stddev([float(x) for x in para_lens]), 2) if para_lens else 0.0, } if len(lengths) >= 4: # Low burstiness: almost every sentence same length if stats["sentence_stddev"] < 3.0 and stats["sentence_mean"] >= 12: issues.append( f"BURSTINESS: Sentence lengths are too uniform " f"(mean={stats['sentence_mean']}, stddev={stats['sentence_stddev']}). " "Alternate short 7-14 word lines with longer 25-45 word ones." ) # All long run-ons if stats["sentence_min"] >= 20 and stats["sentence_mean"] >= 30: issues.append( "BURSTINESS: Every sentence is long (min>=20 words). " "Add short 1-sentence observations (7-14 words)." ) # All tiny if stats["sentence_max"] <= 14 and len(lengths) >= 5: issues.append( "BURSTINESS: Every sentence is short (max<=14). " "Add a concrete 25-45 word paragraph." ) if len(para_lens) >= 3: # Uniform paragraphs if stats["paragraph_stddev"] < 4.0 and max(para_lens) - min(para_lens) <= 8: issues.append( f"PARAGRAPH UNIFORMITY: Paragraph lengths nearly identical " f"{para_lens}. Vary sharply." ) # Single-sentence run-on paragraphs of similar length (GPTZero 4.1o trigger) one_sent_paras = sum(1 for p in paras if len(_sentences(p)) == 1) if one_sent_paras == len(paras) and all(25 <= n <= 70 for n in para_lens): issues.append( "BURSTINESS: Every paragraph is one long sentence of similar length. " "This zero-burstiness pattern fails GPTZero Model 4.1o." ) return issues, stats def check_three_item_lists(text: str) -> list[str]: """Flag 3+ comma-separated item enumerations (GPTZero 4.1o trigger).""" issues = [] # Rough: "a X, a Y, or a Z" / "X, Y, and Z" pats = [ r"\b[\w$€£%-]+(?:\s+[\w$€£%-]+){0,4},\s+[\w$€£%-]+(?:\s+[\w$€£%-]+){0,4}," r"\s+(?:and|or)\s+[\w$€£%-]+", ] for pat in pats: for m in re.finditer(pat, text, flags=re.IGNORECASE): snippet = m.group(0) if len(snippet) > 80: snippet = snippet[:77] + "..." issues.append( f"LIST ENUMERATION: 3+ item list flagged ('{snippet}'). " "Keep examples to at most two items joined by 'and'/'or'." ) break # one is enough return issues def check_repeated_openers(text: str) -> list[str]: issues = [] sents = _sentences(text) openers = [] for s in sents: ws = _words(s) if not ws: continue # first 1-2 words as opener key key = " ".join(w.lower() for w in ws[:2]) openers.append(key) counts = Counter(openers) repeats = [f"'{k}' x{v}" for k, v in counts.items() if v >= 3] if repeats: issues.append( "REPEATED OPENERS: " + ", ".join(repeats) + ". Vary how sentences start." ) return issues def check_channel_limits(text: str, channel: str) -> list[str]: issues = [] limit = CHANNEL_WORD_LIMITS.get((channel or "generic").lower()) n = len(text.split()) if limit is not None and n > limit: issues.append(f"LENGTH: {channel} message exceeds {limit} words ({n} words).") return issues def check_privacy_generic(text: str) -> list[str]: """Generic privacy: flag bare email addresses in body (shareable rule).""" issues = [] if EMAIL_ADDR_RE.search(text): issues.append( "PRIVACY: Email address found in message body. " "Keep contact details out of cold copy unless required." ) return issues def score_heuristic(text: str) -> dict: """ Produce a 0-100 'human-likeness' heuristic score from rule hits. This is NOT a neural detector. It only reflects structural/lexical tells. """ penalty = 0 # weight buckets by severity later via issue prefixes return {"_placeholder": True} # filled in evaluate_text def evaluate_text(text: str, channel: str = "generic") -> dict: text = text or "" issues: list[str] = [] issues.extend(check_punctuation(text)) issues.extend(check_banned(text)) issues.extend(check_grammar(text)) soft = check_contractions(text) issues.extend(check_three_item_lists(text)) issues.extend(check_repeated_openers(text)) burst_issues, burst_stats = check_burstiness(text) issues.extend(burst_issues) issues.extend(check_channel_limits(text, channel)) issues.extend(check_privacy_generic(text)) # Soft contraction hints do not fail the run by themselves, but are reported warnings = soft # Heuristic score: start at 100, subtract score = 100.0 for iss in issues: if iss.startswith("BANNED WORD"): score -= 8 elif iss.startswith("BANNED PHRASE"): score -= 10 elif iss.startswith("BURSTINESS") or iss.startswith("PARAGRAPH"): score -= 12 elif iss.startswith("LIST ENUMERATION"): score -= 10 elif iss.startswith("PUNCTUATION") and ("em-dash" in iss or "en-dash" in iss): score -= 15 elif iss.startswith("REPEATED OPENERS"): score -= 6 elif iss.startswith("GRAMMAR") or iss.startswith("CAPITALIZATION"): score -= 5 elif iss.startswith("LENGTH"): score -= 5 elif iss.startswith("PRIVACY"): score -= 10 else: score -= 4 for _ in warnings: score -= 2 score = max(0.0, min(100.0, score)) hard_fail_prefixes = ( "BANNED", "PUNCTUATION: Contains em-dash", "PUNCTUATION: Contains en-dash", "PUNCTUATION: Contains spaced", "GRAMMAR", "CAPITALIZATION", "BURSTINESS", "PARAGRAPH", "LIST ENUMERATION", "REPEATED OPENERS", "LENGTH", "PRIVACY", ) hard_issues = [ i for i in issues if any(i.startswith(p) for p in hard_fail_prefixes) ] # Ending-punctuation and soft contraction are warnings unless other hard issues passed = len(hard_issues) == 0 and score >= 70 return { "ok": passed, "passed": passed, "verdict": "HUMAN_HEURISTIC" if passed else "AI_OR_RULE_VIOLATION", "channel": channel, "words": len(text.split()), "heuristic_human_score": round(score, 2), "note": ( "Stdlib heuristic only. Neural 6-model ensemble and live GPTZero " "are NOT run by this standalone script." ), "burstiness": burst_stats, "issue_count": len(issues) + len(warnings), "issues": issues, "warnings": warnings, } def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) if not argv: print( "Usage: python3 humanizer.py | --text \"...\" | -\n" " python3 humanizer.py check --channel email --text \"...\"", file=sys.stderr, ) return 2 channel = "generic" text = None if argv[0] == "check": i = 1 while i < len(argv): if argv[i] == "--channel" and i + 1 < len(argv): channel = argv[i + 1] i += 2 elif argv[i] == "--text" and i + 1 < len(argv): text = argv[i + 1] i += 2 elif argv[i] == "--file" and i + 1 < len(argv): with open(argv[i + 1], "r", encoding="utf-8") as f: text = f.read() i += 2 else: i += 1 elif argv[0] == "--text" and len(argv) >= 2: text = argv[1] if "--channel" in argv: j = argv.index("--channel") if j + 1 < len(argv): channel = argv[j + 1] elif argv[0] == "-": text = sys.stdin.read() else: path = argv[0] try: with open(path, "r", encoding="utf-8") as f: text = f.read() except OSError: text = path # treat as literal text if "--channel" in argv: j = argv.index("--channel") if j + 1 < len(argv): channel = argv[j + 1] if text is None: print("Error: no text provided", file=sys.stderr) return 2 result = evaluate_text(text, channel=channel) print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 if result["ok"] else 1 if __name__ == "__main__": sys.exit(main())