From 6aead492fd1b4158993ea0c2f8abebd9abd2fe02 Mon Sep 17 00:00:00 2001 From: Interchouette <484423+Interchouette@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:47:50 +0200 Subject: [PATCH] fix(tweet): scrub not-just mush and retry writer like LinkedIn Strip banned slogan contrast lines from tweet output, validate that commentary beats remain, and retry once from ResearchPack before failing. Wire rework and self-intro through the same scrub path. Co-authored-by: Cursor --- backend/crates/itcy/src/prompts.rs | 23 +++ backend/crates/itcy/src/sources/rework.rs | 2 +- backend/crates/itcy/src/sources/self_intro.rs | 2 +- backend/crates/itcy/src/sources/tweet.rs | 160 ++++++++++++++++-- 4 files changed, 171 insertions(+), 16 deletions(-) diff --git a/backend/crates/itcy/src/prompts.rs b/backend/crates/itcy/src/prompts.rs index ee4b7a5..55a30e1 100644 --- a/backend/crates/itcy/src/prompts.rs +++ b/backend/crates/itcy/src/prompts.rs @@ -584,6 +584,29 @@ mod tests { assert!(lower.contains("browse_url") || lower.contains("browse")); } + #[test] + fn x_craft_bans_not_just_broader_trend_mush() { + assert!( + CREATIVE_X.contains("it's not about") || CREATIVE_X.contains("not just another tool"), + "Creative X must ban not-just / not-about slogan class" + ); + assert!( + CREATIVE_X.contains("broader trend"), + "Creative X must ban broader-trend mush" + ); + assert!( + FORM_CRAFT_X.contains("not just") + || FORM_CRAFT_X.contains("not-about") + || FORM_CRAFT_X.contains("broader trend"), + "Form craft X must forbid not-just mush" + ); + let tweet_user = TWEET_USER_TMPL; + assert!( + tweet_user.contains("Slogan mush") || tweet_user.contains("broader trend"), + "tweet user must forbid slogan mush" + ); + } + #[test] fn linkedin_craft_bans_not_just_broader_trend_mush() { assert!( diff --git a/backend/crates/itcy/src/sources/rework.rs b/backend/crates/itcy/src/sources/rework.rs index 26a915d..850764e 100644 --- a/backend/crates/itcy/src/sources/rework.rs +++ b/backend/crates/itcy/src/sources/rework.rs @@ -420,7 +420,7 @@ async fn run_tweet_rework_llm( } fn scrub_rework_tweet_body(raw: &str) -> String { - crate::llm::sanitize_itcy_text(raw.trim()) + crate::sources::tweet::scrub_tweet_body(raw) } fn strip_leading_draft_id(body: &str) -> String { diff --git a/backend/crates/itcy/src/sources/self_intro.rs b/backend/crates/itcy/src/sources/self_intro.rs index 3b9fea2..3e64aed 100644 --- a/backend/crates/itcy/src/sources/self_intro.rs +++ b/backend/crates/itcy/src/sources/self_intro.rs @@ -170,7 +170,7 @@ pub async fn build_itcy_self_tweet( return Err(e.into()); } }; - let mut body = crate::sources::tweet::scrub_tweet_body(&response.message.content); + let mut body = crate::sources::tweet::scrub_and_validate_tweet_body(&response.message.content)?; if tweet_body_exploded(&body) { warn!("self_tweet: writer dump coerced to tweet shape (no retry)"); body = coerce_tweet_body(&body, subject); diff --git a/backend/crates/itcy/src/sources/tweet.rs b/backend/crates/itcy/src/sources/tweet.rs index 2d5d271..e3799cb 100644 --- a/backend/crates/itcy/src/sources/tweet.rs +++ b/backend/crates/itcy/src/sources/tweet.rs @@ -28,6 +28,11 @@ use crate::tools::ItcyTools; use std::path::{Path, PathBuf}; use tracing::{info, warn}; +const TWEET_SCRUB_RETRY_NOTE: &str = + "HARD REWRITE: Write only from ResearchPack. Name entities and numbers from the cite. \ +Do not paste the brief opening. Forbidden: not-just / not-about contrast lines \ +(\"It's not just X, it's Y\"). 3-4 aerated beats with 2-3 unique emoji."; + fn tweet_system_prompt() -> String { format!( "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}", @@ -218,6 +223,17 @@ pub async fn build_grounded_tweet_from_pack( }) } +struct TweetWriterCtx<'a> { + router: &'a FailoverRouter, + subject: &'a str, + research_pack: &'a str, + pack_urls: &'a [String], + subject_https: bool, + session_dir: Option<&'a PathBuf>, + tools: Option<&'a ItcyTools>, + extra_pack_note: Option<&'a str>, +} + async fn run_tweet_phase( router: &FailoverRouter, subject: &str, @@ -228,32 +244,81 @@ async fn run_tweet_phase( tools: Option<&ItcyTools>, ) -> Result<(String, CompletionTrace), RagError> { crate::sources::rag::log_pipeline_banner("TWEET (writer)"); - // Pack already grounded by LOAD. Writer must not re-search / corpus-drift. - let user = tweet_user_message( - research_pack, - tweet_pack_note(pack_urls.is_empty(), subject_https), + let base = TweetWriterCtx { + router, subject, - ); + research_pack, + pack_urls, + subject_https, + session_dir, + tools, + extra_pack_note: None, + }; + let (content, trace) = complete_tweet_writer(&base).await?; + match finalize_tweet_writer_body(&content, subject) { + Ok(body) => Ok((body, trace)), + Err(first_err) => { + warn!(error = %first_err, "load_tweet: scrub failed; retrying writer once"); + let retry_ctx = TweetWriterCtx { + extra_pack_note: Some(TWEET_SCRUB_RETRY_NOTE), + ..base + }; + let (retry_content, retry_trace) = complete_tweet_writer(&retry_ctx).await?; + let body = finalize_tweet_writer_body(&retry_content, subject)?; + Ok((body, trace.accumulate(&retry_trace))) + } + } +} + +async fn complete_tweet_writer( + ctx: &TweetWriterCtx<'_>, +) -> Result<(String, CompletionTrace), RagError> { + let mut pack_note = tweet_pack_note(ctx.pack_urls.is_empty(), ctx.subject_https).to_string(); + if let Some(note) = ctx.extra_pack_note { + pack_note.push_str("\n\n"); + pack_note.push_str(note); + } + let user = tweet_user_message(ctx.research_pack, &pack_note, ctx.subject); let messages = vec![ LlmMessage::system(tweet_system_prompt()), LlmMessage::user(user), ]; - let (response, trace) = match router + match ctx + .router .complete_with_tools(TaskKind::Draft, &messages, None, 0) .await { - Ok(v) => v, + Ok((response, trace)) => Ok((response.message.content, trace)), Err(e) => { - end_session_best_effort(tools, session_dir, &format!("tweet writer failed: {e}")).await; - return Err(e.into()); + end_session_best_effort( + ctx.tools, + ctx.session_dir, + &format!("tweet writer failed: {e}"), + ) + .await; + Err(e.into()) } - }; - let body = scrub_tweet_body(&response.message.content); + } +} + +fn finalize_tweet_writer_body(content: &str, subject: &str) -> Result { + let body = scrub_and_validate_tweet_body(content)?; if tweet_body_exploded(&body) { warn!("load_tweet: writer dump coerced to tweet shape (no retry)"); - return Ok((coerce_tweet_body(&body, subject), trace)); + return Ok(coerce_tweet_body(&body, subject)); } - Ok((body, trace)) + Ok(body) +} + +fn tweet_has_commentary_beats(body: &str) -> bool { + body.split("\n\n").any(|para| { + let t = para.trim(); + if t.is_empty() || t.starts_with("https://") { + return false; + } + !t.split_whitespace() + .all(|tok| tok.starts_with('#') && tok.len() > 1) + }) } pub(crate) fn attach_tweet_cites( @@ -296,10 +361,41 @@ pub(crate) fn attach_tweet_cites( ) } +/// Sanitize tweet writer output and drop banned slogan-mush beats (salvage-only). +#[must_use] pub(crate) fn scrub_tweet_body(raw: &str) -> String { let raw = crate::llm::sanitize_itcy_text(raw); let raw = crate::sources::draft_url::strip_sources_section(&raw); - crate::sources::tweet_footer::strip_own_x_handle(&raw) + let raw = crate::sources::tweet_footer::strip_own_x_handle(&raw); + if crate::sources::corpus_propose::body_has_slogan_mush(&raw) { + let stripped = crate::sources::corpus_propose::strip_slogan_mush_sentences(&raw); + if !stripped.trim().is_empty() { + warn!("load_tweet: slogan mush stripped from writer body"); + return stripped; + } + } + raw +} + +/// Writer path: sanitize, salvage mush, hard-fail when mush or empty body remains. +pub(crate) fn scrub_and_validate_tweet_body(raw: &str) -> Result { + let body = scrub_tweet_body(raw); + if body.trim().is_empty() { + return Err(RagError::Store( + "writer returned empty tweet after scrub".into(), + )); + } + if crate::sources::corpus_propose::body_has_slogan_mush(&body) { + return Err(RagError::Store( + "writer kept banned X slogan mush after salvage".into(), + )); + } + if !tweet_has_commentary_beats(&body) { + return Err(RagError::Store( + "writer left no commentary beats after scrub".into(), + )); + } + Ok(body) } fn ensure_tweet_handles_from_pack(tools: Option<&ItcyTools>, body: &str, pack: &str) -> String { @@ -315,6 +411,42 @@ fn ensure_tweet_handles_from_pack(tools: Option<&ItcyTools>, body: &str, pack: & mod tests { use super::*; + #[test] + fn scrub_tweet_body_strips_not_just_mush_keeps_other_beats() { + let raw = "\ +🚀 @DoorDash shifted 130k tasks to Flux, no more laptop clutter. + +🤖 Firecracker microVMs + MCP gateways? Scoped access beats laptop sprawl. + +🦉 25k code reviews weekly. It's not just automation, it's control. + +#DevOps #CloudNative #AIInfra"; + let out = scrub_tweet_body(raw); + assert!( + out.contains("130k tasks"), + "concrete hook beat must survive: {out}" + ); + assert!( + out.contains("Firecracker"), + "angle beat must survive: {out}" + ); + assert!( + !out.contains("not just automation"), + "mush beat must drop: {out}" + ); + assert!(out.contains("#DevOps"), "hashtag line must survive: {out}"); + } + + #[test] + fn scrub_and_validate_tweet_body_fails_when_only_mush_remains() { + let raw = "It's not just automation, it's control.\n\n#DevOps"; + let err = scrub_and_validate_tweet_body(raw).unwrap_err(); + assert!( + err.to_string().contains("commentary beats"), + "expected empty-commentary failure: {err}" + ); + } + #[test] fn locked_x_cite_puts_x_url_in_body_no_sources() { let raw = "\