diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0c587ad..d923774 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "personawriter", "source": "./", "description": "Capture any writing style from a sample and turn it into a reusable skill that writes new text in that voice.", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "Moustafa Farhat" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index cb0406b..e89ac23 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "personawriter", - "version": "1.1.0", + "version": "1.2.0", "description": "Capture any writing style from a sample and turn it into a reusable skill that writes new text in that voice. English, German, Arabic (MSA and dialects), French, Spanish and more.", "author": { "name": "Moustafa Farhat", diff --git a/CHANGELOG.md b/CHANGELOG.md index b34760d..7ebd290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 1.2.0 + +- Merges the Style Forge line of work into PersonaWriter, so PersonaWriter is the one current version. +- `style_stats.py compare` now prints a 0-100 similarity score and the three most important fixes, + and exits with 1 below 70. Per-metric tolerances and noise floors from 1.1 are kept. +- New `style_stats.py check`: counts signature-move dosage and never-list hits defined in the + generated skill's `references/markers.json` (Arabic matched through diacritics and alef/ya variants). +- New `style_stats.py overlap`: finds passages copied verbatim from the source or anchors + (Chinese/Japanese measured in characters). +- `--json` output for `compare`, `check` and `overlap`. +- Text cleaning before measuring: Markdown, code, HTML, URLs and front matter are removed, and + hard-wrapped PDF/e-book text is re-joined into paragraphs. +- New `validate.py` (quality gate for a generated skill, including a copy check against the + source) and `package.py` (validates and zips a skill into a `.skill` file). +- `scaffold.py` writes a starter `markers.json` (never overwritten on re-runs), checks that the + samples exist, and requires an ASCII slug of at most 64 characters, as skill names must be. +- New **Profile only** mode; Update mode also refreshes markers and anchors and works on style + skills built elsewhere. +- Generated skills get `markers.json`, the three-step drift check, and a "Rewriting a draft" section. +- SKILL.md: intake strips non-author material, one voice per skill, a manual path when code + cannot run, a 70+ score target in the proving step, and a "drift watch-list" in the guide. + ## 1.1.0 - **Quick mode:** "write X like this" now produces the text right away and offers to save the diff --git a/README.md b/README.md index 5031266..a7b3d85 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,23 @@ Claude: Measured 6,200 words. Your load-bearing traits: you open on a small con +### Example: a skill built with PersonaWriter + +**[Arabic Critic Essay Writer](https://github.com/moustafarhat/arabic-critic-essay-writer)** was +generated from a single 2,500-word Arabic music-criticism essay. It writes reviews (albums, films, +books, series, exhibitions, games), cultural essays and opinion pieces in formal Arabic, in a +reflective critic's voice: long qualified sentences landed by a short blunt one, "not X, but Y" as +the engine of every thesis, one fair concession, and an ending that leaves the tension open. + +Look inside to see what PersonaWriter actually produces: a +[style guide](https://github.com/moustafarhat/arabic-critic-essay-writer/blob/main/skills/arabic-critic-essay-writer/references/style-guide.md) +built from decisions, a never-list, a drift watch-list learned while testing, a +[markers.json](https://github.com/moustafarhat/arabic-critic-essay-writer/blob/main/skills/arabic-critic-essay-writer/references/markers.json) +with measured dosage targets, and an +[example essay](https://github.com/moustafarhat/arabic-critic-essay-writer/blob/main/examples/the-photos-we-never-look-at.md) +on a topic the source never touched, with its check results. The example page also shows the first +draft failing the dosage check for overusing the voice's signature moves, and what was cut to fix it. + ## Install **Claude Code** (plugin marketplace): @@ -84,6 +101,7 @@ Just talk to Claude. No special syntax needed. | "Write a LinkedIn post about our launch, in the style of this article: …" | **Quick mode:** analyzes the sample and writes the post right away, then offers to save the voice as a skill. | | "Turn my old blog posts into a skill that writes like me." | **Build mode:** full analysis, builds `-writer`, tests it on unrelated topics, and refines it with your feedback. | | "Here are three more of my essays. Update my voice skill." | **Update mode:** re-measures, revises the style guide, and tells you what changed. | +| "Just analyze how this author writes, I don't need a skill." | **Profile only:** delivers the style guide as a document. | | "اكتب مقالاً عن العمل عن بعد بأسلوب هذا النص" | Works the same in Arabic (MSA or dialect). | | "Schreib eine Produktankündigung im Stil dieses Textes." | …and in German, French, Spanish, and more. | @@ -98,9 +116,10 @@ my-newsletter-writer/ ├── references/ │ ├── style-guide.md # load-bearing traits, rhythm, diction, structure, never-list, dosage │ ├── anchors.md # 5-10 short labelled excerpts, to tune the ear, never to copy -│ └── profile.json # the measured fingerprint +│ ├── profile.json # the measured fingerprint +│ └── markers.json # signature-move dosage + never-list as checkable patterns └── scripts/ - └── style_stats.py # drift check: compares every new draft to the fingerprint + └── style_stats.py # drift, dosage and copy checks for every new draft ``` ## Why it works @@ -113,8 +132,12 @@ my-newsletter-writer/ explaining the joke) is often the strongest signal, and the thing generic imitations miss. - **Dosage.** Signature moves get a frequency ("one aside per section, at most"). Without it you get a caricature. -- **Measured drift.** Every draft can be checked against the source's numbers: sentence rhythm, - paragraph shape, punctuation habits, person, openers. +- **Measured drift.** Every draft gets a similarity score against the source's numbers (sentence + rhythm, paragraph shape, punctuation habits, person, openers) plus the three fixes that matter most. +- **Checked dosage and never-list.** `markers.json` turns signature moves and forbidden habits into + patterns, so overuse and violations are counted, not guessed. +- **No copying.** An overlap check flags any passage shared verbatim with the source, and a + validator refuses to package a skill that reproduces it. - **Proven on new topics.** Each skill is tested on subjects the source never touched before it is handed over, with an optional blind test: can *you* tell which paragraph is real? @@ -137,13 +160,26 @@ The scripts work on their own too: # fingerprint one or more texts python skills/personawriter/scripts/style_stats.py analyze essay1.txt essay2.txt --json profile.json -# how far is a draft from that fingerprint? +# how far is a draft from that fingerprint? (score 0-100 + top fixes) python skills/personawriter/scripts/style_stats.py compare profile.json draft.txt +# dosage of signature moves and never-list hits +python skills/personawriter/scripts/style_stats.py check markers.json draft.txt + +# did the draft copy from the source? (8+ shared words) +python skills/personawriter/scripts/style_stats.py overlap draft.txt source.txt + # scaffold a new style skill folder from samples python skills/personawriter/scripts/scaffold.py my-style-writer ./out sample.txt + +# check a finished skill, then zip it into an installable .skill file +python skills/personawriter/scripts/validate.py ./out/my-style-writer --source sample.txt +python skills/personawriter/scripts/package.py ./out/my-style-writer ./dist ``` +Add `--json` to `compare`, `check` or `overlap` for machine-readable output. Exit code 1 means +problems were found (score under 70, violations, or copied text), so the tools work in CI. + ## Ethics & fair use PersonaWriter learns **patterns**: rhythm, structure, diction habits. Studying and imitating @@ -165,7 +201,7 @@ case, and there is no ambiguity there. ├── skills/personawriter/ # the skill itself (this folder is what gets installed) │ ├── SKILL.md │ ├── references/ # analysis lens + templates for generated skills -│ └── scripts/ # style_stats.py, scaffold.py +│ └── scripts/ # style_stats.py, scaffold.py, validate.py, package.py └── tests/ # python -m unittest discover tests ``` diff --git a/skills/personawriter/SKILL.md b/skills/personawriter/SKILL.md index 1ead620..2670dc8 100644 --- a/skills/personawriter/SKILL.md +++ b/skills/personawriter/SKILL.md @@ -37,11 +37,15 @@ This skill runs anywhere (Claude Code, claude.ai, Claude Desktop, the API, other reusable skill?" If yes, continue with Build mode from stage 2. - **Build mode** - the user asks for a skill, agent, reusable voice, or brand voice, or accepts the offer above. Do all stages. -- **Update mode** - the user has an existing style skill (built by PersonaWriter) and gives more - samples or feedback. Re-run stage 2 into the same folder on all samples you have (the originals - are usually not stored in the skill - that is fine, measure the new ones; the scaffold keeps the - old numbers as `references/profile.previous.json` so you can weigh the two). Re-read against the existing style guide and revise it: keep what - still holds, fix what the new material contradicts, and tell the user what changed. +- **Update mode** - the user has an existing style skill (from PersonaWriter or elsewhere) and gives + more samples or feedback ("too formal", "overuses questions"). Re-run stage 2 into the same folder + on all samples you have (the originals are usually not stored in the skill - that is fine, measure + the new ones; the scaffold keeps the old numbers as `references/profile.previous.json` so you can + weigh the two, and never overwrites `markers.json`). Re-read against the existing style guide and + revise it, plus markers and anchors: keep what still holds and what the user liked, fix what the + new material contradicts. Re-run stage 6 on one topic, repackage, and tell the user what changed. +- **Profile only** - the user wants the analysis, not a text or a skill. Do stages 1-4 and deliver + the style guide as a document. When unsure, use Quick mode - a good first text is the best argument for building the skill. @@ -50,14 +54,22 @@ When unsure, use Quick mode - a good first text is the best argument for buildin Work through these stages in order. Keep the user informed in plain language at each stage, but do not ask for permission at every step - this is a build task. +If you cannot run code, do every stage by hand: estimate the metrics by counting a few +paragraphs, write the files as text, and give the user the folder contents. The scripts make +it faster and more reliable; they are not required. + ### 1. Intake - **Get the sample** (pasted or uploaded) and save the plain text to `/sample.txt` - (several files are fine). + (several files are fine). Strip front matter, footnotes, reader comments, and quotations that + are not by the writer; they pollute the profile. (Markdown, code, URLs and hard line wraps are + cleaned out by the scripts.) - **Judge if it is enough.** Under ~1,000 words gives only a sketch; 3,000-10,000 words across different parts is the sweet spot. For a book, choose a spread (opening, middle, a dialogue-heavy stretch, a reflective stretch) rather than only chapter one. If the sample is thin, say so, build anyway, and mark the profile as provisional. +- **One voice per skill.** If the samples come from several writers, or one writer across very + different registers, say so and ask whether to build one skill per voice or a deliberate blend. - **Build mode only: ask only what you cannot infer**, at most three short questions, in one message: 1. What will the finished skill write (essays, chapters, posts, emails...)? This sets the genres in its description. 2. Whose writing is it - the user's own, or someone else's? (Affects how many excerpts to keep.) @@ -66,13 +78,17 @@ but do not ask for permission at every step - this is a build task. ### 2. Measure -Run the scaffold script. It measures the sample, writes `references/profile.json`, and -copies the stats script into the new skill so it is self-contained: +Run the scaffold script. It measures the sample, writes `references/profile.json` and a +starter `references/markers.json`, and copies the stats script into the new skill so it is +self-contained: ```bash python /scripts/scaffold.py -writer /sample.txt ``` +The slug is lowercase ASCII words joined by hyphens, ending in `-writer`; transliterate +non-Latin names (e.g. `nahj-hikayat-writer`). + In Quick mode, measure without scaffolding: `python /scripts/style_stats.py analyze /sample.txt`. Read the printed report: sentence-length distribution, rhythm variation, paragraph size, @@ -110,6 +126,9 @@ Create, inside the scaffolded folder, using the templates in `references/generat - `SKILL.md` - essence, writing procedure, dials, guardrails. Keep it short (under ~100 lines). - `references/style-guide.md` - the full analysis from stage 4. - `references/anchors.md` - 5-10 short excerpts (one or two sentences each), each labelled with the trait it shows. If the source is a third party's published work, keep quoted material minimal and lean on paraphrased patterns; if it is the user's own writing you can be more generous while still keeping each anchor short. +- `references/markers.json` - the machine-checkable version of the dosage and never-list (template + section 4b). Measure each dosage target on the sample with `style_stats.py check` and set the + range around it. Make the generated skill's `description` pushy and concrete: name the genres, the phrasings users will actually say (in the user's languages), and the "rewrite my draft in this voice" case. @@ -122,31 +141,45 @@ conventions explicitly when the sample uses them. A style skill is only as good as what it writes on topics the source never touched. 1. Choose two test topics unrelated to the source, one close to the source's genre and one further away (e.g. a product update, a short story scene). -2. Following the generated skill's own procedure, write ~300-500 words for each, then run its drift check (`python scripts/style_stats.py compare references/profile.json draft.txt`). -3. Read both drafts critically against the never-list. Fix the guide (not just the draft) where something felt off. +2. Following the generated skill's own procedure, write ~300-500 words for each, then run from the skill folder: + ```bash + python scripts/style_stats.py compare references/profile.json draft.txt # score, top fixes + python scripts/style_stats.py check references/markers.json draft.txt # dosage, never-list + python scripts/style_stats.py overlap draft.txt /sample.txt # copying check + ``` + Aim for a compare score of 70+, no never-list hits and no overlap. Numbers are hints; your reading decides. +3. Read both drafts critically against the never-list. Fix the guide (not just the draft) where + something felt off, and add what you learned as a "drift watch-list" in the guide. 4. Show the user one draft and ask what feels right and what feels off. For a sharper test, offer a blind check: three short paragraphs on the same topic, one original and two generated - can they tell which is real? 5. Revise the style guide once or twice based on feedback. Stop when the user is satisfied; subjective work needs human judgment, not a benchmark. -### 7. Package and deliver +### 7. Validate, package, deliver + +```bash +python /scripts/validate.py / --source /sample.txt # must PASS +python /scripts/package.py / # writes .skill +``` -- If the skill was written straight into the user's skills folder, it is already installed - say so. -- Otherwise zip the folder so the zip contains `-writer/` at its root (use a - skill-creator packager if one is available, else any zip tool or - `python -c "import shutil; shutil.make_archive('/', 'zip', '', '')"`), - and hand the user the file with whatever file-sharing mechanism the environment offers. +- If the skill was written straight into the user's skills folder, it is already installed - say + so; packaging is optional then. +- Otherwise hand the user the `.skill` file (a zip with `/` at its root) with whatever + file-sharing or skill-saving mechanism the environment offers. -Finish with a short message: the style's essence (one or two sentences), what was tested, -where the skill is, and how to use it ("ask for a text and mention the style name" or "paste a +Finish with a short message: the style's essence (one or two sentences), what was tested and +how it scored, where the skill is, and how to use it ("ask for a text and mention the style name" or "paste a draft and ask me to rewrite it in this voice"). ## Guardrails - **Patterns, not passages.** Learning rhythm, structure and diction habits from a published work is ordinary stylistic study. Store patterns plus at most a few short anchors - never large passages. + `overlap` and `validate.py --source` enforce this. - **Original text in a manner, not forgery.** The output is new writing *in a style*. Do not present it as written by the source's author, invent quotes for real people, or write messages meant to be taken as coming from a real, identifiable person (e.g. an email "from" a CEO, a - statement "by" a politician) - unless that person is the user themself. + statement "by" a politician) - unless that person is the user themself. If the request is + clearly to impersonate someone to others, decline that part and offer a voice framed as + "inspired by", used openly. - **No deception at scale.** Decline to build voices meant for fake reviews, astroturfing, scams, or academic work that the user's institution forbids ghost-writing for. - **Facts stay facts.** Style never justifies making things up. @@ -158,11 +191,13 @@ draft and ask me to rewrite it in this voice"). - **Topic contamination** - encoding the sample's subject vocabulary as "style". The fix is the swap test. - **Averaging** - smoothing away exactly the odd choices that make the voice recognizable. Keep the oddities, with dosage. - **Over-fitting one genre** - a novelist's narration rules will not automatically fit a tweet. Flag what is inference. -- **Copying** - reusing the sample's actual sentences. Anchors tune the ear; they are never source material. +- **Copying** - reusing the sample's actual sentences. Anchors tune the ear; they are never source material. `overlap` catches the rest. ## Files in this skill - `references/style-dimensions.md` - the analysis lens (voice, syntax, diction, rhetoric, structure, fiction, language notes, swap test). Read in stage 3. -- `references/generated-skill-template.md` - folder layout and templates for the generated skill's SKILL.md, style guide and anchors. Read in stages 4-5. -- `scripts/style_stats.py` - `analyze` fingerprints a text; `compare` checks a draft against a saved profile. -- `scripts/scaffold.py` - creates the new skill folder, measures the sample, copies the stats script. +- `references/generated-skill-template.md` - folder layout and templates for the generated skill's SKILL.md, style guide, anchors and markers. Read in stages 4-5. +- `scripts/style_stats.py` - `analyze` fingerprints a text; `compare` scores a draft against a saved profile and lists the top fixes; `check` counts dosage and never-list hits from markers.json; `overlap` finds copied passages. +- `scripts/scaffold.py` - creates the new skill folder, measures the sample, writes starter markers, copies the stats script. +- `scripts/validate.py` - quality gate for a generated skill (structure, placeholders, required sections, anchor size, markers, copying). +- `scripts/package.py` - validates and zips a skill into an installable `.skill` file. diff --git a/skills/personawriter/references/generated-skill-template.md b/skills/personawriter/references/generated-skill-template.md index 19beb7b..d941ddf 100644 --- a/skills/personawriter/references/generated-skill-template.md +++ b/skills/personawriter/references/generated-skill-template.md @@ -1,6 +1,6 @@ # Template for a generated style skill -The output of PersonaWriter is a folder named `-writer/`. Use the three +The output of PersonaWriter is a folder named `-writer/`. Use the templates below, filling every `<...>` with what you learned from the sample. Delete any section that has nothing distinctive to say - an empty section is noise. @@ -9,6 +9,7 @@ any section that has nothing distinctive to say - an empty section is noise. 2. SKILL.md template 3. references/style-guide.md template 4. references/anchors.md template +4b. references/markers.json template 5. Quality bar for the finished skill --- @@ -21,7 +22,8 @@ any section that has nothing distinctive to say - an empty section is noise. ├── references/ │ ├── style-guide.md # the full analysis (read on every writing task) │ ├── anchors.md # a few short excerpts to tune the ear -│ └── profile.json # metrics saved by `style_stats.py analyze --json` +│ ├── profile.json # metrics measured from the sample (written by scaffold.py) +│ └── markers.json # signature-move dosage + never-list as regexes └── scripts/ └── style_stats.py # copied from PersonaWriter, used for the drift check ``` @@ -62,9 +64,12 @@ Language(s): . the first sentence. Do not write neutral prose and "add style" afterwards - that produces a costume instead of a voice. 3. **Check drift.** Save the draft to a file and run (`python` or `python3`): - `python scripts/style_stats.py compare references/profile.json draft.txt` + `python scripts/style_stats.py compare references/profile.json draft.txt` (score and top fixes) + `python scripts/style_stats.py check references/markers.json draft.txt` (dosage and never-list) + `python scripts/style_stats.py overlap draft.txt references/anchors.md` (no copying) Treat flagged metrics as hints, not commands - fix the ones that make the voice feel off - (typically sentence length and paragraph shape), ignore noise on short drafts. + (typically sentence length and paragraph shape), ignore noise on drafts under ~250 words. + If you cannot run code, check the never-list and dosage by reading. 4. **Read against the never-list** in the style guide and cut every violation. 5. **Deliver** the text. Add a one-line note only if you made an assumption or the user should choose between options. No commentary about the style unless asked. @@ -74,6 +79,10 @@ Language(s): . - warmth: more = direct address and confession; less = observational distance - density of signature moves: default is ; "lighter" = half, "stronger" = 1.5x> +## Rewriting a draft +Keep every fact, name, number and quote of the draft; change how it is said, not what. Apply the +same procedure from step 2, using the draft's content as the brief. + ## Guardrails - Write original text. Do not reproduce passages from the source, and do not invent quotes attributed to real people. @@ -161,10 +170,43 @@ Rules for anchors: --- +## 4b. references/markers.json template + +Turns the dosage and never-list into checks that `style_stats.py check` can run. Patterns are +Python regexes, matched case-insensitively on normalised text (lowercase, Arabic diacritics and +tatweel removed, alef variants and alef maqsura unified), so write them in that form. + +```json +{ + "dosage": [ + {"name": "negation then correction", "patterns": ["\\bnot\\b[^.]{0,60}\\bbut\\b", "ليس[^.]{0,80}(?:بل|وانما)"], "per1k": [3, 7]}, + {"name": "first-person pivot", "patterns": ["\\bhere is what i (?:believe|think)\\b"], "per1k": [0.5, 3]} + ], + "never": [ + {"name": "exclamation mark", "pattern": "!"}, + {"name": "bullet list", "pattern": "^\\s*[-*•]\\s"}, + {"name": "recap ending", "pattern": "\\bin conclusion\\b|في الختام"} + ], + "paragraph_openers_no_repeat": ["^(?:لا|ليس)\\b"] +} +``` + +How to set it: +- 3-6 dosage entries for the signature moves that can be caught by a pattern. Run + `style_stats.py check references/markers.json ` and set each range around the measured + rate (roughly 0.6x to 1.5x). Moves that no regex can catch (imagery, stance) stay in the guide only. +- 4-10 never entries straight from the never-list. +- `paragraph_openers_no_repeat` (optional): openers that must not start two paragraphs in a row. +- Keep patterns tight; a noisy pattern is worse than none. +- Chinese/Japanese: rates are per 1,000 characters, and `\b` does not work between CJK characters. + +--- + ## 5. Quality bar for the finished skill - **Concrete:** every trait is a mechanism plus evidence, not an adjective. - **Prioritized:** the top five traits are marked as load-bearing. - **Bounded:** the never-list and dosage sections prevent caricature. - **Portable:** works on topics unrelated to the source (validated in the test-writing step). +- **Checkable:** markers.json encodes the dosage and never-list; `validate.py` passes. - **Lean:** SKILL.md under ~100 lines; the detail lives in the style guide. diff --git a/skills/personawriter/scripts/package.py b/skills/personawriter/scripts/package.py new file mode 100644 index 0000000..5dffe77 --- /dev/null +++ b/skills/personawriter/scripts/package.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""package.py - zip a skill folder into an installable .skill file. + +Usage: + python package.py SKILL_DIR OUTPUT_DIR + +Runs validate.py first and refuses to package a skill that fails it. +Skips caches, drafts and hidden files. Prints the path of the .skill file. +""" +import os +import sys +import zipfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import validate # noqa: E402 + +SKIP_DIRS = {"__pycache__", ".git", "drafts"} +SKIP_FILES = {".DS_Store"} + + +def main(): + if len(sys.argv) != 3: + raise SystemExit(__doc__) + root, outdir = os.path.normpath(sys.argv[1]), sys.argv[2] + must, warn = validate.validate(root) + for w in warn: + print(f"warning: {w}") + if must: + for m in must: + print(f"must fix: {m}") + raise SystemExit("Not packaged: fix the problems above first.") + name = os.path.basename(root) + os.makedirs(outdir, exist_ok=True) + out = os.path.join(outdir, f"{name}.skill") + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for dirpath, dirs, files in os.walk(root): + dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")] + for f in files: + if f in SKIP_FILES or f.startswith(".") or f.endswith(".pyc"): + continue + full = os.path.join(dirpath, f) + z.write(full, os.path.join(name, os.path.relpath(full, root))) + print(out) + + +if __name__ == "__main__": + main() diff --git a/skills/personawriter/scripts/scaffold.py b/skills/personawriter/scripts/scaffold.py index fd59c45..c746e71 100644 --- a/skills/personawriter/scripts/scaffold.py +++ b/skills/personawriter/scripts/scaffold.py @@ -5,10 +5,12 @@ python scaffold.py SLUG OUTDIR SAMPLE [SAMPLE ...] Creates OUTDIR/SLUG/ containing references/profile.json (metrics measured from -the samples) and scripts/style_stats.py (copied so the finished skill is -self-contained). Re-running on an existing folder (update mode) keeps the old -profile as references/profile.previous.json. Claude then writes SKILL.md, references/style-guide.md and -references/anchors.md following references/generated-skill-template.md. +the samples), a starter references/markers.json, and scripts/style_stats.py +(copied so the finished skill is self-contained). Re-running on an existing +folder (update mode) keeps the old profile as references/profile.previous.json +and never overwrites markers.json. Claude then writes SKILL.md, +references/style-guide.md, references/anchors.md and fills markers.json, +following references/generated-skill-template.md. """ import json import os @@ -20,6 +22,14 @@ sys.path.insert(0, HERE) import style_stats # noqa: E402 +STARTER_MARKERS = { + "_help": "Fill from the style guide. dosage: regexes counting a signature move, with the target " + "rate per 1,000 words measured on the sample. never: regexes for never-list items. " + "Patterns match normalised text (lowercase, no Arabic diacritics, unified alef/ya).", + "dosage": [], + "never": [], +} + def main(): if hasattr(sys.stdout, "reconfigure"): # Windows consoles default to a legacy code page @@ -27,8 +37,12 @@ def main(): if len(sys.argv) < 4: raise SystemExit(__doc__) slug, outdir, samples = sys.argv[1], sys.argv[2], sys.argv[3:] - if not re.fullmatch(r"[a-z0-9\u0600-\u06FF]+(?:-[a-z0-9\u0600-\u06FF]+)*", slug): - raise SystemExit("SLUG must be lowercase letters/digits separated by hyphens, e.g. warm-essayist-writer") + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", slug) or len(slug) > 64: + raise SystemExit("SLUG must be lowercase a-z/0-9 words joined by hyphens (max 64 chars), " + "e.g. warm-essayist-writer. Transliterate non-Latin names.") + for sample in samples: + if not os.path.isfile(sample): + raise SystemExit(f"Sample not found: {sample}") root = os.path.join(outdir, slug) os.makedirs(os.path.join(root, "references"), exist_ok=True) @@ -42,10 +56,14 @@ def main(): print("Existing profile kept as references/profile.previous.json\n") with open(profile_path, "w", encoding="utf-8") as f: json.dump(profile, f, ensure_ascii=False, indent=2) + markers_path = os.path.join(root, "references", "markers.json") + if not os.path.exists(markers_path): + with open(markers_path, "w", encoding="utf-8") as f: + json.dump(STARTER_MARKERS, f, ensure_ascii=False, indent=2) print(style_stats.report(profile)) print(f"\nScaffold ready: {root}") - print("Next: write SKILL.md, references/style-guide.md, references/anchors.md") + print("Next: write SKILL.md, references/style-guide.md, references/anchors.md, fill references/markers.json") if __name__ == "__main__": diff --git a/skills/personawriter/scripts/style_stats.py b/skills/personawriter/scripts/style_stats.py index f9596fb..3356c5e 100644 --- a/skills/personawriter/scripts/style_stats.py +++ b/skills/personawriter/scripts/style_stats.py @@ -1,21 +1,34 @@ #!/usr/bin/env python3 """style_stats.py - quantitative fingerprint of a writing style. -Two commands: +Commands: analyze FILE [FILE ...] [--json OUT.json] Measure sentence rhythm, paragraph shape, punctuation habits, vocabulary texture, sentence openers and recurring phrases. Prints a readable report and optionally saves the numbers as a JSON "profile". - compare PROFILE.json DRAFT_FILE + compare PROFILE.json DRAFT_FILE [--json] Measure a new draft and show, metric by metric, how far it is from the - profile. Use it as a sanity check while writing in a captured style. + profile. Prints a similarity score and the three most important fixes first. + + check MARKERS.json DRAFT_FILE [--json] + Count the style's own signature moves (dosage per 1,000 words) and + never-list violations, as defined in the skill's references/markers.json. + + overlap DRAFT_FILE SOURCE [SOURCE ...] [--n 8] [--json] + Find word sequences (default 8+ words; twice as many characters for + Chinese/Japanese) that the draft shares verbatim with the source or the + anchors. Any hit means copying: rewrite it. + +Exit code: 0 = ok, 1 = problems found (compare: score below 70; check: +violations or dosage out of range; overlap: shared sequences), 2 = usage error. Works on any language written in Unicode. Stop-words and person pronouns are built in for English, German, French, Spanish, Portuguese, Italian and Arabic; other languages get every metric except the person counts. Chinese and -Japanese are measured in characters instead of words. +Japanese are measured in characters instead of words. Markdown, code, URLs and +hard line wraps (PDF/e-book copies) are cleaned out before measuring. Sentence splitting is heuristic, so treat numbers as good approximations, not exact truth. Numbers describe the surface; the real style lives in the qualitative analysis - use this as evidence, not as the goal. @@ -150,12 +163,48 @@ def ngrams(tokens, n): return zip(*[tokens[i:] for i in range(n)]) -def analyze_text(text): - text = TASHKEEL.sub("", text.replace("\r\n", "\n")) +LINE_END = rf"[.!?؟…。!?:{CLOSERS}]" # a line ending in one of these was not hard-wrapped + + +def unwrap(text): + """Join hard-wrapped lines (PDF/e-book copies) back into paragraphs. + A line is treated as wrapped when most lines are long and don't end a sentence.""" + lines = [l for l in text.split("\n") if l.strip()] + if len(lines) < 8: + return text + typical = sorted(len(l) for l in lines)[len(lines) // 2] + open_ends = sum(1 for l in lines if not re.search(LINE_END + r"\s*$", l)) + if typical < 45 or open_ends / len(lines) < 0.5: + return text + out = re.sub(rf"(?\n]+>", " ", text) + text = re.sub(r"`[^`\n]+`", " ", text) + text = re.sub(r"!?\[([^\]\n]*)\]\([^)\n]*\)", r"\1", text) + text = re.sub(r"https?://\S+|www\.\S+", " ", text) + if keep_markup: + return text + text = re.sub(r"^[ \t]*#{1,6}[ \t]+", "", text, flags=re.M) + text = re.sub(r"^[ \t]*(?:[-*•]|\d+[.)])[ \t]+", "", text, flags=re.M) + text = re.sub(r"\*\*|__", "", text) + return unwrap(text) + + +def analyze_text(text, min_words=50): + text = TASHKEEL.sub("", clean(text)) tokens = WORD.findall(text) n_words = len(tokens) - if n_words < 50: - raise SystemExit("Sample too short to measure (need at least ~50 words; 1,500+ is far better).") + if n_words < min_words: + raise SystemExit(f"Text too short to measure ({n_words} words; need at least {min_words}, " + "1,500+ is far better for a source).") lw = [t.casefold() for t in tokens] lang = detect_lang(text, tokens) stop = STOP.get(lang, set()) @@ -305,36 +354,154 @@ def report(p): PUNCT_FLOOR = 1.5 -def compare(profile, draft): +WEIGHTS = { # how much each metric matters for how a voice *feels*; punctuation rows weigh 1 + "mean_sentence_len": 3, "sentence_len_cv": 3, "burstiness": 2, "paragraph_len_sentences": 2, + "avg_word_len": 1, "ttr_500": 1, "quoted_share": 1, "first_person_per1k": 2, + "second_person_per1k": 2, "opener_top1_share": 1, +} + + +def compare_data(profile, draft): + """Score a draft profile against a source profile (0-100) and list the top fixes.""" scale = 1.5 if draft["words"] < 400 else 1.0 # short drafts: widen every tolerance rows = [] for k, (hi, lo, tol) in HINTS.items(): s, d = profile.get(k), draft.get(k) if s is None or d is None: continue - rows.append((k, s, d, hi, lo, tol, FLOOR.get(k, 0))) + rows.append((k, s, d, hi, lo, tol, FLOOR.get(k, 0), WEIGHTS.get(k, 1))) for k, s in profile["punct_per1k"].items(): d = draft["punct_per1k"].get(k, 0) - rows.append((f"{k}/1k", s, d, f"more {k} than the source", f"fewer {k} than the source", PUNCT_TOL, PUNCT_FLOOR)) + rows.append((f"{k}/1k", s, d, f"more {k} than the source", f"fewer {k} than the source", + PUNCT_TOL, PUNCT_FLOOR, 1)) + out, total, good = [], 0, 0.0 + for name, s, d, hi, lo, tol, floor, w in rows: + dev = abs(d - s) / max(abs(s), 1e-9) + allowed = tol * scale + ok = max(abs(s), abs(d)) < floor or dev <= allowed + total += w + good += w if ok else w * max(0.0, 1 - (dev - allowed)) # partial credit for near misses + out.append({"metric": name, "source": s, "draft": d, "ok": ok, "weight": w, + "deviation": round(dev, 2), "tolerance": round(allowed, 2), + "hint": "" if ok else (hi if d > s else lo)}) + score = round(100 * good / total) if total else 100 + misses = [r for r in out if not r["ok"]] + fixes = sorted(misses, key=lambda r: -r["weight"] * min(r["deviation"] / max(r["tolerance"], 1e-9), 3))[:3] + return {"score": score, "rows": out, "top_fixes": [f"{r['metric']}: {r['hint']}" for r in fixes], + "draft_words": draft["words"]} - L = [f"{'metric':<26}{'source':>10}{'draft':>10} verdict"] + +def compare(profile, draft): + res = compare_data(profile, draft) + L = [f"Similarity score: {res['score']}/100 (surface metrics only; 70+ is usually close enough)"] + if res["top_fixes"]: + L.append("Fix first:") + L += [f" {i}. {f}" for i, f in enumerate(res["top_fixes"], 1)] + L.append("") + L.append(f"{'metric':<26}{'source':>10}{'draft':>10} verdict") off = 0 - for name, s, d, hi, lo, tol, floor in rows: - ok = max(abs(s), abs(d)) < floor or abs(d - s) <= tol * scale * max(abs(s), 1e-9) - if ok: - verdict = "ok" - else: + for r in res["rows"]: + if not r["ok"]: off += 1 - verdict = "-> " + (hi if d > s else lo) - L.append(f"{name:<26}{s:>10}{d:>10} {verdict}") + L.append(f"{r['metric']:<26}{r['source']:>10}{r['draft']:>10} {'ok' if r['ok'] else '-> ' + r['hint']}") L.append("") - L.append(f"{off} of {len(rows)} metrics outside their tolerance.") + L.append(f"{off} of {len(res['rows'])} metrics outside their tolerance.") if draft["words"] < 200: L.append("(!) Draft under 200 words: these numbers are noisy - weigh them lightly.") L.append("Reminder: metrics catch surface drift only. Re-read the draft against the style guide's signature moves and never-list.") return "\n".join(L) +# ------------------------------------------------------------ markers check +_AR_UNIFY = str.maketrans({"آ": "ا", "أ": "ا", "إ": "ا", "ى": "ي"}) + + +def norm(text): + """Normalise for matching: no diacritics/tatweel, unified alef/ya, casefolded.""" + return TASHKEEL.sub("", text).translate(_AR_UNIFY).casefold() + + +def norm_pat(pattern): + """Normalise a regex the same way, without casefolding (\\S must stay \\S).""" + return TASHKEEL.sub("", pattern).translate(_AR_UNIFY) + + +def check_data(markers, raw): + """markers.json format: + {"dosage": [{"name": "...", "patterns": ["regex", ...], "per1k": [min, max]}], + "never": [{"name": "...", "pattern": "regex"}], + "paragraph_openers_no_repeat": ["regex", ...]} (optional) + Patterns are matched on normalised text (see norm), multiline, case-insensitive.""" + text = norm(clean(raw, keep_markup=True)) + words = max(len(WORD.findall(text)), 1) + res = {"words": words, "dosage": [], "never": [], "problems": 0} + for d in markers.get("dosage", []): + n = sum(len(re.findall(norm_pat(p), text, flags=re.M | re.I)) for p in d["patterns"]) + rate = round(n * 1000 / words, 2) + lo, hi = d.get("per1k", [0, 1e9]) + status = "ok" if lo <= rate <= hi else ("too few" if rate < lo else "too many") + if status != "ok" and words >= 250: + res["problems"] += 1 + res["dosage"].append({"name": d["name"], "count": n, "per1k": rate, "target": [lo, hi], "status": status}) + for v in markers.get("never", []): + hits = [m.group(0).strip()[:60] for m in re.finditer(norm_pat(v["pattern"]), text, flags=re.M | re.I)] + if hits: + res["problems"] += 1 + res["never"].append({"name": v["name"], "count": len(hits), "examples": hits[:3]}) + pats = markers.get("paragraph_openers_no_repeat", []) + if pats: + paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] + flags_ = [any(re.match(norm_pat(p), para, flags=re.I) for p in pats) for para in paras] + runs = sum(1 for a, b in zip(flags_, flags_[1:]) if a and b) + if runs: + res["problems"] += 1 + res["never"].append({"name": "consecutive paragraphs open with the same move", "count": runs, "examples": []}) + return res + + +def check(markers, raw): + r = check_data(markers, raw) + L = [f"Signature check on {r['words']} words: {'OK' if not r['problems'] else str(r['problems']) + ' problem(s)'}"] + if r["words"] < 250: + L.append("(!) Under 250 words: dosage is only indicative, not enforced.") + if r["dosage"]: + L.append("\nDosage (per 1,000 words):") + for d in r["dosage"]: + L.append(f" {d['name']:<38} {d['per1k']:>6} target {d['target'][0]}-{d['target'][1]} {d['status']}") + if r["never"]: + L.append("\nNever-list violations:") + for v in r["never"]: + ex = f" e.g. {' | '.join(v['examples'])}" if v["examples"] else "" + L.append(f" - {v['name']} (x{v['count']}){ex}") + return "\n".join(L), r + + +# ------------------------------------------------------------ copy check +def overlap_data(draft, sources, n=8): + """Find runs of n+ tokens the draft shares verbatim with any source (CJK: 2n characters).""" + dt = WORD.findall(norm(clean(draft))) + cjk = bool(dt) and sum(1 for t in dt if CJK_CHAR.match(t)) / len(dt) > 0.3 + if cjk: + n *= 2 # one CJK token is one character, roughly half a word + joiner = "" if cjk else " " + grams = set() + for src in sources: + st = WORD.findall(norm(clean(src))) + grams.update(tuple(st[i:i + n]) for i in range(len(st) - n + 1)) + spans, i = [], 0 + while i <= len(dt) - n: + if tuple(dt[i:i + n]) in grams: + j = i + n + while j < len(dt) and tuple(dt[j - n + 1:j + 1]) in grams: + j += 1 + spans.append(joiner.join(dt[i:j])) + i = j + else: + i += 1 + return {"n": n, "shared_sequences": spans, + "shared_words": sum(len(s) if cjk else len(s.split()) for s in spans)} + + def read_files(paths): chunks = [] for p in paths: @@ -354,6 +521,16 @@ def main(): c = sub.add_parser("compare", help="compare a draft to a saved profile") c.add_argument("profile") c.add_argument("draft") + c.add_argument("--json", action="store_true") + k = sub.add_parser("check", help="count signature moves and never-list violations") + k.add_argument("markers") + k.add_argument("draft") + k.add_argument("--json", action="store_true") + o = sub.add_parser("overlap", help="find word sequences copied from the source/anchors") + o.add_argument("draft") + o.add_argument("sources", nargs="+") + o.add_argument("--n", type=int, default=8) + o.add_argument("--json", action="store_true") args = ap.parse_args() if args.cmd == "analyze": @@ -363,10 +540,32 @@ def main(): with open(args.json, "w", encoding="utf-8") as f: json.dump(prof, f, ensure_ascii=False, indent=2) print(f"\nProfile saved to {args.json}") - else: + return 0 + if args.cmd == "compare": with open(args.profile, encoding="utf-8") as f: prof = json.load(f) - print(compare(prof, analyze_text(read_files([args.draft])))) + draft = analyze_text(read_files([args.draft]), min_words=30) + res = compare_data(prof, draft) + print(json.dumps(res, ensure_ascii=False, indent=2) if args.json else compare(prof, draft)) + return 0 if res["score"] >= 70 else 1 + if args.cmd == "check": + with open(args.markers, encoding="utf-8") as f: + markers = json.load(f) + text, res = check(markers, read_files([args.draft])) + print(json.dumps(res, ensure_ascii=False, indent=2) if args.json else text) + return 1 if res["problems"] else 0 + if args.cmd == "overlap": + res = overlap_data(read_files([args.draft]), [read_files([p]) for p in args.sources], args.n) + if args.json: + print(json.dumps(res, ensure_ascii=False, indent=2)) + elif res["shared_sequences"]: + print(f"COPYING: {len(res['shared_sequences'])} sequence(s) of {res['n']}+ tokens shared with the source:") + for sp in res["shared_sequences"]: + print(f" - {sp}") + else: + print(f"OK: no {res['n']}-token sequence shared with the source.") + return 1 if res["shared_sequences"] else 0 + return 2 if __name__ == "__main__": diff --git a/skills/personawriter/scripts/validate.py b/skills/personawriter/scripts/validate.py new file mode 100644 index 0000000..6b70214 --- /dev/null +++ b/skills/personawriter/scripts/validate.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""validate.py - quality gate for a generated style skill. + +Usage: + python validate.py SKILL_DIR [--source SAMPLE ...] + +Checks structure, frontmatter, leftover template placeholders, required style-guide +sections, anchor size, markers.json syntax, and (with --source) that the skill files +do not reproduce long passages of the source. +Exit code: 0 = ready to package, 1 = must fix, 2 = usage error. +""" +import argparse +import json +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import style_stats # noqa: E402 + +REQUIRED = ["SKILL.md", "references/style-guide.md", "references/anchors.md", + "references/profile.json", "references/markers.json", "scripts/style_stats.py"] +GUIDE_SECTIONS = { # section -> accepted heading keywords (any language the guide was written in) + "load-bearing traits": ["load-bearing"], + "never-list": ["never"], + "dosage": ["dosage", "dosierung", "dosis", "dosaggio", "الجرعة"], +} + + +def read(path): + with open(path, encoding="utf-8") as f: + return f.read().replace("\r\n", "\n") + + +def frontmatter(text): + m = re.match(r"\A---\n(.*?)\n---\n", text, re.S) + if not m: + return None + fm = {} + for line in m.group(1).splitlines(): + if ":" in line and not line.startswith(" "): + k, v = line.split(":", 1) + fm[k.strip()] = v.strip().strip('"').strip("'") + return fm + + +def validate(root, sources=()): + must, warn = [], [] + for rel in REQUIRED: + if not os.path.isfile(os.path.join(root, rel)): + must.append(f"missing file: {rel}") + have = lambda rel: os.path.isfile(os.path.join(root, rel)) + if not have("SKILL.md"): + return must, warn + + skill = read(os.path.join(root, "SKILL.md")) + fm = frontmatter(skill) + if not fm: + must.append("SKILL.md has no YAML frontmatter") + else: + name, desc = fm.get("name", ""), fm.get("description", "") + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) or len(name) > 64: + must.append(f"frontmatter name must be lowercase-hyphenated, <=64 chars: '{name}'") + if name != os.path.basename(os.path.normpath(root)): + warn.append(f"folder name '{os.path.basename(os.path.normpath(root))}' differs from skill name '{name}'") + if not desc: + must.append("frontmatter description is empty") + elif len(desc) > 1024: + must.append(f"description is {len(desc)} chars (max 1024)") + elif len(desc) < 150: + warn.append("description is short: name genres and the phrasings users will say, so it triggers") + if "<" in desc or ">" in desc: + must.append("description contains angle brackets (template placeholder left?)") + n_lines = skill.count("\n") + if n_lines > 150: + warn.append(f"SKILL.md is {n_lines} lines; keep it under ~100 and move detail to the style guide") + + for rel in ("SKILL.md", "references/style-guide.md", "references/anchors.md"): + if not have(rel): + continue + left = re.findall(r"<(?:[a-z][^<>\n]{2,60})>", read(os.path.join(root, rel))) + left = [x for x in left if not re.match(r"", x)] + if left: + must.append(f"{rel}: unfilled placeholders {left[:3]}") + + if not all(have(r) for r in REQUIRED): + return must, warn + guide = read(os.path.join(root, "references/style-guide.md")).lower() + heads = " ".join(re.findall(r"^#{1,3}\s+(.+)$", guide, re.M)) + for sec, keys in GUIDE_SECTIONS.items(): + if not any(k in heads for k in keys): + must.append(f"style-guide.md lacks a '{sec}' section") + if not re.search(r"\b\d+\s*(?:-|–|to|bis|à|a|إلى)\s*\d+\b", guide): + warn.append("style-guide.md has no numeric ranges; quantify rhythm from profile.json") + + anchors = [l for l in read(os.path.join(root, "references/anchors.md")).splitlines() + if re.match(r"^\s*\d+[.)]\s", l)] + if not 3 <= len(anchors) <= 12: + warn.append(f"{len(anchors)} anchors; aim for 5-10") + quoted = 0 + for a in anchors: + q = re.findall(r"[\"“«„](.+?)[\"”»“]", a) + words = sum(len(style_stats.WORD.findall(x)) for x in q) + quoted += words + if words > 45: + must.append(f"anchor too long ({words} words): {a[:60]}...") + if quoted > 350: + must.append(f"anchors quote {quoted} words in total; keep it under ~350") + + try: + markers = json.loads(read(os.path.join(root, "references/markers.json"))) + for d in markers.get("dosage", []): + for p in d["patterns"]: + re.compile(p) + lo, hi = d["per1k"] + if lo > hi: + must.append(f"markers.json: dosage '{d['name']}' has min > max") + for v in markers.get("never", []): + re.compile(v["pattern"]) + if not markers.get("dosage"): + warn.append("markers.json has no dosage entries") + if not markers.get("never"): + warn.append("markers.json has no never-list entries") + except (ValueError, KeyError, TypeError, re.error) as e: + must.append(f"markers.json invalid: {e}") + + try: + prof = json.loads(read(os.path.join(root, "references/profile.json"))) + if prof.get("words", 0) < 1000: + warn.append(f"profile based on {prof.get('words')} words: mark the style as provisional") + except ValueError as e: + must.append(f"profile.json invalid: {e}") + + if sources: + src = [read(s) for s in sources] + for rel in ("SKILL.md", "references/style-guide.md"): + ov = style_stats.overlap_data(read(os.path.join(root, rel)), src, n=12) + if ov["shared_sequences"]: + must.append(f"{rel} reproduces {len(ov['shared_sequences'])} passage(s) of 12+ words from the source") + return must, warn + + +def main(): + ap = argparse.ArgumentParser(description="Validate a generated style skill.") + ap.add_argument("skill_dir") + ap.add_argument("--source", nargs="*", default=[]) + a = ap.parse_args() + if not os.path.isdir(a.skill_dir): + print(f"error: not a folder: {a.skill_dir}", file=sys.stderr) + return 2 + must, warn = validate(a.skill_dir, a.source) + print(f"VALIDATION: {'PASS' if not must else 'FAIL'}") + for m in must: + print(f" must fix: {m}") + for w in warn: + print(f" warning: {w}") + return 1 if must else 0 + + +if __name__ == "__main__": + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.exit(main()) diff --git a/tests/fixtures/sample_en.txt b/tests/fixtures/sample_en.txt new file mode 100644 index 0000000..17db3d3 --- /dev/null +++ b/tests/fixtures/sample_en.txt @@ -0,0 +1,21 @@ +I bought the kettle on a Tuesday. This matters less than you think, but I remember it, and memory is the only instrument I have for this kind of thing. + +The shop was closing. The man behind the counter had the look of someone who has sold a great many kettles and expects to sell a great many more, none of them to people he will remember. He wrapped it in newspaper. I did not ask him to. He did it anyway, with the small care of a person folding a flag, and I left feeling that I had been handed something heavier than a kettle. + +Here is what I believe about objects. They do not hold memories; we lend memories to them, and then we forget we made the loan. The kettle is not sad. I am sad near the kettle. The difference is small and it is everything. + +My mother kept a tin of buttons for forty years. Nobody sewed. Nobody, as far as I could tell, had ever sewn in our family, not once, not in an emergency, not even the year my father tore his only good jacket at a wedding and wore it torn for the rest of the evening with the dignity of a wounded general. Still, the tin stayed. It moved house with us three times. When she died I opened it expecting to cry and instead I laughed, because there on top, alone, was a button from that jacket. + +She had kept it. Of course she had. + +So I distrust people who tell me to declutter my life. I understand the argument. Fewer things, fewer burdens, more light in the room. But a room full of light is also a room where nothing has happened. I would rather live in a room where something has happened, even if I have to step around it. + +The kettle, by the way, is terrible. It whistles in a key that no human song has ever used. It takes eleven minutes to boil. I have timed it. I timed it on the first morning, out of curiosity, and on the second morning out of disbelief, and on the third I stopped timing and started waiting, which is a different activity altogether and one I recommend. + +Waiting, it turns out, is where the thinking happens. Not the useful thinking. The other kind. The kind that notices the light moving across the floor and wonders, without any urgency, whether the floor minds. + +I am not saying the kettle made me a better person. I am saying it made me slower, and slowness is the closest thing I know to attention. You cannot love what you do not notice. You cannot notice at speed. + +People ask why I do not simply buy a new one. They are right, in the way that people who ask sensible questions are always right. A new kettle would boil in three minutes. It would whistle in a reasonable key. It would come wrapped in cardboard, not in newspaper, and no man with a flag-folder's hands would hand it to me as the shop lights went out. + +I will keep the terrible kettle. Not because it is good. Because it is mine, and because somewhere in its eleven minutes there is room for a button, a jacket, a wedding, and a woman who never sewed and kept everything anyway. diff --git a/tests/test_checks.py b/tests/test_checks.py new file mode 100644 index 0000000..511fdb4 --- /dev/null +++ b/tests/test_checks.py @@ -0,0 +1,192 @@ +"""Tests for cleaning, scoring, markers, copy checks, validate.py and package.py. +Run: python -m unittest discover tests""" +import io +import json +import os +import subprocess +import sys +import tempfile +import textwrap +import unittest +import zipfile +from contextlib import redirect_stdout + +HERE = os.path.dirname(__file__) +SCRIPTS = os.path.join(HERE, "..", "skills", "personawriter", "scripts") +sys.path.insert(0, SCRIPTS) +import style_stats as ss # noqa: E402 +import validate # noqa: E402 + +SAMPLE = os.path.join(HERE, "fixtures", "sample_en.txt") +with open(SAMPLE, encoding="utf-8") as f: + TEXT = f.read() + + +class Cleaning(unittest.TestCase): + def test_hard_wrapped_text_gives_the_same_sentences(self): + prof = ss.analyze_text(TEXT) + wrapped = "\n\n".join(textwrap.fill(p, 70) for p in TEXT.split("\n\n")) + self.assertEqual(ss.analyze_text(wrapped)["sentences"], prof["sentences"]) + + def test_markdown_code_and_urls_are_ignored(self): + md = "# Heading\n\n```\ncode block\n```\n\nSee https://example.com and `x()`.\n\n" + TEXT + self.assertLessEqual(abs(ss.analyze_text(md)["words"] - ss.analyze_text(TEXT)["words"]), 4) + + +class Score(unittest.TestCase): + def test_identical_profile_scores_100(self): + prof = ss.analyze_text(TEXT) + res = ss.compare_data(prof, prof) + self.assertEqual(res["score"], 100) + self.assertEqual(res["top_fixes"], []) + + def test_flat_uniform_draft_scores_below_70(self): + prof = ss.analyze_text(TEXT) + flat = ss.analyze_text(" ".join(["The system processes the request and returns the result to the user."] * 12)) + res = ss.compare_data(prof, flat) + self.assertLess(res["score"], 70) + self.assertTrue(res["top_fixes"]) + + def test_compare_cli_exit_code_follows_score(self): + with tempfile.TemporaryDirectory() as tmp: + prof, same = os.path.join(tmp, "p.json"), os.path.join(tmp, "d.txt") + with open(prof, "w", encoding="utf-8") as f: + json.dump(ss.analyze_text(TEXT), f) + with open(same, "w", encoding="utf-8") as f: + f.write(TEXT) + r = subprocess.run([sys.executable, os.path.join(SCRIPTS, "style_stats.py"), "compare", prof, same], + capture_output=True, text=True, encoding="utf-8") + self.assertEqual(r.returncode, 0) + self.assertIn("Similarity score: 100/100", r.stdout) + + +class Markers(unittest.TestCase): + MARKERS = {"dosage": [{"name": "belief pivot", "patterns": [r"\bhere is what i believe\b"], "per1k": [1, 5]}], + "never": [{"name": "exclamation", "pattern": "!"}, + {"name": "non-space run", "pattern": r"\S{40,}"}]} + + def test_never_list_violation_found(self): + res = ss.check_data(self.MARKERS, "Here is what I believe! " * 30) + names = [v["name"] for v in res["never"]] + self.assertIn("exclamation", names) + self.assertNotIn("non-space run", names, "regex escapes like \\S must not be case-folded") + + def test_arabic_patterns_match_through_diacritics(self): + res = ss.check_data({"never": [{"name": "khitam", "pattern": "في الختام"}]}, "وفي الختامِ نقول شيئًا.") + self.assertTrue(res["never"]) + + def test_dosage_out_of_range_is_a_problem(self): + res = ss.check_data(self.MARKERS, "A quiet sentence without the move. " * 60) + self.assertEqual(res["dosage"][0]["status"], "too few") + self.assertGreater(res["problems"], 0) + + +class Overlap(unittest.TestCase): + def test_copied_run_found_and_merged(self): + ov = ss.overlap_data("Yesterday she had kept it of course she had and left.", [TEXT], n=6) + self.assertEqual(ov["shared_sequences"], ["she had kept it of course she had"]) + + def test_original_text_passes(self): + self.assertFalse(ss.overlap_data("An entirely new sentence about trains and weather.", [TEXT])["shared_sequences"]) + + def test_cjk_counts_characters(self): + src = "町が目を覚ます前に、私は港まで歩いた。船はまだ杭につながれていた。" + self.assertTrue(ss.overlap_data("昨日、町が目を覚ます前に、私は港まで歩いた。", [src])["shared_sequences"]) + self.assertFalse(ss.overlap_data("昨日は町が静かだった。", [src])["shared_sequences"]) + + +GOOD_SKILL = { + "SKILL.md": "---\nname: test-voice-writer\ndescription: Writes short personal essays in a calm, " + "observant first-person voice built from small objects and plain sentences. Use whenever " + "the user asks for an essay, column or post in this style, or wants a draft rewritten in this voice.\n" + "---\n\n# Test Voice Writer\n\nCalm and observant.\n", + "references/style-guide.md": "# Style guide\n\n## 1. Load-bearing traits\nSentences run 6-18 words.\n\n" + "## 8. Never-list\n- no exclamation marks\n\n## 9. Dosage\nOne pivot per 500 words.\n", + "references/anchors.md": "# Anchors\n\n1. \"I bought the kettle on a Tuesday.\" - flat opener\n" + "2. \"He wrapped it in newspaper.\" - short beat\n" + "3. \"I did not ask him to.\" - short beat\n", + "references/markers.json": json.dumps({"dosage": [{"name": "pivot", "patterns": ["here is what i"], "per1k": [0, 3]}], + "never": [{"name": "exclamation", "pattern": "!"}]}), +} + + +class ValidateAndPackage(unittest.TestCase): + def scaffold(self, tmp): + r = subprocess.run([sys.executable, os.path.join(SCRIPTS, "scaffold.py"), "test-voice-writer", tmp, SAMPLE], + capture_output=True, text=True, encoding="utf-8") + self.assertEqual(r.returncode, 0, r.stderr) + return os.path.join(tmp, "test-voice-writer") + + def write(self, root, files): + for rel, content in files.items(): + with open(os.path.join(root, rel), "w", encoding="utf-8") as f: + f.write(content) + + def test_scaffold_writes_starter_markers_and_keeps_filled_ones(self): + with tempfile.TemporaryDirectory() as tmp: + sk = self.scaffold(tmp) + path = os.path.join(sk, "references", "markers.json") + with open(path, encoding="utf-8") as f: + self.assertEqual(json.load(f)["dosage"], []) + self.write(sk, {"references/markers.json": GOOD_SKILL["references/markers.json"]}) + self.scaffold(tmp) # update mode must not wipe the user's markers + with open(path, encoding="utf-8") as f: + self.assertTrue(json.load(f)["dosage"]) + + def test_incomplete_skill_fails(self): + with tempfile.TemporaryDirectory() as tmp: + must, _ = validate.validate(self.scaffold(tmp)) + self.assertTrue(any("SKILL.md" in m for m in must)) + + def test_leftover_placeholders_are_caught(self): + with tempfile.TemporaryDirectory() as tmp: + sk = self.scaffold(tmp) + self.write(sk, dict(GOOD_SKILL, **{"SKILL.md": "---\nname: test-voice-writer\n" + "description: Writes in this voice.\n---\n# X\n"})) + must, _ = validate.validate(sk) + self.assertTrue(any("placeholder" in m or "angle" in m for m in must)) + + def test_complete_skill_passes_validates_crlf_and_packages(self): + with tempfile.TemporaryDirectory() as tmp: + sk = self.scaffold(tmp) + crlf = {k: v.replace("\n", "\r\n") if k.endswith(".md") else v for k, v in GOOD_SKILL.items()} + self.write(sk, crlf) + must, _ = validate.validate(sk, [SAMPLE]) + self.assertEqual(must, []) + out = os.path.join(tmp, "dist") + r = subprocess.run([sys.executable, os.path.join(SCRIPTS, "package.py"), sk, out], + capture_output=True, text=True, encoding="utf-8") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + with zipfile.ZipFile(os.path.join(out, "test-voice-writer.skill")) as z: + names = [n.replace("\\", "/") for n in z.namelist()] + self.assertIn("test-voice-writer/SKILL.md", names) + self.assertIn("test-voice-writer/scripts/style_stats.py", names) + + def test_copied_passage_in_style_guide_fails(self): + with tempfile.TemporaryDirectory() as tmp: + sk = self.scaffold(tmp) + copied = " ".join(TEXT.split()[:30]) + self.write(sk, dict(GOOD_SKILL, **{"references/style-guide.md": + GOOD_SKILL["references/style-guide.md"] + "\n" + copied + "\n"})) + must, _ = validate.validate(sk, [SAMPLE]) + self.assertTrue(any("reproduces" in m for m in must)) + + +class CheckCli(unittest.TestCase): + def test_check_cli_json(self): + with tempfile.TemporaryDirectory() as tmp: + markers, draft = os.path.join(tmp, "m.json"), os.path.join(tmp, "d.txt") + with open(markers, "w", encoding="utf-8") as f: + json.dump({"never": [{"name": "exclamation", "pattern": "!"}]}, f) + with open(draft, "w", encoding="utf-8") as f: + f.write("Wow! " * 10) + sys.argv = ["style_stats.py", "check", markers, draft, "--json"] + buf = io.StringIO() + with redirect_stdout(buf): + code = ss.main() + self.assertEqual(code, 1) + self.assertEqual(json.loads(buf.getvalue())["never"][0]["count"], 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skill_package.py b/tests/test_skill_package.py index ea67b51..ff63b15 100644 --- a/tests/test_skill_package.py +++ b/tests/test_skill_package.py @@ -40,8 +40,8 @@ def test_referenced_files_exist(self): for folder in os.listdir(SKILLS): text = read("skills", folder, "SKILL.md") for path in set(re.findall(r"`((?:references|scripts)/[\w./-]+\.\w+)`", text)): - if path.startswith("references/profile") or path.startswith("references/style-guide") \ - or path.startswith("references/anchors"): + if path.startswith(("references/profile", "references/style-guide", + "references/anchors", "references/markers")): continue # files of the *generated* skill, not this one with self.subTest(skill=folder, path=path): self.assertTrue(os.path.exists(os.path.join(SKILLS, folder, path)), f"{path} is missing")