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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ 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`).

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.
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. Section prompts use an article-specific system rule set: the BRIEF is the only fact source, allowed names/figures from the BRIEF are listed explicitly, thin briefs lower the per-section word aim, and a section that still invents after retry is omitted from the stitch rather than padded in. 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

Expand All @@ -215,7 +215,7 @@ 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.
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. Invention is judged against the visible brief both arms saw (not the full source article). 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.

Expand Down
112 changes: 89 additions & 23 deletions src/personality_protect/eval_write_article.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from personality_protect.chat_prompt import flatten_chat_messages
from personality_protect.config import ProfilePaths, load_config
from personality_protect.corpus_text import normalize_corpus_text
from personality_protect.draft_trim import drop_repeated_paragraphs, trim_draft
from personality_protect.draft_trim import drop_repeated_paragraphs, trim_draft, word_count
from personality_protect.eval_write_holdout import (
TIE_EPSILON,
assert_receipt_contoso_safe,
Expand All @@ -53,21 +53,28 @@
from personality_protect.prompt_write import build_write_messages
from personality_protect.style_profile import (
article_section_words,
article_word_aim,
load_style_profile,
)
from personality_protect.write import DEFAULT_WRITE_K, GenerateFn, mlx_generate_no_adapter
from personality_protect.write import (
DEFAULT_WRITE_K,
GenerateFn,
build_brief,
mlx_generate_no_adapter,
)
from personality_protect.write_article import (
DEFAULT_ARTICLE_SECTION_MAX_TOKENS,
SECTION_TOKENS_PER_WORD,
SECTION_TRIM_HEADROOM,
THIN_SECTION_WORD_FLOOR,
_guard_flags,
_section_brief,
article_draft_ceiling,
count_indexed_article_pieces,
outline_from_brief,
run_write_article,
scale_section_words_for_brief,
section_structure_directives,
)
from personality_protect.writer_guards import brief_allowed_facts

# One-sided bar, matching the writer ship gate. Stated rather than enforced:
# with fourteen articles a carve cannot exceed five, and a clean sweep of three
Expand All @@ -84,18 +91,29 @@ def article_word_budget(paths: ProfilePaths, topic: str, points: str) -> dict[st
"""
style = load_style_profile(paths)
sections = outline_from_brief(topic, points)
section_words = article_section_words(style, sections=len(sections))
brief = build_brief(topic, points)
brief_words = word_count(brief)
section_words = scale_section_words_for_brief(
article_section_words(style, sections=len(sections)),
brief_words=brief_words,
sections=len(sections),
)
word_aim = section_words * len(sections)
allowed = brief_allowed_facts(brief)
return {
"sections": sections,
"section_count": len(sections),
"word_aim": article_word_aim(style),
"word_aim": word_aim,
"section_words": section_words,
"section_trim_words": int(round(section_words * SECTION_TRIM_HEADROOM)),
"word_ceiling": article_draft_ceiling(style, sections=len(sections)),
"word_ceiling": int(round(section_words * SECTION_TRIM_HEADROOM)) * len(sections),
"max_tokens": max(
DEFAULT_ARTICLE_SECTION_MAX_TOKENS,
int(round(section_words * SECTION_TOKENS_PER_WORD)),
),
"allowed_entities": allowed["entities"],
"allowed_numbers": allowed["numbers"],
"visible_brief": brief,
}


Expand All @@ -122,31 +140,52 @@ def run_bare_base_article(
exemplars and the measured style profile — which is the only thing the
comparison is meant to be about.
"""
invent_brief = str(budget.get("visible_brief") or build_brief(topic, points))
section_drafts: list[str] = []
messages: list[dict[str, str]] = []
for index, section in enumerate(budget["sections"], start=1):
section_topic, section_points = _section_brief(topic, section, points)
messages = build_write_messages(
topic=section_topic,
points=section_points,
examples=(),
channel="article",
style_directives=section_structure_directives(
section=section,
index=index,
total=budget["section_count"],
word_aim=budget["word_aim"],
section_words=budget["section_words"],
section_trim_words=budget["section_trim_words"],
allowed_entities=budget.get("allowed_entities") or (),
allowed_numbers=budget.get("allowed_numbers") or (),
),
)
raw = str(
generate_fn(
messages,
base_model=base_model,
max_tokens=budget["max_tokens"],
prompt_sink=prompt_sink,
)
).strip()
section_drafts.append(trim_draft(raw, max_words=budget["section_trim_words"]))
draft = ""
kept = False
for attempt in range(1, 3):
attempt_trim = budget["section_trim_words"]
attempt_tokens = budget["max_tokens"]
if attempt > 1:
attempt_trim = max(THIN_SECTION_WORD_FLOOR, budget["section_words"] // 2)
attempt_tokens = max(
256, int(round(attempt_trim * SECTION_TOKENS_PER_WORD))
)
raw = str(
generate_fn(
messages,
base_model=base_model,
max_tokens=attempt_tokens,
prompt_sink=prompt_sink,
)
).strip()
draft = trim_draft(raw, max_words=attempt_trim)
guards = _guard_flags(invent_brief, draft, ())
if not guards["parrot_reject"] and not guards["invent_reject"]:
kept = True
break
if kept and draft.strip():
section_drafts.append(draft)

text = drop_repeated_paragraphs(
"\n\n".join(part for part in section_drafts if part.strip())
Expand All @@ -158,7 +197,7 @@ def run_bare_base_article(
"model": base_model,
"k": 0,
"exemplar_ids": [],
"section_count": len(budget["sections"]),
"section_count": len(section_drafts),
"prompt": flatten_chat_messages(messages) if budget["sections"] else "",
}

Expand Down Expand Up @@ -326,19 +365,46 @@ def run_eval_write_article(
base_model=config.base_model,
prompt_sink=base_prompts,
)
visible = f"{brief['topic']}\n{brief['points']}"
score = score_rag_vs_base(
holdout_text,
article_result["text"],
base_result["text"],
brief["guard_facts"],
# Invent against the same visible brief the product arm fact-locks
# to — not the full source article the model never saw.
visible,
rag_exemplars=list(article_result.get("exemplar_texts") or []),
tie_epsilon=tie_epsilon,
# Invention is judged against the article; echo against the outline
# the model was handed. On a de-voiced brief those are different
# texts, and checking echo against the article would miss a draft
# that simply lists the bullets back.
visible_brief=f"{brief['topic']}\n{brief['points']}",
visible_brief=visible,
)
# Sections dropped for invent never reach the stitch; surface that as
# invent DQ even when the remaining text is empty or brief-clean.
if article_result.get("invent_reject"):
score["rag"]["invent_reject"] = True
score["rag"]["disqualified"] = True
score["rag"]["invented_entities"] = sorted(
set(score["rag"].get("invented_entities") or [])
| set(article_result.get("invented_entities") or [])
)
score["rag"]["invented_numbers"] = sorted(
set(score["rag"].get("invented_numbers") or [])
| set(article_result.get("invented_numbers") or [])
)
score["rag"]["invented_entities_count"] = len(
score["rag"]["invented_entities"]
)
score["rag"]["invented_numbers_count"] = len(
score["rag"]["invented_numbers"]
)
if score["winner"] == "rag":
score["winner"] = "base" if not score["base"]["disqualified"] else "tie"
if base_result.get("section_count", 1) == 0:
score["base"]["invent_reject"] = True
score["base"]["disqualified"] = True
if score["winner"] == "base":
score["winner"] = (
"rag" if not score["rag"]["disqualified"] else "tie"
)
item = _item_receipt(
holdout_id=piece.id,
holdout_text=holdout_text,
Expand Down
48 changes: 46 additions & 2 deletions src/personality_protect/prompt_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@
"lines, no hashtags copied from the EXAMPLES."
)

WRITE_ARTICLE_SYSTEM_PROMPT = (
"You write one section of a LinkedIn article in the author's voice.\n"
"\n"
"Rules:\n"
"- Write ONLY the current section about the BRIEF. The BRIEF is the only "
"source of facts.\n"
"- Expand claims already in the BRIEF. Prefer a shorter true section over "
"padding that invents companies, people, products, places, or figures.\n"
"- Do not invent companies, people, products, or figures the BRIEF did not "
"give you. If the BRIEF has no number, write no number.\n"
"- Follow the VOICE cadence targets. Treat the section word aim as a soft "
"ceiling: stop early rather than invent.\n"
"- The EXAMPLES are rhythm reference only: match their line lengths, "
"paragraph breaks, and sentence cadence.\n"
"- Never copy, quote, continue, summarize, or list the EXAMPLES. Reuse none "
"of their words, sentences, facts, names, numbers, or links.\n"
"- Names have been removed from the EXAMPLES. Do not guess them, and never "
"output bracketed or capitalized placeholders of any kind.\n"
"- No AI filler (leverage, delve, moreover, tapestry).\n"
"- Output the section text and nothing else: no title, no preamble, "
"no commentary, no markdown headings, no section labels, no separator "
"lines, no hashtags copied from the EXAMPLES."
)

_EXAMPLES_HEADER = (
"EXAMPLES (rhythm reference only — names removed; never reuse their words, "
"facts, or names):"
Expand All @@ -48,6 +72,11 @@
"Write one new post from the BRIEF now, in the rhythm of the EXAMPLES. "
"Do not repeat any EXAMPLE."
)
_WRITE_ARTICLE_INSTRUCTION = "Write this article section from the BRIEF now."
_WRITE_ARTICLE_INSTRUCTION_WITH_EXAMPLES = (
"Write this article section from the BRIEF now, in the rhythm of the "
"EXAMPLES. Do not repeat any EXAMPLE."
)


_STYLE_HEADER = "VOICE (cadence targets measured from the author's own posts):"
Expand All @@ -59,6 +88,7 @@ def build_write_user_content(
points: str,
examples: Sequence[str],
style_directives: Sequence[str] = (),
channel: str = "post",
) -> str:
"""User turn: voice card, optional exemplars, the brief, then the instruction.

Expand All @@ -81,7 +111,14 @@ def build_write_user_content(
if kept:
blocks.append(_EXAMPLES_HEADER + "\n\n" + _EXAMPLE_SEPARATOR.join(kept))
blocks.append(f"BRIEF:\nTopic: {topic}\nPoints:\n{points}")
blocks.append(_WRITE_INSTRUCTION_WITH_EXAMPLES if kept else _WRITE_INSTRUCTION)
if channel == "article":
blocks.append(
_WRITE_ARTICLE_INSTRUCTION_WITH_EXAMPLES
if kept
else _WRITE_ARTICLE_INSTRUCTION
)
else:
blocks.append(_WRITE_INSTRUCTION_WITH_EXAMPLES if kept else _WRITE_INSTRUCTION)
return "\n\n".join(blocks)


Expand All @@ -91,17 +128,22 @@ def build_write_messages(
points: str,
examples: Sequence[str],
style_directives: Sequence[str] = (),
channel: str = "post",
) -> list[dict[str, str]]:
"""Locked writing prompt as chat turns (system + user)."""
system = (
WRITE_ARTICLE_SYSTEM_PROMPT if channel == "article" else WRITE_SYSTEM_PROMPT
)
return [
{"role": "system", "content": WRITE_SYSTEM_PROMPT},
{"role": "system", "content": system},
{
"role": "user",
"content": build_write_user_content(
topic=topic,
points=points,
examples=examples,
style_directives=style_directives,
channel=channel,
),
},
]
Expand All @@ -113,6 +155,7 @@ def build_write_prompt(
points: str,
examples: Sequence[str],
style_directives: Sequence[str] = (),
channel: str = "post",
) -> str:
"""Flat rendering of the locked prompt (no chat template available)."""
return flatten_chat_messages(
Expand All @@ -121,5 +164,6 @@ def build_write_prompt(
points=points,
examples=examples,
style_directives=style_directives,
channel=channel,
)
)
Loading
Loading