diff --git a/crates/postghost-cli/src/main.rs b/crates/postghost-cli/src/main.rs index 7e6f46e..7756680 100644 --- a/crates/postghost-cli/src/main.rs +++ b/crates/postghost-cli/src/main.rs @@ -66,11 +66,11 @@ async fn main() -> Result<()> { Commands::Format { id, platform } => { let resp = client - .post(format!("{}/api/v1/content/{}/variants", base, id)) + .post(format!("{}/api/v1/content/{}/format", base, id)) .json(&json!({ "platform": platform, - "formatted_text": "", - "metadata": {}, + "include_hashtags": true, + "include_call_to_action": false, })) .send() .await?; diff --git a/crates/postghost-server/src/api.rs b/crates/postghost-server/src/api.rs index f78e7c4..87ce247 100644 --- a/crates/postghost-server/src/api.rs +++ b/crates/postghost-server/src/api.rs @@ -41,6 +41,7 @@ pub fn build_router_from_arc(state: Arc) -> Router { "/api/v1/content/:id/state", patch(transition_workflow_state), ) + .route("/api/v1/content/:id/format", post(format_content_route)) .route("/api/v1/schedule", get(list_schedule).post(create_schedule)) .route("/api/v1/publish/:id", post(publish_content)) .with_state(state) @@ -288,9 +289,28 @@ async fn list_schedule( // --- Publishing via Iris --- +/// Query parameters for the publish endpoint. +#[derive(Debug, Deserialize)] +pub struct PublishQuery { + /// Target platform (snake_case, e.g. "twitter", "linked_in"). + /// If omitted, publishes the raw body as a single message. + pub platform: Option, +} + +/// Per-segment Iris dispatch result. +#[derive(Debug, Serialize)] +pub struct SegmentResult { + pub index: usize, + pub total: usize, + pub status: String, + pub iris_response: Option, + pub error: Option, +} + async fn publish_content( State(state): State>, Path(id): Path, + axum::extract::Query(query): axum::extract::Query, ) -> Result, (StatusCode, String)> { let content_id = Uuid::parse_str(&id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; let content = state @@ -299,15 +319,163 @@ async fn publish_content( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Content not found".to_string()))?; - match state.iris_client.send_message(&id, &content.body).await { - Ok(resp) => Ok(Json(json!({ - "content_id": id, - "status": "published", - "iris_response": resp, - }))), - Err(e) => Err(( - StatusCode::BAD_GATEWAY, - format!("Iris publish failed: {}", e), - )), + // Determine the platform and select the best text. + // Parse via from_db_key (handles "other:" round-trip; serde JSON + // parsing cannot reconstruct Platform::Other and would silently default). + let platform = query + .platform + .as_deref() + .map(postghost::Platform::from_db_key); + + // 1. Look up the saved PlatformVariant for the target platform. + // Fall back to raw body if no variant exists. + let (text, variant_limit) = match &platform { + Some(p) => content + .variants + .iter() + .find(|v| &v.platform == p) + .map(|v| { + // Prefer the char_limit recorded when the variant was formatted, + // so max_length overrides survive to publish-time splitting. + let limit = v + .metadata + .get("char_limit") + .and_then(Value::as_u64) + .map(|n| n as usize); + (v.formatted_text.clone(), limit) + }) + .unwrap_or_else(|| (content.body.clone(), None)), + None => (content.body.clone(), None), + }; + + if text.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "Content body is empty; nothing to publish".to_string(), + )); + } + + // 2. Split into segments based on platform char limit and thread support. + let segments = match &platform { + Some(p) => crate::format::split_for_platform(&text, p, variant_limit), + None => vec![text], + }; + + let total = segments.len(); + + // 3. Dispatch each segment to Iris individually. + // A per-run nonce prevents conversation_id collisions on re-publish. + let run_id = chrono::Utc::now().timestamp_millis(); + let mut results = Vec::with_capacity(total); + let mut all_ok = true; + + for (index, seg) in segments.into_iter().enumerate() { + let conversation_id = if total > 1 { + format!("{}/{}#{}", id, index, run_id) + } else { + format!("{}#{}", id, run_id) + }; + + match state.iris_client.send_message(&conversation_id, &seg).await { + Ok(resp) => { + results.push(SegmentResult { + index, + total, + status: "published".to_string(), + iris_response: Some(resp), + error: None, + }); + } + Err(e) => { + all_ok = false; + results.push(SegmentResult { + index, + total, + status: "failed".to_string(), + iris_response: None, + error: Some(e.to_string()), + }); + } + } } + + let overall_status = if all_ok { "published" } else { "partial" }; + + Ok(Json(json!({ + "content_id": id, + "platform": platform.as_ref().map(serde_json::to_value).and_then(Result::ok), + "status": overall_status, + "segments": results, + }))) +} + +// --- Formatting --- + +/// Request body for the format endpoint. +#[derive(Debug, Deserialize)] +pub struct FormatContentRequest { + /// Target platform (snake_case). + pub platform: String, + /// Include hashtags derived from content tags. + #[serde(default = "default_include_hashtags")] + pub include_hashtags: bool, + /// Include a call-to-action suffix. + #[serde(default)] + pub include_call_to_action: bool, + /// Override the platform's default max length. + pub max_length: Option, + /// Optional tone (reserved for future LLM formatting — ignored by baseline). + #[serde(default)] + pub tone: Option, +} + +fn default_include_hashtags() -> bool { + true +} + +/// Generate a platform variant from raw content using PlatformSpec constraints. +/// +/// This is the non-LLM formatting baseline. It applies char limits, hashtags, +/// and CTA. The variant is persisted to storage. +async fn format_content_route( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let content_id = Uuid::parse_str(&id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let content = state + .storage + .get_content(content_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Content not found".to_string()))?; + + let platform: postghost::Platform = postghost::Platform::from_db_key(&req.platform); + + let spec = postghost::PlatformSpec { + platform: platform.clone(), + tone: req.tone, + max_length: req.max_length, + include_hashtags: req.include_hashtags, + include_call_to_action: req.include_call_to_action, + }; + + let result = crate::format::format_content(&content, &spec); + + // Persist the variant. + state + .storage + .add_variant(content_id, &result.variant) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(( + StatusCode::CREATED, + Json(json!({ + "content_id": content_id, + "platform": serde_json::to_value(&platform).unwrap_or(Value::Null), + "segment_count": result.segment_count, + "formatted_text": result.variant.formatted_text, + "metadata": result.variant.metadata, + "formatted_at": result.variant.formatted_at.to_rfc3339(), + })), + )) } diff --git a/crates/postghost-server/src/format.rs b/crates/postghost-server/src/format.rs new file mode 100644 index 0000000..1ec1817 --- /dev/null +++ b/crates/postghost-server/src/format.rs @@ -0,0 +1,458 @@ +//! Platform-aware content formatting. +//! +//! Non-LLM formatting baseline. Produces platform-specific text from raw +//! content using `PlatformSpec` constraints (char limit, hashtags, CTA). +//! Thread splitting is handled by [`split_for_platform`]. + +use postghost::{Content, Platform, PlatformSpec, PlatformVariant}; +use serde::Serialize; +use serde_json::json; + +/// Result of formatting content for a single platform. +#[derive(Debug, Clone, Serialize)] +pub struct FormatResult { + /// The variant produced by formatting. + pub variant: PlatformVariant, + /// Number of thread segments the formatted text splits into. + pub segment_count: usize, +} + +/// Format raw content into a platform variant using `PlatformSpec` constraints. +/// +/// This is the non-LLM baseline. It applies: +/// 1. Truncation or thread-splitting to fit `Platform::char_limit()` +/// 2. Hashtag append (from content tags) if `spec.include_hashtags` +/// 3. Call-to-action append if `spec.include_call_to_action` +/// +/// The resulting text is within the platform's char limit if one exists. +pub fn format_content(content: &Content, spec: &PlatformSpec) -> FormatResult { + let mut text = content.body.clone(); + + // Append hashtags derived from content tags. + if spec.include_hashtags && !content.tags.is_empty() { + let hashtags: String = content + .tags + .iter() + .map(|t| { + // Hashtags: alphanumeric only, spaces removed. + let clean: String = t.chars().filter(|c| c.is_alphanumeric()).collect(); + if clean.is_empty() { + String::new() + } else { + format!("#{}", clean) + } + }) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + if !hashtags.is_empty() { + text = format!("{}\n{}", text, hashtags); + } + } + + // Append a call-to-action. + if spec.include_call_to_action { + text = format!("{}\n\nRead more →", text); + } + + // Apply platform-specific splitting/truncation. + let char_limit = spec.max_length.or_else(|| spec.platform.char_limit()); + + let (final_text, segment_count) = match char_limit { + Some(limit) if spec.platform.supports_threads() => { + // Thread platform: keep the full formatted text in the variant. + // Splitting into ordered segments happens at dispatch time + // (publish_content calls split_for_platform). + let count = split_text(&text, limit).len().max(1); + (text, count) + } + Some(limit) => { + // Non-thread platform: truncate to a single segment. + let seg = split_text(&text, limit) + .into_iter() + .next() + .unwrap_or_default(); + (seg, 1) + } + None => (text, 1), + }; + + FormatResult { + variant: PlatformVariant { + platform: spec.platform.clone(), + formatted_text: final_text, + metadata: json!({ + "segment_count": segment_count, + "char_limit": char_limit, + }), + formatted_at: chrono::Utc::now(), + }, + segment_count, + } +} + +/// Split text into segments that each fit within `limit` characters. +/// +/// Splits on paragraph boundaries first, then sentence boundaries, then +/// hard-wraps on word boundaries as a last resort. A single word longer +/// than `limit` is hard-cut into `limit`-sized chunks. This produces +/// natural thread segments rather than arbitrary mid-word cuts. +/// +/// Counts characters (Unicode scalar values), not bytes, matching the +/// documented semantics of `Platform::char_limit()`. +fn split_text(text: &str, limit: usize) -> Vec { + if text.chars().count() <= limit || limit == 0 { + return vec![text.to_string()]; + } + + let mut segments = Vec::new(); + let mut current = String::new(); + + // Helper: try to push a token (word) onto `current`, flushing if needed. + // If the token itself is longer than `limit`, hard-cut it into chunks. + let push_token = |current: &mut String, segments: &mut Vec, token: &str| { + let token_chars = token.chars().count(); + if current.is_empty() { + if token_chars <= limit { + current.push_str(token); + } else { + // Hard-cut the oversized token into limit-sized chunks. + push_hard_cut(segments, current, token, limit); + } + } else if current.chars().count() + 1 + token_chars <= limit { + current.push(' '); + current.push_str(token); + } else { + segments.push(std::mem::take(current)); + // Re-try this token against a fresh segment. + if token_chars <= limit { + current.push_str(token); + } else { + push_hard_cut(segments, current, token, limit); + } + } + }; + + // Split into paragraphs first. + for paragraph in text.split("\n\n") { + let para_chars = paragraph.chars().count(); + if !current.is_empty() && current.chars().count() + 2 + para_chars <= limit { + current.push_str("\n\n"); + current.push_str(paragraph); + continue; + } + + if para_chars <= limit && current.is_empty() { + current.push_str(paragraph); + continue; + } + + // Paragraph either doesn't fit or `current` is occupied — split by sentences. + if !current.is_empty() { + segments.push(std::mem::take(&mut current)); + } + + for sentence in split_sentences(paragraph) { + let sent_chars = sentence.chars().count(); + if !current.is_empty() && current.chars().count() + 1 + sent_chars <= limit { + current.push(' '); + current.push_str(&sentence); + continue; + } + + if sent_chars <= limit && current.is_empty() { + current.push_str(&sentence); + continue; + } + + // Sentence too long — word-wrap. + if !current.is_empty() { + segments.push(std::mem::take(&mut current)); + } + + for word in sentence.split_whitespace() { + push_token(&mut current, &mut segments, word); + } + } + } + + if !current.is_empty() { + segments.push(current); + } + + if segments.is_empty() { + vec![text.to_string()] + } else { + segments + } +} + +/// Hard-cut a token longer than `limit` into `limit`-sized char chunks. +/// Completed chunks are pushed onto `segments`; any remainder is left in `current`. +fn push_hard_cut(segments: &mut Vec, current: &mut String, token: &str, limit: usize) { + let mut buf = String::with_capacity(limit); + for c in token.chars() { + if buf.chars().count() == limit { + segments.push(std::mem::take(&mut buf)); + } + buf.push(c); + } + if !buf.is_empty() { + current.push_str(&buf); + } +} + +/// Split text into sentences (naive — splits on `. `, `! `, `? `). +fn split_sentences(text: &str) -> Vec { + let mut sentences = Vec::new(); + let mut current = String::new(); + + for word in text.split_whitespace() { + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + if word.ends_with('.') || word.ends_with('!') || word.ends_with('?') { + sentences.push(std::mem::take(&mut current)); + } + } + if !current.is_empty() { + sentences.push(current); + } + sentences +} + +/// Count how many segments text will be split into for a given limit. +#[cfg(test)] +fn segments_count(text: &str, limit: usize) -> usize { + if limit == 0 || text.len() <= limit { + return 1; + } + split_text(text, limit).len() +} + +/// Split content into thread segments for a specific platform. +/// +/// Unlike [`format_content`], this does not apply hashtags or CTA — it +/// only handles character-limit splitting. Use this when you already have +/// formatted text and just need to segment it for dispatch. +/// +/// `limit_override` lets the caller supply the effective char limit (e.g. +/// a `max_length` recorded on the variant), so limits set at format time +/// survive to publish-time splitting. Falls back to `platform.char_limit()`. +pub fn split_for_platform( + text: &str, + platform: &Platform, + limit_override: Option, +) -> Vec { + let limit = limit_override.or_else(|| platform.char_limit()); + match limit { + Some(limit) if platform.supports_threads() => split_text(text, limit), + Some(limit) => { + // Non-thread platform: truncate to one segment. + let segments = split_text(text, limit); + vec![segments.into_iter().next().unwrap_or_default()] + } + None => vec![text.to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use postghost::{ContentFormat, Platform}; + use uuid::Uuid; + + fn make_content(body: &str, tags: &[&str]) -> Content { + let now = Utc::now(); + Content { + id: Uuid::new_v4(), + title: "Test".to_string(), + body: body.to_string(), + format: ContentFormat::Markdown, + tags: tags.iter().map(|s| s.to_string()).collect(), + created_at: now, + updated_at: now, + variants: vec![], + } + } + + #[test] + fn test_format_short_content_twitter() { + let content = make_content("Hello world", &[]); + let spec = PlatformSpec { + platform: Platform::Twitter, + ..Default::default() + }; + let result = format_content(&content, &spec); + assert_eq!(result.variant.formatted_text, "Hello world"); + assert_eq!(result.segment_count, 1); + } + + #[test] + fn test_format_with_hashtags() { + let content = make_content("Hello world", &["rust", "coding"]); + let spec = PlatformSpec { + platform: Platform::Twitter, + include_hashtags: true, + ..Default::default() + }; + let result = format_content(&content, &spec); + assert!(result.variant.formatted_text.contains("#rust")); + assert!(result.variant.formatted_text.contains("#coding")); + } + + #[test] + fn test_format_with_cta() { + let content = make_content("Hello world", &[]); + let spec = PlatformSpec { + platform: Platform::Blog, + include_call_to_action: true, + ..Default::default() + }; + let result = format_content(&content, &spec); + assert!(result.variant.formatted_text.contains("Read more")); + } + + #[test] + fn test_format_truncates_long_content_no_threads() { + // LinkedIn has a char limit but does NOT support threads. + let long_body = "A".repeat(5000); + let content = make_content(&long_body, &[]); + let spec = PlatformSpec { + platform: Platform::LinkedIn, + ..Default::default() + }; + let result = format_content(&content, &spec); + assert!( + result.variant.formatted_text.chars().count() <= 3000, + "LinkedIn text should be truncated to 3000 chars, got {}", + result.variant.formatted_text.chars().count() + ); + } + + #[test] + fn test_split_text_respects_limit() { + let text = "A".repeat(100); + let segments = split_text(&text, 30); + assert!(segments.len() >= 3); + for seg in &segments { + assert!( + seg.chars().count() <= 30, + "segment exceeds limit: {} > 30", + seg.chars().count() + ); + } + } + + #[test] + fn test_split_text_preserves_paragraphs() { + let text = "First paragraph here.\n\nSecond paragraph here.\n\nThird one."; + let segments = split_text(text, 50); + // Should keep paragraphs together when possible. + assert!(!segments.is_empty()); + for seg in &segments { + assert!(seg.chars().count() <= 50); + } + } + + #[test] + fn test_split_text_multibyte_no_panic() { + // Emoji are 4 bytes each. Byte-based split_at would panic on a + // non-char boundary; char-counting must not. + let emoji_text = "😀".repeat(100); + let segments = split_text(&emoji_text, 10); + assert!(segments.len() >= 10); + for seg in &segments { + assert!( + seg.chars().count() <= 10, + "emoji segment exceeds char limit: {} > 10", + seg.chars().count() + ); + } + } + + #[test] + fn test_split_text_mixed_ascii_emoji() { + // Mixed content must split on char boundaries, not byte boundaries. + let text = format!("Hello {} world {}", "😀".repeat(20), "🚀".repeat(20)); + let segments = split_text(&text, 15); + assert!(segments.len() > 1); + for seg in &segments { + assert!(seg.chars().count() <= 15); + } + } + + #[test] + fn test_split_for_platform_twitter_threads() { + let long_text = "A".repeat(600); + let segments = split_for_platform(&long_text, &Platform::Twitter, None); + assert!(segments.len() >= 3, "should split into 3+ thread segments"); + for seg in &segments { + assert!(seg.chars().count() <= 280); + } + } + + #[test] + fn test_split_for_platform_no_limit() { + let text = "A".repeat(10000); + let segments = split_for_platform(&text, &Platform::Blog, None); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0], text); + } + + #[test] + fn test_split_for_platform_truncates_non_thread() { + let long_text = "A".repeat(5000); + let segments = split_for_platform(&long_text, &Platform::Instagram, None); + assert_eq!(segments.len(), 1); + assert!(segments[0].chars().count() <= 2200); + } + + #[test] + fn test_hashtag_filter_special_chars() { + let content = make_content("Hello", &["rust-lang", "hello world!"]); + let spec = PlatformSpec { + platform: Platform::Twitter, + include_hashtags: true, + ..Default::default() + }; + let result = format_content(&content, &spec); + assert!(result.variant.formatted_text.contains("#rustlang")); + assert!(result.variant.formatted_text.contains("#helloworld")); + } + + #[test] + fn test_empty_tags_no_hashtags() { + let content = make_content("Hello world", &[]); + let spec = PlatformSpec { + platform: Platform::Twitter, + include_hashtags: true, + ..Default::default() + }; + let result = format_content(&content, &spec); + assert!(!result.variant.formatted_text.contains("#")); + } + + #[test] + fn test_segments_count_single() { + assert_eq!(segments_count("short", 100), 1); + } + + #[test] + fn test_segments_count_multiple() { + let text = "A".repeat(100); + assert!(segments_count(&text, 30) >= 3); + } + + #[test] + fn test_split_sentences_basic() { + let text = "Hello world. This is a test! Is it working?"; + let sentences = split_sentences(text); + assert_eq!(sentences.len(), 3); + assert_eq!(sentences[0], "Hello world."); + assert_eq!(sentences[1], "This is a test!"); + assert_eq!(sentences[2], "Is it working?"); + } +} diff --git a/crates/postghost-server/src/lib.rs b/crates/postghost-server/src/lib.rs index 91fb16c..841b3b7 100644 --- a/crates/postghost-server/src/lib.rs +++ b/crates/postghost-server/src/lib.rs @@ -1,5 +1,6 @@ pub mod api; pub mod config; +pub mod format; pub mod iris; pub mod scheduler; pub mod storage;