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 = "\