Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ PersonalityProtect keeps the corpus on disk, measures your cadence, retrieves sh
4. **`build-style-profile`** measures cadence from the selection (sentence length, short lines, post length band, banned filler).
5. **`write --topic --points`** drafts from the brief only; retrieved pieces are rhythm reference.

Two channels come out of step 5:
Two channels come out of step 5, and each takes its length from its own pieces:

- **`--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.
- **`--channel article`** runs outline → sections → stitch. Total length comes from the p50/p75/p90 of your `linkedin_article` pieces, split across the outline; the post ceiling never applies. Needs at least five `linkedin_article` pieces both in the corpus and in the voice index.

Local LoRA training stays in the CLI as an experiment, not as the path to a first draft — see [Advanced](#advanced-optional).

Expand Down Expand Up @@ -181,7 +181,14 @@ personality-protect build-style-profile

Defaults: **≥50 words**, dates through the **current year**. Use `--through-year` when you intentionally want an older slice. Corpus gates: **warn** below 50 selected pieces; **block** below 20 unless `--force`. Holding pieces back from retrieval is separate — `index-voice --holdout-id`, scored by `eval-write-holdout`.

Post length targets come from `linkedin_post` pieces (p75/p90), clamped to the LinkedIn ~3000-character band (~550 words).
Length targets are per channel and never borrow across channels:

| Channel | Measured from | Aim | Ceiling |
| --- | --- | --- | --- |
| post | `linkedin_post` pieces | p75 (floor 300) | p90, clamped to ~550 words (~3000 chars) |
| article | `linkedin_article` pieces | median, clamped to 600–3000 words | p90, clamped to 3000 words |

The article aim is divided across the outline to get a per-section budget (clamped to 180–600 words), so a five-section article asks for five short sections rather than five posts. With no `linkedin_article` pieces in the corpus, the article aim falls back to a stated default of 1100 words instead of borrowing the post band.

### Write

Expand All @@ -191,7 +198,26 @@ 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 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.
`--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`).

On `--channel article`, each `--points` bullet becomes a section (2–8), retrieval is restricted to `linkedin_article` pieces so posts cannot become the rhythm reference, and sections that restate each other are dropped before stitching. The channel refuses to draft unless at least five `linkedin_article` pieces are in the corpus *and* five are in the voice index — a large carve that leaves retrieval empty is an error, not a silently thinner draft.

### Article holdout eval

The post channel is scored by `eval-write-holdout`. The article channel has its own carve and eval:

```bash
personality-protect select-article-holdouts # report only
personality-protect select-article-holdouts --apply
personality-protect index-voice --from-carve # holdouts leave retrieval
PP_MLX_ALLOW=1 personality-protect eval-write-article --out receipt.json
```

The carve is deterministic (`blake2b(piece_id)` order), keeps previously carved ids pinned, and never drops the voice index below the five-article floor. Each holdout is reduced to a lossy brief — a topic plus 3–6 section bullets drawn one per segment of the piece, capped at 60 words and 10% of the source — so neither arm is handed the article back to paraphrase.

Two arms then write the same brief with the same outline, per-section budget, and trim. The product arm gets retrieved exemplars and the measured style card; the control arm gets neither. Drafts are scored on distance to the holdout's own cadence axes, and a draft that parrots its exemplars, echoes the brief, or invents entities or figures is disqualified regardless of distance. Receipts carry ids, distances, and flags — never draft or corpus text.

The verdict needs all three of: the article arm wins the majority, the margin clears `--alpha` (default 0.10) on a one-sided sign test, and it is not disqualified more often than the control. When both arms are disqualified on every holdout, distance never decided anything, and the receipt says so (`distance_ever_decided: false`) rather than reporting it as a cadence loss.

### Status / API

Expand All @@ -215,7 +241,9 @@ Global flags (most commands): `--profile`, `--home`, `--json`, plus branding `--
| `index-voice` | Build local voice retrieval index |
| `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) |
| `eval-write-holdout` | Score post-channel writes on held-out pieces (local receipt) |
| `select-article-holdouts` | Deterministic article carve that respects the retrieval floor |
| `eval-write-article` | Score article-channel writes against a no-voice control (local receipt) |
| `status` | Show profile state |
| `demo` | Optional synthetic smoke tour of the write path (no download) |
| `api` | Loopback HTTP stub |
Expand All @@ -241,6 +269,25 @@ Global flags (most commands): `--profile`, `--home`, `--json`, plus branding `--
| `--save-raw` | Local prompts/drafts under the profile (never commit) |
| `--out PATH` | Contoso-safe aggregate receipt JSON |

### `eval-write-article` flags

| Flag | Meaning |
| --- | --- |
| `--holdout-id` | Article id to score (repeatable); defaults to the saved carve |
| `--k` | Article exemplars retrieved per section |
| `--alpha` | One-sided significance the run must reach (default 0.10) |
| `--save-raw` | Local prompts/drafts under the profile (never commit) |
| `--out PATH` | Contoso-safe receipt JSON |

### `select-article-holdouts` flags

| Flag | Meaning |
| --- | --- |
| `--apply` | Write the carve (default is report-only) |
| `--fraction` | Share of briefable articles to reserve |
| `--min` / `--max` | Carve size band (4–5; three cannot reach `--alpha` on a sign test) |
| `--keep-indexed` | Articles the carve must leave in retrieval (default 5) |

---

## Advanced (optional)
Expand Down
219 changes: 219 additions & 0 deletions src/personality_protect/article_brief.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Lossy brief mining for article holdouts.

The post path already answers this question for posts: a brief is what the
author jotted down *before* writing, not an extract of the finished piece, and
:mod:`personality_protect.eval_write_holdout` enforces that with a hard word cap
plus a source-overlap cap. Articles need the same guarantee and cannot reuse the
post miner unchanged, for two reasons:

* **the overlap cap stops binding.** A 25% cap on a 1,000-word article permits a
250-word "brief". The cap has to shrink as the source grows, so the article
budget is a small fixed word count that a longer source cannot inflate.
* **an article brief is an outline.** Ranking every sentence by fact density and
taking the top three returns three claims from whichever passage happens to be
the densest. Bullets are therefore drawn one per segment of the piece, in
document order, so the brief describes the shape of an article instead of one
paragraph of it.

Mining runs against the de-voiced clauses from
:mod:`personality_protect.devoice`, so the bullets carry the author's claims
without the author's phrasing, and the result is measured against the original
article on both overlap and 5-gram copy ratio before it is returned.
"""

from __future__ import annotations

from typing import Any

from personality_protect.corpus_text import normalize_corpus_text
from personality_protect.devoice import (
MAX_PAIR_COPY_RATIO,
devoice_sentences,
pair_copy_ratio,
)
from personality_protect.eval_write_holdout import (
_fact_score,
_fit_phrase,
_mine_topic_with_source,
_word_tokens,
brief_word_overlap_ratio,
)

# Absolute budget for the model-visible brief. Fixed rather than proportional:
# a share of the source grows with the source, and the whole point is that a
# longer article does not earn a longer head start.
ARTICLE_MAX_BRIEF_WORDS = 60
# Share of the source the brief may return. Binds on short articles, where the
# absolute cap alone would be generous.
ARTICLE_MAX_BRIEF_OVERLAP = 0.10
ARTICLE_TOPIC_WORD_CAP = 10
ARTICLE_POINT_WORD_CAP = 12
ARTICLE_MIN_POINTS = 3
ARTICLE_MAX_POINTS = 6
ARTICLE_MIN_POINT_WORDS = 3
# Share of brief words sitting inside a 5-gram of the article. Same meter and
# same threshold as the writer pair gate: a brief that trips it is an extract
# whichever channel produced it, and a second number here would be a second
# thing to justify.
ARTICLE_MAX_COPY_RATIO = MAX_PAIR_COPY_RATIO
# Below this a piece is a long post, not an article, and the outline segmenting
# has nothing to segment.
MIN_ARTICLE_BRIEF_WORDS = 200


class ArticleBriefRejected(ValueError):
"""An article could not be reduced to a brief that is not an extract."""

def __init__(self, reasons: list[str], report: dict[str, Any]) -> None:
super().__init__("article brief rejected: " + ", ".join(reasons))
self.reasons = reasons
self.report = report


def _outline_positions(candidate_count: int, points: int) -> list[int]:
"""Segment boundaries splitting ``candidate_count`` clauses into ``points``."""
if candidate_count <= 0 or points <= 0:
return []
step = candidate_count / points
return [int(index * step) for index in range(points + 1)]


def select_outline_clauses(
candidates: list[tuple[int, str]],
*,
points: int,
) -> list[tuple[int, str]]:
"""Highest-substance clause from each equal segment, in document order.

Coverage is the property that matters here. Global ranking is what the post
miner does and it is correct for a post, where every sentence is in the same
passage; on an article it returns a cluster.
"""
wanted = max(1, min(int(points), len(candidates)))
bounds = _outline_positions(len(candidates), wanted)
chosen: list[tuple[int, str]] = []
for start, end in zip(bounds, bounds[1:]):
segment = candidates[start:max(end, start + 1)]
if not segment:
continue
best = max(segment, key=lambda item: (_fact_score(item[1]), -item[0]))
if best not in chosen:
chosen.append(best)
return sorted(chosen, key=lambda item: item[0])


def _content_word_count(topic: str, points: str) -> int:
"""Words the brief hands over, ignoring the bullet markers we added."""
point_words = [word for word in points.split() if word not in {"-", "*", "•"}]
return len(topic.split()) + len(point_words)


def mine_article_brief(
text: str,
*,
holdout_id: str = "",
max_points: int = ARTICLE_MAX_POINTS,
max_overlap: float = ARTICLE_MAX_BRIEF_OVERLAP,
max_copy_ratio: float = ARTICLE_MAX_COPY_RATIO,
) -> tuple[dict[str, str], dict[str, Any]]:
"""Mine a topic plus section bullets from an article, and prove it is lossy.

Returns ``(brief, report)``. ``brief['guard_facts']`` stays the original
article so the invention guard can reject facts the author never wrote
without those facts reaching the generation prompt — the same split the post
path uses. Receipts serialize neither field.
"""
original = normalize_corpus_text(text)
source_words = len(_word_tokens(original))
if source_words < MIN_ARTICLE_BRIEF_WORDS:
raise ArticleBriefRejected(
["article_too_short"], {"source_words": source_words}
)

clauses = devoice_sentences(original)
if len(clauses) < ARTICLE_MIN_POINTS + 1:
raise ArticleBriefRejected(
["devoiced_clauses_too_few"],
{"source_words": source_words, "clauses": len(clauses)},
)

budget = min(ARTICLE_MAX_BRIEF_WORDS, int(source_words * max_overlap))
if budget < ARTICLE_MIN_POINTS * ARTICLE_MIN_POINT_WORDS:
raise ArticleBriefRejected(
["brief_budget_too_small"],
{"source_words": source_words, "budget": budget},
)

topic, topic_index = _mine_topic_with_source(
clauses,
word_cap=min(
ARTICLE_TOPIC_WORD_CAP,
budget - ARTICLE_MIN_POINTS * ARTICLE_MIN_POINT_WORDS,
),
)
candidates = [(i, clause) for i, clause in enumerate(clauses) if i != topic_index]
target_points = max(ARTICLE_MIN_POINTS, min(ARTICLE_MAX_POINTS, int(max_points)))
selected = select_outline_clauses(candidates, points=target_points)

remaining = budget - len(topic.split())
bullets: list[str] = []
for _, clause in selected:
minimum_after = max(
0, (ARTICLE_MIN_POINTS - len(bullets) - 1) * ARTICLE_MIN_POINT_WORDS
)
word_cap = min(ARTICLE_POINT_WORD_CAP, remaining - minimum_after)
if word_cap < ARTICLE_MIN_POINT_WORDS:
break
fitted = _fit_phrase(clause, word_cap)
fitted_words = len(_word_tokens(fitted))
if fitted_words < ARTICLE_MIN_POINT_WORDS:
continue
remaining -= fitted_words
bullets.append("- " + fitted)

if len(bullets) < ARTICLE_MIN_POINTS:
raise ArticleBriefRejected(
["too_few_bullets"],
{"source_words": source_words, "bullets": len(bullets)},
)

points = "\n".join(bullets)
brief = {
"holdout_id": holdout_id,
"topic": topic,
"points": points,
"guard_facts": original,
}
brief_words = _content_word_count(topic, points)
overlap = brief_word_overlap_ratio(brief, original)
copy_ratio = pair_copy_ratio(f"{topic}\n{points}", original)
report = {
"source_words": source_words,
"brief_words": brief_words,
"bullets": len(bullets),
"brief_overlap_ratio": overlap,
"brief_copy_ratio": copy_ratio,
"max_brief_words": ARTICLE_MAX_BRIEF_WORDS,
"max_overlap": float(max_overlap),
"max_copy_ratio": float(max_copy_ratio),
}

reasons: list[str] = []
if brief_words > ARTICLE_MAX_BRIEF_WORDS:
reasons.append("brief_word_cap")
if overlap > float(max_overlap):
reasons.append("brief_overlap")
if copy_ratio > float(max_copy_ratio):
reasons.append("brief_copy_ratio")
if reasons:
raise ArticleBriefRejected(reasons, report)
return brief, report


def is_article_briefable(text: str) -> bool:
"""True when a lossy article brief can be mined from this text."""
try:
mine_article_brief(text)
except (ArticleBriefRejected, ValueError):
return False
return True
Loading
Loading