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
6 changes: 3 additions & 3 deletions crates/postghost-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down
188 changes: 178 additions & 10 deletions crates/postghost-server/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub fn build_router_from_arc(state: Arc<AppState>) -> 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)
Expand Down Expand Up @@ -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<String>,
}

/// Per-segment Iris dispatch result.
#[derive(Debug, Serialize)]
pub struct SegmentResult {
pub index: usize,
pub total: usize,
pub status: String,
pub iris_response: Option<Value>,
pub error: Option<String>,
}

async fn publish_content(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
axum::extract::Query(query): axum::extract::Query<PublishQuery>,
) -> Result<Json<Value>, (StatusCode, String)> {
let content_id = Uuid::parse_str(&id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let content = state
Expand All @@ -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:<name>" 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<usize>,
/// Optional tone (reserved for future LLM formatting — ignored by baseline).
#[serde(default)]
pub tone: Option<String>,
}

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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<FormatContentRequest>,
) -> Result<(StatusCode, Json<Value>), (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(),
})),
))
}
Loading
Loading