diff --git a/README.md b/README.md index 495c52b..4a9410d 100644 --- a/README.md +++ b/README.md @@ -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. 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, and thin briefs lower the per-section word aim. A section that invents is repaired before it is dropped: the regenerate names the fabricated entities and figures it has to lose, and if they survive that, the sentences carrying them are cut and the section is re-checked. Only a section with nothing left is omitted, and the whole article is flagged for invention only when the stitch is empty or the stitched text still invents. 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 (including paraphrases) are dropped before stitching. Each section prompt sees only its own bullet — not the full brief dumped into every call — plus titles of the other sections so it does not rewrite them. Exemplar clips strip export chrome (duplicate titles, Created/Published lines) so the word budget is real prose. The style card for this channel uses article-only cadence when enough articles were measured, not the comment-dominated corpus card. Section prompts also use an article-specific system rule set: the BRIEF is the only fact source, allowed names/figures from the BRIEF are listed explicitly, and thin briefs lower the per-section word aim. A section that invents is repaired before it is dropped: the regenerate names the fabricated entities and figures it has to lose, and if they survive that, the sentences carrying them are cut and the section is re-checked. Only a section with nothing left is omitted, and the whole article is flagged for invention only when the stitch is empty or the stitched text still invents. 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 diff --git a/src/personality_protect/draft_trim.py b/src/personality_protect/draft_trim.py index 70b54b1..3b4fecd 100644 --- a/src/personality_protect/draft_trim.py +++ b/src/personality_protect/draft_trim.py @@ -51,6 +51,40 @@ def drop_repeated_paragraphs(text: str) -> str: return "\n\n".join(kept) +# Section drafts are generated independently. A later section can rephrase an +# earlier one without any single paragraph hitting NEAR_DUPLICATE_RATIO, which +# is how a five-section stitch becomes the same argument five times. Coverage +# against the kept article catches that paraphrase; pairwise similarity catches +# near-copies of one prior section. +SECTION_RESTATE_SIMILARITY = 0.55 +SECTION_RESTATE_COVERAGE = 0.62 + + +def drop_restated_sections(sections: list[str] | tuple[str, ...]) -> list[str]: + """Keep section drafts that still add substance after earlier ones.""" + kept: list[str] = [] + kept_token_sets: list[set[str]] = [] + for section in sections: + text = (section or "").strip() + if not text: + continue + tokens = set(_tokens(text)) + if not tokens: + continue + if any( + _similarity(tokens, earlier) >= SECTION_RESTATE_SIMILARITY + for earlier in kept_token_sets + ): + continue + if kept_token_sets: + prior = set().union(*kept_token_sets) + if len(tokens & prior) / len(tokens) >= SECTION_RESTATE_COVERAGE: + continue + kept.append(text) + kept_token_sets.append(tokens) + return kept + + def trim_to_word_target(text: str, max_words: int) -> str: """Keep whole paragraphs up to ``max_words``, never cutting mid-sentence. diff --git a/src/personality_protect/eval_write_article.py b/src/personality_protect/eval_write_article.py index 2c11c64..5b9799c 100644 --- a/src/personality_protect/eval_write_article.py +++ b/src/personality_protect/eval_write_article.py @@ -41,7 +41,11 @@ 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, word_count +from personality_protect.draft_trim import ( + drop_repeated_paragraphs, + drop_restated_sections, + word_count, +) from personality_protect.eval_write_holdout import ( TIE_EPSILON, assert_receipt_contoso_safe, @@ -150,8 +154,10 @@ def run_bare_base_article( scrubbed_sections: list[str] = [] messages: list[dict[str, str]] = [] attempts_total = 0 - for index, section in enumerate(budget["sections"], start=1): + outline = list(budget["sections"]) + for index, section in enumerate(outline, start=1): section_topic, section_points = _section_brief(topic, section, points) + others = [title for title in outline if title != section] outcome = draft_section_with_repair( build_messages=partial( build_section_messages, @@ -167,6 +173,7 @@ def run_bare_base_article( section_trim_words=budget["section_trim_words"], allowed_entities=budget.get("allowed_entities") or (), allowed_numbers=budget.get("allowed_numbers") or (), + other_sections=others, ), ), generate_fn=generate_fn, @@ -189,7 +196,7 @@ def run_bare_base_article( scrubbed_sections.append(section) text = drop_repeated_paragraphs( - "\n\n".join(part for part in section_drafts if part.strip()) + "\n\n".join(drop_restated_sections(section_drafts)) ).strip() return { "text": text, diff --git a/src/personality_protect/style_profile.py b/src/personality_protect/style_profile.py index 3a9bafd..79a9f5e 100644 --- a/src/personality_protect/style_profile.py +++ b/src/personality_protect/style_profile.py @@ -14,6 +14,7 @@ from typing import Any, Iterable from personality_protect.config import ProfilePaths +from personality_protect.corpus_text import normalize_corpus_text from personality_protect.eval_compare import _sentence_word_counts, _word_tokens from personality_protect.models import Piece from personality_protect.select import selected_pieces @@ -325,6 +326,51 @@ def article_section_words(profile: dict[str, Any], *, sections: int) -> int: ) +# Below this, article cadence is noise — fall back to the corpus-wide card. +MIN_ARTICLE_CADENCE_SAMPLES = 3 + + +def article_cadence_stats(pieces: Iterable[Piece]) -> dict[str, Any]: + """Cadence measured on linkedin_article pieces only. + + The corpus-wide card is dominated by short posts and comments. Feeding that + into the article channel produces punchy LinkedIn-slop sentences instead of + the author's longform rhythm — which is exactly the complaint a real article + draft gets when the style card says most sentences run 3–14 words. + """ + articles = [ + p + for p in pieces + if p.source in _ARTICLE_LENGTH_SOURCES and (p.text or "").strip() + ] + if len(articles) < MIN_ARTICLE_CADENCE_SAMPLES: + return {"article_cadence_samples": float(len(articles))} + # Article exports often carry Medium/Ghost CSS ahead of the body. Measuring + # that raw paste reports 2–8 word "sentences" and 80%+ short lines — which + # then tells the article channel to write LinkedIn-comment slop. + texts: list[str] = [] + for piece in articles: + cleaned = normalize_corpus_text(piece.text) + if cleaned.strip(): + texts.append(cleaned) + if len(texts) < MIN_ARTICLE_CADENCE_SAMPLES: + return {"article_cadence_samples": float(len(texts))} + axes = corpus_style_stats(texts) + spread = sentence_length_spread(texts) + return { + "article_cadence_samples": float(len(texts)), + "article_sentence_words_p25": spread["sentence_words_p25"], + "article_sentence_words_p75": spread["sentence_words_p75"], + "article_median_sentence_words": float(axes.get("median_sentence_words") or 0), + "article_short_line_ratio": float(axes.get("short_line_ratio") or 0), + "article_multi_sentence_paragraph_ratio": multi_sentence_paragraph_ratio(texts), + "article_you_count": int(axes.get("you_count") or 0), + "article_i_count": int(axes.get("i_count") or 0), + "article_you_gt_i": bool(axes.get("you_gt_i")), + "article_contraction_rate": float(axes.get("contraction_rate") or 0), + } + + def build_style_profile( pieces: Iterable[Piece], *, @@ -337,6 +383,7 @@ def build_style_profile( texts = [p.text for p in piece_list if (p.text or "").strip()] stats.update(post_length_stats(piece_list)) stats.update(article_length_stats(piece_list)) + stats.update(article_cadence_stats(piece_list)) stats.update(sentence_length_spread(texts)) stats["multi_sentence_paragraph_ratio"] = multi_sentence_paragraph_ratio(texts) return { @@ -382,18 +429,40 @@ def style_directives(profile: dict[str, Any], *, channel: str = "post") -> list[ model copyable text and it copies; cadence targets carry the same voice signal with nothing to paste. - ``channel='article'`` drops the post length directive. Cadence transfers - across channels; a word budget does not, and telling an article section to - stay under the LinkedIn post ceiling is how a longform draft came out post - length. The article path states its own budget per section. + ``channel='article'`` drops the post length directive and, when enough + articles were measured, uses article-only cadence instead of the + comment-dominated corpus card. A word budget never transfers; cadence only + transfers when there is no article sample to speak for itself. """ stats = profile.get("stats") or {} is_article = (channel or "post").strip().lower() == "article" + use_article_cadence = ( + is_article + and float(stats.get("article_cadence_samples") or 0) >= MIN_ARTICLE_CADENCE_SAMPLES + ) directives: list[str] = [] - low = float(stats.get("sentence_words_p25") or 0) - high = float(stats.get("sentence_words_p75") or 0) - median_sentence = float(stats.get("median_sentence_words") or 0) + if use_article_cadence: + low = float(stats.get("article_sentence_words_p25") or 0) + high = float(stats.get("article_sentence_words_p75") or 0) + median_sentence = float(stats.get("article_median_sentence_words") or 0) + short_ratio = float(stats.get("article_short_line_ratio") or 0) + multi_ratio = float(stats.get("article_multi_sentence_paragraph_ratio") or 0) + you_n = int(stats.get("article_you_count") or 0) + i_n = int(stats.get("article_i_count") or 0) + you_gt_i = bool(stats.get("article_you_gt_i")) + contraction_rate = float(stats.get("article_contraction_rate") or 0) + else: + low = float(stats.get("sentence_words_p25") or 0) + high = float(stats.get("sentence_words_p75") or 0) + median_sentence = float(stats.get("median_sentence_words") or 0) + short_ratio = float(stats.get("short_line_ratio") or 0) + multi_ratio = float(stats.get("multi_sentence_paragraph_ratio") or 0) + you_n = int(stats.get("you_count") or 0) + i_n = int(stats.get("i_count") or 0) + you_gt_i = bool(stats.get("you_gt_i")) + contraction_rate = float(stats.get("contraction_rate") or 0) + if low and high and high > low: directives.append( f"Sentence length varies: most run {low:.0f}–{high:.0f} words. " @@ -405,19 +474,30 @@ def style_directives(profile: dict[str, Any], *, channel: str = "post") -> list[ "but do not write long academic sentences." ) - short_ratio = float(stats.get("short_line_ratio") or 0) - if short_ratio: - directives.append( - f"About {short_ratio * 100:.0f}% of lines are 8 words or fewer. " - "Use short standalone lines and frequent paragraph breaks." - ) - - multi_ratio = float(stats.get("multi_sentence_paragraph_ratio") or 0) - if multi_ratio: + # Article exports mix short headings with long prose. Prefer the + # multi-sentence paragraph signal when it is present, or the short-line + # directive turns every section into comment-length punch lines. + if use_article_cadence and multi_ratio >= 0.15: directives.append( f"About {multi_ratio * 100:.0f}% of paragraphs carry two or more " - "sentences. Do not write the whole post as single short lines." + "sentences. Write in prose paragraphs, not a stack of one-liners." ) + if short_ratio >= 0.55: + directives.append( + f"About {short_ratio * 100:.0f}% of lines are 8 words or fewer. " + "Short lines are fine for emphasis, not for the whole article." + ) + else: + if short_ratio: + directives.append( + f"About {short_ratio * 100:.0f}% of lines are 8 words or fewer. " + "Use short standalone lines and frequent paragraph breaks." + ) + if multi_ratio: + directives.append( + f"About {multi_ratio * 100:.0f}% of paragraphs carry two or more " + "sentences. Do not write the whole post as single short lines." + ) median_post = float(stats.get("median_post_words") or 0) if median_post and not is_article: @@ -427,14 +507,20 @@ def style_directives(profile: dict[str, Any], *, channel: str = "post") -> list[ ) # Absent counts must stay silent: an empty profile asserting a pronoun lean - # would put a made-up voice rule in the prompt. - if int(stats.get("you_count") or 0) or int(stats.get("i_count") or 0): - if stats.get("you_gt_i"): + # would put a made-up voice rule in the prompt. On articles, a pure you>i + # rule erases first-person experience voice when both pronouns are real. + if you_n or i_n: + if use_article_cadence and you_n and i_n: + directives.append( + "Use first person for experience and judgment; use 'you' when " + "giving the reader a direct instruction." + ) + elif you_gt_i: directives.append("Address the reader as 'you' more often than 'I'.") else: directives.append("Speak in first person more often than addressing 'you'.") - if float(stats.get("contraction_rate") or 0) > 0.01: + if contraction_rate > 0.01: directives.append("Use contractions; write the way people speak.") banned = [str(phrase) for phrase in (profile.get("banned_ai_filler") or [])][:12] diff --git a/src/personality_protect/write_article.py b/src/personality_protect/write_article.py index aae7c94..951c2e1 100644 --- a/src/personality_protect/write_article.py +++ b/src/personality_protect/write_article.py @@ -15,7 +15,13 @@ 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 drop_repeated_paragraphs, trim_draft, word_count +from personality_protect.corpus_text import normalize_corpus_text +from personality_protect.draft_trim import ( + drop_repeated_paragraphs, + drop_restated_sections, + 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 ( @@ -58,14 +64,41 @@ # Tokens per target word. Roughly 1.35 tokens/word for English plus room to # finish the closing sentence before the budget runs out. SECTION_TOKENS_PER_WORD = 2.0 -# Same clip as the post channel, and the article eval is why. A 60-word clip of -# a 1,000-word article shows little of its section rhythm, so this was widened -# to 120 on the theory that longform needs a longer look. The holdout run -# answered: at 120 the stitched draft shared 150+ exact 8-token windows with its -# own exemplars and was disqualified for parroting on three holdouts of four. -# Longform gives the model more room to copy, not less, so the clip stays short -# and voice travels as measured cadence instead. +# Same clip budget as the post channel for parrot reasons: at 120 words the +# holdout shared 150+ exact 8-token windows with its exemplars. The article +# path still has to spend that budget on prose, not on LinkedIn title / +# "Created on" / "Published on" chrome — otherwise the model never sees the +# author's longform rhythm inside the 60-word window. ARTICLE_EXEMPLAR_WORDS = MAX_EXEMPLAR_WORDS +_ARTICLE_META_LINE_RE = re.compile( + r"^(created on|published on|edited on)\b", + re.IGNORECASE, +) +_ARTICLE_DATE_LINE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def prepare_article_exemplar(text: str, *, max_words: int = ARTICLE_EXEMPLAR_WORDS) -> str: + """Normalize, drop export chrome, then clip — so the budget is real prose.""" + cleaned = normalize_corpus_text(text) + lines: list[str] = [] + seen_title = "" + for raw in cleaned.splitlines(): + line = raw.strip() + if not line: + if lines and lines[-1] != "": + lines.append("") + continue + if _ARTICLE_META_LINE_RE.match(line) or _ARTICLE_DATE_LINE_RE.match(line): + continue + if not seen_title: + seen_title = line.casefold() + lines.append(line) + continue + if line.casefold() == seen_title: + continue + lines.append(line) + body = "\n".join(lines).strip() + return clip_exemplar(body, max_words=max_words) # When the visible brief is thin, demanding a full article-length section is # what forces invention. Cap the per-section aim from brief richness instead. THIN_BRIEF_WORDS = 80 @@ -168,11 +201,18 @@ def outline_from_brief(topic: str, points: str) -> list[str]: return sections -def _section_brief(topic: str, section: str, points: str) -> tuple[str, str]: - """Topic/points pair for one section generation call.""" +def _section_brief(topic: str, section: str, points: str = "") -> tuple[str, str]: + """Topic/points pair for one section generation call. + + Only this section's claim goes in the points field. Handing every section + the full brief is what made section 1 dump the article and sections 2–N + rewrite it; invent checking still uses the full brief separately. + ``points`` is accepted for call-site compatibility and ignored. + """ + del points return ( f"{topic} — {section}" if topic and topic != section else section, - f"- {section}\n- Stay on this section only.\n- Brief points:\n{points}", + f"- {section}", ) @@ -212,6 +252,7 @@ def section_structure_directives( section_trim_words: int, allowed_entities: Sequence[str] = (), allowed_numbers: Sequence[str] = (), + other_sections: Sequence[str] = (), ) -> list[str]: """Where this section sits, how long it may run, and which facts are allowed. @@ -224,10 +265,16 @@ def section_structure_directives( f"This is section {index} of {total} in a longform article of about " f"{word_aim} words; the other sections cover the rest of the brief.", f"Write only the section about: {section}", + "Do not restate or rewrite other sections. Advance only this section.", f"Aim for about {section_words} words in this section, and no more " f"than {section_trim_words}. Prefer stopping early over inventing " "facts to fill the count.", ] + others = [str(item).strip() for item in other_sections if str(item).strip()] + if others: + lines.append( + "Other sections (do not cover them here): " + " | ".join(others) + "." + ) entities = [str(item).strip() for item in allowed_entities if str(item).strip()] numbers = [str(item).strip() for item in allowed_numbers if str(item).strip()] if entities: @@ -489,7 +536,8 @@ def run_write_article( exemplars = [str(match["text"]) for match in matches] masked = [ mask_exemplar_entities( - clip_exemplar(exemplar, max_words=ARTICLE_EXEMPLAR_WORDS), full_brief + prepare_article_exemplar(exemplar, max_words=ARTICLE_EXEMPLAR_WORDS), + full_brief, ) for exemplar in exemplars ] @@ -505,6 +553,7 @@ def run_write_article( for index, section in enumerate(sections, start=1): section_topic, section_points = _section_brief(topic, section, points) + others = [title for title in sections if title != section] outcome = draft_section_with_repair( build_messages=partial( build_section_messages, @@ -522,6 +571,7 @@ def run_write_article( section_trim_words=section_trim_words, allowed_entities=allowed["entities"], allowed_numbers=allowed["numbers"], + other_sections=others, ), ], ), @@ -548,11 +598,11 @@ def run_write_article( elif outcome["status"] == "scrubbed": scrubbed_sections.append(section) - # Sections are generated independently from the same brief, so two of them - # can arrive as the same paragraph. Stitching them unfiltered is what turns - # a five-section article into the same point made five times. + # Sections are generated independently, so two of them can arrive as the + # same argument in different words. Drop restated section drafts first, + # then collapse near-duplicate paragraphs inside what remains. text = drop_repeated_paragraphs( - "\n\n".join(part for part in section_drafts if part.strip()) + "\n\n".join(drop_restated_sections(section_drafts)) ).strip() # Final invent check against the full brief the author supplied. The flag # describes the text that ships: a section dropped for inventing is a gap diff --git a/tests/test_draft_trim.py b/tests/test_draft_trim.py index b24df2a..cefab6e 100644 --- a/tests/test_draft_trim.py +++ b/tests/test_draft_trim.py @@ -4,6 +4,7 @@ from personality_protect.draft_trim import ( drop_repeated_paragraphs, + drop_restated_sections, trim_draft, trim_to_word_target, word_count, @@ -42,6 +43,23 @@ def test_drop_repeated_paragraphs_keeps_distinct_short_lines(): assert len(drop_repeated_paragraphs(text).split("\n\n")) == 3 +def test_drop_restated_sections_drops_paraphrase_of_earlier_section(): + first = ( + "Contoso Ledger names one owner before a packaging tier opens and " + "keeps the renewal test boring so the signal stays readable." + ) + paraphrase = ( + "Before a packaging tier opens Contoso Ledger names one owner and " + "keeps the renewal test boring so its signal stays readable." + ) + third = ( + "Exceptions are where a published price list quietly stops describing " + "anyone who still renews Contoso." + ) + kept = drop_restated_sections([first, paraphrase, third]) + assert kept == [first, third] + + def test_trim_to_word_target_cuts_on_paragraph_boundaries(): text = "One two three four.\n\nFive six seven eight.\n\nNine ten eleven twelve.\n" trimmed = trim_to_word_target(text, 8) diff --git a/tests/test_eval_write_article.py b/tests/test_eval_write_article.py index a415e11..c7d54f2 100644 --- a/tests/test_eval_write_article.py +++ b/tests/test_eval_write_article.py @@ -111,6 +111,8 @@ def fake_generate(messages, **_kwargs: object) -> str: assert "section 1 of 3" in seen[0] assert f"Aim for about {budget['section_words']} words" in seen[0] assert "ALLOWED names from the BRIEF only" in seen[0] + assert "Other sections (do not cover them here):" in seen[0] + assert "Brief points:" not in seen[0] # ...and none of the voice machinery. assert "EXAMPLES" not in seen[0] assert "Sentence length varies" not in seen[0] diff --git a/tests/test_style_profile.py b/tests/test_style_profile.py index 45fcd08..a73eee2 100644 --- a/tests/test_style_profile.py +++ b/tests/test_style_profile.py @@ -349,3 +349,29 @@ def test_article_directives_drop_the_post_word_ceiling(): assert "words total" not in article # Cadence still travels: only the length target is channel specific. assert "Never use these words" in article + + +def test_article_directives_use_article_cadence_not_comment_slop(): + """Short comments must not tell the article channel to write 3-word lines.""" + from personality_protect.models import Piece + + short_comments = [ + Piece( + id=f"c{i}", + source="linkedin_comment", + text="Yes. Do this. Ship it now.", + year=2026, + ) + for i in range(20) + ] + profile = build_style_profile([*contoso_articles(8), *short_comments]) + stats = profile["stats"] + assert stats["article_cadence_samples"] >= 3 + assert stats["article_sentence_words_p75"] > stats["sentence_words_p75"] + article = " ".join(style_directives(profile, channel="article")) + post = " ".join(style_directives(profile, channel="post")) + assert f"{stats['article_sentence_words_p25']:.0f}–{stats['article_sentence_words_p75']:.0f}" in article + assert f"{stats['sentence_words_p25']:.0f}–{stats['sentence_words_p75']:.0f}" in post + assert f"{stats['sentence_words_p25']:.0f}–{stats['sentence_words_p75']:.0f}" not in article + assert "first person for experience" in article + assert "more often than 'I'" in post diff --git a/tests/test_write_article.py b/tests/test_write_article.py index 78b41ab..2226a2d 100644 --- a/tests/test_write_article.py +++ b/tests/test_write_article.py @@ -24,6 +24,7 @@ assert_article_corpus, count_indexed_article_pieces, outline_from_brief, + prepare_article_exemplar, run_write_article, scale_section_words_for_brief, ) @@ -40,6 +41,23 @@ def _seed_articles(tmp_path: Path, n: int = MIN_ARTICLE_CORPUS) -> Path: return tmp_path +def test_prepare_article_exemplar_spends_the_clip_on_prose_not_chrome(): + raw = ( + "Contoso Ledger keeps packaging boring\n\n" + "Contoso Ledger keeps packaging boring\n" + "Created on 2026-04-05 01:41\n" + "Published on 2026-04-05 19:17\n" + "Every Contoso renewal has an owner. Most packaging changes fail the " + "moment the first exception lands without a name on it.\n" + ) + clipped = prepare_article_exemplar(raw, max_words=40) + assert "Created on" not in clipped + assert "Published on" not in clipped + assert clipped.count("Contoso Ledger keeps packaging boring") == 1 + assert "Every Contoso renewal has an owner" in clipped + assert len(clipped.split()) <= 40 + + def test_outline_from_brief_uses_bullets(): sections = outline_from_brief("Contoso pricing", BRIEF_POINTS) assert sections == ["Name one owner", "Cut exceptions", "Keep Ledger boring"] @@ -160,6 +178,62 @@ def fake_generate(messages, **_kwargs: object) -> str: assert "section 3 of 3" in seen[2] +def test_section_prompt_scopes_to_current_bullet_not_full_brief(tmp_path: Path): + """Every section used to receive every bullet, so section 1 dumped the article.""" + _seed_articles(tmp_path, n=6) + paths, _, _ = init_profile("contoso", home=tmp_path) + seen: list[str] = [] + + def fake_generate(messages, **_kwargs: object) -> str: + seen.append(messages[1]["content"]) + return "Contoso Ledger names one owner before the packaging change lands." + + run_write_article( + "Contoso packaging", BRIEF_POINTS, paths, k=1, generate_fn=fake_generate + ) + first = seen[0] + assert "Brief points:" not in first + assert "Stay on this section only." not in first + assert "Name one owner" in first + assert "Cut exceptions" in first # listed as another section, not as points + assert "Other sections (do not cover them here):" in first + # The points field for section 1 is only that section's bullet. + assert "- Name one owner" in first + assert "- Cut exceptions\n- Keep Ledger boring" not in first + + +def test_stitch_drops_paraphrased_section_restatement(tmp_path: Path): + """Near-copy section drafts must not survive as a longer looping article.""" + _seed_articles(tmp_path, n=MIN_ARTICLE_CORPUS) + paths, _, _ = init_profile("contoso", home=tmp_path) + bodies = [ + ( + "Contoso Ledger names one owner before a packaging tier opens and " + "keeps the renewal test boring so the signal stays readable." + ), + ( + "Before a packaging tier opens Contoso Ledger names one owner and " + "keeps the renewal test boring so its signal stays readable." + ), + ( + "Exceptions are where a published price list quietly stops " + "describing anyone who still renews Contoso." + ), + ] + calls = {"n": 0} + + def fake_generate(_messages, **_kwargs: object) -> str: + text = bodies[min(calls["n"], len(bodies) - 1)] + calls["n"] += 1 + return text + + result = run_write_article( + "Contoso packaging", BRIEF_POINTS, paths, k=1, generate_fn=fake_generate + ) + assert "Exceptions are where a published price list" in result["text"] + assert result["text"].count("names one owner") == 1 + + def test_section_trim_keeps_a_long_section_from_running_away(tmp_path: Path): _seed_articles(tmp_path, n=6) paths, _, _ = init_profile("contoso", home=tmp_path)