polish-language
Academic English consistency linting and non-native (ESL) language polish for medical manuscripts. Deterministically flags abbreviation define-once violations, US/UK spelling drift, hyphen-vs-en-dash numeric ranges, P/p case, hyphenation variants, small-number style, and value/un
Install
npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/polish-language
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
git clone https://github.com/Aperivue/medsci-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole aperivue/medsci-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Polish-Language Skill
You help a medical researcher tighten a manuscript's mechanical language consistency and clarity before circulation or submission — the copy-editor pass that content-focused skills skip. The author is frequently a non-native (ESL) English writer, so clarity edits must preserve the formal academic register while never touching facts.
Communication Rules
- Manuscript content and edits in English.
- Converse with the user in their preferred language.
- Report issues first; only edit after the user approves (see gates below).
Scope boundary (what this skill is, and is not)
| Concern | Skill |
|---|---|
| Mechanical consistency + ESL clarity (this skill) | polish-language |
| Removing AI writing tells / de-AI | humanize (it explicitly does not do general copy-editing) |
| Drafting or restructuring content | write-paper |
| Reporting-guideline item compliance (STROBE, CLAIM, …) | check-reporting |
| AI-search-engine optimization (GEO) | academic-aio |
| Reference formatting / citation integrity | manage-refs, verify-refs |
This skill never rewrites scientific claims, changes numeric values, edits citations, or judges study quality. It only standardizes house style and improves sentence-level clarity with explicit user approval.
Inputs / Outputs
- Input: a manuscript or section (Markdown / plain text).
- Output: (1) a deterministic consistency report, and (2) — only after a user gate — a clarity-polished revision with a change log limited to style.
Workflow
Phase 1: Deterministic consistency lint (no LLM judgement)
Run the bundled deterministic linter — it reports, never edits:
python3 scripts/lint_consistency.py path/to/manuscript.md
# add --strict to exit non-zero when any issue is found (CI / pre-submission gate)
It flags seven families, each with line numbers and a per-category + total count:
- Abbreviations — used-before-defined, defined-but-unused, defined-twice, used-but-never-defined (define-once discipline).
- Spelling — mixed US/UK variants (analyze/analyse, tumor/tumour, …); reports the minority side against the document's dominant variant.
- Numeric ranges — hyphen between numbers where an en-dash belongs
(
5-10→5–10). - p-values — mixed
P/pcase; impossibleP = 0.000. - Hyphenation / terminology — variant forms of one term (follow-up / followup / "follow up").
- Small numbers — single digits 1–9 written as digits in prose.
- Units — missing space between value and unit (
5mg→5 mg).
Present the report to the user. The linter output is the source of truth for what is mechanically wrong; do not invent additional "issues" from memory.
Phase 1b: Figure-SOURCE locale drift (text no grep can reach)
Phase 1 only sees prose. Text baked into a figure lives in a rendered raster, so a co-author who types "Behavioural alignment" in a PowerPoint panel or a plotting script ships a UK word into a US manuscript and no text gate sees it — it surfaces when someone opens the image, typically on submission day. Scan the figure sources instead (no OCR):
python3 scripts/lint_figure_locale.py --manuscript path/to/manuscript.md --figures-dir figures/
# --spelling us|uk forces the target; otherwise it reads a `spelling:` front-matter field,
# then falls back to the body's own US/UK majority. --strict exits non-zero on any drift.
It reads <a:t> runs inside *.pptx slide XML and the text of *.py / *.R plotting
scripts, and reuses Phase 1's US↔UK families verbatim so the two gates never disagree.
FIGURE_LOCALE_DRIFT is Minor — copy-edit the source before the raster is re-exported.
A missing figures directory is not an error; it exits 0 with nothing judged.
Phase 2: Triage with the user (gate)
Walk the user through the report. Some flags are author choices (a journal may mandate UK spelling, or digits for all numbers). User approval is required before any edit — confirm per category which to apply and which to keep. Record the decisions; do not auto-apply.
Phase 3: Apply mechanical fixes (style-only)
For each approved category, apply the deterministic fix with Edit:
- standardize spelling to the chosen variant,
- replace numeric-range hyphens with en-dashes,
- normalize
P/pand fixP = 0.000to the reported inequality, - unify hyphenation, spell out small numbers, add value/unit spaces,
- define each abbreviation once at first use; remove redundant redefinitions.
Re-run lint_consistency.py after editing — the count should drop to the
issues the user chose to keep. This re-run is the verification gate.
Phase 4: ESL clarity polish (optional, gated, style-only)
If the user requests a clarity pass, improve readability sentence by sentence while preserving meaning, register, numbers, and citations:
- split run-on sentences; fix article (a/an/the) and preposition usage;
- correct subject–verb agreement and awkward non-native phrasings;
- prefer active voice only where it does not change emphasis or claims.
Show each proposed change as a before/after diff and get user review before writing. If a sentence's meaning is even slightly uncertain, leave it and ask — do not guess. Never merge, add, or drop a scientific claim, number, or reference during clarity polishing.
Reproducible challenge card
A deterministic, network-free challenge card lives in
scripts/lint_challenge/ (synthetic manuscript with seeded defects +
expected/report.txt + verify.sh):
bash scripts/lint_challenge/verify.sh # PASS = 11 seeded issues across 8 categories + 2 clean controls
What This Skill Does NOT Do
- Does not rewrite or generate scientific content, claims, or conclusions.
- Does not change any numeric value, statistic, or result.
- Does not add, remove, or reformat citations or references.
- Does not assess reporting-guideline or journal compliance.
- Does not remove AI writing patterns (use
humanize). - Does not translate between languages.
- Applies no edit without explicit user approval (gates in Phases 2–4).
Anti-Hallucination
- Report deterministic findings as linter findings, and other observations as editorial suggestions. The fixed rules do not resolve every grammar or journal preference; triage flags in context. Never claim a fix without re-running the linter.
- Clarity edits are constrained to wording. Numbers, p-values, effect sizes, units, citations, and claims are copied verbatim — if an edit would change any of them, it is out of scope and must be skipped.
- When a sentence's intended meaning is ambiguous, ask the user rather than inferring; do not invent domain facts to "smooth" a sentence.
- Every applied change is style-only and traceable to a linter flag or an explicit user-approved clarity suggestion.
Files (medsci-skills)
-
scripts
-
lint_challenge
-
expected
-
report.txt 1 KB
# Consistency Lint Report ## Abbreviations - L5: "PET" defined but never used - L7: "DKA" used 2x but never defined ## Spelling (US/UK consistency) - L10: "analyze" family: US spelling here (document is predominantly UK) ## Numeric ranges - L9: "5-10" — use en-dash for numeric range (5–10) ## p-values - L12: "p = 0.03" — inconsistent case (document uses "P") - L13: "P = 0.000" — a p-value cannot be exactly 0; report as P < .001 ## Hyphenation / terminology - L15: inconsistent forms of "follow-up" (multiple variants present) - L17: inconsistent forms of "health care" (multiple variants present) ## Small numbers in prose - L7: "3 patients" — spell out single-digit numbers in prose ## Units - L16: "5mg" — insert a space between value and unit (5 mg) ## Thousands separator (title vs body) - L21: "3.681" in a float title uses a period thousands-separator while the body writes it "3,681" (L19) — harmonize the thousands separator across titles and body --- Summary: 11 issue(s) across 8 category(ies).
-
-
fixture
-
consistent_uk.md 524 B
# Synthetic methods example Computed tomography (CT) was used for acquisition. CT images were reviewed. The analysis characterised tumour size and modelled the association. The organisation optimised colour labels without changing the characteristics. Follow-up lasted 12 months. We will follow up with the study team. Long-term monitoring continued; the protocol remained stable in the long term. Measurements ranged from 5–10 mm. The group comparison yielded p < 0.001. Figure 2 shows the workflow for type 2 diabetes. -
consistent_us.md 763 B
# Synthetic methods example Computed tomography (CT) was used for acquisition. CT images were reviewed. Magnetic resonance imaging (MRI) was used for confirmation. MRI findings were recorded. The analysis characterized tumor size and modeled the association. The organization optimized color labels without changing the characteristics. Follow-up lasted 12 months. We will follow up with the study team. Long-term monitoring continued; the protocol remained stable in the long term. Coronaviruses were discussed in a background section on coronavirus disease 2019 (COVID-19). COVID-19 terminology was consistent throughout the document. Measurements ranged from 5–10 mm. The group comparison yielded P < 0.001. Figure 2 shows the workflow for type 2 diabetes. -
manuscript.md 709 B
# Synthetic Study Manuscript We performed magnetic resonance imaging (MRI) and computed tomography (CT) scans. The MRI was reviewed; CT findings were recorded. We also obtained positron emission tomography (PET) images. DKA occurred in 3 patients and DKA resolved quickly. Tumour size ranged from 5-10 cm across the cohort. We analyse the data and then analyze again. Significance was set at p = 0.03 and P = 0.001. One result showed P = 0.000 unexpectedly. Patients had regular follow-up, though some had no followup at all. The dose was 5mg daily. Healthcare access and health care equity were assessed. The cohort comprised 3,681 records in total. **Table 1.** Baseline characteristics (n = 3.681).
-
-
problem.md 2.9 KB
# Challenge card — Consistency linting (polish-language) ## Problem Medical manuscripts routinely ship mechanical inconsistencies that copy-editors catch but that content-focused passes ignore: an abbreviation used before it is defined (or never defined), mixed US/UK spelling, hyphen-vs-en-dash numeric ranges, mixed `P`/`p` case, variant hyphenation of the same term, single-digit numbers written as digits in prose, and missing spaces between a value and its unit. `/humanize` explicitly does **not** do general copy-editing (it only removes AI tells), and `/check-reporting` checks guideline items, not house style — so none of the existing skills caught these. ## What the linter does `scripts/lint_consistency.py` deterministically reports (never rewrites) eight families of inconsistency with line numbers and a per-category + total count. It changes no text, numbers, or citations — it is advisory input for a human/LLM polish pass. ## Fixture (synthetic only — no real manuscript/PII) `fixture/manuscript.md` seeds exactly one or more defect per category: abbreviation (PET unused, DKA undefined), spelling (analyse/analyze in a UK-dominant doc), numeric range (`5-10`), p-values (`p` vs `P`, impossible `P = 0.000`), hyphenation (follow-up/followup, healthcare/health care), small number (`3 patients`), unit (`5mg`). `fixture/consistent_us.md` and `fixture/consistent_uk.md` contain normal synthetic prose. They combine consistent regional spelling, defined abbreviations, spaced units, and grammatical noun/verb pairs. Both must produce zero findings under `--strict`; a detector that only catches planted errors is not enough. `follow-up` (noun/adjective) may coexist with `follow up` (verb), as in the [CDC writing guidance](https://www.cdc.gov/nceh/clearwriting/writing-tips/2024/writing-tip-wed-05-22-2024.html). The same distinction applies to `long-term` versus `in the long term`. The fixed variant lists are advisory; they do not adjudicate every grammar or journal-style choice. Numeric/hyphenated uppercase abbreviations such as `COVID-19` are kept whole when comparing definitions and uses. ## Expected `expected/report.txt` — 11 issues across 8 categories. ## Baseline vs linter | | Baseline (humanize / check-reporting) | Consistency linter | |---|---|---| | Abbreviation define-once | not checked | reported | | US/UK spelling drift | not checked | reported (minority side) | | en-dash numeric ranges | not checked | reported | | `P`/`p` case + `P = 0.000` | not checked | reported | | hyphenation variants | not checked | reported | | value/unit spacing | not checked | reported | ## Verifier (deterministic, no network) ```bash bash verify.sh ``` ## Acknowledgement The "fixture + expected + deterministic verifier" packaging is inspired by public reproducible-audit layouts such as [EinsteinArena](https://einsteinarena.com/) (design inspiration only; no code, solutions, or data were copied). -
verify.sh 1.1 KB
#!/usr/bin/env bash # Deterministic verifier for the consistency-linter challenge card. # Runs lint_consistency.py on a synthetic manuscript with seeded defects and # diffs against expected/report.txt. Exit 0 = match. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" LINTER="$HERE/../lint_consistency.py" actual="$(python3 "$LINTER" "$HERE/fixture/manuscript.md")" if diff -u "$HERE/expected/report.txt" <(printf '%s\n' "$actual"); then echo "PASS: linter report matches expected (11 seeded issues across 8 categories)." else echo "FAIL: linter output drifted from expected/report.txt" >&2 exit 1 fi # Normal US and UK prose must also stay quiet, including grammatical noun/verb # pairs and a defined numeric/hyphenated abbreviation. for variant in us uk; do clean="$(python3 "$LINTER" "$HERE/fixture/consistent_${variant}.md" --strict)" if ! grep -q '^Summary: 0 issue(s) across 0 category(ies)\.$' <<<"$clean"; then echo "FAIL: consistent $variant prose produced findings" >&2 printf '%s\n' "$clean" >&2 exit 1 fi echo "PASS: consistent $variant control has zero findings under --strict." done
-
-
lint_figure_locale_challenge
-
problem.md 2.2 KB
# Challenge — figure-SOURCE locale drift (no OCR) A manuscript declares US spelling. Every text gate enforces it across the prose. Then a co-author builds a panel in PowerPoint and types **"Behavioural alignment"**, or a plotting script sets `ax.set_title("Tumour colour")` — and the word ships inside a **rendered raster**, where no grep can reach it. A full locale sweep over manuscript + supplement + cover letter returns exactly one hit, and it is an internal YAML comment. The real one is found by opening the image, on submission day. `lint_figure_locale.py` reads the figure **sources** instead of the raster — `<a:t>` runs inside `*.pptx` slide XML, and the text of `*.py` / `*.R` plotting scripts — and compares them against the manuscript's spelling (a `spelling:` front-matter field, or the body's own US/UK majority). ## The precision trap this card exists to lock down The shared US↔UK families in `lint_consistency.py` originally matched the UK side with a greedy `\w*` suffix, so words that are **identical in both dialects** counted as UK evidence: | greedy pattern | universal word it wrongly matched | |---|---| | `analys` + `\w*` | **analysis**, **analyses** | | `organis` + `\w*` | **organism(s)** | | `characteris` + `\w*` | **characteristic(s)** — the most common table label in medicine | | `optimis` + `\w*` | **optimism** | A figure-source gate inherits that noise directly: "Baseline characteristics" is a figure label in almost every clinical paper, and it is not a spelling error. The families now enumerate the genuinely dialectal inflections, and this card asserts both halves of the contract. ## What `verify.sh` asserts (network-free, no committed binaries) - **Positive**: in a US manuscript, `Behavioural` (a `.py` label) and `Randomised` / `centre` (a `.pptx` `<a:t>` run) are flagged `FIGURE_LOCALE_DRIFT`. - **Negative — the precision guard**: `characteristics`, `analysis`, `organisms` appear in the very same sources and **must stay silent**. - **Negative — clean**: US-spelled figure sources in a US manuscript produce nothing. - **No sources**: a missing figures directory exits 0 (nothing to judge), never an error. Fixtures are written at runtime (the `.pptx` via python-pptx), so nothing binary is committed. -
verify.sh 4 KB
#!/usr/bin/env bash # Deterministic verifier for the figure-SOURCE locale-drift challenge card. # Network-free, no committed binaries (the .pptx is written at runtime via python-pptx). # Positive: genuine UK words in a .py label and a .pptx <a:t> run are flagged in a US manuscript. # Negative: universal words (characteristics / analysis / organisms) sitting in the SAME # sources must stay silent — the precision trap the shared families used to fail. # Negative: US-spelled sources are clean; a missing figures dir exits 0 (nothing judged). set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../lint_figure_locale.py" tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT [ -f "$DET" ] || { echo "ENV-ERR: lint_figure_locale.py missing" >&2; exit 2; } figs="$tmp/figures"; mkdir -p "$figs" cat > "$tmp/manuscript.md" <<'MD' --- title: Test manuscript spelling: US --- We analyzed the baseline characteristics and the analysis of tumor color in the center. MD # A plotting script whose labels mix a genuine UK word with two universals. cat > "$figs/panel.py" <<'PY' ax.set_title("Behavioural alignment") ax.set_xlabel("Baseline characteristics") ax.set_ylabel("Analysis of organisms") PY have_pptx=0 if python3 -c "import pptx" 2>/dev/null; then have_pptx=1 python3 - "$figs/panel.pptx" <<'PY' import sys from pptx import Presentation from pptx.util import Inches prs = Presentation(); s = prs.slides.add_slide(prs.slide_layouts[6]) tb = s.shapes.add_textbox(Inches(1), Inches(1), Inches(6), Inches(1)) tb.text_frame.text = "Randomised centre — analysis of characteristics" prs.save(sys.argv[1]) PY else echo "NOTE: python-pptx unavailable — the .pptx source stage is skipped (script stage still runs)." fi # (1) Positive + (2) precision guard, in one scan. python3 "$DET" --manuscript "$tmp/manuscript.md" --figures-dir "$figs" \ --strict --quiet --out "$tmp/drift.json" && { echo "FAIL: UK drift in a US manuscript did not flag" >&2; exit 1; } python3 - "$tmp/drift.json" "$have_pptx" <<'PY' import json, os, sys d = json.load(open(sys.argv[1])); have_pptx = sys.argv[2] == "1" assert d["detector"] == "lint_figure_locale", "envelope does not self-identify" assert d["spelling"] == "us", d["spelling"] words = {f["word"].lower() for f in d["findings"]} # Positive: genuine UK spellings are caught. assert "behavioural" in words, f"missed the .py UK label: {words}" if have_pptx: assert "randomised" in words, f"missed a .pptx <a:t> UK word: {words}" assert "centre" in words, f"missed a .pptx <a:t> UK word: {words}" # Negative (precision guard): universals in the SAME sources must be silent. for universal in ("characteristics", "analysis", "organisms", "characteristic", "analyses", "organism"): assert universal not in words, ( f"FALSE POSITIVE — '{universal}' is identical in US and UK spelling: {sorted(words)}") print(f"OK-POSITIVE+PRECISION: flagged {sorted(words)}; universals stayed silent") PY # (3) Negative — US-spelled sources in a US manuscript are clean. clean="$tmp/clean_figs"; mkdir -p "$clean" cat > "$clean/panel.py" <<'PY' ax.set_title("Behavioral alignment") ax.set_xlabel("Baseline characteristics") ax.set_ylabel("Analysis of organisms") PY python3 "$DET" --manuscript "$tmp/manuscript.md" --figures-dir "$clean" \ --strict --quiet --out "$tmp/clean.json" || { echo "FAIL: clean US sources flagged (false positive)" >&2; cat "$tmp/clean.json" >&2; exit 1; } grep -qE '"kind":' "$tmp/clean.json" && { echo "FAIL: clean fixture produced a finding" >&2; cat "$tmp/clean.json" >&2; exit 1; } echo "OK-CLEAN: US-spelled figure sources are silent" # (4) No figures directory -> exit 0 (nothing to judge), never an error. python3 "$DET" --manuscript "$tmp/manuscript.md" --figures-dir "$tmp/nope" --quiet \ || { echo "FAIL: a missing figures dir must exit 0" >&2; exit 1; } echo "OK-NOSOURCE: a missing figures directory exits 0" echo "PASS: figure-source locale drift flags genuine UK spellings in .py labels and .pptx runs, stays silent on universals (characteristics/analysis/organisms) and on US-spelled sources."
-
-
lint_consistency.py 15.8 KB
#!/usr/bin/env python3 """ Deterministic consistency linter for medical manuscripts (polish-language). Flags mechanical, style-guide-level inconsistencies that copy-editors catch and that AI-tell removal (`/humanize`) deliberately does NOT touch: 1. Abbreviations — defined-once, used-before-defined, defined-but-unused, used-but-never-defined 2. Spelling — mixed US/UK variants (analyze/analyse, tumor/tumour, …) 3. Numeric ranges — hyphen between numbers where an en-dash belongs (5-10) 4. p-values — mixed P/p case; impossible "P = 0.000" 5. Hyphenation — variant forms of the same term (follow-up/followup/…) 6. Small numbers — single digits 1–9 written as digits in prose 7. Units — missing space between value and unit (5mg) It NEVER rewrites text, changes numbers, edits citations, or judges scientific content — it only reports. All output is deterministic (stable ordering), so it doubles as a reproducible challenge-card verifier. Usage: python3 lint_consistency.py manuscript.md python3 lint_consistency.py manuscript.md --strict # exit 1 if any issue """ import argparse import re import sys from pathlib import Path # --------------------------------------------------------------------------- # # Config (fixed, deterministic) # --------------------------------------------------------------------------- # # Abbreviations so ubiquitous they need no in-text definition. ABBR_WHITELIST = { "AI", "DNA", "RNA", "USA", "UK", "EU", "WHO", "FDA", "HIV", "AIDS", "ID", "OK", "PDF", "URL", "HTML", "API", "AND", "OR", "NOT", "ROC", } # US ↔ UK spelling families: canonical "us" form -> regex matching the UK variant. # # PRECISION NOTE — do NOT "simplify" the first four back to a trailing `\w*`. The -ise/-ize # families collide with words that are IDENTICAL in both dialects, and a greedy suffix match # counts them as UK evidence: # analys + \w* -> "analysis", "analyses" (universal nouns) # organis + \w* -> "organism", "organisms" (universal) # characteris + \w* -> "characteristic(s)" (universal — and the single most # common table label in medicine) # optimis + \w* -> "optimism" (universal) # Enumerating the genuinely dialectal inflections keeps the US/UK tally honest. (randomise / # standardise have no universal collision, so their `\w*` form is left alone.) SPELLING_FAMILIES = [ ("analyze", r"\banalys(e|ed|ing|able)\b", r"analy(s)"), ("organize", r"\borganis(e|es|ed|ing|ation|ations|ational|er|ers)\b", r"organis"), ("characterize", r"\bcharacteris(e|es|ed|ing|ation|ations)\b", r"characteris"), ("optimize", r"\boptimis(e|es|ed|ing|ation|ations)\b", r"optimis"), ("randomize", r"\brandomis\w*\b", r"randomis"), ("standardize", r"\bstandardis\w*\b", r"standardis"), ("tumor", r"\btumour(s)?\b", r"tumour"), ("color", r"\bcolour(s|ed|ing)?\b", r"colour"), ("behavior", r"\bbehaviour(s|al)?\b", r"behaviour"), ("favor", r"\bfavour(s|ed|able)?\b", r"favour"), ("center", r"\bcentre(s|d)?\b", r"centre"), ("labeled", r"\blabelled\b", r"labelled"), ("modeling", r"\bmodelling\b", r"modelling"), ("fetal", r"\bfoetal\b", r"foetal"), ] # For each family we also need the US-variant regex to count it. SPELLING_US = { "analyze": r"\banaly(z|ze|zed|zing|zes)\w*\b", "organize": r"\borganiz\w*\b", "characterize": r"\bcharacteriz\w*\b", "optimize": r"\boptimiz\w*\b", "randomize": r"\brandomiz\w*\b", "standardize": r"\bstandardiz\w*\b", "tumor": r"\btumor(s)?\b", "color": r"\bcolor(s|ed|ing)?\b", "behavior": r"\bbehavior(s|al)?\b", "favor": r"\bfavor(s|ed|able)?\b", "center": r"\bcenter(s|ed)?\b", "labeled": r"\blabeled\b", "modeling": r"\bmodeling\b", "fetal": r"\bfetal\b", } # Hyphenation/terminology variant families (all lowercase match, word-ish). HYPHEN_FAMILIES = [ # The spaced verb "follow up" is grammatical beside the noun "follow-up". # Likewise "in the long term" is not a misspelling of the adjective. ("follow-up", [r"\bfollow-up\b", r"\bfollowup\b"]), ("health care", [r"\bhealthcare\b", r"\bhealth care\b", r"\bhealth-care\b"]), ("long-term", [r"\blong-term\b", r"\blongterm\b"]), ("well-being", [r"\bwell-being\b", r"\bwellbeing\b"]), ("decision-making", [r"\bdecision-making\b", r"\bdecision making\b"]), ("COVID-19", [r"\bCOVID-19\b", r"\bCOVID19\b", r"\bCovid-19\b"]), ] UNIT_TOKENS = ( "mg", "kg", "mL", "ml", "mm", "cm", "mcg", "ug", "mmHg", "mGy", "mSv", "mmol", "mol", "IU", "mGy", "Gy", "Sv", "Hz", "kPa", ) UNIT_RE = re.compile( r"(?<![\w.])(\d+(?:\.\d+)?)(" + "|".join(sorted(UNIT_TOKENS, key=len, reverse=True)) + r")(?![\w])" ) # Keep numeric/hyphenated identifiers whole: a definition of (COVID-19) must # match uses of COVID-19, not manufacture an undefined abbreviation "COVID". ABBR_DEF_RE = re.compile(r"\(([A-Z][A-Z0-9]{1,5}[0-9]*(?:-[A-Z0-9]{1,6})*)\)") ABBR_USE_RE = re.compile(r"(?<![A-Za-z0-9-])([A-Z]{2,6}[0-9]*(?:-[A-Z0-9]{1,6})*)(?![A-Za-z0-9-])") NUM_RANGE_RE = re.compile(r"(?<![\w/.\-])(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)(?![\w/.\-])") PVAL_RE = re.compile(r"(?<![A-Za-z])([Pp])\s*([=<>])\s*(0?\.\d+|\d+\.\d+|\.\d+)") SMALL_NUM_RE = re.compile(r"(?<![\w.=<>+\-/])([1-9])\s+([a-z]{3,})") # A single digit that NAMES something is not a counted quantity, and spelling it out is wrong. # "type 2 diabetes" is not "type two diabetes" — nor is Grade 3, Stage 4, Phase 3, day 7, Table 2 or # Figure 1. The rule fired on every one of those: across nine ordinary clinical sentences it was # right twice. Across this repo's own markdown it produced 1,499 flags, 277 of them a digit directly # after a designator word. Advice that turns a correct sentence into a wrong one is the fastest way # to teach an author to stop running the linter, so the designator — not the digit — decides. # # Deliberately keyed on the preceding word rather than on what follows: "8 patients" and "3 deaths" # are counts and must still be flagged, and they look identical to "type 2 diabetes" from the right. DESIGNATOR_WORDS = ( # document structure "figure", "figures", "fig", "figs", "table", "tables", "section", "sections", "panel", "appendix", "supplement", "supplementary", "reference", "ref", "equation", "eq", "chapter", "box", "step", "item", "question", "aim", "objective", "part", "page", "line", "row", "column", "note", "phase", # clinical and study designators "type", "grade", "stage", "class", "level", "group", "arm", "cohort", "visit", "cycle", "tier", "category", "version", "model", "site", "center", "centre", "wave", "round", "session", "period", "day", "week", "month", "year", "trimester", "quarter", ) DESIGNATOR_RE = re.compile( r"(?:^|[^\w-])(?:" + "|".join(DESIGNATOR_WORDS) + r")\.?\s*$", re.IGNORECASE) # --------------------------------------------------------------------------- # # Checks (each returns list[(line, message)]) # --------------------------------------------------------------------------- # def check_abbreviations(lines): out = [] defs = {} # abbr -> first definition line uses = {} # abbr -> list of (line) excluding the def-parenthesis line def_lines = {} # abbr -> set of lines where defined for i, line in enumerate(lines, 1): for m in ABBR_DEF_RE.finditer(line): ab = m.group(1) defs.setdefault(ab, i) def_lines.setdefault(ab, set()).add(i) for i, line in enumerate(lines, 1): # mask the "(ABBR)" definition spans so they don't count as bare uses masked = ABBR_DEF_RE.sub(lambda m: " " * len(m.group(0)), line) for m in ABBR_USE_RE.finditer(masked): ab = m.group(1) uses.setdefault(ab, []).append(i) all_abbr = set(defs) | {a for a in uses} for ab in sorted(all_abbr): if ab in ABBR_WHITELIST: continue u = uses.get(ab, []) d = defs.get(ab) if d is None: if len(u) >= 2: out.append((min(u), f'"{ab}" used {len(u)}x but never defined')) continue if len(def_lines.get(ab, set())) > 1: out.append((sorted(def_lines[ab])[1], f'"{ab}" defined more than once')) before = [ln for ln in u if ln < d] if before: out.append((min(before), f'"{ab}" used before its definition (defined L{d})')) if not u: out.append((d, f'"{ab}" defined but never used')) return out def check_spelling(lines): out = [] text = "\n".join(lines) us_total = 0 uk_total = 0 fam_hits = [] # (line, msg, side) for us_form, uk_re, _ in SPELLING_FAMILIES: us_re = SPELLING_US[us_form] uk_count = len(re.findall(uk_re, text, re.I)) us_count = len(re.findall(us_re, text, re.I)) us_total += us_count uk_total += uk_count for i, line in enumerate(lines, 1): for _m in re.finditer(uk_re, line, re.I): fam_hits.append((i, f'"{us_form}" family: UK spelling here', "uk")) for _m in re.finditer(us_re, line, re.I): fam_hits.append((i, f'"{us_form}" family: US spelling here', "us")) if us_total == 0 and uk_total == 0: return out dominant = "US" if us_total >= uk_total else "UK" minority = "uk" if dominant == "US" else "us" for line, msg, side in fam_hits: if side == minority: out.append((line, f"{msg} (document is predominantly {dominant})")) return out, dominant, us_total, uk_total def check_numeric_ranges(lines): out = [] for i, line in enumerate(lines, 1): for m in NUM_RANGE_RE.finditer(line): out.append((i, f'"{m.group(0)}" — use en-dash for numeric range ({m.group(1)}–{m.group(2)})')) return out def check_pvalues(lines): out = [] cap = low = 0 hits = [] for i, line in enumerate(lines, 1): for m in PVAL_RE.finditer(line): letter, op, val = m.group(1), m.group(2), m.group(3) if letter == "P": cap += 1 else: low += 1 hits.append((i, letter, op, val, m.group(0))) if not hits: return out, None dominant = "P" if cap >= low else "p" for i, letter, op, val, raw in hits: try: num = float(val) except ValueError: num = None if num is not None and num == 0: out.append((i, f'"{raw}" — a p-value cannot be exactly 0; report as {letter} < .001')) if letter != dominant: out.append((i, f'"{raw}" — inconsistent case (document uses "{dominant}")')) return out, dominant def check_hyphenation(lines): out = [] for canon, variants in HYPHEN_FAMILIES: # These variants intentionally differ in case. Ignoring case makes a # single COVID-19 occurrence match both patterns and report a false mix. flags = 0 if canon == "COVID-19" else re.I present = [] per_variant = {} for vre in variants: vlines = [i for i, line in enumerate(lines, 1) if re.search(vre, line, flags)] if vlines: present.append(vre) per_variant[vre] = vlines if len(present) >= 2: first_line = min(min(v) for v in per_variant.values()) out.append((first_line, f'inconsistent forms of "{canon}" (multiple variants present)')) return out def check_small_numbers(lines): out = [] for i, line in enumerate(lines, 1): for m in SMALL_NUM_RE.finditer(line): word = m.group(2) if word in UNIT_TOKENS: continue # A digit that names something ("type 2", "Grade 3", "Table 2", "day 7") is a label, not # a count. Spelling it out produces "type two diabetes". if DESIGNATOR_RE.search(line[: m.start(1)]): continue out.append((i, f'"{m.group(1)} {word}" — spell out single-digit numbers in prose')) return out def check_units(lines): out = [] for i, line in enumerate(lines, 1): for m in UNIT_RE.finditer(line): out.append((i, f'"{m.group(0)}" — insert a space between value and unit ({m.group(1)} {m.group(2)})')) return out # A float title/caption line: "**Table 2.** ...", "Figure 1 ...". FLOAT_TITLE_RE = re.compile(r"^\s*\*{0,2}\s*(?:Table|Figure)\s+\d+", re.IGNORECASE) # Thousands-grouped integers: comma style (3,681) and period/European style (3.681). COMMA_GROUP_RE = re.compile(r"\b\d{1,3}(?:,\d{3})+\b") PERIOD_GROUP_RE = re.compile(r"\b\d{1,3}(?:\.\d{3})+\b") def check_thousands_separator(lines): """Float-title thousands-separator drift. High-precision: only flags when the SAME integer appears comma-grouped somewhere (e.g. "3,681" in the body) AND period-grouped inside a float title/caption (e.g. "n = 3.681"). This avoids the decimal ambiguity — a genuine 3-decimal number never also appears comma-grouped.""" out = [] comma_vals = {} # normalized digits -> first line it appears comma-grouped for i, line in enumerate(lines, 1): for m in COMMA_GROUP_RE.finditer(line): comma_vals.setdefault(m.group(0).replace(",", ""), i) if not comma_vals: return out for i, line in enumerate(lines, 1): if not FLOAT_TITLE_RE.match(line): continue for m in PERIOD_GROUP_RE.finditer(line): norm = m.group(0).replace(".", "") if norm in comma_vals: out.append((i, f'"{m.group(0)}" in a float title uses a period thousands-separator ' f'while the body writes it "{int(norm):,}" (L{comma_vals[norm]}) — ' f'harmonize the thousands separator across titles and body')) return out # --------------------------------------------------------------------------- # # Report # --------------------------------------------------------------------------- # def section(title, items): lines = [f"## {title}"] if items: for ln, msg in sorted(items, key=lambda x: (x[0], x[1])): lines.append(f"- L{ln}: {msg}") else: lines.append("- OK: no issues") lines.append("") return lines, len(items) def main(argv=None): ap = argparse.ArgumentParser(description="Deterministic manuscript consistency linter.") ap.add_argument("path", help="manuscript markdown/text file") ap.add_argument("--strict", action="store_true", help="exit 1 if any issue found") args = ap.parse_args(argv) lines = Path(args.path).read_text(errors="ignore").splitlines() abbr = check_abbreviations(lines) spell_res = check_spelling(lines) spell = spell_res[0] if isinstance(spell_res, tuple) else spell_res ranges = check_numeric_ranges(lines) pval_res = check_pvalues(lines) pvals = pval_res[0] hyph = check_hyphenation(lines) small = check_small_numbers(lines) units = check_units(lines) thousands = check_thousands_separator(lines) report = ["# Consistency Lint Report", ""] total = 0 cats_hit = 0 for title, items in [ ("Abbreviations", abbr), ("Spelling (US/UK consistency)", spell), ("Numeric ranges", ranges), ("p-values", pvals), ("Hyphenation / terminology", hyph), ("Small numbers in prose", small), ("Units", units), ("Thousands separator (title vs body)", thousands), ]: sec, n = section(title, items) report += sec total += n cats_hit += 1 if n else 0 report.append("---") report.append(f"Summary: {total} issue(s) across {cats_hit} category(ies).") sys.stdout.write("\n".join(report) + "\n") if args.strict and total > 0: return 1 return 0 if __name__ == "__main__": raise SystemExit(main()) -
lint_figure_locale.py 9.7 KB
#!/usr/bin/env python3 """Figure-SOURCE locale drift — US/UK spelling in a figure that no text gate can see. A manuscript declares (or consistently uses) one spelling — say US — and every text gate (polish-language's lint_consistency, a repo-wide locale sweep) enforces it across the prose. None of them reach the text baked into a FIGURE, because that text lives in a rendered raster (a PNG/TIFF) that a grep cannot read. So a co-author who builds a panel in PowerPoint or a plotting script and types "Behavioural alignment" ships a UK word into a US manuscript, and it is found only by opening the image on submission day. This gate reads the figure SOURCES instead of the raster — no OCR: * `*.pptx` (and `.pptm`) — the `<a:t>` text runs inside `ppt/slides/slide*.xml`; * `*.py` / `*.R` / `*.r` figure scripts — the file text (label literals live there). It compares each source against the manuscript's spelling (declared in a `spelling:` YAML front-matter field, or inferred from the body's own US/UK majority) and flags any word in the OPPOSITE variant. It reuses lint_consistency's US↔UK spelling families verbatim, so the two gates never disagree on what "US" and "UK" mean. Verdict: FIGURE_LOCALE_DRIFT (Minor) a figure source uses the spelling the manuscript does not (e.g. "Behavioural" in a US manuscript). Copy-edit before the raster is exported; the raster itself stays out of scope. INPUT --figures-dir DIR directory scanned recursively for figure sources (.pptx/.pptm/.py/.R/.r). Default: <manuscript-dir>/figures, then ./figures. --manuscript FILE manuscript markdown — supplies the spelling target (front matter or body majority) and, if --figures-dir is absent, the default figures directory. --spelling us|uk force the target spelling (overrides front matter / inference). OUTPUT (--out PATH) {"detector": "lint_figure_locale", "spelling", "scanned", "findings": [{path, kind, word, expected, line, severity}], "summary", "submission_safe"} Stdlib-only. Exit codes: 0 clean / no figure sources / spelling undetermined (nothing judged), 1 drift found under --strict, 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys import zipfile from pathlib import Path # Reuse the US↔UK spelling families verbatim so this gate and lint_consistency never disagree. from lint_consistency import SPELLING_FAMILIES, SPELLING_US FIGURE_SCRIPT_EXTS = {".py", ".r"} # .R lowercases to .r PPTX_EXTS = {".pptx", ".pptm"} AT_RE = re.compile(r"<a:t>(.*?)</a:t>", re.S) FRONT_SPELLING_RE = re.compile(r"(?im)^\s*spelling\s*:\s*['\"]?(us|uk|american|british)\b") _US_RES = {k: re.compile(v, re.IGNORECASE) for k, v in SPELLING_US.items()} _UK_RES = {us: re.compile(uk, re.IGNORECASE) for us, uk, _ in SPELLING_FAMILIES} def _front_matter(text: str) -> str: """Return the leading YAML front-matter block (between the first two '---' lines), or ''.""" m = re.match(r"^---\n(.*?)\n---\n", text, re.S) return m.group(1) if m else "" def target_spelling(manuscript_text: str) -> "str | None": """'us' / 'uk' from a `spelling:` front-matter field, else the body's US/UK majority, else None.""" fm = _front_matter(manuscript_text) m = FRONT_SPELLING_RE.search(fm) if m: return "uk" if m.group(1).lower().startswith(("uk", "brit")) else "us" us = sum(len(r.findall(manuscript_text)) for r in _US_RES.values()) uk = sum(len(r.findall(manuscript_text)) for r in _UK_RES.values()) if us == uk: return None # no signal / perfectly split — cannot judge a target return "us" if us > uk else "uk" def _opposite_hits(text: str, spelling: str): """Yield (word, expected_us_form) for every word in the variant OPPOSITE to `spelling`.""" wrong = _UK_RES if spelling == "us" else _US_RES for us_form, rx in wrong.items(): for m in rx.finditer(text): yield m.group(0), us_form def _pptx_runs(path: Path): """Text runs from a .pptx/.pptm's slide XML. Returns [] on a corrupt/non-zip file.""" try: with zipfile.ZipFile(path) as z: names = [n for n in z.namelist() if n.startswith("ppt/slides/slide") and n.endswith(".xml")] runs = [] for n in sorted(names): xml = z.read(n).decode("utf-8", "replace") runs += AT_RE.findall(xml) return runs except (zipfile.BadZipFile, OSError): return [] def _collect_sources(figures_dir: Path): """(pptx_paths, script_paths) under figures_dir.""" pptx, scripts = [], [] for p in sorted(figures_dir.rglob("*")): if not p.is_file(): continue ext = p.suffix.lower() if ext in PPTX_EXTS: pptx.append(p) elif ext in FIGURE_SCRIPT_EXTS: scripts.append(p) return pptx, scripts def analyze(figures_dir: Path, spelling: str) -> dict: pptx, scripts = _collect_sources(figures_dir) findings: list[dict] = [] def _emit(path, word, us_form): expected = us_form if spelling == "us" else next( (uk for us, uk, _ in SPELLING_FAMILIES if us == us_form), us_form) findings.append({ "path": str(path), "kind": "FIGURE_LOCALE_DRIFT", "word": word, "expected_variant": spelling.upper(), "family": us_form, "severity": "Minor", "label": (f'"{word}" is {"UK" if spelling == "us" else "US"} spelling in a ' f'{spelling.upper()} manuscript — copy-edit the figure source ' f'(expected the {spelling.upper()} form, e.g. "{expected}")'), }) for p in pptx: for run in _pptx_runs(p): for word, us_form in _opposite_hits(run, spelling): _emit(p, word, us_form) for p in scripts: text = p.read_text(encoding="utf-8", errors="replace") for word, us_form in _opposite_hits(text, spelling): _emit(p, word, us_form) # de-dup identical (path, word) pairs (a word repeated across runs/lines counts once) seen, uniq = set(), [] for f in findings: key = (f["path"], f["word"].lower()) if key in seen: continue seen.add(key) uniq.append(f) return { "spelling": spelling, "scanned": {"pptx": len(pptx), "scripts": len(scripts)}, "findings": uniq, "summary": {"drift": len(uniq)}, "submission_safe": not uniq, } def render(result: dict) -> str: lines = ["| Figure source | Word | Detail |", "|---|---|---|"] for f in result["findings"]: lines.append(f"| {Path(f['path']).name} | {f['word']} | {f['label']} |") if len(lines) == 2: lines.append("| (none) | — | no locale drift in any figure source |") return "\n".join(lines) def _resolve_figures_dir(args) -> "Path | None": if args.figures_dir: d = Path(args.figures_dir) return d if d.is_dir() else None if args.manuscript: cand = Path(args.manuscript).resolve().parent / "figures" if cand.is_dir(): return cand cand = Path("figures") return cand if cand.is_dir() else None def main() -> int: ap = argparse.ArgumentParser(description="Figure-source US/UK locale drift gate (no OCR).") ap.add_argument("--figures-dir", help="directory of figure sources (.pptx/.py/.R); default <manuscript-dir>/figures") ap.add_argument("--manuscript", help="manuscript markdown (spelling target + default figures dir)") ap.add_argument("--spelling", choices=["us", "uk"], help="force the target spelling (overrides inference)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any drift is found") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() figures_dir = _resolve_figures_dir(args) if figures_dir is None: if not args.quiet: print("OK: no figure-source directory found — nothing to scan.") return 0 # no sources: nothing to judge (also the crossfire-safe path) spelling = args.spelling if not spelling: if not args.manuscript: sys.stderr.write("ERROR: need --spelling us|uk or --manuscript to determine the target spelling\n") return 2 mtext = Path(args.manuscript).read_text(encoding="utf-8", errors="replace") spelling = target_spelling(mtext) if spelling is None: if not args.quiet: print("OK: manuscript spelling could not be determined (no US/UK signal) — nothing judged.") return 0 result = analyze(figures_dir, spelling) if not args.quiet: print("=" * 44) print(" Figure-Source Locale Drift") print("=" * 44) print(render(result)) print() n = result["summary"]["drift"] if n: print(f"DRIFT: {n} figure-source word(s) in the wrong spelling for a " f"{spelling.upper()} manuscript — copy-edit the source before export.") else: print(f"OK: {result['scanned']['pptx']} pptx + {result['scanned']['scripts']} script " f"source(s) match the {spelling.upper()} spelling.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text( json.dumps({"detector": "lint_figure_locale", **result}, indent=2, ensure_ascii=False), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["findings"]) else 0 if __name__ == "__main__": sys.exit(main())
-
-
tests
-
test_consistency_controls.py 4.2 KB
#!/usr/bin/env python3 """Normal prose controls plus matching failure cases; all inputs are synthetic.""" import importlib.util from pathlib import Path import subprocess import sys import tempfile import unittest SKILL = Path(__file__).resolve().parents[1] SCRIPT = SKILL / 'scripts/lint_consistency.py' spec = importlib.util.spec_from_file_location('linter', SCRIPT) linter = importlib.util.module_from_spec(spec) spec.loader.exec_module(linter) class ConsistencyControls(unittest.TestCase): def test_normal_us_and_uk_manuscripts_are_clean_and_unchanged(self): for name in ('consistent_us.md', 'consistent_uk.md'): with self.subTest(name=name): path = SKILL / 'scripts/lint_challenge/fixture' / name before = path.read_bytes() run = subprocess.run([sys.executable, str(SCRIPT), str(path), '--strict'], capture_output=True, text=True) self.assertEqual(run.returncode, 0, run.stdout + run.stderr) self.assertIn('Summary: 0 issue(s) across 0 category(ies).', run.stdout) self.assertEqual(path.read_bytes(), before) def test_one_covid_form_is_not_a_mix(self): for text in ('COVID-19', 'COVID19', 'Covid-19', 'COVID-19 and COVID-19'): self.assertEqual(linter.check_hyphenation([text]), [], text) def test_actual_covid_spelling_and_case_mixes_still_fire(self): for text in ('COVID-19 and COVID19', 'COVID-19 and Covid-19'): found = linter.check_hyphenation([text]) self.assertEqual(len(found), 1) self.assertIn('COVID-19', found[0][1]) def test_grammatically_distinct_forms_can_coexist(self): self.assertEqual(linter.check_hyphenation([ 'Follow-up continued. We will follow up with the team.', 'Long-term monitoring helps in the long term.']), []) def test_closed_form_mixes_still_fire(self): for text in ('Follow-up and followup', 'Long-term and longterm', 'Healthcare and health care'): self.assertEqual(len(linter.check_hyphenation([text])), 1, text) def test_defined_numeric_and_hyphenated_abbreviations_remain_whole(self): for abbr in ('CT', 'MRI', 'BRCA1', 'COVID-19', 'COVID19'): self.assertEqual(linter.check_abbreviations([ f'Expanded name ({abbr}) was introduced.', f'{abbr} was used.']), []) def test_missing_definitions_are_not_hidden(self): found = linter.check_abbreviations(['COVID-19 was discussed.', 'COVID-19 was mentioned again.']) self.assertEqual(found, [(1, '"COVID-19" used 2x but never defined')]) self.assertEqual(linter.check_abbreviations(['Expanded name (CT).']), [(1, '"CT" defined but never used')]) def test_definition_order_and_duplicate_definition_still_fire(self): found = linter.check_abbreviations(['CT was used.', 'Computed tomography (CT).', 'Computed tomography (CT) was mentioned again.']) self.assertTrue(any('before its definition' in msg for _, msg in found)) self.assertTrue(any('more than once' in msg for _, msg in found)) def test_designators_and_measurements_are_not_counts(self): self.assertEqual(linter.check_small_numbers([ 'Figure 2 shows type 2 diabetes. The measurement was 5 mm.']), []) self.assertEqual(len(linter.check_small_numbers(['There were 3 samples.'])), 1) def test_a_defect_added_to_normal_prose_changes_strict_exit_only(self): with tempfile.TemporaryDirectory() as td: path = Path(td) / 'text.md' path.write_text('The value was 5mg.\n', encoding='utf-8') before = path.read_bytes() for extra, expected in [((), 0), (('--strict',), 1)]: run = subprocess.run([sys.executable, str(SCRIPT), str(path), *extra], capture_output=True, text=True) self.assertEqual(run.returncode, expected) self.assertIn('insert a space', run.stdout) self.assertEqual(path.read_bytes(), before) if __name__ == '__main__': unittest.main() -
test_numeral_designators.sh 3.1 KB
#!/usr/bin/env bash # Regression test: a digit that NAMES something is not a counted quantity. # # `check_small_numbers` flagged any single digit followed by a lowercase word and told the author to # spell it out. On nine ordinary clinical sentences it was right twice. The rest were labels: # # "Patients with type 2 diabetes" -> "2 diabetes" — spell out -> "type two diabetes" # "Grade 3 adverse events" -> "3 adverse" # "Stage 4 disease" -> "4 disease" # "See Table 2 for details" -> "2 for" # "At day 7 follow-up" -> "7 follow" # # Advice that turns a correct sentence into a wrong one is the fastest way to teach an author to stop # running the linter — and "spell out Table 2" additionally breaks `check_figure_citation`, which # then cannot find the reference. Across this repo's own markdown the rule produced 1,499 flags; 308 # of them were this. # # The exemption is keyed on the word BEFORE the digit, not on what follows: "8 patients" and # "3 deaths" are genuine counts and look identical from the right-hand side. set -u REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" L="$REPO_ROOT/skills/polish-language/scripts/lint_consistency.py" pass=0 fail=0 ck() { local label="$1" expected="$2" actual="$3" if [ "$expected" = "$actual" ]; then printf ' PASS %-56s %s\n' "$label" "$actual" pass=$((pass + 1)) else printf ' FAIL %-56s expected=%s actual=%s\n' "$label" "$expected" "$actual" fail=$((fail + 1)) fi } flags() { # flags <sentence> -> number of small-number findings python3 - "$L" "$1" <<'PY' import importlib.util, sys spec = importlib.util.spec_from_file_location("lc", sys.argv[1]) m = importlib.util.module_from_spec(spec) sys.modules["lc"] = m spec.loader.exec_module(m) print(len(m.check_small_numbers([sys.argv[2]]))) PY } echo "==== a designator + digit is a LABEL: must be quiet ====" for s in \ "Patients with type 2 diabetes were included." \ "Grade 3 adverse events occurred in four patients." \ "Stage 4 disease was present at baseline." \ "Phase 3 trials confirmed this." \ "See Table 2 for details." \ "The CONSORT diagram appears in Figure 1 above." \ "Section 6 describes the audit." \ "Group 2 received placebo." \ "At day 7 follow-up, the effect persisted." \ "In year 3 of follow-up, attrition rose." do ck "quiet: ${s:0:44}" 0 "$(flags "$s")" done echo "==== NEGATIVE CONTROLS — a genuine count must still be flagged ====" for s in \ "We enrolled 8 patients." \ "There were 3 deaths." \ "A total of 4 centres participated." \ "The model used 5 covariates." \ "We excluded 2 records after review." do ck "flags: ${s:0:44}" 1 "$(flags "$s")" done echo "==== the exemption is anchored to the word, not merely present in the line ====" # "type" appears, but the digit belongs to a count later in the sentence. ck "designator elsewhere does not excuse a count" 1 \ "$(flags "Among patients with type 2 diabetes we excluded 6 records.")" echo echo " passed=$pass failed=$fail" [ "$fail" -eq 0 ] || exit 1 echo "OK: labels are left alone, counts are still counted."
-
-
SKILL.md 7.5 KB
--- name: polish-language description: Academic English consistency linting and non-native (ESL) language polish for medical manuscripts. Deterministically flags abbreviation define-once violations, US/UK spelling drift, hyphen-vs-en-dash numeric ranges, P/p case, hyphenation variants, small-number style, and value/unit spacing, then guides a style-only clarity pass that never alters numbers, citations, or scientific meaning. Distinct from humanize (AI-tell removal) and check-reporting (guideline items). triggers: polish language, copy-edit, consistency check, ESL, non-native English, house style, abbreviation consistency, en-dash, US UK spelling, proofread manuscript, 일관성 검사, 교정 tools: Read, Write, Edit, Grep, Glob, Bash model: inherit --- # Polish-Language Skill You help a medical researcher tighten a manuscript's **mechanical language consistency and clarity** before circulation or submission — the copy-editor pass that content-focused skills skip. The author is frequently a non-native (ESL) English writer, so clarity edits must preserve the formal academic register while never touching facts. ## Communication Rules - Manuscript content and edits in English. - Converse with the user in their preferred language. - Report issues first; only edit after the user approves (see gates below). ## Scope boundary (what this skill is, and is not) | Concern | Skill | |---|---| | Mechanical consistency + ESL clarity (this skill) | **polish-language** | | Removing AI writing tells / de-AI | `humanize` (it explicitly does **not** do general copy-editing) | | Drafting or restructuring content | `write-paper` | | Reporting-guideline item compliance (STROBE, CLAIM, …) | `check-reporting` | | AI-search-engine optimization (GEO) | `academic-aio` | | Reference formatting / citation integrity | `manage-refs`, `verify-refs` | This skill **never** rewrites scientific claims, changes numeric values, edits citations, or judges study quality. It only standardizes house style and improves sentence-level clarity with explicit user approval. ## Inputs / Outputs - **Input**: a manuscript or section (Markdown / plain text). - **Output**: (1) a deterministic consistency report, and (2) — only after a user gate — a clarity-polished revision with a change log limited to style. ## Workflow ### Phase 1: Deterministic consistency lint (no LLM judgement) Run the bundled deterministic linter — it reports, never edits: ```bash python3 scripts/lint_consistency.py path/to/manuscript.md # add --strict to exit non-zero when any issue is found (CI / pre-submission gate) ``` It flags seven families, each with line numbers and a per-category + total count: 1. **Abbreviations** — used-before-defined, defined-but-unused, defined-twice, used-but-never-defined (define-once discipline). 2. **Spelling** — mixed US/UK variants (analyze/analyse, tumor/tumour, …); reports the minority side against the document's dominant variant. 3. **Numeric ranges** — hyphen between numbers where an en-dash belongs (`5-10` → `5–10`). 4. **p-values** — mixed `P`/`p` case; impossible `P = 0.000`. 5. **Hyphenation / terminology** — variant forms of one term (follow-up / followup / "follow up"). 6. **Small numbers** — single digits 1–9 written as digits in prose. 7. **Units** — missing space between value and unit (`5mg` → `5 mg`). Present the report to the user. The linter output is the source of truth for what is mechanically wrong; do not invent additional "issues" from memory. ### Phase 1b: Figure-SOURCE locale drift (text no grep can reach) Phase 1 only sees prose. Text baked into a **figure** lives in a rendered raster, so a co-author who types "Behavioural alignment" in a PowerPoint panel or a plotting script ships a UK word into a US manuscript and no text gate sees it — it surfaces when someone opens the image, typically on submission day. Scan the figure **sources** instead (no OCR): ```bash python3 scripts/lint_figure_locale.py --manuscript path/to/manuscript.md --figures-dir figures/ # --spelling us|uk forces the target; otherwise it reads a `spelling:` front-matter field, # then falls back to the body's own US/UK majority. --strict exits non-zero on any drift. ``` It reads `<a:t>` runs inside `*.pptx` slide XML and the text of `*.py` / `*.R` plotting scripts, and reuses Phase 1's US↔UK families verbatim so the two gates never disagree. `FIGURE_LOCALE_DRIFT` is **Minor** — copy-edit the source before the raster is re-exported. A missing figures directory is not an error; it exits 0 with nothing judged. ### Phase 2: Triage with the user (gate) Walk the user through the report. Some flags are author choices (a journal may mandate UK spelling, or digits for all numbers). **User approval is required** before any edit — confirm per category which to apply and which to keep. Record the decisions; do not auto-apply. ### Phase 3: Apply mechanical fixes (style-only) For each **approved** category, apply the deterministic fix with `Edit`: - standardize spelling to the chosen variant, - replace numeric-range hyphens with en-dashes, - normalize `P`/`p` and fix `P = 0.000` to the reported inequality, - unify hyphenation, spell out small numbers, add value/unit spaces, - define each abbreviation once at first use; remove redundant redefinitions. Re-run `lint_consistency.py` after editing — the count should drop to the issues the user chose to keep. This re-run is the verification gate. ### Phase 4: ESL clarity polish (optional, gated, style-only) If the user requests a clarity pass, improve readability sentence by sentence while preserving meaning, register, numbers, and citations: - split run-on sentences; fix article (a/an/the) and preposition usage; - correct subject–verb agreement and awkward non-native phrasings; - prefer active voice only where it does not change emphasis or claims. Show each proposed change as a before/after diff and get **user review** before writing. If a sentence's meaning is even slightly uncertain, leave it and ask — do not guess. Never merge, add, or drop a scientific claim, number, or reference during clarity polishing. ## Reproducible challenge card A deterministic, network-free challenge card lives in `scripts/lint_challenge/` (synthetic manuscript with seeded defects + `expected/report.txt` + `verify.sh`): ```bash bash scripts/lint_challenge/verify.sh # PASS = 11 seeded issues across 8 categories + 2 clean controls ``` ## What This Skill Does NOT Do - Does not rewrite or generate scientific content, claims, or conclusions. - Does not change any numeric value, statistic, or result. - Does not add, remove, or reformat citations or references. - Does not assess reporting-guideline or journal compliance. - Does not remove AI writing patterns (use `humanize`). - Does not translate between languages. - Applies no edit without explicit user approval (gates in Phases 2–4). ## Anti-Hallucination - Report deterministic findings as linter findings, and other observations as editorial suggestions. The fixed rules do not resolve every grammar or journal preference; triage flags in context. Never claim a fix without re-running the linter. - Clarity edits are constrained to wording. Numbers, p-values, effect sizes, units, citations, and claims are copied verbatim — if an edit would change any of them, it is out of scope and must be skipped. - When a sentence's intended meaning is ambiguous, ask the user rather than inferring; do not invent domain facts to "smooth" a sentence. - Every applied change is style-only and traceable to a linter flag or an explicit user-approved clarity suggestion. -
skill.yml 2 KB
schema_version: 2 name: polish-language layer: C owner_domain: manuscript_optimization maturity: official when_to_use: "Lint a medical manuscript for mechanical language consistency (abbreviations, US/UK spelling, en-dash ranges, P/p case, hyphenation, units) and run a style-only ESL clarity pass." when_NOT_to_use: "Removing AI writing tells (use humanize); drafting content (use write-paper); reporting-guideline checks (use check-reporting); citation formatting (use manage-refs)." inputs: - "manuscript / section text (Markdown)" outputs: - "deterministic consistency report (scripts/lint_consistency.py)" - "style-only polished revision after user approval" deterministic_scripts: - scripts/lint_consistency.py # challenge card in scripts/lint_challenge/ - scripts/lint_figure_locale.py # challenge card in scripts/lint_figure_locale_challenge/ side_effects: - writes_project_artifacts downstream_consumers: - self-review - write-paper forbidden_actions: - alter_numeric_values_or_citations - change_scientific_meaning - rewrite_scientific_content # v2.1 quality card purpose: "Standardize house-style consistency and improve non-native clarity without changing facts, numbers, or citations." safety_boundaries: - "Edits style only; never alters numeric values, p-values, units, citations, or scientific meaning." - "Linter findings remain advisory and need contextual review; no edit without user approval." known_limitations: - "Spelling/hyphenation families are a fixed list; uncommon variants may be missed." - "Small-number and abbreviation heuristics can flag intended author choices — triage with the user." validation_commands: - "python3 scripts/lint_consistency.py <manuscript.md>" - "bash scripts/lint_challenge/verify.sh # deterministic, network-free" - "python3 tests/test_consistency_controls.py" - "python3 scripts/lint_figure_locale.py --manuscript <manuscript.md> --figures-dir <figures/>" - "bash scripts/lint_figure_locale_challenge/verify.sh # deterministic, network-free" evidence_surface: bundled_script
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.