Guest User

Untitled

a guest
Sep 17th, 2026
94
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 18.09 KB | None | 0 0
  1. # /// script
  2. # requires-python = ">=3.10"
  3. # dependencies = [
  4. # "textstat>=0.7.4",
  5. # "spacy>=3.8,<3.9",
  6. # "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",
  7. # ]
  8. # ///
  9. """Readability analysis for prose and Markdown documents.
  10.  
  11. Computes document-level readability metrics and flags individual sentences that
  12. exceed configurable difficulty thresholds, so they can be targeted for rewriting.
  13.  
  14. Markdown markup (fenced code, tables, links, headings, list markers, bare URLs) is
  15. stripped before measurement so the scores reflect prose, not syntax. Measuring a
  16. Markdown table or a list of URLs as if it were prose produces meaningless numbers;
  17. stripping first is the difference between a useful score and noise.
  18. """
  19. from __future__ import annotations
  20.  
  21. import argparse
  22. import json
  23. import sys
  24. import textwrap
  25. from dataclasses import asdict, dataclass, field
  26. from pathlib import Path
  27.  
  28. import re
  29.  
  30. try:
  31. import textstat
  32. import spacy
  33. except ModuleNotFoundError:
  34. # Launched with a bare interpreter (python3, the repo .venv), which skips the
  35. # PEP 723 metadata above. Re-exec under uv so the inline deps resolve.
  36. import os
  37. import shutil
  38.  
  39. uv = shutil.which("uv")
  40. if os.environ.get("ANALYZE_READABILITY_REEXEC") or not uv:
  41. sys.exit(
  42. "analyze_readability: textstat/spacy missing and uv unavailable -- run as\n"
  43. f" uv run {Path(__file__).resolve()} <file>"
  44. )
  45. os.environ["ANALYZE_READABILITY_REEXEC"] = "1"
  46. os.execv(uv, [uv, "run", "--script", str(Path(__file__).resolve()), *sys.argv[1:]]) # nosec B606 -- resolved uv path, no shell
  47.  
  48. # A fenced block opens with 3+ backticks or tildes and closes on a line whose
  49. # fence is the same character and at least as long. Matching the opener's length
  50. # via a backreference matters: ```` ``` ```` nested inside a 4-backtick fence
  51. # must not be treated as the close, or the remaining code leaks into the prose.
  52. FENCE_RE = re.compile(
  53. r"^(?P<indent>[ \t]*)(?P<fence>`{3,}|~{3,})[^\n]*\n"
  54. r".*?"
  55. r"(?:^[ \t]*(?P=fence)`*~*[ \t]*$|\Z)",
  56. re.DOTALL | re.MULTILINE,
  57. )
  58. TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$", re.MULTILINE)
  59. # A horizontal rule carries no prose but, left in place, glues the item above it
  60. # to the heading below into one fake sentence.
  61. HRULE_RE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$|^\s{0,3}(?:_\s*){3,}$",
  62. re.MULTILINE)
  63. MD_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)")
  64. BARE_URL_RE = re.compile(r"https?://\S+")
  65. HEADING_LINE_RE = re.compile(r"^\s{0,3}#{1,6}\s*(.*)$", re.MULTILINE)
  66. REF_SECTION_RE = re.compile(
  67. r"^\s{0,3}#{1,6}\s*(?:references|sources|bibliography|works cited|citations)\b"
  68. r".*?(?=^\s{0,3}#{1,6}\s|\Z)",
  69. re.IGNORECASE | re.MULTILINE | re.DOTALL,
  70. )
  71. BLOCKQUOTE_RE = re.compile(r"^\s{0,3}>\s?", re.MULTILINE)
  72. INLINE_CODE_RE = re.compile(r"`([^`\n]+)`")
  73. LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.*)$", re.MULTILINE)
  74. EMPHASIS_RE = re.compile(r"[*_`]+")
  75. SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
  76.  
  77. # Jira wiki markup (raw, not Markdown): {code}/{noformat} macros, {{monospace}},
  78. # hN. headings, [text|url] links, {macro} tokens, and \\ line breaks. Text pulled
  79. # from Jira as Markdown does not need this; raw wiki markup does (--jira).
  80. JIRA_CODE_RE = re.compile(r"\{(code|noformat)(?::[^}]*)?\}.*?\{\1\}", re.DOTALL)
  81. JIRA_MONO_RE = re.compile(r"\{\{(.+?)\}\}", re.DOTALL)
  82. JIRA_HEADING_RE = re.compile(r"^\s*h[1-6]\.\s*(.*)$", re.MULTILINE)
  83. JIRA_LINK_RE = re.compile(r"\[[^\]\n]+\](?!\()")
  84. JIRA_LINEBREAK_RE = re.compile(r"\\\\")
  85. JIRA_MACRO_RE = re.compile(r"\{[^}\n]+\}")
  86.  
  87. # Flesch-Kincaid, Fog, and SMOG are unstable on very short fragments (a 3-word
  88. # polysyllabic heading can score grade 25+). Below this length, flag on raw sentence
  89. # length only, not on the grade/fog formulas.
  90. MIN_WORDS_FOR_GRADE = 10
  91.  
  92. SPACY_MODEL = "en_core_web_sm"
  93. # Subordinate-clause dependency labels; their nesting depth measures how deeply
  94. # a sentence embeds clauses inside clauses.
  95. CLAUSE_DEPS = frozenset(
  96. {"advcl", "ccomp", "xcomp", "relcl", "acl", "parataxis", "pcomp"})
  97. NOMINAL_SUFFIXES = ("tion", "sion", "ment", "ness", "ity", "ance", "ence",
  98. "al", "ure")
  99.  
  100. #: Threshold sources, resolved from this file so the report cites a real path
  101. #: regardless of the working directory the CLI was invoked from.
  102. THRESHOLDS_DOC = Path(__file__).resolve().parent.parent / "references" / "thresholds.md"
  103.  
  104.  
  105. def _inline_code_repl(match: re.Match) -> str:
  106. """Neutralize an inline code span so it is not measured as prose."""
  107. content = match.group(1)
  108. # A single plain word set in code font is still prose (e.g. `signed`, `draft`).
  109. if re.fullmatch(r"[A-Za-z]+", content):
  110. return content
  111. # A path / identifier / symbol (`patient_data_export`, `evidence_summary.py`)
  112. # is not prose; count it as one generic noun so it fills its sentence slot
  113. # without inflating the syllable/complex-word counts.
  114. return "code"
  115.  
  116.  
  117. def _list_item_repl(match: re.Match) -> str:
  118. """Strip a list marker and terminate the item so bullets do not fuse."""
  119. body = match.group(1).rstrip()
  120. # Adjacent list items carry no terminal punctuation, so the sentence splitter
  121. # would otherwise measure a whole bulleted list as one run-on "sentence".
  122. # Only .!? count as already-terminated: SENTENCE_SPLIT_RE does not break on a
  123. # colon, so treating "Requirements:" as terminated left it fused to the item
  124. # below it -- the exact run-on this function exists to prevent. A trailing
  125. # colon is replaced rather than appended to, to avoid a stray ":." in output.
  126. if body and body[-1] in ":;,":
  127. body = body[:-1] + "."
  128. elif body and body[-1] not in ".!?":
  129. body += "."
  130. return body
  131.  
  132.  
  133. def strip_markdown(text: str) -> str:
  134. """Reduce Markdown to plain prose for measurement."""
  135. text = FENCE_RE.sub(" ", text)
  136. text = REF_SECTION_RE.sub(" ", text) # citation lists are not prose
  137. text = TABLE_ROW_RE.sub(" ", text)
  138. text = HRULE_RE.sub(" ", text)
  139. text = INLINE_CODE_RE.sub(_inline_code_repl, text) # identifiers are not prose
  140. text = MD_LINK_RE.sub(r"\1", text)
  141. text = BARE_URL_RE.sub(" ", text)
  142. text = HEADING_LINE_RE.sub(r"\1. ", text) # terminate so headings do not fuse
  143. text = BLOCKQUOTE_RE.sub("", text)
  144. text = LIST_ITEM_RE.sub(_list_item_repl, text) # terminate so items do not fuse
  145. text = EMPHASIS_RE.sub("", text)
  146. return text
  147.  
  148.  
  149. def _jira_link_repl(match: re.Match) -> str:
  150. """[text|url] -> text; a bare [url]/[#anchor] link -> nothing; else leave."""
  151. inner = match.group(0)[1:-1]
  152. if "|" in inner:
  153. return inner.split("|", 1)[0]
  154. if re.match(r"(?:https?://|mailto:|#|\^|~)", inner):
  155. return " "
  156. return match.group(0)
  157.  
  158.  
  159. def strip_jira(text: str) -> str:
  160. """Neutralize raw Jira wiki markup so it can be measured as prose.
  161.  
  162. Handles syntax Markdown stripping does not recognize: {code}/{noformat}
  163. macros, {{monospace}}, hN. headings, [text|url] links, {macro} tokens, and
  164. \\ line breaks. Monospace maps to inline code so strip_markdown neutralizes
  165. it uniformly. Run before strip_markdown.
  166. """
  167. text = JIRA_CODE_RE.sub(" ", text)
  168. text = JIRA_MONO_RE.sub(r"`\1`", text) # monospace == inline code
  169. text = JIRA_HEADING_RE.sub(r"\1. ", text)
  170. text = JIRA_LINK_RE.sub(_jira_link_repl, text)
  171. text = JIRA_LINEBREAK_RE.sub(" ", text)
  172. text = JIRA_MACRO_RE.sub(" ", text)
  173. return text
  174.  
  175.  
  176. @dataclass
  177. class Metrics:
  178. words: int
  179. sentences: int
  180. words_per_sentence: float
  181. syllables_per_word: float
  182. flesch_reading_ease: float
  183. flesch_kincaid_grade: float
  184. gunning_fog: float
  185. smog: float
  186. dale_chall: float
  187. coleman_liau: float
  188. automated_readability: float
  189. linsear_write: float
  190. text_standard: str
  191. difficult_words: int
  192.  
  193.  
  194. def metrics_for(text: str) -> Metrics:
  195. words = textstat.lexicon_count(text, removepunct=True)
  196. # No prose left after stripping (an all-code or all-table document) means
  197. # there is nothing to score. Reporting zeros beats clamping sentences to 1
  198. # and inventing numbers: textstat happily returns SMOG 3.1 for whitespace,
  199. # which reads as a real grade level derived from no words at all.
  200. if words == 0:
  201. return Metrics(0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
  202. "", 0)
  203. sentences = max(textstat.sentence_count(text), 1)
  204. syllables = textstat.syllable_count(text)
  205. return Metrics(
  206. words=words,
  207. sentences=sentences,
  208. words_per_sentence=round(words / sentences, 1),
  209. syllables_per_word=round(syllables / max(words, 1), 2),
  210. flesch_reading_ease=round(textstat.flesch_reading_ease(text), 1),
  211. flesch_kincaid_grade=round(textstat.flesch_kincaid_grade(text), 1),
  212. gunning_fog=round(textstat.gunning_fog(text), 1),
  213. smog=round(textstat.smog_index(text), 1),
  214. dale_chall=round(textstat.dale_chall_readability_score(text), 1),
  215. coleman_liau=round(textstat.coleman_liau_index(text), 1),
  216. automated_readability=round(textstat.automated_readability_index(text), 1),
  217. linsear_write=round(textstat.linsear_write_formula(text), 1),
  218. text_standard=textstat.text_standard(text, float_output=False),
  219. difficult_words=textstat.difficult_words(text),
  220. )
  221.  
  222.  
  223. def split_sentences(text: str) -> list[str]:
  224. chunks = SENTENCE_SPLIT_RE.split(text)
  225. return [c.strip() for c in chunks if len(c.split()) >= 3]
  226.  
  227.  
  228. @dataclass
  229. class Flag:
  230. words: int
  231. flesch_kincaid_grade: float
  232. gunning_fog: float
  233. sentence: str
  234. notes: list[str] = field(default_factory=list)
  235.  
  236.  
  237. @dataclass
  238. class Thresholds:
  239. """The three flagging limits, which are always passed and reported together."""
  240.  
  241. max_wps: int
  242. max_grade: float
  243. max_fog: float
  244.  
  245.  
  246. def grammar_notes(sentence: str, nlp) -> list[str]:
  247. """Name the specific faults that make a sentence hard -- passive voice,
  248. nominalizations, deep clause nesting -- so a rewrite knows what to fix
  249. instead of only knowing a score was exceeded."""
  250. doc = nlp(sentence)
  251. notes = []
  252. # A passive auxiliary is labeled auxpass; no other surface pattern
  253. # distinguishes "was reviewed" (passive) from "was ready" (adjective).
  254. if any(t.dep_ in ("auxpass", "aux:pass") for t in doc):
  255. notes.append("passive voice")
  256. # ponytail: lemma-suffix heuristic; false-positives on underived nouns
  257. # ("employment", "document"). Upgrade path: WordNet derivation check if
  258. # precision starts to matter more than zero extra dependencies.
  259. nominals = sorted({t.lemma_ for t in doc
  260. if t.pos_ == "NOUN" and len(t.lemma_) > 4
  261. and t.lemma_.endswith(NOMINAL_SUFFIXES)})
  262. if nominals:
  263. notes.append("nominalizations: " + ", ".join(nominals))
  264. depth = max(
  265. (1 + sum(1 for a in t.ancestors if a.dep_ in CLAUSE_DEPS)
  266. for t in doc if t.dep_ in CLAUSE_DEPS),
  267. default=0,
  268. )
  269. if depth >= 3:
  270. notes.append(f"{depth} nested clauses")
  271. return notes
  272.  
  273.  
  274. def flag_sentences(text: str, limits: Thresholds,
  275. notes: dict[str, list[str]] | None = None) -> list[Flag]:
  276. flagged: list[Flag] = []
  277. for sentence in split_sentences(text):
  278. words = len(sentence.split())
  279. grade = textstat.flesch_kincaid_grade(sentence)
  280. fog = textstat.gunning_fog(sentence)
  281. too_long = words > limits.max_wps
  282. too_hard = words >= MIN_WORDS_FOR_GRADE and (
  283. grade > limits.max_grade or fog > limits.max_fog
  284. )
  285. if too_long or too_hard:
  286. flagged.append(Flag(words, round(grade, 1), round(fog, 1), sentence,
  287. notes.get(sentence, []) if notes else []))
  288. flagged.sort(key=lambda f: f.words, reverse=True)
  289. return flagged
  290.  
  291.  
  292. def read_input(path: str) -> str:
  293. if path == "-":
  294. return sys.stdin.read()
  295. with open(path, "r", encoding="utf-8") as handle:
  296. return handle.read()
  297.  
  298.  
  299. def render_report(doc: Metrics, flags: list[Flag], source: str,
  300. limits: Thresholds, passive: tuple[int, int]) -> str:
  301. lines = []
  302. lines.append(f"Readability report: {source}")
  303. lines.append("=" * 60)
  304. lines.append("Document-level metrics (prose only; markup stripped):")
  305. lines.append(f" words .................. {doc.words}")
  306. lines.append(f" sentences .............. {doc.sentences}")
  307. lines.append(f" words / sentence ....... {doc.words_per_sentence}")
  308. lines.append(f" syllables / word ....... {doc.syllables_per_word}")
  309. lines.append(f" Flesch Reading Ease .... {doc.flesch_reading_ease} (higher = easier)")
  310. lines.append(f" Flesch-Kincaid grade ... {doc.flesch_kincaid_grade} (US grade level)")
  311. lines.append(f" Gunning Fog ............ {doc.gunning_fog} (years of education)")
  312. lines.append(f" SMOG ................... {doc.smog} (years of education)")
  313. # The four above are all syllable-based and correlate strongly; the metrics
  314. # below key on word familiarity (Dale-Chall) or characters (CLI, ARI), so
  315. # they catch dense jargon that syllable counts are blind to.
  316. lines.append(f" Dale-Chall ............. {doc.dale_chall} (familiar-word based)")
  317. lines.append(f" Coleman-Liau ........... {doc.coleman_liau} (character-based)")
  318. lines.append(f" Automated Readability .. {doc.automated_readability} (character-based)")
  319. lines.append(f" Linsear Write .......... {doc.linsear_write} (technical manuals)")
  320. lines.append(f" Consensus .............. {doc.text_standard}")
  321. lines.append(f" Difficult words ........ {doc.difficult_words} (outside the familiar-word list)")
  322. lines.append(f" Passive sentences ...... {passive[0]} of {passive[1]} (grammar check, spaCy)")
  323. lines.append("")
  324. # Resolved against this file, not the working directory: the CLI accepts any
  325. # path and stdin, so a relative "references/thresholds.md" pointed at nothing
  326. # for every invocation from outside the skill directory.
  327. lines.append(f"Interpretation (see {THRESHOLDS_DOC} for sources):")
  328. lines.append(" Flesch Reading Ease: 60-70 general public, 30-50 academic/technical.")
  329. lines.append(" Technical/SOP prose realistically lands grade 10-12 / Fog ~12;")
  330. lines.append(" domain vocabulary sets a floor, so the lever is sentence structure.")
  331. lines.append("")
  332. lines.append(f"Flagged sentences (words>{limits.max_wps} or "
  333. f"grade>{limits.max_grade} or fog>{limits.max_fog}): {len(flags)}")
  334. lines.append("-" * 60)
  335. if not flags:
  336. lines.append(" none")
  337. for i, f in enumerate(flags, 1):
  338. lines.append(f"[{i}] words={f.words} FK-grade={f.flesch_kincaid_grade} "
  339. f"fog={f.gunning_fog}")
  340. for wrapped in textwrap.wrap(f.sentence, width=76):
  341. lines.append(f" {wrapped}")
  342. if f.notes:
  343. lines.append(f" notes: {'; '.join(f.notes)}")
  344. lines.append("")
  345. return "\n".join(lines)
  346.  
  347.  
  348. def main() -> int:
  349. # Reports carry Unicode (arrows, dashes); force UTF-8 so they print on a
  350. # legacy-encoding console (e.g. Windows cp1252) instead of raising.
  351. for stream in (sys.stdout, sys.stderr):
  352. reconfigure = getattr(stream, "reconfigure", None)
  353. if reconfigure is not None:
  354. reconfigure(encoding="utf-8")
  355.  
  356. parser = argparse.ArgumentParser(
  357. description="Compute readability metrics and flag over-threshold sentences "
  358. "in a prose or Markdown document.")
  359. parser.add_argument("path", help="file to analyze, or '-' for stdin")
  360. parser.add_argument("--max-words-per-sentence", type=int, default=25,
  361. help="flag sentences longer than this (default 25)")
  362. parser.add_argument("--max-grade", type=float, default=14.0,
  363. help="flag sentences above this Flesch-Kincaid grade (default 14)")
  364. parser.add_argument("--max-fog", type=float, default=14.0,
  365. help="flag sentences above this Gunning Fog (default 14)")
  366. parser.add_argument("--raw", action="store_true",
  367. help="treat input as plain text (do not strip Markdown)")
  368. parser.add_argument("--jira", action="store_true",
  369. help="pre-process raw Jira wiki markup before Markdown stripping")
  370. parser.add_argument("--json", action="store_true",
  371. help="emit JSON instead of a text report")
  372. args = parser.parse_args()
  373.  
  374. raw_text = read_input(args.path)
  375. if args.raw:
  376. prose = raw_text
  377. else:
  378. prose = strip_jira(raw_text) if args.jira else raw_text
  379. prose = strip_markdown(prose)
  380.  
  381. limits = Thresholds(args.max_words_per_sentence, args.max_grade, args.max_fog)
  382. doc = metrics_for(prose)
  383. notes: dict[str, list[str]] = {}
  384. if doc.words:
  385. nlp = spacy.load(SPACY_MODEL, disable=["ner"])
  386. for sentence in split_sentences(prose):
  387. notes[sentence] = grammar_notes(sentence, nlp)
  388. flags = flag_sentences(prose, limits, notes)
  389. passive = (sum("passive voice" in n for n in notes.values()), len(notes))
  390. source = "stdin" if args.path == "-" else args.path
  391.  
  392. if args.json:
  393. print(json.dumps({
  394. "source": source,
  395. "document": asdict(doc),
  396. "difficult_words_list": sorted(textstat.difficult_words_list(prose)),
  397. "grammar": {"passive_sentences": passive[0],
  398. "sentences_analyzed": passive[1]},
  399. "thresholds": {
  400. "max_words_per_sentence": limits.max_wps,
  401. "max_grade": limits.max_grade,
  402. "max_fog": limits.max_fog,
  403. },
  404. "flagged": [asdict(f) for f in flags],
  405. }, indent=2))
  406. else:
  407. print(render_report(doc, flags, source, limits, passive))
  408. return 0
  409.  
  410.  
  411. if __name__ == "__main__":
  412. raise SystemExit(main())
  413.  
Add Comment
Please, Sign In to add comment