Files
Clayton Kim 0f630c11be Owner-contributed patterns: slogan fragments and Every-template openers
Three AI-ism patterns wild-caught by the owner on his own marketing page,
added eval-first (rows appended red, then made green):

- slogan_fragment (banned_phrase_scan STRUCTURAL_PATTERNS, soft): standalone
  "N X, one Y." slogan-cadence line/header ("Four presets, one input.").
- spec_fragment (banned_phrase_scan STRUCTURAL_PATTERNS, soft): standalone
  "N noun-phrase, past-participle ..." spec fragment ("Eight criteria,
  scored 1 to 5.").
- every_template_openers (structure_scan, soft, min 2 paragraphs): repeated
  paragraph-initial "Every <noun> <verb>" template opener; a lone
  "Every child deserves a good school." stays clean.

Both banned-phrase patterns fire only on whole-line/header contexts, so
prose-embedded counts ("The unit has two bedrooms, one bath, and a den.",
"We rated eight criteria, scored 1 to 5, before deciding.") stay clean.
Regexes are linear (bounded interior runs, no adjacent unbounded overlaps).

Rows OWNER-01/02/03 (FN specimens) went red first, then green after the
scanner changes; OWNER-04 + FP-86/FP-87 guard the false-positive twins.
New categories mapped into pack-voice and documented in taboo-phrases.md.

Suite: 462 pass / 1 xfail / 0 fail. Coverage, schema, taboo parity, packs,
kata, seeded-docs, pairs, silhouette, voice, benchmark, model-parity green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CYksdLbXbTAxcAQjvHz5
2026-07-07 07:56:05 -07:00

323 lines
13 KiB
Python

#!/usr/bin/env python3
"""Scan prose for macro-structure AI-writing patterns."""
from __future__ import annotations
import argparse
import json
import math
import re
import sys
from collections import Counter
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from _lang import ( # noqa: E402
ENGLISH_FUNCTION_WORDS,
english_function_share,
is_probably_english,
paragraphs as _prose_paragraphs,
words,
)
from readability_metrics import split_sentences # noqa: E402
STOPWORDS = {
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of",
"with", "by", "from", "is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "do", "does", "did", "will", "would", "could",
"should", "may", "might", "must", "can", "it", "its", "this", "that",
"these", "those", "we", "you", "they", "i", "he", "she", "as", "than",
"if", "then", "so", "not", "no", "yes", "into", "over", "under",
}
CONNECTIVE_OPENERS = re.compile(
r"^(however|moreover|furthermore|additionally|in addition|overall|"
r"consequently|nevertheless)\b",
re.I,
)
# Paragraph-initial "Every <noun> <verb-phrase>" template opener ("Every
# transformation is scored ...", "Every rewrite passes ..."). As a RHYTHM tell
# this is about REPETITION, so the metric below fires only at 2+ such paragraph
# openers; a lone "Every child deserves a good school." stays clean. The verb is
# a copula/auxiliary or a present/past inflection (-s / -ed) so the second word
# reads as a predicate, not another noun.
EVERY_OPENER_RE = re.compile(
r"^every\s+[a-z][\w'-]*\s+(?:is|are|was|were|be|been|being|has|have|had|"
r"do|does|did|can|will|shall|must|should|would|may|might|"
r"[a-z]+(?:s|ed))\b",
re.I,
)
SIGNPOST_RE = re.compile(
r"\b(first,|next,|having (?:covered|established)|in (?:this|the following|"
r"the next) section|as (?:mentioned|noted) (?:above|earlier)|let us turn|"
r"let's turn)\b",
re.I,
)
CLOSER_RE = re.compile(
r",\s+(ensuring|highlighting|reflecting|allowing|enabling|underscoring|"
r"showcasing|emphasizing|fostering|driving|paving|reinforcing|solidifying|"
r"demonstrating|contributing to)\b[^.!?]*[.!?\"]?$",
re.I,
)
CODA_START_RE = re.compile(
r"^(ultimately,|in the end,|in conclusion|as we've seen|only time will tell|"
r"remember,|the future\b)",
re.I,
)
BOLD_COLON_RE = re.compile(r"^\s*[-*+]?\s*\*\*[^*]{1,40}\*\*\s*:", re.M)
def prose_paragraphs(text: str) -> list[str]:
return _prose_paragraphs(text, blank_blockquotes=True)
def cv(values: list[int]) -> float:
if not values:
return 0.0
mean = sum(values) / len(values)
if mean == 0:
return 0.0
return math.sqrt(sum((v - mean) ** 2 for v in values) / len(values)) / mean
def content_bigrams(text: str) -> set[tuple[str, str]]:
toks = [w for w in words(text) if len(w) > 3 and w not in STOPWORDS]
return set(zip(toks, toks[1:]))
def triad_count(text: str) -> int:
return len(re.findall(r"\b[A-Za-z][A-Za-z-]+,\s+[A-Za-z][A-Za-z-]+,\s+and\s+[A-Za-z][A-Za-z-]+\b", text))
def flag(metric: str, value: float | int, threshold: str, detail: str, suggestion: str) -> dict:
return {
"metric": metric,
"value": value,
"threshold": threshold,
"severity": "soft",
"detail": detail,
"suggestion": suggestion,
}
# Metrics a given --genre suppresses outright (the genre's normal shape trips
# the metric without being AI-slop). Table form so each suppression is a single
# lookup instead of an inline `genre != "..."` conditional per metric.
GENRE_SUPPRESSIONS = {
"docs": {"bold_colon_listicle"},
"social": {"one_line_staccato"},
}
def scan(text: str, genre: str = "prose") -> dict:
paragraphs = prose_paragraphs(text)
prose_text = "\n\n".join(paragraphs)
sentences = split_sentences(prose_text)
sentence_lengths = [len(words(s)) for s in sentences]
prose_words = words(prose_text)
para_lengths = [len(words(p)) for p in paragraphs]
metrics = {
"sentence_burstiness": round(cv(sentence_lengths), 3),
"summary_sandwich": 0.0,
"paragraph_cv": round(cv(para_lengths), 3),
"sentence_mean_len": round(sum(sentence_lengths) / len(sentence_lengths), 1) if sentence_lengths else 0,
"triad_density": round((triad_count(prose_text) / len(prose_words) * 1000), 3) if prose_words else 0,
"em_dash_per_1k": round((text.count("—") / len(prose_words) * 1000), 3) if prose_words else 0,
"bold_colon_listicle_count": len(BOLD_COLON_RE.findall(text)),
"one_line_staccato_share": 0.0,
"connective_paragraph_openers": 0,
"every_template_openers": 0,
"signpost_density": 0.0,
"opener_unique_ratio": 0.0,
"top_opener_share": 0.0,
"max_consecutive_opener": 0,
"participial_closer_share": 0.0,
"conclusion_coda": False,
}
flags = []
if len(paragraphs) >= 2:
first = content_bigrams(paragraphs[0])
last = content_bigrams(paragraphs[-1])
union = first | last
metrics["summary_sandwich"] = round(len(first & last) / len(union), 3) if union else 0.0
if len(sentences) >= 8 and metrics["sentence_burstiness"] < 0.55:
flags.append(flag(
"sentence_burstiness",
metrics["sentence_burstiness"],
"< 0.55 over at least 8 prose sentences",
"Sentence lengths are unusually uniform for running prose.",
"Vary sentence length and cadence; if this is formal reference prose, review before treating it as blocking.",
))
if len(paragraphs) >= 3:
coda = bool(CODA_START_RE.search(paragraphs[-1]))
if not coda:
coda = len(content_bigrams(paragraphs[0]) & content_bigrams(paragraphs[-1])) >= 2
metrics["conclusion_coda"] = coda
if coda:
flags.append(flag(
"conclusion_coda",
1,
"last paragraph starts with a stock coda or repeats 2+ first-paragraph content bigrams",
"The ending reads like a recap/moral coda instead of a concrete final point.",
"Cut the wrap-up or end on a specific fact; if this is an abstract or executive summary, judge the genre before changing it.",
))
if (metrics["bold_colon_listicle_count"] >= 3
and "bold_colon_listicle" not in GENRE_SUPPRESSIONS.get(genre, set())):
flags.append(flag(
"bold_colon_listicle",
metrics["bold_colon_listicle_count"],
">= 3 bold-label colon lines",
"The raw Markdown has repeated bold-label listicle formatting.",
"Convert to prose or plain bullets; if this is a reference doc, rerun with --genre docs.",
))
if paragraphs:
one_line = 0
for p in paragraphs:
ps = split_sentences(p)
if len(ps) == 1 and len(words(ps[0])) < 12:
one_line += 1
metrics["one_line_staccato_share"] = round(one_line / len(paragraphs), 3)
if (len(paragraphs) >= 6 and metrics["one_line_staccato_share"] > 0.6
and "one_line_staccato" not in GENRE_SUPPRESSIONS.get(genre, set())):
flags.append(flag(
"one_line_staccato",
metrics["one_line_staccato_share"],
"> 0.60 over at least 6 paragraphs",
"Most paragraphs are short single-sentence beats.",
"Merge related beats and vary paragraph length; if this is social copy, rerun with --genre social.",
))
connective = sum(1 for p in paragraphs if CONNECTIVE_OPENERS.search(p))
metrics["connective_paragraph_openers"] = connective
if connective >= 3 or (len(paragraphs) >= 8 and connective / len(paragraphs) > 0.4):
flags.append(flag(
"connective_paragraph_openers",
connective,
">= 3 paragraphs or > 40% of 8+ paragraphs",
"Paragraphs repeatedly open with formal transition words.",
"Replace scaffold openers with specific topic sentences; academic prose may justify some connectors.",
))
every_openers = sum(1 for p in paragraphs if EVERY_OPENER_RE.search(p))
metrics["every_template_openers"] = every_openers
if every_openers >= 2:
flags.append(flag(
"every_template_openers",
every_openers,
">= 2 paragraphs opening 'Every <noun> <verb>'",
"Paragraphs repeatedly open on the 'Every ___ is/does ...' template.",
"Vary the paragraph openings; a repeated Every-template is a machine rhythm tell even when each sentence is fine on its own.",
))
if prose_words:
signposts = len(SIGNPOST_RE.findall(prose_text))
metrics["signpost_density"] = round(signposts / len(prose_words) * 100, 3)
if len(prose_words) >= 150 and metrics["signpost_density"] > 0.6:
flags.append(flag(
"signpost_density",
metrics["signpost_density"],
"> 0.6 per 100 prose words, minimum 150 words",
"The text over-explains its own structure.",
"Remove roadmap language unless the genre is a textbook, legal brief, or long guide.",
))
if len(sentences) >= 5:
openers = []
for s in sentences:
ws = words(s)
if not ws:
continue
openers.append(ws[0])
enumeration = {"the", "a", "an", "section", "chapter", "figure", "table",
"step", "part", "appendix"}
counted = [o for o in openers if o not in enumeration]
top_count = 0
if counted:
counts = Counter(counted)
top_count = max(counts.values())
metrics["opener_unique_ratio"] = round(len(counts) / len(counted), 3)
metrics["top_opener_share"] = round(top_count / len(counted), 3)
run = max_run = 0
prev = None
for opener in counted:
run = run + 1 if opener == prev else 1
prev = opener
max_run = max(max_run, run)
metrics["max_consecutive_opener"] = max_run
top_repeat = metrics["top_opener_share"] > 0.25 and top_count >= 4
if metrics["opener_unique_ratio"] < 0.55 or top_repeat or max_run >= 4:
flags.append(flag(
"opener_repetition",
metrics["opener_unique_ratio"],
"unique ratio < 0.55, one opener > 25% with 4+ uses, or 4 consecutive identical openers",
"Sentence openings repeat in a template-like rhythm.",
"Rewrite repeated starts; step-by-step docs may need repeated imperative openers.",
))
if sentences:
closer_count = sum(1 for s in sentences if CLOSER_RE.search(s))
metrics["participial_closer_share"] = round(closer_count / len(sentences), 3)
if len(sentences) >= 8 and metrics["participial_closer_share"] >= 0.15:
flags.append(flag(
"participial_closer_share",
metrics["participial_closer_share"],
">= 0.15 over at least 8 prose sentences",
"Many sentences end with editorial -ing consequence tails.",
"Make the consequence concrete or cut the tail; analytical prose may allow an occasional closer.",
))
return {
"flags": flags,
"flagged": {f["metric"]: True for f in flags},
"metrics": metrics,
"genre": genre,
"prose_sentences": len(sentences),
"prose_paragraphs": len(paragraphs),
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", nargs="?")
parser.add_argument("--genre", choices=["prose", "docs", "social"], default="prose")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
if args.path:
path = Path(args.path)
if not path.exists():
print(f"Missing file: {path}", file=sys.stderr)
return 2
text = path.read_text(errors="replace")
else:
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
# English-only graceful decline, matching banned_phrase_scan.py.
result = scan(text, args.genre)
# Function-word absence alone is not evidence of a foreign language
# (imperative stacks and buzzword lists are English slop with few function
# words). Decline only when the heuristic fails AND nothing flagged.
if not result.get("flags") and not is_probably_english(text):
print(json.dumps({"non_english": True, "violations": [], "flags": []}, indent=2))
print("note: input appears non-English; scanner declined (English-only).", file=sys.stderr)
return 0
print(json.dumps(result, indent=2))
return 1 if result["flags"] else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))