|
#!/usr/bin/env python3 |
|
""" |
|
Lint text for AI writing patterns from the anti-ai-writing guide. |
|
|
|
Usage: |
|
python check_text.py <file> |
|
echo "text" | python check_text.py - |
|
python check_text.py <file> --json |
|
python check_text.py <file> --severity warning # only warnings + errors |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import json |
|
import re |
|
import sys |
|
from collections import defaultdict |
|
from dataclasses import asdict, dataclass |
|
from typing import Iterable |
|
|
|
|
|
@dataclass |
|
class Issue: |
|
category: str |
|
severity: str |
|
line: int |
|
matched: str |
|
rule: str |
|
|
|
|
|
SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2} |
|
|
|
|
|
CATEGORIES: dict[str, dict] = { |
|
"puffery": { |
|
"severity": "error", |
|
"patterns": [ |
|
(r"\b(nestled|breathtaking|stunning|captivating|must-visit|groundbreaking|renowned|exemplifies|profound)\b", "promotional adjective"), |
|
(r"\bboasts?\s+a\b", "'boasts a'"), |
|
(r"\bcommitment\s+to\b(?!\s+(memory|the\s+\w+\s+of))", "'commitment to' (vague)"), |
|
(r"\bnatural\s+beauty\b", "'natural beauty'"), |
|
(r"\bin\s+the\s+heart\s+of\b", "'in the heart of'"), |
|
(r"\bgateway\s+to\b(?!\s+(the\s+)?(API|app|service|database|cluster))", "'gateway to' (figurative)"), |
|
(r"\bdiverse\s+(array|tapestry)\b", "'diverse array/tapestry'"), |
|
(r"\brich\s+(history|cultural\s+heritage|tapestry|legacy)\b", "'rich [history/heritage]'"), |
|
(r"\bfascinating\s+glimpse\b", "'fascinating glimpse'"), |
|
(r"\b(showcases?|showcasing|showcased)\b", "showcase verb"), |
|
(r"\b(scenic\s+landscapes|historical\s+landmarks)\b", "tourism puffery"), |
|
(r"\b(watershed\s+moment|key\s+turning\s+point|deeply\s+rooted|profound\s+heritage|steadfast\s+dedication|indelible\s+mark)\b", "puffery cliche"), |
|
(r"\b(continues?\s+to\s+captivate)\b", "'continues to captivate'"), |
|
(r"\bdependable\s+value-driven\s+experiences\b", "marketing jargon"), |
|
], |
|
}, |
|
"importance_emphasis": { |
|
"severity": "error", |
|
"patterns": [ |
|
(r"\b(stands|serves)\s+as\s+a\s+(testament|reminder|symbol|hub|celebration)\b", "'stands/serves as a testament'"), |
|
(r"\b(plays?|played)\s+a\s+(vital|significant|crucial|pivotal)\s+role\b", "'plays a [vital] role'"), |
|
(r"\b(pivotal|crucial)\s+moment\b", "'pivotal moment'"), |
|
(r"\b(underscores?|highlights?)\s+(its|their)\s+(importance|significance)\b", "'underscores its importance'"), |
|
(r"\breflects\s+broader\b", "'reflects broader'"), |
|
(r"\bsymbolizing\s+(its|their)\s+(ongoing|enduring|lasting)\b", "'symbolizing its ongoing'"), |
|
(r"\bsetting\s+the\s+stage\s+for\b", "'setting the stage for'"), |
|
(r"\b(marks|marking|shaping)\s+(the|a)\s+\w+\s+(in|of|for)\b", "'marking the X of'"), |
|
(r"\b(represents?|marks?)\s+a\s+shift\b", "'represents/marks a shift'"), |
|
(r"\bevolving\s+landscape\b", "'evolving landscape'"), |
|
(r"\bfocal\s+point\b", "'focal point'"), |
|
(r"\bcontributing\s+to\s+the\b", "'contributing to the'"), |
|
(r"\b(enduring|lasting)\s+(impact|legacy|influence)\b", "'enduring impact/legacy'"), |
|
(r"\bpreserving\s+this\s+\w+\s+is\s+vital\b", "'preserving this X is vital'"), |
|
(r"\bcrucial\s+for\s+the\s+survival\s+of\b", "'crucial for the survival of'"), |
|
], |
|
}, |
|
"superficial_participial": { |
|
"severity": "error", |
|
"patterns": [ |
|
(r"\b(highlighting|emphasizing|reflecting|underscoring|fostering|cultivating|demonstrating|confirming|illustrating|encompassing|ensuring)\s+(its|their|the)\s+(significance|importance|role|relevance|influence|ongoing|enduring|lasting|continued|rich)\b", "trailing -ing clause"), |
|
(r"\bcreating\s+a\s+(space|sense|community)\s+where\b", "'creating a space where'"), |
|
(r"\bembodying\s+the\s+spirit\s+of\b", "'embodying the spirit of'"), |
|
(r"\bfostering\s+a\s+sense\s+of\b", "'fostering a sense of'"), |
|
], |
|
}, |
|
"notability_media": { |
|
"severity": "error", |
|
"patterns": [ |
|
(r"\bindependent\s+coverage\b", "'independent coverage'"), |
|
(r"\b(local|regional|national)\s+media\s+outlets\b", "vague media outlets"), |
|
(r"\b(featured|cited|profiled|documented)\s+in\s+(archived|multiple|various|numerous)\b", "vague media coverage claim"), |
|
(r"\bmaintains\s+an?\s+(active|strong)\s+(social\s+media|digital)\s+presence\b", "'maintains an active social media presence'"), |
|
(r"\bsignificant,\s+substantial,\s+secondary\s+coverage\b", "stacked vague intensifiers"), |
|
(r"\brepeated\s+national\s+media\s+coverage\b", "'repeated national media coverage'"), |
|
(r"\bongoing\s+public\s+presence\s+in\s+respected\s+media\b", "'ongoing public presence'"), |
|
(r"\bwritten\s+by\s+a\s+leading\s+expert\b", "'written by a leading expert'"), |
|
], |
|
}, |
|
"vague_attribution": { |
|
"severity": "warning", |
|
"patterns": [ |
|
(r"\bindustry\s+reports?\s+(suggest|show|indicate|cite)\b", "'industry reports suggest'"), |
|
(r"\bobservers\s+have\s+cited\b", "'observers have cited'"), |
|
(r"\b(experts|some\s+critics)\s+argue\b", "'experts/critics argue'"), |
|
(r"\bresearchers\s+and\s+conservationists\b", "'researchers and conservationists'"), |
|
(r"\b(several|various|multiple)\s+(sources|publications)\s+(have|suggest|indicate)\b", "'several sources suggest'"), |
|
(r"\bdescribed\s+in\s+scholarship\b", "'described in scholarship' (no scholar named)"), |
|
(r"\bmodern\s+researchers\s+treat\b", "'modern researchers treat'"), |
|
], |
|
}, |
|
"challenges_future": { |
|
"severity": "error", |
|
"patterns": [ |
|
(r"\bdespite\s+(its|these|the)\s+(success|achievements|challenges|popularity)[\s\S]{1,200}(continues?\s+to\s+thrive|positions?\s+(it|them|the)|future\s+prospects|ongoing\s+initiatives|future\s+(growth|breakthroughs))", "challenges-and-future formula"), |
|
(r"^\s*#+\s+(challenges\s+and\s+future|future\s+outlook|future\s+directions)\s*$", "ritual 'Challenges and future' section"), |
|
], |
|
}, |
|
"negative_parallelism": { |
|
"severity": "warning", |
|
"patterns": [ |
|
(r"\bnot\s+(just|only)\b[\s\S]{1,80}\bbut\s+(also|rather)\b", "'not only...but also'"), |
|
(r"\b(it'?s|it\s+is)\s+not\s+(about|just)\b[\s\S]{1,60}\b(it'?s|it\s+is)\b", "'it's not about...it's'"), |
|
(r"\bis\s+not\s+\w+\s+but\s+\w+\b", "'is not X but Y'"), |
|
(r"\bno\s+\w+,\s+no\s+\w+,\s+just\b", "'no X, no Y, just'"), |
|
], |
|
}, |
|
"ritual_conclusions": { |
|
"severity": "error", |
|
"patterns": [ |
|
(r"^\s*(in\s+summary|in\s+conclusion|overall),", "ritual conclusion opener"), |
|
(r"^\s*#+\s+(conclusion|summary|future\s+outlook)\s*$", "standalone Conclusion/Summary section"), |
|
], |
|
}, |
|
"collaborative_meta": { |
|
"severity": "error", |
|
"patterns": [ |
|
(r"\bI\s+hope\s+this\s+helps\b", "'I hope this helps'"), |
|
(r"^\s*(certainly|of\s+course)!", "chatbot pleasantry"), |
|
(r"\b(would\s+you\s+like|let\s+me\s+know|I'?d\s+be\s+happy\s+to)\b", "interactive prompt"), |
|
(r"\bhere\s+is\s+a\s+(more\s+)?detailed\s+(breakdown|version)\b", "'here is a detailed breakdown'"), |
|
(r"\byou'?re\s+absolutely\s+right\b", "'you're absolutely right'"), |
|
(r"^\s*subject:\s+", "Subject: header"), |
|
(r"\bas\s+an?\s+(AI|large)\s+(language\s+)?(model|assistant)\b", "AI self-reference"), |
|
(r"\bI\s+am\s+open\s+to\s+(any|further)\s+(suggestions|feedback|guidance|input)\b", "open-to-feedback closer"), |
|
(r"\bif\s+(there\s+are|you\s+have)\s+specific\s+(areas|sections|concerns)\b", "'if you have specific concerns'"), |
|
(r"\bI\s+would\s+(greatly\s+)?appreciate\s+(your\s+)?(guidance|feedback|input)\b", "appreciation closer"), |
|
(r"\bhappy\s+to\s+address\s+any\s+further\s+concerns\b", "'happy to address concerns'"), |
|
(r"\bit'?s\s+(important|worth)\s+to\s+(note|remember|consider)\b", "'it's important to note'"), |
|
(r"\bworth\s+noting\s+that\b", "'worth noting that'"), |
|
], |
|
}, |
|
"knowledge_cutoff": { |
|
"severity": "error", |
|
"patterns": [ |
|
(r"\bas\s+of\s+my\s+(last|latest)\s+(update|training|knowledge)\b", "knowledge cutoff disclaimer"), |
|
(r"\bup\s+to\s+my\s+last\s+training\s+update\b", "training update disclaimer"), |
|
(r"\bbased\s+on\s+available\s+information\b", "'based on available information'"), |
|
(r"\bin\s+the\s+provided\s+sources\b", "'in the provided sources'"), |
|
(r"\bwhile\s+specific\s+details\s+are\s+(limited|scarce)\b", "'specific details are limited'"), |
|
(r"\bnot\s+widely\s+(available|documented|disclosed)\b", "'not widely available/documented'"), |
|
(r"\bkeeps?\s+(much\s+of\s+)?(his|her|their)\s+personal\s+(life|details)\s+private\b", "speculative privacy claim"), |
|
(r"\blikely\s+due\s+to\s+limited\s+(mainstream\s+)?exposure\b", "'likely due to limited exposure'"), |
|
(r"\bI'?ve?\s+inferred\b", "'I've inferred'"), |
|
(r"\bmy\s+analysis\s+is\s+based\s+on\s+available\b", "'my analysis is based on available'"), |
|
(r"\bmay\s+vary\s+depending\b", "'may vary depending'"), |
|
], |
|
}, |
|
"placeholder": { |
|
"severity": "error", |
|
"patterns": [ |
|
(r"\bINSERT[_\s]?(SOURCE[_\s]?URL|PUBLISHER|TEXT|CONTENT|DATE)\b", "INSERT placeholder"), |
|
(r"\bPASTE[_\s]?URL[_\s]?HERE\b", "PASTE placeholder"), |
|
(r"\bSOURCE[_\s]?PUBLISHER\b", "SOURCE_PUBLISHER placeholder"), |
|
(r"\b20\d\d-xx-xx\b", "fill-in date"), |
|
(r"\b20XX\b", "20XX placeholder"), |
|
(r"\[insert\s+[^\]]+\]", "[insert ...] placeholder"), |
|
], |
|
}, |
|
"ai_vocabulary": { |
|
# Single uses are info; clustering escalates to warning (handled separately). |
|
"severity": "info", |
|
"patterns": [ |
|
(r"\b(delve|delving|delved)\b", "delve"), |
|
(r"\b(crucial)\b", "crucial"), |
|
(r"\b(pivotal)\b", "pivotal"), |
|
(r"\b(tapestry)\b", "tapestry"), |
|
(r"\b(underscore[sd]?|underscoring)\b", "underscore"), |
|
(r"\b(intricate|intricacies)\b", "intricate"), |
|
(r"\b(meticulous(?:ly)?)\b", "meticulous"), |
|
(r"\b(garner(?:ed|ing)?)\b", "garner"), |
|
(r"\b(interplay)\b", "interplay"), |
|
(r"\b(testament)\b", "testament"), |
|
(r"\b(bolstered)\b", "bolstered"), |
|
(r"\b(enduring)\b", "enduring"), |
|
(r"\b(enhance[sd]?|enhancing)\b", "enhance"), |
|
(r"\b(foster(?:ing|ed)?)\b", "foster"), |
|
(r"\blandscape\b(?!\s+(painting|architect|garden|design|mode|orientation))", "landscape (abstract)"), |
|
(r"\b(vibrant)\b", "vibrant"), |
|
(r"\baligns?\s+with\b", "align with"), |
|
(r"\bvaluable\s+insights?\b", "valuable insights"), |
|
(r"\bresonates?\s+with\b", "resonate with"), |
|
(r"^\s*Additionally,", "sentence-initial 'Additionally'"), |
|
], |
|
}, |
|
"copulative_avoidance": { |
|
"severity": "warning", |
|
"patterns": [ |
|
(r"\b(serves?|stands?|marks?)\s+as\s+(a|an|the)\s+(major|key|critical|important|primary|leading|central|defining)\b", "copulative + intensifier (use is/are/has)"), |
|
(r"\brepresents?\s+a\s+(major|significant|critical|paradigm)\b", "'represents a [major]'"), |
|
(r"\b(features?|offers?)\s+(a|an)\s+(state-of-the-art|world-class|cutting-edge|innovative)\b", "promotional copulative"), |
|
], |
|
}, |
|
"inline_header_bullets": { |
|
"severity": "warning", |
|
"patterns": [ |
|
(r"^\s*[-•*]\s+\*\*[^*\n]+\*\*:\s+\S", "inline-header bullet (- **X**: text)"), |
|
(r"^\s*\d+\.\s+\*\*[^*\n]+\*\*:\s+\S", "inline-header numbered list"), |
|
], |
|
}, |
|
"false_range": { |
|
"severity": "warning", |
|
"patterns": [ |
|
(r"\b(ranges?|spans?|spanning|ranging)\s+from\s+\w+(\s+\w+){0,3}\s+to\s+\w+", "figurative 'from X to Y' range"), |
|
], |
|
}, |
|
"mechanical_triad": { |
|
"severity": "info", |
|
"patterns": [ |
|
(r"\b\w+,\s+\w+,\s+and\s+\w+\s+(alike|together|combined)\b", "rule-of-three closer"), |
|
], |
|
}, |
|
} |
|
|
|
|
|
TITLE_CASE_SKIP = { |
|
"a", "an", "the", "and", "or", "but", "of", "in", "on", "at", "to", "for", |
|
"with", "by", "from", "as", "is", "are", "vs", "via", |
|
} |
|
|
|
|
|
def find_line(text: str, offset: int) -> int: |
|
return text.count("\n", 0, offset) + 1 |
|
|
|
|
|
def check_title_case_heading(line: str, line_num: int) -> list[Issue]: |
|
m = re.match(r"^\s*(#+)\s+(.+?)\s*$", line) |
|
if not m: |
|
return [] |
|
heading = m.group(2) |
|
words = re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", heading) |
|
if len(words) < 3: |
|
return [] |
|
capitalized = [w for w in words if w[0].isupper() and w.lower() not in TITLE_CASE_SKIP] |
|
if len(capitalized) >= 3: |
|
return [Issue( |
|
category="formatting_tells", |
|
severity="warning", |
|
line=line_num, |
|
matched=heading[:120], |
|
rule="Title Case heading (use sentence case)", |
|
)] |
|
return [] |
|
|
|
|
|
def check_excessive_bold(text: str) -> list[Issue]: |
|
issues: list[Issue] = [] |
|
for line_num, line in enumerate(text.split("\n"), 1): |
|
bolds = re.findall(r"\*\*[^*\n]+\*\*", line) |
|
if len(bolds) >= 3: |
|
issues.append(Issue( |
|
category="formatting_tells", |
|
severity="warning", |
|
line=line_num, |
|
matched=f"{len(bolds)} bolded spans on one line", |
|
rule="excessive boldface clustering", |
|
)) |
|
return issues |
|
|
|
|
|
def check_em_dash_overuse(text: str) -> list[Issue]: |
|
issues: list[Issue] = [] |
|
line_cursor = 1 |
|
for paragraph in text.split("\n\n"): |
|
em_count = paragraph.count("—") |
|
if em_count >= 3: |
|
issues.append(Issue( |
|
category="formatting_tells", |
|
severity="info", |
|
line=line_cursor, |
|
matched=f"{em_count} em dashes in one paragraph", |
|
rule="em dash overuse", |
|
)) |
|
line_cursor += paragraph.count("\n") + 2 |
|
return issues |
|
|
|
|
|
def detect_vocab_clusters(singles: list[Issue]) -> list[Issue]: |
|
"""Escalate AI-vocab to warning when 3+ flagged terms appear within 5 lines.""" |
|
if len(singles) < 3: |
|
return [] |
|
by_line: dict[int, list[Issue]] = defaultdict(list) |
|
for issue in singles: |
|
by_line[issue.line].append(issue) |
|
line_nums = sorted(by_line) |
|
cluster_issues: list[Issue] = [] |
|
seen_lines: set[int] = set() |
|
for ln in line_nums: |
|
if ln in seen_lines: |
|
continue |
|
nearby = [l for l in line_nums if ln <= l <= ln + 5] |
|
nearby_count = sum(len(by_line[l]) for l in nearby) |
|
if nearby_count >= 3: |
|
terms = sorted({iss.matched for l in nearby for iss in by_line[l]}) |
|
cluster_issues.append(Issue( |
|
category="ai_vocabulary", |
|
severity="warning", |
|
line=ln, |
|
matched=", ".join(terms), |
|
rule=f"AI-vocabulary cluster ({nearby_count} flagged terms within ~5 lines)", |
|
)) |
|
seen_lines.update(nearby) |
|
return cluster_issues |
|
|
|
|
|
def check_text(text: str) -> list[Issue]: |
|
issues: list[Issue] = [] |
|
|
|
for category, info in CATEGORIES.items(): |
|
for pattern, rule in info["patterns"]: |
|
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE): |
|
issues.append(Issue( |
|
category=category, |
|
severity=info["severity"], |
|
line=find_line(text, match.start()), |
|
matched=match.group(0).strip()[:140], |
|
rule=rule, |
|
)) |
|
|
|
for line_num, line in enumerate(text.split("\n"), 1): |
|
issues.extend(check_title_case_heading(line, line_num)) |
|
|
|
issues.extend(check_excessive_bold(text)) |
|
issues.extend(check_em_dash_overuse(text)) |
|
|
|
vocab_singles = [i for i in issues if i.category == "ai_vocabulary" and i.severity == "info"] |
|
issues.extend(detect_vocab_clusters(vocab_singles)) |
|
|
|
return issues |
|
|
|
|
|
def filter_severity(issues: Iterable[Issue], min_severity: str) -> list[Issue]: |
|
threshold = SEVERITY_ORDER[min_severity] |
|
return [i for i in issues if SEVERITY_ORDER[i.severity] <= threshold] |
|
|
|
|
|
def main() -> int: |
|
parser = argparse.ArgumentParser(description="Lint text for AI writing patterns") |
|
parser.add_argument("file", help="File path, or '-' for stdin") |
|
parser.add_argument("--json", action="store_true", help="Output JSON") |
|
parser.add_argument("--severity", choices=["error", "warning", "info"], default="info", |
|
help="Minimum severity to report (default: info)") |
|
args = parser.parse_args() |
|
|
|
if args.file == "-": |
|
text = sys.stdin.read() |
|
else: |
|
with open(args.file, "r", encoding="utf-8") as f: |
|
text = f.read() |
|
|
|
issues = check_text(text) |
|
issues = filter_severity(issues, args.severity) |
|
issues.sort(key=lambda i: (i.line, SEVERITY_ORDER[i.severity], i.category)) |
|
|
|
if args.json: |
|
print(json.dumps([asdict(i) for i in issues], indent=2)) |
|
return 1 if any(i.severity == "error" for i in issues) else 0 |
|
|
|
if not issues: |
|
print("No AI writing patterns detected.") |
|
return 0 |
|
|
|
counts: dict[str, int] = defaultdict(int) |
|
for i in issues: |
|
counts[i.severity] += 1 |
|
summary = ", ".join(f"{counts[s]} {s}" for s in ("error", "warning", "info") if counts[s]) |
|
print(f"Found {len(issues)} pattern(s): {summary}\n") |
|
|
|
for issue in issues: |
|
print(f"Line {issue.line} [{issue.severity}] {issue.category}: {issue.rule}") |
|
print(f" -> \"{issue.matched}\"") |
|
print() |
|
|
|
return 1 if any(i.severity == "error" for i in issues) else 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(main()) |