diff --git a/README.md b/README.md
index 6e47f9a..7f3fdf6 100644
--- a/README.md
+++ b/README.md
@@ -2,16 +2,18 @@
**Stop sounding like everyone else's AI.**
-Draft LinkedIn posts in *your* voice on your machine. Index your writing, retrieve a few of your own posts for rhythm, and generate from a quantized local model (~5–7 GB). Your corpus never leaves this Mac.
+Draft LinkedIn posts and articles in *your* voice on your machine. Index your writing, measure your cadence, retrieve short rhythm references from your own pieces, and generate with a local quantized model (~5–7 GB). Your corpus never leaves this Mac.
Built by [Telivity](https://telivity.com). Apache-2.0.
```text
-your writing ──► ingest ──► index-voice ──► build-style-profile ──► write
+your writing ──► ingest ──► index-voice ──► build-style-profile ──► write [--channel post|article]
```
+No fine-tune is required to get a draft. `write` runs the local base model with your retrieval index plus your measured style card (`adapter=none`).
+
-
+
---
@@ -22,13 +24,29 @@ The feed is drowning in AI writing that all sounds the same — *“In today’s
Cloud fine-tunes are a non-option for personal writing. Notes, emails, and posts are biometric-adjacent. Shipping them to a rented GPU so someone else’s stack can imitate you is a strange bargain.
-PersonalityProtect keeps the corpus on disk, measures your cadence, retrieves short rhythm references from your own posts, and drafts locally with MLX on Apple Silicon. Treat outputs as drafts you still own.
+PersonalityProtect keeps the corpus on disk, measures your cadence, retrieves short rhythm references from your own writing, and drafts locally with MLX on Apple Silicon. Treat outputs as drafts you still own.
+
+---
+
+## How you get voice
+
+1. **Ingest** your LinkedIn export and/or local notes (stays on disk).
+2. **`index-voice`** builds a local retrieval index.
+3. **`build-style-profile`** measures cadence (sentence length, short lines, post length band, banned filler).
+4. **`write --topic --points`** drafts from the brief only; retrieved pieces are rhythm reference.
+
+Two channels come out of step 4:
+
+- **`--channel post`** (default) targets your long-post band, up to the LinkedIn ~3000-character limit (~550 words).
+- **`--channel article`** runs outline → sections → stitch, and needs at least five `linkedin_article` pieces in the corpus.
+
+Local LoRA training stays in the CLI as an experiment, not as the path to a first draft — see [Advanced](#advanced-optional).
---
## Quick start
-Requires Python 3.10+ and an Apple Silicon Mac for the happy path.
+Requires Python 3.10+ and an Apple Silicon Mac: `write` runs on MLX and needs Metal.
```bash
git clone https://github.com/TelivityAI/personality-protect.git
@@ -40,19 +58,28 @@ pip install -e ".[dev,mlx]"
personality-protect init
personality-protect download --format mlx # ~6 GB, once
-# Your exports stay on this machine
personality-protect ingest --linkedin ~/path/to/linkedin-export
personality-protect ingest --path ~/path/to/notes --source note
personality-protect index-voice
personality-protect build-style-profile
+
+# LinkedIn post (targets your long-post band, up to ~550 words / ~3k chars)
personality-protect write \
--topic "Contoso Ledger exceptions" \
- --points "Name one owner. Keep the rollout boring."
+ --points "- Name one owner\n- Keep the rollout boring"
+
+# Article (outline → sections → stitch)
+personality-protect write \
+ --channel article \
+ --topic "Contoso Ledger guide" \
+ --points "- Name one owner\n- Cut exceptions\n- Keep rollbacks boring"
personality-protect status
```
+That is the whole path to a draft — no training step.
+
Optional extras:
```bash
@@ -67,9 +94,9 @@ pip install -e ".[cuda]" # NVIDIA path (optional)
Public docs use **synthetic Contoso / synergy-slop text only**. No personal corpus.
-| Write path | Status | Mark |
+| `write` (post + article) | `status` | Mark |
| --- | --- | --- |
-|
|
|
|
+|
|
|
|
```bash
personality-protect index-voice
@@ -93,8 +120,9 @@ personality-protect demo
| Stays in `~/.personality-protect/` | Never commit / never upload |
| --- | --- |
| Profiles, corpus index, voice index, style profile | Real LinkedIn / email / note exports |
-| Downloaded weights under `models/` / HF cache | Profile URLs, personal paths |
-| Local eval receipts | API keys, `.env`, tokens |
+| Writer LoRA adapters under `adapters/` | Profile URLs, personal paths |
+| Downloaded weights under `models/` / HF cache | API keys, `.env`, tokens |
+| Local eval receipts | Cloud train uploads |
Override the home directory with `--home` or `PERSONALITY_PROTECT_HOME`.
@@ -106,13 +134,14 @@ This README uses synthetic examples only (e.g. Contoso, “leverage synergies”
## Hardware
-**Happy path: Apple Silicon Mac** (MLX).
+**`write` requires an Apple Silicon Mac.** Drafting runs Qwen3.5-9B 4-bit through MLX/Metal; there is no cloud fallback.
| What | Size / note |
| --- | --- |
-| MLX 4-bit base (default write) | ~6 GB download |
-| GGUF Q4_K_M (optional) | ~5.6 GB download |
-| Peak RAM while writing | Memory-capped; typically comfortable on 16 GB+ |
+| MLX 4-bit base (what `write` loads) | ~6 GB download, once |
+| Peak RAM while writing | Memory-capped; 16 GB+ recommended |
+| GGUF Q4_K_M (optional, for `filter`) | ~5.6 GB download |
+| Writer LoRA (optional) | Small (MBs) under the profile |
MLX applies a wired-memory cap so Metal does not jetsam-kill Python on mid-size Macs.
@@ -122,34 +151,13 @@ MLX applies a wired-memory cap so Metal does not jetsam-kill Python on mid-size
State lives in `~/.personality-protect/profiles//`.
-### Init
+### Init / download / ingest
```bash
personality-protect init
-personality-protect init --profile work
-```
-
-### Download
-
-```bash
-personality-protect download --format mlx # → Hugging Face cache, ~6 GB
-personality-protect download --format gguf # → ~/.personality-protect/models/*.gguf
-```
-
-### Ingest
-
-LinkedIn export (folder or `.zip`) — CSV/HTML read in place; zips unpack only into the profile cache:
-
-```bash
-personality-protect ingest --linkedin ~/path/to/linkedin-export
+personality-protect download --format mlx
personality-protect ingest --linkedin ~/path/to/linkedin-export.zip
-```
-
-Local docs / notes / mail archives (read in place — **no mandatory copy**):
-
-```bash
personality-protect ingest --path ~/path/to/notes --source note
-personality-protect ingest --path ~/path/to/mail-archive --source email
```
### Index and style
@@ -159,32 +167,23 @@ personality-protect index-voice
personality-protect build-style-profile
```
-`index-voice` builds a local retrieval index from your corpus. `build-style-profile` measures cadence targets (sentence length, short lines, typical post length, banned filler) used by `write`.
+Post length targets come from `linkedin_post` pieces (p75/p90), clamped to the LinkedIn ~3000-character band (~550 words).
### Write
```bash
-personality-protect write \
- --topic "Contoso Ledger exceptions" \
- --points "Name one owner. Keep the rollout boring."
+personality-protect write --topic "…" --points "…"
+personality-protect write --channel article --topic "…" --points "…"
personality-protect write --topic "…" --points "…" --json
```
-`--topic` and `--points` are the only content the draft may use. Retrieved posts are rhythm reference only — facts come from the brief.
+`--topic` and `--points` are the only content the draft may use; retrieved pieces supply rhythm, not facts. Every `write` above runs base weights (`adapter=none`). Article channel requires at least five `linkedin_article` pieces in the corpus.
-### Status
+### Status / API
```bash
personality-protect status
-```
-
-### Local API stub
-
-Loopback only (`127.0.0.1`). Refuses non-local binds. Future browser-extension hook.
-
-```bash
-personality-protect api
-# GET http://127.0.0.1:8765/health
+personality-protect api # loopback 127.0.0.1 only
```
---
@@ -199,40 +198,27 @@ Global flags (most commands): `--profile`, `--home`, `--json`, plus branding `--
| `download` | Prefetch quantized MLX or GGUF base |
| `ingest` | Index LinkedIn export and/or local paths |
| `index-voice` | Build local voice retrieval index |
-| `build-style-profile` | Build cadence / banned-filler style card |
-| `write` | Draft a post from topic + points |
+| `build-style-profile` | Build cadence / length / banned-filler style card |
+| `write` | Draft a post or article (`--channel post\|article`) |
| `eval-write-holdout` | Score write quality on held-out pieces (local receipt) |
| `status` | Show profile state |
| `demo` | Optional synthetic smoke tour (no download) |
| `api` | Loopback HTTP stub |
| `logo` | Print Telivity CLI mark |
+| `build-writer-sft`, `train` | Optional LoRA experiments — see [Advanced](#advanced-optional) |
-### Important flags
-
-**`download`**
-
-| Flag | Meaning |
-| --- | --- |
-| `--format mlx\|gguf` | Which quantized artifact to fetch |
-
-**`ingest`**
-
-| Flag | Meaning |
-| --- | --- |
-| `--linkedin PATH` | LinkedIn export folder or `.zip` |
-| `--path PATH` | Local docs/notes/mail (repeatable) |
-| `--source NAME` | Label for `--path` sources |
-
-**`write`**
+### `write` flags
| Flag | Meaning |
| --- | --- |
-| `--topic` | What the post is about |
+| `--topic` | What the piece is about |
| `--points` | Facts/claims the draft may use |
-| `--k` | How many rhythm exemplars to retrieve |
+| `--channel post\|article` | Post (default) or article outline→sections→stitch |
+| `--k` | Rhythm exemplars to retrieve |
+| `--adapter` / `--no-adapter` | Default `--no-adapter` (base weights); `--adapter` needs a trained LoRA |
| `--json` | Machine-readable receipt |
-**`eval-write-holdout`**
+### `eval-write-holdout` flags
| Flag | Meaning |
| --- | --- |
@@ -244,30 +230,37 @@ Global flags (most commands): `--profile`, `--home`, `--json`, plus branding `--
## Advanced (optional)
-These commands are available for experimentation. The shipped path above does not require them.
+Nothing here is needed for a draft. These commands stay in the CLI for local experiments and receipts.
+
+### Writer LoRA (experimental plumbing)
+
+`write` defaults to base weights. The adapter path exists so a trained LoRA *can* be loaded, and `--adapter` errors out when no adapter is present:
```bash
-personality-protect select
-personality-protect train --backend mlx
-personality-protect filter --text "…"
-personality-protect compare --synthetic slop_branding
+personality-protect build-writer-sft
+personality-protect train --writer --backend mlx
+personality-protect eval-write-holdout --out receipt.json
+personality-protect write --adapter --topic "…" --points "…"
```
-See `personality-protect train --help` and `filter --help` for flags. Adapters, when used, stay under `~/.personality-protect/profiles//adapters/`.
+Keep an adapter only if `eval-write-holdout` shows it beating RAG-alone on held-out pieces. Otherwise delete it and stay on the default. Training is not a prerequisite for `write`, and an untested adapter is not an upgrade.
----
+### Other experiment commands
-## Launch script
+`select`, `filter`, `compare`, `eval`, and the translator-pair commands remain available. They score or rewrite existing text and are not part of the drafting path above.
-Operator checklist: [docs/LAUNCH.md](docs/LAUNCH.md).
+### Operator script
+
+`scripts/beast_demo.sh` drives the older `select` → `train` → `compare` → `eval` sequence, not `write`. Use it for train/compare runs only:
```bash
chmod +x scripts/beast_demo.sh
./scripts/beast_demo.sh --linkedin ~/path/to/linkedin-export
-# synthetic smoke (no personal data, no multi-GB download):
-./scripts/beast_demo.sh --skip-download
+./scripts/beast_demo.sh --skip-download # synthetic smoke
```
+Operator checklist: [docs/LAUNCH.md](docs/LAUNCH.md).
+
---
## Develop
diff --git a/docs/LAUNCH.md b/docs/LAUNCH.md
index c0e7eca..f7a4b2d 100644
--- a/docs/LAUNCH.md
+++ b/docs/LAUNCH.md
@@ -6,9 +6,10 @@ Operator guide for a local PersonalityProtect run. Corpus, SFT JSONL, adapters,
| Path | Machine | Disk download |
| --- | --- | --- |
-| MLX train + filter | Apple Silicon | MLX 4-bit **~6 GB** |
-| GGUF filter | Any (llama.cpp) | Q4_K_M **~5.6 GB** |
-| CUDA QLoRA train | NVIDIA 24GB+ VRAM | Prefer GGUF for day-to-day filter |
+| `write` (post + article) | Apple Silicon | MLX 4-bit **~6 GB** |
+| GGUF `filter` (optional) | Any (llama.cpp) | Q4_K_M **~5.6 GB** |
+| MLX LoRA train (optional) | Apple Silicon | Reuses the MLX 4-bit base |
+| CUDA QLoRA train (optional) | NVIDIA 24GB+ VRAM | Prefer GGUF for day-to-day `filter` |
| Mock / smoke | CI or pipeline check | None |
Quantized defaults stay in the **~5–7 GB** range. Full BF16 is not the happy path.
@@ -22,15 +23,23 @@ Quantized defaults stay in the **~5–7 GB** range. Full BF16 is not the happy p
## Operator steps
-1. Install: `pip install -e ".[dev]"` plus extras (`mlx`, `gguf`, `cuda`, `models`) as needed.
+1. Install: `pip install -e ".[dev,mlx]"` plus extras (`gguf`, `cuda`, `models`) as needed.
2. Init: `personality-protect init`
-3. Download one quantized artifact: `personality-protect download` (GGUF) and/or `--format mlx` on Apple Silicon.
+3. Download the MLX base: `personality-protect download --format mlx` (add `--format gguf` only if you want `filter`).
4. Ingest local writing: `personality-protect ingest --linkedin --path `
-5. Select: `personality-protect select` (warns below 50 pieces; blocks below 20 unless `--force`)
-6. Full train: `personality-protect train` (auto steps from SFT count). Useful flags: `--proof`, `--resume`, `--chunk-steps`, `--memory-gb`. CI uses `--smoke` / `--backend mock`.
-7. Filter: `personality-protect filter --text "…"`
-8. Compare: `personality-protect compare --synthetic slop_branding`
-9. Eval: `personality-protect eval --synthetic slop_branding`
+5. Index: `personality-protect index-voice`
+6. Style card: `personality-protect build-style-profile`
+7. Draft: `personality-protect write --topic "…" --points "…"` (add `--channel article` with 5+ `linkedin_article` pieces)
+8. Receipt: `personality-protect eval-write-holdout --out receipt.json`
+9. State check: `personality-protect status`
+
+Steps 1–9 need no training run. `write` uses base weights (`adapter=none`) plus the retrieval index and style card.
+
+## Optional experiments
+
+- Select + train a LoRA: `personality-protect select`, then `personality-protect train` (`--writer` for the brief→post writer LoRA). Useful flags: `--proof`, `--resume`, `--chunk-steps`, `--memory-gb`. CI uses `--smoke` / `--backend mock`.
+- Load an adapter for a draft: `personality-protect write --adapter …` — only after `eval-write-holdout` shows it beating the default on holdouts.
+- Rewrite/score existing text: `personality-protect filter --text "…"`, `personality-protect compare --synthetic slop_branding`, `personality-protect eval --synthetic slop_branding`.
MLX train is chunked and checkpointed — a crash does not wipe a full run; use `--resume` (incomplete runs also auto-resume).
diff --git a/docs/images/cli-shipped.png b/docs/images/cli-shipped.png
index ca52724..3590681 100644
Binary files a/docs/images/cli-shipped.png and b/docs/images/cli-shipped.png differ
diff --git a/src/personality_protect/cli.py b/src/personality_protect/cli.py
index 21a978b..0910dec 100644
--- a/src/personality_protect/cli.py
+++ b/src/personality_protect/cli.py
@@ -504,11 +504,42 @@ def build_style_profile_cmd(
console.print(f"Saved: {out_path}")
+@app.command("build-writer-sft")
+def build_writer_sft_cmd(
+ ctx: typer.Context,
+ profile: str = typer.Option(DEFAULT_PROFILE, "--profile"),
+ home: Optional[Path] = typer.Option(None, "--home"),
+ as_json: bool = typer.Option(False, "--json"),
+) -> None:
+ """Build brief→post SFT JSONL for the writer LoRA (holdouts excluded)."""
+ from personality_protect.writer_sft import run_build_writer_sft
+
+ _banner_from_ctx(ctx, json_mode=as_json)
+ paths = get_paths(profile, home=home)
+ try:
+ receipt = run_build_writer_sft(paths)
+ except (FileNotFoundError, ValueError) as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
+ if as_json:
+ typer.echo(json.dumps(receipt, indent=2, ensure_ascii=False))
+ return
+ console.print(
+ f"writer SFT: {receipt['examples']} examples "
+ f"(skipped {receipt['skipped']}) → {receipt['path']}"
+ )
+
+
@app.command("write")
def write_cmd(
ctx: typer.Context,
topic: str = typer.Option(..., "--topic", help="What the post is about."),
points: str = typer.Option(..., "--points", help="Facts/claims the post may use."),
+ channel: str = typer.Option(
+ "post",
+ "--channel",
+ help="post (default LinkedIn post) or article (outline→sections→stitch).",
+ ),
k: int = typer.Option(
DEFAULT_WRITE_K,
"--k",
@@ -517,38 +548,27 @@ def write_cmd(
max_tokens: int = typer.Option(
DEFAULT_WRITE_MAX_TOKENS,
"--max-tokens",
- help="Generation budget for the draft.",
+ help="Generation budget for the draft (per section when --channel article).",
),
no_adapter: bool = typer.Option(
True,
"--no-adapter/--adapter",
- help="RAG write runs on base weights only; --adapter is unsupported.",
+ help="Default: base weights. --adapter loads a local writer LoRA when present.",
),
out: Optional[Path] = typer.Option(None, "--out", help="Write draft to file."),
profile: str = typer.Option(DEFAULT_PROFILE, "--profile"),
home: Optional[Path] = typer.Option(None, "--home"),
as_json: bool = typer.Option(False, "--json"),
) -> None:
- """Draft a post from retrieved exemplars on base weights (no adapter)."""
+ """Draft a post or article from retrieved exemplars (optional writer LoRA)."""
_banner_from_ctx(ctx, json_mode=as_json)
- if not no_adapter:
- console.print(
- "[red]--adapter is not supported: the RAG write path always runs "
- "base weights with adapter=none.[/red]"
- )
- raise typer.Exit(2)
paths = get_paths(profile, home=home)
try:
- config = load_config(paths)
+ load_config(paths)
except FileNotFoundError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
- if config.write_adapter is not None and not as_json:
- console.print(
- f"[yellow]Ignoring profile write_adapter={config.write_adapter} — "
- "write is RAG-only.[/yellow]"
- )
try:
result = run_write(
@@ -557,6 +577,8 @@ def write_cmd(
paths,
k=k,
max_tokens=max_tokens,
+ channel=channel,
+ use_adapter=not no_adapter,
)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
@@ -818,6 +840,11 @@ def train_cmd(
"Voice-pair mode: translator SFT only; skips leave_alone/identity minting."
),
),
+ writer: bool = typer.Option(
+ False,
+ "--writer",
+ help="Build brief→post writer SFT and train a writer LoRA (RAG write path).",
+ ),
profile: str = typer.Option(DEFAULT_PROFILE, "--profile"),
home: Optional[Path] = typer.Option(None, "--home"),
as_json: bool = typer.Option(False, "--json"),
@@ -829,6 +856,9 @@ def train_cmd(
console.print(f"[red]Unknown backend: {backend}[/red]")
raise typer.Exit(2)
+ if pairs is not None and writer:
+ console.print("[red]Pass only one of --writer or --pairs.[/red]")
+ raise typer.Exit(2)
if pairs is not None and not pairs.is_file():
console.print(f"[red]pairs file not found: {pairs}[/red]")
raise typer.Exit(2)
@@ -1055,8 +1085,9 @@ def on_progress(info: dict) -> None:
force_retrain=force_retrain,
progress_callback=callback,
pairs=pairs,
+ writer=writer,
)
- except (FileNotFoundError, RuntimeError, MockFallbackError) as exc:
+ except (FileNotFoundError, RuntimeError, MockFallbackError, ValueError) as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
finally:
diff --git a/src/personality_protect/style_profile.py b/src/personality_protect/style_profile.py
index bb3b67a..bc465d1 100644
--- a/src/personality_protect/style_profile.py
+++ b/src/personality_protect/style_profile.py
@@ -194,6 +194,47 @@ def corpus_style_stats(texts: Iterable[str]) -> dict[str, Any]:
}
+# Short posts and comments pull medians down; length targets come from posts
+# that are already post-shaped. LinkedIn's ~3000 character limit is ~480–550
+# words — that is the hard ceiling for the post channel.
+_POST_LENGTH_SOURCES = frozenset({"linkedin_post"})
+_MIN_LENGTH_SAMPLE_WORDS = 80
+LINKEDIN_POST_WORD_CEILING = 550
+DEFAULT_DRAFT_WORD_TARGET = 500
+DEFAULT_DRAFT_WORD_FLOOR = 300
+
+
+def post_length_stats(pieces: Iterable[Piece]) -> dict[str, float]:
+ """Word-length percentiles from post-shaped pieces only.
+
+ Falls back to all non-empty pieces when no linkedin_post rows qualify, so
+ Contoso fixtures and note-only corpora still get a length card.
+ """
+ piece_list = list(pieces)
+ posts = [
+ p
+ for p in piece_list
+ if p.source in _POST_LENGTH_SOURCES
+ and len(_word_tokens(p.text or "")) >= _MIN_LENGTH_SAMPLE_WORDS
+ ]
+ if not posts:
+ posts = [p for p in piece_list if (p.text or "").strip()]
+ lengths = [float(len(_word_tokens(p.text or ""))) for p in posts]
+ if not lengths:
+ return {
+ "median_post_words": 0.0,
+ "post_words_p75": 0.0,
+ "post_words_p90": 0.0,
+ "post_length_samples": 0.0,
+ }
+ return {
+ "median_post_words": round(_median(lengths), 1),
+ "post_words_p75": round(_percentile(lengths, 0.75), 1),
+ "post_words_p90": round(_percentile(lengths, 0.90), 1),
+ "post_length_samples": float(len(lengths)),
+ }
+
+
def build_style_profile(
pieces: Iterable[Piece],
*,
@@ -204,8 +245,7 @@ def build_style_profile(
stats = corpus_style_stats(p.text for p in piece_list)
banned = list(banned_phrases) if banned_phrases is not None else list(BANNED_AI_FILLER)
texts = [p.text for p in piece_list if (p.text or "").strip()]
- lengths = [len(_word_tokens(text)) for text in texts]
- stats["median_post_words"] = round(_median([float(n) for n in lengths]), 1)
+ stats.update(post_length_stats(piece_list))
stats.update(sentence_length_spread(texts))
stats["multi_sentence_paragraph_ratio"] = multi_sentence_paragraph_ratio(texts)
return {
@@ -217,19 +257,31 @@ def build_style_profile(
}
-# Slight headroom over the author's median so a legitimately long post is kept
-# whole, while a runaway generation gets cut. The prompt states this same
-# ceiling, so the instruction and the edit cannot drift apart.
-_LENGTH_HEADROOM = 1.15
-DEFAULT_DRAFT_WORD_TARGET = 300
+def draft_word_target(profile: dict[str, Any]) -> int:
+ """Word ceiling for a finished post draft.
+
+ Prefer the author's long-post band (p90, else p75, else median) so short
+ comments in the selection cannot shrink drafts to stub length. Clamp to the
+ LinkedIn character budget in words.
+ """
+ stats = profile.get("stats") or {}
+ for key in ("post_words_p90", "post_words_p75", "median_post_words"):
+ value = float(stats.get(key) or 0)
+ if value > 0:
+ target = int(round(max(value, DEFAULT_DRAFT_WORD_FLOOR)))
+ return min(LINKEDIN_POST_WORD_CEILING, target)
+ return DEFAULT_DRAFT_WORD_TARGET
-def draft_word_target(profile: dict[str, Any]) -> int:
- """Word ceiling for a finished draft, measured from the author's posts."""
- median = float((profile.get("stats") or {}).get("median_post_words") or 0)
- if median <= 0:
- return DEFAULT_DRAFT_WORD_TARGET
- return int(round(median * _LENGTH_HEADROOM))
+def draft_word_aim(profile: dict[str, Any]) -> int:
+ """Typical finished length stated in the prompt (below the hard ceiling)."""
+ stats = profile.get("stats") or {}
+ for key in ("post_words_p75", "post_words_p90", "median_post_words"):
+ value = float(stats.get(key) or 0)
+ if value > 0:
+ aim = int(round(max(value, DEFAULT_DRAFT_WORD_FLOOR)))
+ return min(draft_word_target(profile), aim)
+ return min(DEFAULT_DRAFT_WORD_TARGET, draft_word_target(profile))
def style_directives(profile: dict[str, Any]) -> list[str]:
diff --git a/src/personality_protect/train.py b/src/personality_protect/train.py
index 4cc9296..83876ad 100644
--- a/src/personality_protect/train.py
+++ b/src/personality_protect/train.py
@@ -238,19 +238,37 @@ def run_train(
force_retrain: bool = False,
progress_callback: ProgressCallback | None = None,
pairs: Path | None = None,
+ writer: bool = False,
) -> TrainResult:
config = load_config(paths)
+ if writer and pairs is not None:
+ raise ValueError("Pass only one of --writer or --pairs")
voice_pair_mode = pairs is not None
if voice_pair_mode:
# Gated flatten→author pairs are the data floor; skip selected-piece gate.
corpus_note = None
+ elif writer:
+ corpus_note = None
else:
n_selected = len(selected_pieces(paths))
corpus_note = check_corpus_size(
n_selected, force=force or sft_only, smoke=smoke or mock
)
- if voice_pair_mode:
+ if writer:
+ from personality_protect.writer_sft import run_build_writer_sft, writer_sft_path
+
+ receipt = run_build_writer_sft(paths)
+ sft_path = writer_sft_path(paths)
+ # Train pipeline expects train.jsonl under sft/; copy writer rows there.
+ paths.sft_jsonl.write_text(sft_path.read_text(encoding="utf-8"), encoding="utf-8")
+ sft_path = paths.sft_jsonl
+ n = int(receipt["examples"])
+ if n < 1:
+ raise FileNotFoundError(
+ "Writer SFT produced 0 examples. Need longer linkedin_post pieces."
+ )
+ elif voice_pair_mode:
assert pairs is not None # for type checkers
sft_path, n = build_sft_from_pairs(
pairs,
diff --git a/src/personality_protect/voice_index.py b/src/personality_protect/voice_index.py
index 2dd2a03..5cebdbb 100644
--- a/src/personality_protect/voice_index.py
+++ b/src/personality_protect/voice_index.py
@@ -137,17 +137,26 @@ def retrieve(
*,
profile: str = DEFAULT_PROFILE,
home: Path | None = None,
+ sources: Iterable[str] | None = None,
) -> list[dict[str, Any]]:
- """Return up to k indexed exemplars ranked by similarity to the brief."""
+ """Return up to k indexed exemplars ranked by similarity to the brief.
+
+ ``sources`` limits matches to those piece sources (e.g. article-only
+ retrieval). When the filter yields fewer than ``k`` rows, the shortlist is
+ returned as-is rather than falling back to other sources.
+ """
if k <= 0:
return []
paths = get_paths(profile, home=home)
embedder, rows = _load_voice_index(paths)
query = embedder.embed(brief)
+ allowed = {str(source) for source in sources} if sources is not None else None
scored: list[tuple[float, dict[str, Any]]] = []
for row in rows:
piece = dict(row["piece"])
+ if allowed is not None and str(piece.get("source") or "") not in allowed:
+ continue
score = cosine_similarity(query, row["vector"])
scored.append((score, piece))
scored.sort(key=lambda item: (-item[0], str(item[1]["id"])))
diff --git a/src/personality_protect/write.py b/src/personality_protect/write.py
index 539ed64..7fc7edf 100644
--- a/src/personality_protect/write.py
+++ b/src/personality_protect/write.py
@@ -37,7 +37,9 @@
DEFAULT_WRITE_K = 2
MIN_WRITE_K = 0
MAX_WRITE_K = 5
-DEFAULT_WRITE_MAX_TOKENS = 768
+# Long LinkedIn posts (~500 words) need headroom past the trim target so the
+# model can finish the last paragraph before draft_trim cuts.
+DEFAULT_WRITE_MAX_TOKENS = 1536
MAX_WRITE_ATTEMPTS = 2
MAX_EXEMPLAR_WORDS = 60
@@ -47,17 +49,31 @@
PromptSink = MutableSequence[str]
+def resolve_writer_adapter(paths: ProfilePaths) -> str | None:
+ """Return an adapter directory when a writer LoRA is present on disk.
+
+ MLX train writes under ``adapters/latest/``; prefer that, then the profile
+ adapters root.
+ """
+ for directory in (paths.adapters_dir / "latest", paths.adapters_dir):
+ if (directory / "adapters.safetensors").is_file():
+ return str(directory)
+ return None
+
+
def mlx_generate_no_adapter(
messages: Sequence[Message],
*,
base_model: str,
max_tokens: int = DEFAULT_WRITE_MAX_TOKENS,
+ adapter_path: str | None = None,
prompt_sink: PromptSink | None = None,
) -> str:
- """Generate from MLX base weights with ``adapter_path=None``.
+ """Generate from MLX weights, optionally with a writer LoRA.
- The write path is RAG-only: a LoRA adapter is never loaded, even when one
- exists on disk. Tests inject ``generate_fn`` instead of calling this.
+ ``adapter_path=None`` keeps the Camp A RAG default (base only). When a
+ gated writer adapter directory is supplied, MLX loads it for the call.
+ Tests inject ``generate_fn`` instead of calling this.
``messages`` are rendered through the tokenizer's chat template. Skipping
that step makes the instruct model continue the prompt as a document and
@@ -75,7 +91,7 @@ def mlx_generate_no_adapter(
try:
from mlx_lm import generate, load
- model, tokenizer = load(base_model, adapter_path=None)
+ model, tokenizer = load(base_model, adapter_path=adapter_path)
prompt = render_chat_prompt(
tokenizer,
messages,
@@ -159,10 +175,32 @@ def run_write(
*,
k: int = DEFAULT_WRITE_K,
max_tokens: int = DEFAULT_WRITE_MAX_TOKENS,
+ channel: str = "post",
+ use_adapter: bool = False,
generate_fn: GenerateFn | None = None,
prompt_sink: PromptSink | None = None,
) -> dict[str, Any]:
- """Retrieve exemplars, generate a draft, regenerate once on guard failure."""
+ """Retrieve exemplars, generate a draft, regenerate once on guard failure.
+
+ ``channel='article'`` delegates to the article outline→section→stitch path.
+ ``use_adapter`` loads a local writer LoRA when ``adapters.safetensors`` exists.
+ """
+ resolved = (channel or "post").strip().lower()
+ if resolved == "article":
+ from personality_protect.write_article import run_write_article
+
+ return run_write_article(
+ topic,
+ points,
+ paths,
+ k=k,
+ max_tokens=max_tokens,
+ generate_fn=generate_fn,
+ prompt_sink=prompt_sink,
+ )
+ if resolved != "post":
+ raise ValueError("channel must be one of: post, article")
+
topic = topic.strip()
points = points.strip()
if not topic:
@@ -193,6 +231,13 @@ def run_write(
style_directives=directives,
)
+ adapter_path = resolve_writer_adapter(paths) if use_adapter else None
+ if use_adapter and adapter_path is None:
+ raise FileNotFoundError(
+ f"No writer adapter at {paths.adapters_dir}/latest/adapters.safetensors. "
+ "Train one with: personality-protect train --writer"
+ )
+
generator = generate_fn or mlx_generate_no_adapter
model_id = config.base_model or DEFAULT_MLX_MODEL
@@ -205,6 +250,7 @@ def run_write(
messages,
base_model=model_id,
max_tokens=max_tokens,
+ adapter_path=adapter_path,
prompt_sink=prompt_sink,
)
).strip()
@@ -217,9 +263,10 @@ def run_write(
return {
"text": draft,
+ "channel": "post",
"voice_mode": config.voice_mode,
- "adapter": "none",
- "write_adapter": None,
+ "adapter": "writer" if adapter_path else "none",
+ "write_adapter": adapter_path,
"model": model_id,
"k": len(matches),
"exemplar_ids": [str(match["id"]) for match in matches],
diff --git a/src/personality_protect/write_article.py b/src/personality_protect/write_article.py
new file mode 100644
index 0000000..dbfa3bc
--- /dev/null
+++ b/src/personality_protect/write_article.py
@@ -0,0 +1,232 @@
+"""Article channel: outline → section drafts → stitch.
+
+Posts stay on :mod:`personality_protect.write`. Longform needs section budgets
+and article-only retrieval — raising ``max_tokens`` on the post path is not
+enough and produces one long stub instead of a structured piece.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Sequence
+from typing import Any
+
+from personality_protect.chat_prompt import flatten_chat_messages
+from personality_protect.config import DEFAULT_MLX_MODEL, ProfilePaths, load_config
+from personality_protect.draft_trim import trim_draft, word_count
+from personality_protect.models import load_index
+from personality_protect.prompt_write import build_write_messages
+from personality_protect.style_profile import (
+ draft_word_target,
+ load_style_profile,
+ style_directives,
+)
+from personality_protect.voice_index import retrieve
+from personality_protect.write import (
+ DEFAULT_WRITE_K,
+ GenerateFn,
+ PromptSink,
+ build_brief,
+ clip_exemplar,
+ mlx_generate_no_adapter,
+ normalize_sentence_case,
+)
+from personality_protect.writer_guards import (
+ check_invention,
+ mask_exemplar_entities,
+ parrot_reject,
+)
+
+ARTICLE_SOURCES: tuple[str, ...] = ("linkedin_article",)
+# Below this the article channel has too little rhythm signal to claim voice.
+MIN_ARTICLE_CORPUS = 5
+DEFAULT_ARTICLE_SECTION_MAX_TOKENS = 768
+DEFAULT_ARTICLE_SECTION_WORDS = 280
+MAX_ARTICLE_SECTIONS = 8
+MIN_ARTICLE_SECTIONS = 2
+
+_BULLET_RE = re.compile(r"^\s*[-*•]\s+")
+
+
+def count_article_pieces(paths: ProfilePaths) -> int:
+ """How many article-source pieces are in the local corpus index."""
+ return sum(1 for piece in load_index(paths.index_path) if piece.source in ARTICLE_SOURCES)
+
+
+def assert_article_corpus(paths: ProfilePaths, *, minimum: int = MIN_ARTICLE_CORPUS) -> int:
+ """Raise when the article channel has too little source material."""
+ n = count_article_pieces(paths)
+ if n < minimum:
+ raise FileNotFoundError(
+ f"Article channel needs at least {minimum} linkedin_article pieces "
+ f"in the corpus (found {n}). Ingest more articles, then rebuild "
+ "index-voice."
+ )
+ return n
+
+
+def outline_from_brief(topic: str, points: str) -> list[str]:
+ """Deterministic section titles from the brief (no model call).
+
+ Each bullet becomes a section. Thin briefs get the topic as a lead section
+ plus the bullets so the stitch still has structure.
+ """
+ topic = topic.strip()
+ lines = [line.strip() for line in (points or "").splitlines() if line.strip()]
+ bullets: list[str] = []
+ for line in lines:
+ cleaned = _BULLET_RE.sub("", line).strip()
+ if cleaned:
+ bullets.append(cleaned)
+ if not bullets and points.strip():
+ bullets = [points.strip()]
+
+ sections: list[str] = []
+ if topic and (not bullets or len(bullets) < MIN_ARTICLE_SECTIONS):
+ sections.append(topic)
+ for bullet in bullets:
+ if bullet not in sections:
+ sections.append(bullet)
+ if len(sections) >= MAX_ARTICLE_SECTIONS:
+ break
+ if len(sections) < MIN_ARTICLE_SECTIONS:
+ raise ValueError(
+ "article brief needs at least two section points "
+ "(topic + bullets, or two bullets)"
+ )
+ return sections
+
+
+def _section_brief(topic: str, section: str, points: str) -> tuple[str, str]:
+ """Topic/points pair for one section generation call."""
+ return (
+ f"{topic} — {section}" if topic and topic != section else section,
+ f"- {section}\n- Stay on this section only.\n- Brief points:\n{points}",
+ )
+
+
+def _guard_flags(brief: str, draft: str, exemplars: Sequence[str]) -> dict[str, Any]:
+ invention = check_invention(brief, normalize_sentence_case(draft))
+ return {
+ "parrot_reject": parrot_reject(draft, list(exemplars)),
+ "invent_reject": not invention.passed,
+ "invented_entities": sorted(invention.invented_entities),
+ "invented_numbers": sorted(invention.invented_numbers),
+ }
+
+
+def run_write_article(
+ topic: str,
+ points: str,
+ paths: ProfilePaths,
+ *,
+ k: int = DEFAULT_WRITE_K,
+ max_tokens: int = DEFAULT_ARTICLE_SECTION_MAX_TOKENS,
+ generate_fn: GenerateFn | None = None,
+ prompt_sink: PromptSink | None = None,
+ min_articles: int = MIN_ARTICLE_CORPUS,
+) -> dict[str, Any]:
+ """Outline → per-section RAG draft → stitch into one article."""
+ topic = topic.strip()
+ points = points.strip()
+ if not topic:
+ raise ValueError("topic must not be empty")
+ if not points:
+ raise ValueError("points must not be empty")
+
+ article_count = assert_article_corpus(paths, minimum=min_articles)
+ sections = outline_from_brief(topic, points)
+ config = load_config(paths)
+ style = load_style_profile(paths)
+ directives = style_directives(style)
+ # Articles are longer than posts; do not clamp sections to the post ceiling.
+ section_words = max(
+ DEFAULT_ARTICLE_SECTION_WORDS,
+ int(draft_word_target(style)),
+ )
+ generator = generate_fn or mlx_generate_no_adapter
+ model_id = config.base_model or DEFAULT_MLX_MODEL
+
+ full_brief = build_brief(topic, points)
+ matches = retrieve(
+ full_brief,
+ k=k,
+ profile=paths.name,
+ home=paths.home,
+ sources=ARTICLE_SOURCES,
+ )
+ if k and not matches:
+ raise FileNotFoundError(
+ f"No article exemplars indexed for profile {paths.name}. "
+ "Ingest linkedin_article pieces and run: personality-protect index-voice"
+ )
+ exemplars = [str(match["text"]) for match in matches]
+ masked = [
+ mask_exemplar_entities(clip_exemplar(exemplar), full_brief) for exemplar in exemplars
+ ]
+
+ section_drafts: list[str] = []
+ all_messages: list[list[dict[str, str]]] = []
+ attempts_total = 0
+ last_guards: dict[str, Any] = {
+ "parrot_reject": False,
+ "invent_reject": False,
+ "invented_entities": [],
+ "invented_numbers": [],
+ }
+
+ for section in sections:
+ section_topic, section_points = _section_brief(topic, section, points)
+ section_brief = build_brief(section_topic, section_points)
+ messages = build_write_messages(
+ topic=section_topic,
+ points=section_points,
+ examples=masked,
+ style_directives=[
+ *directives,
+ f"Write only the section about: {section}",
+ f"Aim for about {section_words} words in this section.",
+ ],
+ )
+ all_messages.append(messages)
+ draft = ""
+ for attempt in range(1, 3):
+ attempts_total += 1
+ raw = str(
+ generator(
+ messages,
+ base_model=model_id,
+ max_tokens=max_tokens,
+ prompt_sink=prompt_sink,
+ )
+ ).strip()
+ draft = trim_draft(raw, max_words=section_words * 2)
+ last_guards = _guard_flags(section_brief, draft, exemplars)
+ if not last_guards["parrot_reject"] and not last_guards["invent_reject"]:
+ break
+ section_drafts.append(draft)
+
+ text = "\n\n".join(part for part in section_drafts if part.strip()).strip()
+ # Final invent check against the full brief the author supplied.
+ final_guards = _guard_flags(full_brief, text, exemplars)
+
+ return {
+ "text": text,
+ "channel": "article",
+ "voice_mode": config.voice_mode,
+ "adapter": "none",
+ "write_adapter": None,
+ "model": model_id,
+ "k": len(matches),
+ "exemplar_ids": [str(match["id"]) for match in matches],
+ "attempts": attempts_total,
+ "word_target": section_words * len(sections),
+ "article_count": article_count,
+ "sections": sections,
+ "section_count": len(sections),
+ "draft_words": word_count(text),
+ **final_guards,
+ "exemplar_texts": exemplars,
+ "messages": all_messages[0] if all_messages else [],
+ "prompt": flatten_chat_messages(all_messages[0]) if all_messages else "",
+ }
diff --git a/src/personality_protect/writer_sft.py b/src/personality_protect/writer_sft.py
new file mode 100644
index 0000000..40d5493
--- /dev/null
+++ b/src/personality_protect/writer_sft.py
@@ -0,0 +1,139 @@
+"""Brief→post SFT rows for the writer LoRA (not the translator path)."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Iterable
+
+from personality_protect.config import ProfilePaths
+from personality_protect.eval_write_holdout import mine_brief_from_holdout
+from personality_protect.models import Piece, load_index
+from personality_protect.prompt_write import WRITE_SYSTEM_PROMPT, build_write_user_content
+from personality_protect.style_profile import load_style_profile, style_directives
+
+WRITER_SFT_FILENAME = "writer_train.jsonl"
+_POST_SOURCES = frozenset({"linkedin_post"})
+_MIN_TARGET_WORDS = 50
+
+
+def writer_sft_path(paths: ProfilePaths) -> Path:
+ return paths.sft_dir / WRITER_SFT_FILENAME
+
+
+def load_holdout_id_set(paths: ProfilePaths) -> set[str]:
+ """Ids listed in the local dogfood holdout file (never indexed for eval)."""
+ path = paths.root / "dogfood_holdout_ids.json"
+ if not path.is_file():
+ return set()
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(data, dict):
+ ids = data.get("holdout_ids") or data.get("ids") or []
+ else:
+ ids = data
+ return {str(piece_id) for piece_id in ids}
+
+
+def piece_to_writer_example(
+ piece: Piece,
+ *,
+ style_directives_list: list[str] | None = None,
+) -> dict[str, Any] | None:
+ """One chat example: lossy brief → author's post as assistant target."""
+ body = (piece.text or "").strip()
+ if len(body.split()) < _MIN_TARGET_WORDS:
+ return None
+ if piece.source not in _POST_SOURCES:
+ return None
+ try:
+ brief = mine_brief_from_holdout(body, holdout_id=piece.id)
+ except ValueError:
+ return None
+ user = build_write_user_content(
+ topic=brief["topic"],
+ points=brief["points"],
+ examples=(),
+ style_directives=style_directives_list or (),
+ )
+ return {
+ "messages": [
+ {"role": "system", "content": WRITE_SYSTEM_PROMPT},
+ {"role": "user", "content": user},
+ {"role": "assistant", "content": body},
+ ],
+ "meta": {
+ "piece_id": piece.id,
+ "source": piece.source,
+ "year": piece.year,
+ "word_count": len(body.split()),
+ "pair_kind": "writer",
+ },
+ }
+
+
+def build_writer_sft(
+ pieces: Iterable[Piece],
+ out_path: Path,
+ *,
+ holdout_ids: Iterable[str] = (),
+ style_directives_list: list[str] | None = None,
+) -> dict[str, Any]:
+ """Write writer SFT JSONL; skip holdouts and unbriefable posts."""
+ excluded = {str(piece_id) for piece_id in holdout_ids}
+ rows: list[dict[str, Any]] = []
+ skipped = 0
+ for piece in pieces:
+ if piece.id in excluded:
+ skipped += 1
+ continue
+ example = piece_to_writer_example(
+ piece, style_directives_list=style_directives_list
+ )
+ if example is None:
+ skipped += 1
+ continue
+ rows.append(example)
+
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ with out_path.open("w", encoding="utf-8") as handle:
+ for row in rows:
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
+
+ return {
+ "path": str(out_path),
+ "examples": len(rows),
+ "skipped": skipped,
+ "holdouts_excluded": sorted(excluded),
+ "built_at": datetime.now(timezone.utc).isoformat(),
+ }
+
+
+def run_build_writer_sft(paths: ProfilePaths) -> dict[str, Any]:
+ """Build writer SFT from all linkedin_post pieces in the corpus index.
+
+ Uses the full index (not the year-gated selection) so recent posts past
+ ``through_year`` still train the writer. Holdout ids are excluded.
+ """
+ paths.ensure()
+ holdouts = load_holdout_id_set(paths)
+ try:
+ directives = style_directives(load_style_profile(paths))
+ except FileNotFoundError:
+ directives = []
+ pieces = [
+ piece
+ for piece in load_index(paths.index_path)
+ if piece.source in _POST_SOURCES
+ ]
+ if not pieces:
+ raise FileNotFoundError(
+ f"No linkedin_post pieces available for writer SFT under {paths.root}"
+ )
+ receipt = build_writer_sft(
+ pieces,
+ writer_sft_path(paths),
+ holdout_ids=holdouts,
+ style_directives_list=directives,
+ )
+ return receipt
diff --git a/tests/test_style_profile.py b/tests/test_style_profile.py
index d217bf6..111b4c6 100644
--- a/tests/test_style_profile.py
+++ b/tests/test_style_profile.py
@@ -13,8 +13,10 @@
from personality_protect.select import Selection
from personality_protect.style_profile import (
BANNED_AI_FILLER,
+ LINKEDIN_POST_WORD_CEILING,
build_style_profile,
corpus_style_stats,
+ draft_word_target,
load_style_profile,
run_build_style_profile,
style_directives,
@@ -93,6 +95,35 @@ def test_build_style_profile_records_median_post_words():
]
stats = build_style_profile(pieces)["stats"]
assert stats["median_post_words"] > 0
+ assert stats["post_words_p75"] > 0
+ assert stats["post_words_p90"] > 0
+
+
+def test_post_length_ignores_short_comments_for_targets():
+ """Length targets come from post-shaped pieces, not comment stubs."""
+ short = "Ok." * 5
+ long_post = (
+ "Contoso Ledger. " * 40
+ + "You ship the reconciliation or you own the outage. "
+ * 20
+ )
+ pieces = [
+ Piece(id="comment", source="linkedin_comment", text=short, year=2024),
+ Piece(id="post", source="linkedin_post", text=long_post, year=2024),
+ ]
+ stats = build_style_profile(pieces)["stats"]
+ assert stats["post_words_p90"] >= 80
+ ceiling = draft_word_target(build_style_profile(pieces))
+ assert ceiling >= 300
+ assert ceiling <= LINKEDIN_POST_WORD_CEILING
+
+
+def test_draft_word_target_clamps_to_linkedin_ceiling():
+ huge = "word " * 2000
+ profile = build_style_profile(
+ [Piece(id="p", source="linkedin_post", text=huge, year=2024)]
+ )
+ assert draft_word_target(profile) == LINKEDIN_POST_WORD_CEILING
def test_style_directives_carry_cadence_without_copyable_prose():
diff --git a/tests/test_write.py b/tests/test_write.py
index f6e8018..f6d628a 100644
--- a/tests/test_write.py
+++ b/tests/test_write.py
@@ -293,7 +293,7 @@ def fake_generate(messages, **_kwargs: object) -> str:
assert result["invented_numbers"] == ["18%"]
-def test_cli_write_rejects_adapter_flag(tmp_path: Path):
+def test_cli_write_adapter_requires_weights(tmp_path: Path):
_seed_contoso_index(tmp_path)
result = runner.invoke(
@@ -314,8 +314,8 @@ def test_cli_write_rejects_adapter_flag(tmp_path: Path):
],
)
- assert result.exit_code == 2
- assert "adapter=none" in result.output
+ assert result.exit_code == 1
+ assert "No writer adapter" in result.output
def test_cli_write_exits_nonzero_when_guards_still_fail(tmp_path: Path):
diff --git a/tests/test_write_article.py b/tests/test_write_article.py
new file mode 100644
index 0000000..a8a8dea
--- /dev/null
+++ b/tests/test_write_article.py
@@ -0,0 +1,115 @@
+"""Contoso-safe article channel: outline → sections → stitch."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from personality_protect.config import init_profile
+from personality_protect.models import Piece, save_index
+from personality_protect.style_profile import build_style_profile, save_style_profile
+from personality_protect.voice_index import build_voice_index
+from personality_protect.write import run_write
+from personality_protect.write_article import (
+ MIN_ARTICLE_CORPUS,
+ assert_article_corpus,
+ outline_from_brief,
+ run_write_article,
+)
+
+
+def _article(n: int, words: int = 200) -> Piece:
+ body = ("Contoso Ledger section. You own the outage. " * (words // 8)).strip()
+ return Piece(
+ id=f"contoso-article-{n}",
+ source="linkedin_article",
+ text=body,
+ year=2024,
+ )
+
+
+def _seed_articles(tmp_path: Path, n: int = MIN_ARTICLE_CORPUS) -> Path:
+ paths, _, _ = init_profile("contoso", home=tmp_path)
+ pieces = [_article(i) for i in range(n)]
+ pieces.append(
+ Piece(
+ id="contoso-post",
+ source="linkedin_post",
+ text="Contoso keeps the queue boring. You name one owner.",
+ year=2024,
+ )
+ )
+ save_index(paths.index_path, pieces)
+ build_voice_index(paths)
+ save_style_profile(paths, build_style_profile(pieces))
+ return tmp_path
+
+
+def test_outline_from_brief_uses_bullets():
+ sections = outline_from_brief(
+ "Contoso pricing",
+ "- Name one owner\n- Cut exceptions\n- Keep Ledger boring",
+ )
+ assert sections == ["Name one owner", "Cut exceptions", "Keep Ledger boring"]
+
+
+def test_outline_from_brief_requires_two_sections():
+ with pytest.raises(ValueError, match="two section"):
+ outline_from_brief("", "- Only one")
+
+
+def test_assert_article_corpus_floor(tmp_path: Path):
+ _seed_articles(tmp_path, n=2)
+ paths, _, _ = init_profile("contoso", home=tmp_path)
+ with pytest.raises(FileNotFoundError, match="at least"):
+ assert_article_corpus(paths, minimum=MIN_ARTICLE_CORPUS)
+
+
+def test_run_write_article_stitches_sections(tmp_path: Path):
+ _seed_articles(tmp_path, n=MIN_ARTICLE_CORPUS)
+ paths, _, _ = init_profile("contoso", home=tmp_path)
+ calls: list = []
+
+ def fake_generate(messages, **_kwargs: object) -> str:
+ calls.append(messages)
+ user = messages[1]["content"]
+ # Each section prompt names its focus.
+ assert "Write only the section about:" in user
+ return (
+ "Contoso Ledger holds the line.\n\n"
+ "You name one owner before the packaging change."
+ )
+
+ result = run_write_article(
+ "Contoso packaging",
+ "- Name one owner\n- Cut exceptions\n- Keep Ledger boring",
+ paths,
+ k=2,
+ generate_fn=fake_generate,
+ )
+ assert result["channel"] == "article"
+ assert result["section_count"] == 3
+ assert result["adapter"] == "none"
+ assert len(calls) == 3
+ assert result["text"].count("Contoso Ledger holds the line.") == 3
+ assert result["article_count"] >= MIN_ARTICLE_CORPUS
+
+
+def test_run_write_channel_article_delegates(tmp_path: Path):
+ _seed_articles(tmp_path, n=MIN_ARTICLE_CORPUS)
+ paths, _, _ = init_profile("contoso", home=tmp_path)
+
+ def fake_generate(messages, **_kwargs: object) -> str:
+ return "Contoso section body with enough words to survive trim."
+
+ result = run_write(
+ "Contoso packaging",
+ "- Name one owner\n- Cut exceptions",
+ paths,
+ channel="article",
+ k=1,
+ generate_fn=fake_generate,
+ )
+ assert result["channel"] == "article"
+ assert result["section_count"] == 2
diff --git a/tests/test_writer_sft.py b/tests/test_writer_sft.py
new file mode 100644
index 0000000..42604f4
--- /dev/null
+++ b/tests/test_writer_sft.py
@@ -0,0 +1,84 @@
+"""Contoso-safe writer SFT: brief→post rows, holdouts excluded."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from personality_protect.config import init_profile
+from personality_protect.models import Piece, save_index
+from personality_protect.select import Selection
+from personality_protect.style_profile import build_style_profile, save_style_profile
+from personality_protect.writer_sft import (
+ build_writer_sft,
+ piece_to_writer_example,
+ run_build_writer_sft,
+ writer_sft_path,
+)
+
+CONTOSO_LONG = (
+ "Contoso Ledger keeps the queue boring on purpose.\n\n"
+ "You ship the reconciliation or you own the outage.\n\n"
+ "You name one owner before the packaging change starts.\n\n"
+ "You cut exceptions or you explain them in writing.\n\n"
+ "Partners already know which one you picked this quarter.\n\n"
+ "Stop pretending the roadmap is the work.\n\n"
+ "You own the queue you refuse to look at.\n\n"
+ "Boring beats clever every single time Contoso ships Ledger.\n\n"
+ "You keep Contoso boring and the partners stay calm."
+)
+
+
+def test_piece_to_writer_example_builds_chat_row():
+ piece = Piece(id="c1", source="linkedin_post", text=CONTOSO_LONG, year=2024)
+ row = piece_to_writer_example(piece)
+ assert row is not None
+ assert row["meta"]["pair_kind"] == "writer"
+ assert row["messages"][-1]["role"] == "assistant"
+ assert "Contoso Ledger" in row["messages"][-1]["content"]
+ assert "BRIEF:" in row["messages"][1]["content"]
+
+
+def test_build_writer_sft_excludes_holdouts(tmp_path: Path):
+ pieces = [
+ Piece(id="keep", source="linkedin_post", text=CONTOSO_LONG, year=2024),
+ Piece(id="hold", source="linkedin_post", text=CONTOSO_LONG + " Extra.", year=2024),
+ ]
+ out = tmp_path / "writer.jsonl"
+ receipt = build_writer_sft(pieces, out, holdout_ids={"hold"})
+ assert receipt["examples"] == 1
+ lines = out.read_text(encoding="utf-8").strip().splitlines()
+ assert len(lines) == 1
+ assert json.loads(lines[0])["meta"]["piece_id"] == "keep"
+
+
+def test_run_build_writer_sft_writes_profile_file(tmp_path: Path):
+ paths, _, _ = init_profile("contoso", home=tmp_path)
+ pieces = [
+ Piece(id="c1", source="linkedin_post", text=CONTOSO_LONG, year=2024),
+ Piece(id="c2", source="linkedin_post", text=CONTOSO_LONG + " Again.", year=2024),
+ ]
+ save_index(paths.index_path, pieces)
+ save_style_profile(paths, build_style_profile(pieces))
+ paths.selection_path.write_text(
+ json.dumps(
+ Selection(
+ piece_ids=["c1", "c2"],
+ min_words=10,
+ through_year=2024,
+ include_undated=True,
+ summary={"pieces": 2},
+ ).to_dict(),
+ indent=2,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ paths.root.joinpath("dogfood_holdout_ids.json").write_text(
+ json.dumps({"holdout_ids": ["c2"]}) + "\n",
+ encoding="utf-8",
+ )
+
+ receipt = run_build_writer_sft(paths)
+ assert receipt["examples"] == 1
+ assert writer_sft_path(paths).is_file()