Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- # /// script
- # requires-python = ">=3.10"
- # dependencies = [
- # "textstat>=0.7.4",
- # "spacy>=3.8,<3.9",
- # "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl",
- # ]
- # ///
- """Readability analysis for prose and Markdown documents.
- Computes document-level readability metrics and flags individual sentences that
- exceed configurable difficulty thresholds, so they can be targeted for rewriting.
- Markdown markup (fenced code, tables, links, headings, list markers, bare URLs) is
- stripped before measurement so the scores reflect prose, not syntax. Measuring a
- Markdown table or a list of URLs as if it were prose produces meaningless numbers;
- stripping first is the difference between a useful score and noise.
- """
- from __future__ import annotations
- import argparse
- import json
- import sys
- import textwrap
- from dataclasses import asdict, dataclass, field
- from pathlib import Path
- import re
- try:
- import textstat
- import spacy
- except ModuleNotFoundError:
- # Launched with a bare interpreter (python3, the repo .venv), which skips the
- # PEP 723 metadata above. Re-exec under uv so the inline deps resolve.
- import os
- import shutil
- uv = shutil.which("uv")
- if os.environ.get("ANALYZE_READABILITY_REEXEC") or not uv:
- sys.exit(
- "analyze_readability: textstat/spacy missing and uv unavailable -- run as\n"
- f" uv run {Path(__file__).resolve()} <file>"
- )
- os.environ["ANALYZE_READABILITY_REEXEC"] = "1"
- os.execv(uv, [uv, "run", "--script", str(Path(__file__).resolve()), *sys.argv[1:]]) # nosec B606 -- resolved uv path, no shell
- # A fenced block opens with 3+ backticks or tildes and closes on a line whose
- # fence is the same character and at least as long. Matching the opener's length
- # via a backreference matters: ```` ``` ```` nested inside a 4-backtick fence
- # must not be treated as the close, or the remaining code leaks into the prose.
- FENCE_RE = re.compile(
- r"^(?P<indent>[ \t]*)(?P<fence>`{3,}|~{3,})[^\n]*\n"
- r".*?"
- r"(?:^[ \t]*(?P=fence)`*~*[ \t]*$|\Z)",
- re.DOTALL | re.MULTILINE,
- )
- TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$", re.MULTILINE)
- # A horizontal rule carries no prose but, left in place, glues the item above it
- # to the heading below into one fake sentence.
- HRULE_RE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$|^\s{0,3}(?:_\s*){3,}$",
- re.MULTILINE)
- MD_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)")
- BARE_URL_RE = re.compile(r"https?://\S+")
- HEADING_LINE_RE = re.compile(r"^\s{0,3}#{1,6}\s*(.*)$", re.MULTILINE)
- REF_SECTION_RE = re.compile(
- r"^\s{0,3}#{1,6}\s*(?:references|sources|bibliography|works cited|citations)\b"
- r".*?(?=^\s{0,3}#{1,6}\s|\Z)",
- re.IGNORECASE | re.MULTILINE | re.DOTALL,
- )
- BLOCKQUOTE_RE = re.compile(r"^\s{0,3}>\s?", re.MULTILINE)
- INLINE_CODE_RE = re.compile(r"`([^`\n]+)`")
- LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.*)$", re.MULTILINE)
- EMPHASIS_RE = re.compile(r"[*_`]+")
- SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
- # Jira wiki markup (raw, not Markdown): {code}/{noformat} macros, {{monospace}},
- # hN. headings, [text|url] links, {macro} tokens, and \\ line breaks. Text pulled
- # from Jira as Markdown does not need this; raw wiki markup does (--jira).
- JIRA_CODE_RE = re.compile(r"\{(code|noformat)(?::[^}]*)?\}.*?\{\1\}", re.DOTALL)
- JIRA_MONO_RE = re.compile(r"\{\{(.+?)\}\}", re.DOTALL)
- JIRA_HEADING_RE = re.compile(r"^\s*h[1-6]\.\s*(.*)$", re.MULTILINE)
- JIRA_LINK_RE = re.compile(r"\[[^\]\n]+\](?!\()")
- JIRA_LINEBREAK_RE = re.compile(r"\\\\")
- JIRA_MACRO_RE = re.compile(r"\{[^}\n]+\}")
- # Flesch-Kincaid, Fog, and SMOG are unstable on very short fragments (a 3-word
- # polysyllabic heading can score grade 25+). Below this length, flag on raw sentence
- # length only, not on the grade/fog formulas.
- MIN_WORDS_FOR_GRADE = 10
- SPACY_MODEL = "en_core_web_sm"
- # Subordinate-clause dependency labels; their nesting depth measures how deeply
- # a sentence embeds clauses inside clauses.
- CLAUSE_DEPS = frozenset(
- {"advcl", "ccomp", "xcomp", "relcl", "acl", "parataxis", "pcomp"})
- NOMINAL_SUFFIXES = ("tion", "sion", "ment", "ness", "ity", "ance", "ence",
- "al", "ure")
- #: Threshold sources, resolved from this file so the report cites a real path
- #: regardless of the working directory the CLI was invoked from.
- THRESHOLDS_DOC = Path(__file__).resolve().parent.parent / "references" / "thresholds.md"
- def _inline_code_repl(match: re.Match) -> str:
- """Neutralize an inline code span so it is not measured as prose."""
- content = match.group(1)
- # A single plain word set in code font is still prose (e.g. `signed`, `draft`).
- if re.fullmatch(r"[A-Za-z]+", content):
- return content
- # A path / identifier / symbol (`patient_data_export`, `evidence_summary.py`)
- # is not prose; count it as one generic noun so it fills its sentence slot
- # without inflating the syllable/complex-word counts.
- return "code"
- def _list_item_repl(match: re.Match) -> str:
- """Strip a list marker and terminate the item so bullets do not fuse."""
- body = match.group(1).rstrip()
- # Adjacent list items carry no terminal punctuation, so the sentence splitter
- # would otherwise measure a whole bulleted list as one run-on "sentence".
- # Only .!? count as already-terminated: SENTENCE_SPLIT_RE does not break on a
- # colon, so treating "Requirements:" as terminated left it fused to the item
- # below it -- the exact run-on this function exists to prevent. A trailing
- # colon is replaced rather than appended to, to avoid a stray ":." in output.
- if body and body[-1] in ":;,":
- body = body[:-1] + "."
- elif body and body[-1] not in ".!?":
- body += "."
- return body
- def strip_markdown(text: str) -> str:
- """Reduce Markdown to plain prose for measurement."""
- text = FENCE_RE.sub(" ", text)
- text = REF_SECTION_RE.sub(" ", text) # citation lists are not prose
- text = TABLE_ROW_RE.sub(" ", text)
- text = HRULE_RE.sub(" ", text)
- text = INLINE_CODE_RE.sub(_inline_code_repl, text) # identifiers are not prose
- text = MD_LINK_RE.sub(r"\1", text)
- text = BARE_URL_RE.sub(" ", text)
- text = HEADING_LINE_RE.sub(r"\1. ", text) # terminate so headings do not fuse
- text = BLOCKQUOTE_RE.sub("", text)
- text = LIST_ITEM_RE.sub(_list_item_repl, text) # terminate so items do not fuse
- text = EMPHASIS_RE.sub("", text)
- return text
- def _jira_link_repl(match: re.Match) -> str:
- """[text|url] -> text; a bare [url]/[#anchor] link -> nothing; else leave."""
- inner = match.group(0)[1:-1]
- if "|" in inner:
- return inner.split("|", 1)[0]
- if re.match(r"(?:https?://|mailto:|#|\^|~)", inner):
- return " "
- return match.group(0)
- def strip_jira(text: str) -> str:
- """Neutralize raw Jira wiki markup so it can be measured as prose.
- Handles syntax Markdown stripping does not recognize: {code}/{noformat}
- macros, {{monospace}}, hN. headings, [text|url] links, {macro} tokens, and
- \\ line breaks. Monospace maps to inline code so strip_markdown neutralizes
- it uniformly. Run before strip_markdown.
- """
- text = JIRA_CODE_RE.sub(" ", text)
- text = JIRA_MONO_RE.sub(r"`\1`", text) # monospace == inline code
- text = JIRA_HEADING_RE.sub(r"\1. ", text)
- text = JIRA_LINK_RE.sub(_jira_link_repl, text)
- text = JIRA_LINEBREAK_RE.sub(" ", text)
- text = JIRA_MACRO_RE.sub(" ", text)
- return text
- @dataclass
- class Metrics:
- words: int
- sentences: int
- words_per_sentence: float
- syllables_per_word: float
- flesch_reading_ease: float
- flesch_kincaid_grade: float
- gunning_fog: float
- smog: float
- dale_chall: float
- coleman_liau: float
- automated_readability: float
- linsear_write: float
- text_standard: str
- difficult_words: int
- def metrics_for(text: str) -> Metrics:
- words = textstat.lexicon_count(text, removepunct=True)
- # No prose left after stripping (an all-code or all-table document) means
- # there is nothing to score. Reporting zeros beats clamping sentences to 1
- # and inventing numbers: textstat happily returns SMOG 3.1 for whitespace,
- # which reads as a real grade level derived from no words at all.
- if words == 0:
- return Metrics(0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
- "", 0)
- sentences = max(textstat.sentence_count(text), 1)
- syllables = textstat.syllable_count(text)
- return Metrics(
- words=words,
- sentences=sentences,
- words_per_sentence=round(words / sentences, 1),
- syllables_per_word=round(syllables / max(words, 1), 2),
- flesch_reading_ease=round(textstat.flesch_reading_ease(text), 1),
- flesch_kincaid_grade=round(textstat.flesch_kincaid_grade(text), 1),
- gunning_fog=round(textstat.gunning_fog(text), 1),
- smog=round(textstat.smog_index(text), 1),
- dale_chall=round(textstat.dale_chall_readability_score(text), 1),
- coleman_liau=round(textstat.coleman_liau_index(text), 1),
- automated_readability=round(textstat.automated_readability_index(text), 1),
- linsear_write=round(textstat.linsear_write_formula(text), 1),
- text_standard=textstat.text_standard(text, float_output=False),
- difficult_words=textstat.difficult_words(text),
- )
- def split_sentences(text: str) -> list[str]:
- chunks = SENTENCE_SPLIT_RE.split(text)
- return [c.strip() for c in chunks if len(c.split()) >= 3]
- @dataclass
- class Flag:
- words: int
- flesch_kincaid_grade: float
- gunning_fog: float
- sentence: str
- notes: list[str] = field(default_factory=list)
- @dataclass
- class Thresholds:
- """The three flagging limits, which are always passed and reported together."""
- max_wps: int
- max_grade: float
- max_fog: float
- def grammar_notes(sentence: str, nlp) -> list[str]:
- """Name the specific faults that make a sentence hard -- passive voice,
- nominalizations, deep clause nesting -- so a rewrite knows what to fix
- instead of only knowing a score was exceeded."""
- doc = nlp(sentence)
- notes = []
- # A passive auxiliary is labeled auxpass; no other surface pattern
- # distinguishes "was reviewed" (passive) from "was ready" (adjective).
- if any(t.dep_ in ("auxpass", "aux:pass") for t in doc):
- notes.append("passive voice")
- # ponytail: lemma-suffix heuristic; false-positives on underived nouns
- # ("employment", "document"). Upgrade path: WordNet derivation check if
- # precision starts to matter more than zero extra dependencies.
- nominals = sorted({t.lemma_ for t in doc
- if t.pos_ == "NOUN" and len(t.lemma_) > 4
- and t.lemma_.endswith(NOMINAL_SUFFIXES)})
- if nominals:
- notes.append("nominalizations: " + ", ".join(nominals))
- depth = max(
- (1 + sum(1 for a in t.ancestors if a.dep_ in CLAUSE_DEPS)
- for t in doc if t.dep_ in CLAUSE_DEPS),
- default=0,
- )
- if depth >= 3:
- notes.append(f"{depth} nested clauses")
- return notes
- def flag_sentences(text: str, limits: Thresholds,
- notes: dict[str, list[str]] | None = None) -> list[Flag]:
- flagged: list[Flag] = []
- for sentence in split_sentences(text):
- words = len(sentence.split())
- grade = textstat.flesch_kincaid_grade(sentence)
- fog = textstat.gunning_fog(sentence)
- too_long = words > limits.max_wps
- too_hard = words >= MIN_WORDS_FOR_GRADE and (
- grade > limits.max_grade or fog > limits.max_fog
- )
- if too_long or too_hard:
- flagged.append(Flag(words, round(grade, 1), round(fog, 1), sentence,
- notes.get(sentence, []) if notes else []))
- flagged.sort(key=lambda f: f.words, reverse=True)
- return flagged
- def read_input(path: str) -> str:
- if path == "-":
- return sys.stdin.read()
- with open(path, "r", encoding="utf-8") as handle:
- return handle.read()
- def render_report(doc: Metrics, flags: list[Flag], source: str,
- limits: Thresholds, passive: tuple[int, int]) -> str:
- lines = []
- lines.append(f"Readability report: {source}")
- lines.append("=" * 60)
- lines.append("Document-level metrics (prose only; markup stripped):")
- lines.append(f" words .................. {doc.words}")
- lines.append(f" sentences .............. {doc.sentences}")
- lines.append(f" words / sentence ....... {doc.words_per_sentence}")
- lines.append(f" syllables / word ....... {doc.syllables_per_word}")
- lines.append(f" Flesch Reading Ease .... {doc.flesch_reading_ease} (higher = easier)")
- lines.append(f" Flesch-Kincaid grade ... {doc.flesch_kincaid_grade} (US grade level)")
- lines.append(f" Gunning Fog ............ {doc.gunning_fog} (years of education)")
- lines.append(f" SMOG ................... {doc.smog} (years of education)")
- # The four above are all syllable-based and correlate strongly; the metrics
- # below key on word familiarity (Dale-Chall) or characters (CLI, ARI), so
- # they catch dense jargon that syllable counts are blind to.
- lines.append(f" Dale-Chall ............. {doc.dale_chall} (familiar-word based)")
- lines.append(f" Coleman-Liau ........... {doc.coleman_liau} (character-based)")
- lines.append(f" Automated Readability .. {doc.automated_readability} (character-based)")
- lines.append(f" Linsear Write .......... {doc.linsear_write} (technical manuals)")
- lines.append(f" Consensus .............. {doc.text_standard}")
- lines.append(f" Difficult words ........ {doc.difficult_words} (outside the familiar-word list)")
- lines.append(f" Passive sentences ...... {passive[0]} of {passive[1]} (grammar check, spaCy)")
- lines.append("")
- # Resolved against this file, not the working directory: the CLI accepts any
- # path and stdin, so a relative "references/thresholds.md" pointed at nothing
- # for every invocation from outside the skill directory.
- lines.append(f"Interpretation (see {THRESHOLDS_DOC} for sources):")
- lines.append(" Flesch Reading Ease: 60-70 general public, 30-50 academic/technical.")
- lines.append(" Technical/SOP prose realistically lands grade 10-12 / Fog ~12;")
- lines.append(" domain vocabulary sets a floor, so the lever is sentence structure.")
- lines.append("")
- lines.append(f"Flagged sentences (words>{limits.max_wps} or "
- f"grade>{limits.max_grade} or fog>{limits.max_fog}): {len(flags)}")
- lines.append("-" * 60)
- if not flags:
- lines.append(" none")
- for i, f in enumerate(flags, 1):
- lines.append(f"[{i}] words={f.words} FK-grade={f.flesch_kincaid_grade} "
- f"fog={f.gunning_fog}")
- for wrapped in textwrap.wrap(f.sentence, width=76):
- lines.append(f" {wrapped}")
- if f.notes:
- lines.append(f" notes: {'; '.join(f.notes)}")
- lines.append("")
- return "\n".join(lines)
- def main() -> int:
- # Reports carry Unicode (arrows, dashes); force UTF-8 so they print on a
- # legacy-encoding console (e.g. Windows cp1252) instead of raising.
- for stream in (sys.stdout, sys.stderr):
- reconfigure = getattr(stream, "reconfigure", None)
- if reconfigure is not None:
- reconfigure(encoding="utf-8")
- parser = argparse.ArgumentParser(
- description="Compute readability metrics and flag over-threshold sentences "
- "in a prose or Markdown document.")
- parser.add_argument("path", help="file to analyze, or '-' for stdin")
- parser.add_argument("--max-words-per-sentence", type=int, default=25,
- help="flag sentences longer than this (default 25)")
- parser.add_argument("--max-grade", type=float, default=14.0,
- help="flag sentences above this Flesch-Kincaid grade (default 14)")
- parser.add_argument("--max-fog", type=float, default=14.0,
- help="flag sentences above this Gunning Fog (default 14)")
- parser.add_argument("--raw", action="store_true",
- help="treat input as plain text (do not strip Markdown)")
- parser.add_argument("--jira", action="store_true",
- help="pre-process raw Jira wiki markup before Markdown stripping")
- parser.add_argument("--json", action="store_true",
- help="emit JSON instead of a text report")
- args = parser.parse_args()
- raw_text = read_input(args.path)
- if args.raw:
- prose = raw_text
- else:
- prose = strip_jira(raw_text) if args.jira else raw_text
- prose = strip_markdown(prose)
- limits = Thresholds(args.max_words_per_sentence, args.max_grade, args.max_fog)
- doc = metrics_for(prose)
- notes: dict[str, list[str]] = {}
- if doc.words:
- nlp = spacy.load(SPACY_MODEL, disable=["ner"])
- for sentence in split_sentences(prose):
- notes[sentence] = grammar_notes(sentence, nlp)
- flags = flag_sentences(prose, limits, notes)
- passive = (sum("passive voice" in n for n in notes.values()), len(notes))
- source = "stdin" if args.path == "-" else args.path
- if args.json:
- print(json.dumps({
- "source": source,
- "document": asdict(doc),
- "difficult_words_list": sorted(textstat.difficult_words_list(prose)),
- "grammar": {"passive_sentences": passive[0],
- "sentences_analyzed": passive[1]},
- "thresholds": {
- "max_words_per_sentence": limits.max_wps,
- "max_grade": limits.max_grade,
- "max_fog": limits.max_fog,
- },
- "flagged": [asdict(f) for f in flags],
- }, indent=2))
- else:
- print(render_report(doc, flags, source, limits, passive))
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
Add Comment
Please, Sign In to add comment