diff --git a/agents/geo-analyzer.md b/agents/geo-analyzer.md index ad26ddf..5d5cd77 100644 --- a/agents/geo-analyzer.md +++ b/agents/geo-analyzer.md @@ -551,6 +551,13 @@ sample of a 300-page site says nothing about the other 294. pronouns? 8. **Lists/tables vs prose** — structured where possible? 9. **30/70 rule** (if city/service variants exist) — ≥70% unique? +10. **Filler/AI-slop signal (deterministic)** — feed each sampled page's + body text to `fetch.sh content_quality`. It is a DETERMINISTIC input + that INFORMS checks 1-9 (word-list/density heuristics, no LLM call); + it never replaces your read of them. A low `overall_quality` or a + `filler`/`ai-patterns` flag is a candidate for human review, not an + automatic finding — do not let the number become the verdict, and do + not claim a page "is AI-written" from it. ### Sampling command @@ -561,6 +568,11 @@ for f in index.html $(find . "${FEXCL[@]}" -maxdepth 3 \( -name "*.astro" -o -na echo "=== $f ===" grep -oE '<(h1|h2|h3)[^>]*>[^<]+|^#{1,3} .+' "$f" 2>/dev/null | head -20 done + +# Filler/AI-slop signal (Check 10) — strip markup to plain body text, then +# score it. Advisory only: pair the number with your own read of Checks 1-9. +sed -e 's/<[^>]*>//g' index.html | \ + bash ~/.claude/lib/seo-data/fetch.sh content_quality ``` ### Findings @@ -576,6 +588,9 @@ CITED STATISTICS : FRESHNESS VISIBLE : PRONOUN-HEAVY : 30/70 RULE : pass | fail | N/A +FILLER/AI-SLOP SIGNAL : /100, flags: + (deterministic, advisory — informs checks 1-9, never + a verdict, never scored on its own) PRIORITY ACTIONS : ``` diff --git a/lib/seo-data/README.md b/lib/seo-data/README.md index 3b457d0..1f277c5 100644 --- a/lib/seo-data/README.md +++ b/lib/seo-data/README.md @@ -248,6 +248,41 @@ fetch.sh schema_gen [flags] [--script-tag empty (argparse cannot catch that) → `{"status":"degraded",...}`, exit 0 — fail-open, never a traceback. +fetch.sh content_quality [--file ] < text_on_stdin + → {"status":"ok","source":"content_quality","filler_score":0,"ai_pattern_score":0, + "information_density":1.0,"overall_quality":90,"flags":[], + "matches":{"filler":[],"ai_patterns":[]}} + → {"status":"degraded","reason":"empty_input"|""} + + fetch.sh content_quality --file article.txt + printf '%s' "$BODY_TEXT" | fetch.sh content_quality + + Adapted from claude-seo's `content_quality.py` (MIT) into this contract. + 100% deterministic — regex/word-lists (QRG §4.6 filler phrases + a + Wikipedia "AI Cleanup" catalogue of LLM-typical phrasings, CC BY-SA 4.0), + no LLM call, no network. Reads the text to score from `--file ` or, + when `--file` is `-` or omitted, from stdin — the same idiom `score.py` + uses for `--findings`. + • **ADVISORY, NOT A VERDICT.** The output never claims "this text is + AI-written" — modern generative tools can pass every heuristic here, + and human writers use some of these phrases too. `flags` are + candidates for HUMAN REVIEW, never an automatic finding. geo-analyzer + STEP 8 (Content Shape for AI) treats `overall_quality`/`flags` as ONE + measured input that INFORMS the axis; the axis itself stays an LLM + judgement (30/70, Definition Lead), never replaced by this score. + • `filler_score`/`ai_pattern_score` (0-100, higher = worse) count + phrase-list hits scaled per 1000 tokens; `information_density` + (0.0-1.0) is entities + numbers per 100 tokens; `overall_quality` + (0-100, higher is better) is the weighted composite (also folds in a + bigram-repetition penalty even though that score isn't itself a + top-level field). `flags` fires at fixed thresholds: `filler`, + `ai-patterns`, `low-density`, `repetitive`. + • Stdlib only (argparse/json/re/sys/collections/typing) — runs even + without the venv. Empty/whitespace-only input degrades rather than + returning a false zero-value "ok": an empty analysis is not a result. + • This is filler/AI-pattern SHAPE, not fact-checking — a text can be + dense and well-cited yet still wrong; that stays a human/LLM call. + fetch.sh drift --url https://ex.com/sitemap.xml [--max 500] → {"status":"ok","baseline":true,"captured":"…","pages":24,"store":"…"} → {"status":"ok","baseline":false,"since":"…","gone":[…],"new":[…], diff --git a/lib/seo-data/content_quality.py b/lib/seo-data/content_quality.py new file mode 100644 index 0000000..3ea73bd --- /dev/null +++ b/lib/seo-data/content_quality.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Deterministic filler / AI-slop content-quality scorer. Stdlib only. + +Adapted from claude-seo (github.com/AgriciDaniel/claude-seo, MIT), +content_quality.py — rewritten to the lib/seo-data fail-open contract. + +Scores a block of text against three regex/word-list heuristics: padding +"filler" phrases (QRG §4.6), LLM-typical phrasings ("AI-pattern" list), +and a measured information density (entities + numbers per token). 100% +deterministic — no LLM call, no network. + +ADVISORY, NOT A VERDICT. This never claims "this text is AI-written" — +modern generative tools can pass every heuristic here, and human writers +use some of these phrases too. A low overall_quality or a filler/ +ai-patterns flag is a candidate for human review, nothing more. In +geo-analyzer's STEP 8 (Content Shape for AI) it is ONE measured input +that INFORMS the axis, which stays an LLM judgement (30/70, Definition +Lead) — never a replacement for it, and never auto-filed as a finding on +its own. + +Attribution: the AI-pattern list draws from the Wikipedia "AI Cleanup" +project's catalogue of LLM-typical phrasings (CC BY-SA 4.0), the same +list claude-seo cites. + +Envelope (see `_cli`):: + + {"status": "ok", "source": "content_quality", + "filler_score": 0..100, # higher = more filler-like + "ai_pattern_score": 0..100, # higher = more AI-pattern hits + "information_density": 0.0..1.0, + "overall_quality": 0..100, # composite, higher is better + "flags": ["filler", "ai-patterns", "low-density", "repetitive"], + "matches": {"filler": [...], "ai_patterns": [...]}} + {"status": "degraded", "reason": "empty_input" | ""} +""" +import argparse, json, re, sys +from collections import Counter +from typing import Iterable + +# Padding / filler phrases QRG §4.6 flags as "little-to-no value". The +# lists are the value of this module — kept intact from the source, not +# trimmed. +_FILLER_PHRASES = ( + "it's important to note that", + "in this article, we'll explore", + "in this article we will explore", + "in today's fast-paced world", + "in today's digital age", + "in today's competitive landscape", + "needless to say", + "at the end of the day", + "when it comes to", + "when all is said and done", + "in the realm of", + "in the world of", + "the bottom line is", + "without further ado", + "first and foremost", + "last but not least", + "for what it's worth", + "it goes without saying", + "as we all know", + "the truth is that", + "the fact of the matter is", + "more often than not", + "let's dive in", + "let's dive into", + "let's take a closer look", + "let's take a deeper look", +) + +# LLM-typical phrasings (Wikipedia AI Cleanup catalogue, CC BY-SA 4.0; +# also used by claude-seo, MIT). Conservative: only phrases that +# disproportionately appear in LLM output. Adding to this list should +# require corpus evidence, not intuition. +_AI_PATTERNS = ( + "delve into", + "delve deeper into", + "in the ever-evolving", + "ever-evolving landscape", + "ever-changing landscape", + "in the dynamic landscape", + "navigating the", + "navigate the complexities", + "tapestry of", + "rich tapestry", + "intricate tapestry", + "embark on a journey", + "embarking on this", + "a testament to", + "a beacon of", + "the cornerstone of", + "a cornerstone of", + "at the heart of", + "at its core", + "in essence,", + "in conclusion,", + "ultimately,", + "moreover,", + "furthermore,", + "however, it's worth noting", + "it's worth noting that", + "by leveraging", + "leverage the power of", + "leveraging the power of", + "harness the power of", + "unlock the potential", + "unlock the full potential", + "the realm of possibilities", + "open up a world of", + "a world of possibilities", + "elevate your", + "transform your", + "revolutionize the way", + "game-changer", + "game-changing", + "cutting-edge", + "state-of-the-art", + "in summary,", + "to summarize,", + "to put it simply,", + "in a nutshell,", +) + +_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z'\-]*") +_NUMBER_RE = re.compile(r"\b\d+(?:[.,]\d+)?(?:%|st|nd|rd|th)?\b") +# Capitalised multi-word names: rough proper-noun heuristic. Two or more +# capitalised tokens in a row count as one entity. +_ENTITY_RE = re.compile(r"\b(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b") + + +def _count_phrase_hits(text: str, patterns: Iterable[str]) -> list: + """Patterns that appear at least once in text (case-insensitive).""" + lowered = text.lower() + return [p for p in patterns if p in lowered] + + +def _repetition_score(tokens): + """Bigram repetition: fraction of bigrams that recur more than once.""" + if len(tokens) < 4: + return 0.0 + bigrams = [tokens[i] + " " + tokens[i + 1] for i in range(len(tokens) - 1)] + counts = Counter(bigrams) + repeated = sum(1 for v in counts.values() if v > 1) + return repeated / max(1, len(counts)) + + +def analyse(text): + """Score text against the filler / AI-pattern / density / repetition + heuristics. Advisory only — see module docstring.""" + tokens = [t.lower() for t in _TOKEN_RE.findall(text)] + n_tokens = len(tokens) + + filler_hits = _count_phrase_hits(text, _FILLER_PHRASES) + ai_hits = _count_phrase_hits(text, _AI_PATTERNS) + + # Density: entities + numbers per 100 tokens. A high-density article + # (case studies, data journalism) lands at ~5+; generic filler <2. + entities = len(_ENTITY_RE.findall(text)) + numbers = len(_NUMBER_RE.findall(text)) + density_per_100 = (entities + numbers) * 100.0 / max(1, n_tokens) + information_density = min(1.0, density_per_100 / 10.0) + + rep_score = int(round(_repetition_score(tokens) * 100)) + + # Scale to per-1000 tokens so the score is comparable across lengths. + scale = max(1.0, n_tokens / 1000.0) + filler_score = min(100, int(round(len(filler_hits) / scale * 25))) + ai_pattern_score = min(100, int(round(len(ai_hits) / scale * 15))) + + flags = [] + if filler_score >= 50: + flags.append("filler") + if ai_pattern_score >= 40: + flags.append("ai-patterns") + if information_density < 0.20: + flags.append("low-density") + if rep_score >= 30: + flags.append("repetitive") + + # Composite: invert penalty signals, weight by impact. Same weights + # as the source — the length bonus caps at 1000 tokens. + overall = ( + (100 - filler_score) * 0.25 + + (100 - ai_pattern_score) * 0.25 + + information_density * 100 * 0.25 + + (100 - rep_score) * 0.15 + + min(100, n_tokens / 10.0) * 0.10 + ) + + return { + "filler_score": filler_score, + "ai_pattern_score": ai_pattern_score, + "information_density": round(information_density, 3), + "overall_quality": int(round(overall)), + "flags": flags, + "matches": {"filler": filler_hits, "ai_patterns": ai_hits}, + } + + +def _build_parser(): + p = argparse.ArgumentParser( + description="Deterministic filler / AI-slop content-quality scorer." + ) + p.add_argument("--store", default=None) # accepted+ignored (dispatch) + p.add_argument( + "--file", default="-", + help="Path to a text file, or - for stdin (default -).", + ) + return p + + +def _read_input(path): + """Read the analysis target from stdin ('-'/omitted) or a plain file. + Plain `open()` only — no pathlib, to stay stdlib-minimal per contract.""" + if path in (None, "-"): + return sys.stdin.read() + return open(path, encoding="utf-8", errors="replace").read() + + +def _cli(): + try: + args = _build_parser().parse_args() + text = _read_input(args.file) + if not text or not text.strip(): + print(json.dumps({"status": "degraded", "reason": "empty_input"})) + return + envelope = {"status": "ok", "source": "content_quality"} + envelope.update(analyse(text)) + print(json.dumps(envelope, indent=2)) + except SystemExit as e: + if e.code not in (0, None): + print(json.dumps({"status": "error", "reason": "bad_usage"})) + raise + except Exception as e: + # Fail-open: a missing --file, an unreadable/binary file, or any + # other unexpected error degrades rather than crashing the caller. + print(json.dumps({"status": "degraded", "reason": str(e)})) + + +if __name__ == "__main__": + _cli() diff --git a/lib/seo-data/fetch.sh b/lib/seo-data/fetch.sh index 9934db9..37d9a5e 100644 --- a/lib/seo-data/fetch.sh +++ b/lib/seo-data/fetch.sh @@ -36,6 +36,8 @@ case "$cmd" in exec "$PY" "$HERE/score.py" --store "$STORE" "$@" ;; schema_gen) exec "$PY" "$HERE/schema_gen.py" --store "$STORE" "$@" ;; + content_quality) + exec "$PY" "$HERE/content_quality.py" --store "$STORE" "$@" ;; drift) exec "$PY" "$HERE/drift.py" --store "$STORE" "$@" ;; rendercheck) @@ -54,6 +56,6 @@ case "$cmd" in fi echo '{"status":"error","reason":"usage: fetch.sh forget {--label