mirror of
https://github.com/theclaymethod/unslop.git
synced 2026-09-19 01:45:09 +08:00
dfcfbc137c
# Conflicts: # scripts/_lang.py # scripts/silhouette_scan.py
391 lines
14 KiB
Python
391 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Scan prose for discourse-level silhouette AI-writing patterns.
|
|
|
|
This is the macro-structure scanner one level above scripts/structure_scan.py.
|
|
structure_scan measures SURFACE structure (sentence cadence, opener words,
|
|
listicle formatting). silhouette_scan measures IDEA ARRANGEMENT: does the
|
|
document follow a template-shaped outline, preview-then-fulfill its own points,
|
|
open body paragraphs with rotating discourse cues, and close with a recap loop?
|
|
|
|
Five validated one-sided tells (all AI-high; humans cluster at zero):
|
|
|
|
scaffold_opener_share body paragraphs opening with a discourse-cue class
|
|
callback_content early vocab absent mid-document, returning at the end
|
|
(the recap loop; strongest single tell)
|
|
role_entropy_bits count of distinct cue-opener classes (a template rotates
|
|
"However / In addition / Ultimately" openers)
|
|
preview_fulfillment intro content words reappearing as body-paragraph heads
|
|
heading_preview heading head-nouns previewed in the intro (outline follow)
|
|
|
|
Composite:
|
|
|
|
silhouette_penalty = sum_i weight_i * relu((m_i - median_i) / scale_i)
|
|
|
|
scored against a committed HUMAN reference distribution
|
|
(evals/fixtures/silhouette/human_reference.json), negative side clipped. The
|
|
document flags when silhouette_penalty >= 1.0.
|
|
|
|
Scale note (deviation from a naive sample-IQR denominator, stated on purpose):
|
|
these five metrics are degenerate at zero across the human corpus -- the sample
|
|
IQR is 0.0 for every one, so the literal (m - median)/IQR with a 0.05 floor
|
|
reduces to m/0.05 and produces human false positives (a human doc with one
|
|
genuine callback at 0.17 lands at penalty > 1.0). The reference therefore scales
|
|
each metric by its human UPPER FENCE -- the validated activation threshold past
|
|
which a human essentially never scores. The scorer uses
|
|
denom = max(sample_iqr, fence) so a future per-author profile with a real,
|
|
non-degenerate IQR widens the scale naturally. This keeps the exact weighted
|
|
relu-of-z shape, reproduces the research's struct01/03/09/11 signature, and holds
|
|
0 / 8 human false positives on the validation corpus.
|
|
|
|
Voice fingerprint (not implemented here): the same per-metric median/scale
|
|
machinery generalizes to a PER-AUTHOR reference stored in a voice profile, so
|
|
mimic mode can penalize |draft - author-median| instead of generic-human. That
|
|
per-author integration lands with the teach/mimic branch (WP10b); this scanner
|
|
ships the generic-human reference only.
|
|
|
|
stdlib only. JSON to stdout. Exit 1 on flag, 0 clean, 2 on a missing file.
|
|
"""
|
|
|
|
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 structure_scan import STOPWORDS as _STRUCTURE_STOPWORDS # noqa: E402
|
|
from _lang import ( # noqa: E402
|
|
is_probably_english,
|
|
paragraphs as _prose_paragraphs,
|
|
words,
|
|
)
|
|
|
|
|
|
REFERENCE_PATH = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "evals" / "fixtures" / "silhouette" / "human_reference.json"
|
|
)
|
|
|
|
# Flag the document when the composite reaches this penalty (research-validated).
|
|
PENALTY_THRESHOLD = 1.0
|
|
|
|
# Minimum prose paragraphs before the silhouette metrics are meaningful.
|
|
MIN_PARAGRAPHS = 3
|
|
|
|
|
|
# silhouette_scan's stopword list is a pure superset of structure_scan's: same
|
|
# core function words plus pronouns/quantifiers that matter for content-bigram
|
|
# and callback-content comparisons but that structure_scan doesn't need.
|
|
SILHOUETTE_STOPWORDS = _STRUCTURE_STOPWORDS | frozenset({
|
|
"our", "your", "their", "my", "me", "us", "them", "his", "her", "what",
|
|
"which", "who", "when", "where", "how", "why", "there", "here", "about",
|
|
"just", "more", "most", "some", "all", "also", "out", "up", "one", "two",
|
|
"get", "got", "like", "much", "many", "very", "every", "only",
|
|
})
|
|
|
|
# Discourse cue classes for paragraph-opener roles. Copied verbatim from the
|
|
# validated research prototype (scratchpad/research-silhouette/silhouette_probe.py).
|
|
ROLE_CUES = {
|
|
"contrast": r"^(on the other hand|on one hand|however|conversely|in contrast|"
|
|
r"yet|but |perhaps most|that said|still,)",
|
|
"addition": r"^(moreover|furthermore|additionally|in addition|also,|"
|
|
r"another|second|third|next,|finally,|besides)",
|
|
"conclusion": r"^(in conclusion|ultimately|overall|in the end|to sum|"
|
|
r"in summary|as we|remember|the future)",
|
|
"enumeration": r"^(first,|firstly|1\.|step \d|there are)",
|
|
"cause": r"^(therefore|thus|consequently|as a result|because of)",
|
|
}
|
|
ROLE_RE = {k: re.compile(v, re.I) for k, v in ROLE_CUES.items()}
|
|
|
|
|
|
def content(text: str) -> list[str]:
|
|
return [w for w in words(text) if len(w) > 3 and w not in SILHOUETTE_STOPWORDS]
|
|
|
|
|
|
def paragraphs(text: str) -> list[str]:
|
|
return _prose_paragraphs(text, strip_bold=True)
|
|
|
|
|
|
# ---------------- METRICS (verbatim from the validated prototype) ----------------
|
|
|
|
def m_scaffold_opener_share(paras: list[str]):
|
|
"""Share of body paragraphs opening with a discourse-connective/scaffold cue."""
|
|
body = paras[1:] if len(paras) > 1 else paras
|
|
if not body:
|
|
return 0.0
|
|
hits = 0
|
|
for p in body:
|
|
for rx in ROLE_RE.values():
|
|
if rx.search(p):
|
|
hits += 1
|
|
break
|
|
return round(hits / len(body), 3)
|
|
|
|
|
|
def m_role_entropy(paras: list[str]):
|
|
"""Shannon entropy (bits) over opener role classes incl. 'topic' (none).
|
|
A human never opens paragraphs with cue classes -> entropy 0; a template
|
|
rotates several distinct cue classes -> positive entropy."""
|
|
if len(paras) < 3:
|
|
return None
|
|
roles = []
|
|
for p in paras:
|
|
r = "topic"
|
|
for name, rx in ROLE_RE.items():
|
|
if rx.search(p):
|
|
r = name
|
|
break
|
|
roles.append(r)
|
|
counts = Counter(roles)
|
|
n = len(roles)
|
|
ent = -sum((c / n) * math.log2(c / n) for c in counts.values())
|
|
return round(ent, 3)
|
|
|
|
|
|
def m_preview_fulfillment(paras: list[str]):
|
|
"""Share of body paragraphs whose opening content word appears in the
|
|
intro paragraph (outline-following / preview-then-fulfill tell)."""
|
|
if len(paras) < 4:
|
|
return None
|
|
intro = set(content(paras[0]))
|
|
if not intro:
|
|
return 0.0
|
|
body = paras[1:-1] if len(paras) > 2 else paras[1:]
|
|
hits = tot = 0
|
|
for p in body:
|
|
cs = content(p)
|
|
if not cs:
|
|
continue
|
|
tot += 1
|
|
if cs[0] in intro:
|
|
hits += 1
|
|
return round(hits / tot, 3) if tot else 0.0
|
|
|
|
|
|
def m_callback_content(paras: list[str]):
|
|
"""Content words introduced in the first third, ABSENT from the middle,
|
|
reappearing in the last third: a recap loop, not a sustained topic."""
|
|
n = len(paras)
|
|
if n < 5:
|
|
return None
|
|
third = max(1, n // 3)
|
|
early = set().union(*[set(content(paras[i])) for i in range(third)])
|
|
mid = (set().union(*[set(content(paras[i])) for i in range(third, n - third)])
|
|
if n - 2 * third > 0 else set())
|
|
late = set().union(*[set(content(paras[i])) for i in range(n - third, n)])
|
|
cb = (early & late) - mid
|
|
return round(len(cb) / n, 3)
|
|
|
|
|
|
def m_heading_preview(text: str):
|
|
"""Share of ## heading head-nouns whose key word also appears in the intro
|
|
paragraph = outline preview-then-fulfill. Measures outline-following, not
|
|
heading presence, so it is retained under --genre docs."""
|
|
heads = re.findall(r"(?m)^\s{0,3}#{2,3}\s+(.*)$", text)
|
|
if len(heads) < 3:
|
|
return None
|
|
paras = paragraphs(text)
|
|
intro = set(content(paras[0])) if paras else set()
|
|
if not intro:
|
|
return 0.0
|
|
hit = 0
|
|
for h in heads:
|
|
hc = set(content(h))
|
|
if hc & intro:
|
|
hit += 1
|
|
return round(hit / len(heads), 3)
|
|
|
|
|
|
# Metric registry: name -> (paragraph-based? / text-based?, function).
|
|
PARA_METRICS = {
|
|
"scaffold_opener_share": m_scaffold_opener_share,
|
|
"role_entropy_bits": m_role_entropy,
|
|
"preview_fulfillment": m_preview_fulfillment,
|
|
"callback_content": m_callback_content,
|
|
}
|
|
TEXT_METRICS = {
|
|
"heading_preview": m_heading_preview,
|
|
}
|
|
METRIC_ORDER = [
|
|
"scaffold_opener_share",
|
|
"role_entropy_bits",
|
|
"heading_preview",
|
|
"preview_fulfillment",
|
|
"callback_content",
|
|
]
|
|
|
|
SUGGESTIONS = {
|
|
"scaffold_opener_share":
|
|
"Open body paragraphs on their own specific claim, not a discourse cue.",
|
|
"role_entropy_bits":
|
|
"Stop rotating 'However / In addition / Ultimately' scaffold openers.",
|
|
"heading_preview":
|
|
"Headings restate the intro's outline; let sections carry new ground.",
|
|
"preview_fulfillment":
|
|
"The body just fulfills an outline previewed in the intro; drop the preview.",
|
|
"callback_content":
|
|
"The ending loops back to opening vocabulary; end on a concrete final point.",
|
|
}
|
|
|
|
|
|
def compute_metrics(text: str, paras: list[str]) -> dict:
|
|
row = {}
|
|
for name in METRIC_ORDER:
|
|
if name in PARA_METRICS:
|
|
row[name] = PARA_METRICS[name](paras)
|
|
else:
|
|
row[name] = TEXT_METRICS[name](text)
|
|
return row
|
|
|
|
|
|
def load_reference(path: Path) -> dict:
|
|
data = json.loads(path.read_text())
|
|
return data["metrics"]
|
|
|
|
|
|
def relu(x: float) -> float:
|
|
return x if x > 0 else 0.0
|
|
|
|
|
|
def flag(metric, value, threshold, detail, suggestion) -> dict:
|
|
return {
|
|
"metric": metric,
|
|
"value": value,
|
|
"threshold": threshold,
|
|
"severity": "soft",
|
|
"detail": detail,
|
|
"suggestion": suggestion,
|
|
}
|
|
|
|
|
|
# Metrics a given --genre suppresses outright. See the docstring in scan()
|
|
# for why only callback_content is suppressed under --genre docs.
|
|
GENRE_SUPPRESSIONS = {
|
|
"docs": {"callback_content"},
|
|
}
|
|
|
|
|
|
def scan(text: str, reference: dict, genre: str = "prose") -> dict:
|
|
# Genre is a passthrough echoed for parity with structure_scan.
|
|
# --genre docs suppresses ONLY callback_content: reference docs, specs, and
|
|
# doctrine conventionally reprise opening themes at the end, which is not
|
|
# the essay recap coda the metric exists to catch (SIL rows pin both
|
|
# directions). heading_preview is deliberately retained under docs because
|
|
# it measures outline-following (a tell even in reference docs), and the
|
|
# metric already clears legitimate academic roadmaps (struct17) on its own.
|
|
# --genre social documents that loose social copy rarely has the
|
|
# >=3-paragraph structure these metrics need.
|
|
paras = paragraphs(text)
|
|
base = {
|
|
"genre": genre,
|
|
"prose_paragraphs": len(paras),
|
|
}
|
|
if len(paras) < MIN_PARAGRAPHS:
|
|
base.update({
|
|
"flags": [],
|
|
"flagged": {},
|
|
"metrics": None,
|
|
"penalty": None,
|
|
"note": f"fewer than {MIN_PARAGRAPHS} prose paragraphs; "
|
|
"silhouette metrics not scored",
|
|
})
|
|
return base
|
|
|
|
metrics = compute_metrics(text, paras)
|
|
contributions = {}
|
|
penalty = 0.0
|
|
flags = []
|
|
for name in METRIC_ORDER:
|
|
if name in GENRE_SUPPRESSIONS.get(genre, set()):
|
|
contributions[name] = 0.0
|
|
continue
|
|
ref = reference[name]
|
|
value = metrics[name]
|
|
if not isinstance(value, (int, float)):
|
|
contributions[name] = None
|
|
continue
|
|
median = ref["median"]
|
|
scale = max(ref["iqr"], ref["fence"])
|
|
weight = ref["weight"]
|
|
contribution = round(weight * relu((value - median) / scale), 3)
|
|
contributions[name] = contribution
|
|
penalty += contribution
|
|
# A metric individually clears the human fence when value >= fence,
|
|
# i.e. its contribution reaches its full weight.
|
|
if value >= ref["fence"]:
|
|
flags.append(flag(
|
|
name,
|
|
value,
|
|
f"human fence {ref['fence']} (weight {weight})",
|
|
f"{name} at {value} clears the human upper fence "
|
|
f"{ref['fence']}.",
|
|
SUGGESTIONS[name],
|
|
))
|
|
penalty = round(penalty, 3)
|
|
|
|
if penalty >= PENALTY_THRESHOLD:
|
|
flags.insert(0, flag(
|
|
"silhouette_penalty",
|
|
penalty,
|
|
f">= {PENALTY_THRESHOLD}",
|
|
"The document's idea arrangement matches a templated AI silhouette "
|
|
"(preview-then-fulfill, rotating scaffold openers, recap loop).",
|
|
"Rearrange around the actual argument instead of a symmetric outline; "
|
|
"cut previews and the closing recap.",
|
|
))
|
|
|
|
base.update({
|
|
"flags": flags,
|
|
"flagged": {f["metric"]: True for f in flags},
|
|
"metrics": metrics,
|
|
"contributions": contributions,
|
|
"penalty": penalty,
|
|
})
|
|
return base
|
|
|
|
|
|
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 not REFERENCE_PATH.exists():
|
|
print(f"Missing reference: {REFERENCE_PATH}", file=sys.stderr)
|
|
return 2
|
|
reference = load_reference(REFERENCE_PATH)
|
|
|
|
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")
|
|
|
|
result = scan(text, reference, args.genre)
|
|
|
|
if not result.get("flags") and not is_probably_english(text):
|
|
print(json.dumps({"non_english": True, "flags": [], "penalty": None}, indent=2))
|
|
print("note: input appears non-English; scanner declined (English-only).", file=sys.stderr)
|
|
return 0
|
|
|
|
print(json.dumps(result, indent=2))
|
|
is_flagged = bool(result.get("penalty") is not None
|
|
and result["penalty"] >= PENALTY_THRESHOLD)
|
|
return 1 if is_flagged else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|