Skip to main content

xil/cmd/
publish.rs

1//! `xil publish` — draft social media posts for an episode through the
2//! Claude API. Port of `XILP012_publish.py`.
3
4use std::ffi::OsString;
5use std::fs;
6use std::path::Path;
7
8use clap::Parser;
9use serde_json::{json, Value};
10use xil_core::workspace::{derive_paths, resolve_slug, workspace_root};
11use xil_core::{banner, log};
12
13const SYSTEM_PROMPT: &str = "You are a social media copywriter for the Berkshire Talking Chronicle, a radio reading service for people with visual impairments and print disabilities broadcasting from Pittsfield, Massachusetts.\n\nYou write warm, community-focused Facebook posts for The 413, an original radio drama series about WRRS Radio and the people of Berkshire County. The tone is: welcoming, local, proud of the community, enthusiastic about storytelling. Avoid corporate-speak. This is a volunteer-run community radio station.\n\nYou will produce exactly three post variants — Hype, Quote, and Spotlight — with those exact markdown headings. Each post should be complete, ready to copy-paste into Facebook with minimal editing. Include relevant emoji sparingly. Keep posts under 280 words each.";
14
15#[derive(Parser)]
16#[command(
17    name = "xil-publish",
18    about = "Generate social media post drafts from parsed episode data. Reads a parsed episode JSON and calls the Claude API to draft three ready-to-edit post variants (Hype, Quote, Spotlight) for Facebook or Instagram, written to posts/<slug>/<tag>_posts.md.",
19    after_help = "Requires the ANTHROPIC_API_KEY environment variable (except with --dry-run) and the [publish] extra: pip install 'xil-pipeline[publish]'."
20)]
21#[command(group(clap::ArgGroup::new("tag_group").args(["episode", "tag"])))]
22struct Args {
23    /// Episode tag (e.g. S04E01)
24    #[arg(long)]
25    episode: Option<String>,
26    /// Raw content tag (e.g. V01C03)
27    #[arg(long)]
28    tag: Option<String>,
29    /// Generate posts for every parsed episode under the current show slug
30    #[arg(long)]
31    all: bool,
32    /// Show name override (default: from project.json)
33    #[arg(long)]
34    show: Option<String>,
35    /// Target platform — affects post length/style guidance (default: facebook)
36    #[arg(long, default_value = "facebook", value_parser = ["facebook", "instagram"])]
37    platform: String,
38    /// Print prompt and token estimate without making an API call or writing files
39    #[arg(long)]
40    dry_run: bool,
41    /// Claude model ID (default: claude-haiku-4-5-20251001)
42    #[arg(long, default_value = "claude-haiku-4-5-20251001")]
43    model: String,
44}
45
46fn str_of(v: &Value) -> String {
47    match v {
48        Value::String(s) => s.clone(),
49        Value::Null => "None".into(),
50        other => xil_core::pycsv::cell(other),
51    }
52}
53
54/// `entry.get(k, default)` — a present `null` stays `None`.
55fn get_or(o: &Value, k: &str, default: Value) -> Value {
56    o.get(k).cloned().unwrap_or(default)
57}
58
59fn section_label(slug: &str) -> String {
60    match slug {
61        "cold-open" => "Cold Open".into(),
62        "opening-credits" => "Opening Credits".into(),
63        "act1" => "Act One".into(),
64        "act2" => "Act Two".into(),
65        "act3" => "Act Three".into(),
66        "mid-break" => "Mid-Episode Break".into(),
67        "post-interview" => "Post-Interview".into(),
68        "closing" => "Closing".into(),
69        "prologue" => "Prologue".into(),
70        "epilogue" => "Epilogue".into(),
71        other => super::parse::title_case(&other.replace('-', " ")),
72    }
73}
74
75struct Summary {
76    show: Value,
77    episode: Value,
78    tag: String,
79    title: Value,
80    season_title: Value,
81    cold_open_scene: Value,
82    cold_open_lines: Vec<(Value, Value)>,
83    cast: Vec<(String, Value, String)>,
84    section_arc: String,
85    runtime_minutes: Option<i64>,
86}
87
88fn extract_summary(
89    parsed: &Value,
90    cast_cfg: Option<&Value>,
91    master: &Path,
92) -> anyhow::Result<Summary> {
93    let entries = parsed
94        .get("entries")
95        .and_then(Value::as_array)
96        .cloned()
97        .unwrap_or_default();
98    let mut cold_open_scene = Value::from("");
99    let mut lines = Vec::new();
100    for e in &entries {
101        if e.get("section").and_then(Value::as_str) != Some("cold-open") {
102            continue;
103        }
104        let kind = e.get("type").and_then(Value::as_str);
105        if kind == Some("scene_header") && !super::truthy(&cold_open_scene) {
106            cold_open_scene = get_or(e, "text", Value::from(""));
107        } else if kind == Some("dialogue") && lines.len() < 3 {
108            lines.push((
109                get_or(e, "speaker", Value::from("")),
110                get_or(e, "text", Value::from("")),
111            ));
112        }
113    }
114    let cast_dict = cast_cfg
115        .and_then(|c| c.get("cast"))
116        .and_then(Value::as_object)
117        .cloned()
118        .unwrap_or_default();
119    let mut cast = Vec::new();
120    let speakers = parsed
121        .get("stats")
122        .and_then(|s| s.get("speakers"))
123        .and_then(Value::as_array)
124        .cloned()
125        .unwrap_or_default();
126    for sk in speakers {
127        let Some(key) = sk.as_str() else { continue };
128        if let Some(cfg) = cast_dict.get(key) {
129            let full = get_or(cfg, "full_name", Value::from(key));
130            let role = match cfg.get("role") {
131                None => String::new(),
132                Some(Value::String(r)) => {
133                    r.trim().split('\n').next().unwrap_or("").trim().to_string()
134                }
135                Some(other) => anyhow::bail!(
136                    "AttributeError: '{}' object has no attribute 'strip'",
137                    py_type(other)
138                ),
139            };
140            cast.push((key.to_string(), full, role));
141        }
142    }
143    let mut seen: Vec<String> = Vec::new();
144    let mut labels = Vec::new();
145    for e in &entries {
146        if let Some(sec) = e
147            .get("section")
148            .and_then(Value::as_str)
149            .filter(|s| !s.is_empty())
150        {
151            if !seen.iter().any(|x| x == sec) && sec != "preamble" && sec != "postamble" {
152                seen.push(sec.to_string());
153                labels.push(section_label(sec));
154            }
155        }
156    }
157    let section_arc = if labels.is_empty() {
158        "unknown".to_string()
159    } else {
160        labels.join(" → ")
161    };
162    let runtime_minutes = if master.exists() {
163        xil_audio::mpeg::info(master)
164            .ok()
165            .map(|i| (i.length / 60.0).floor() as i64)
166    } else {
167        None
168    };
169    let season = parsed.get("season").and_then(Value::as_i64);
170    let episode = parsed.get("episode").and_then(Value::as_i64);
171    let (Some(season), Some(ep)) = (season, episode) else {
172        anyhow::bail!("TypeError: unsupported format string passed to NoneType.__format__");
173    };
174    Ok(Summary {
175        show: get_or(parsed, "show", Value::from("")),
176        episode: get_or(parsed, "episode", Value::Null),
177        tag: format!("S{season:02}E{ep:02}"),
178        title: get_or(parsed, "title", Value::from("")),
179        season_title: get_or(parsed, "season_title", Value::Null),
180        cold_open_scene,
181        cold_open_lines: lines,
182        cast,
183        section_arc,
184        runtime_minutes,
185    })
186}
187
188fn py_type(v: &Value) -> &'static str {
189    match v {
190        Value::Null => "NoneType",
191        Value::Bool(_) => "bool",
192        Value::Number(n) if n.is_f64() => "float",
193        Value::Number(_) => "int",
194        Value::String(_) => "str",
195        Value::Array(_) => "list",
196        Value::Object(_) => "dict",
197    }
198}
199
200fn build_user_message(s: &Summary, platform: &str, spotlight_index: i64) -> String {
201    let mut lines: Vec<String> = Vec::new();
202    lines.push(format!("Show: {}", str_of(&s.show)));
203    lines.push(format!("Episode: {} — \"{}\"", s.tag, str_of(&s.title)));
204    if super::truthy(&s.season_title) {
205        lines.push(format!("Arc/Season title: {}", str_of(&s.season_title)));
206    }
207    lines.push(format!("Platform: {platform}"));
208    if let Some(r) = s.runtime_minutes.filter(|r| *r != 0) {
209        lines.push(format!("Runtime: approximately {r} minutes"));
210    }
211    lines.push(String::new());
212    lines.push("Section arc:".into());
213    lines.push(format!("  {}", s.section_arc));
214    lines.push(String::new());
215    if super::truthy(&s.cold_open_scene) {
216        lines.push(format!("Cold open setting: {}", str_of(&s.cold_open_scene)));
217    }
218    if !s.cold_open_lines.is_empty() {
219        lines.push("Cold open excerpt (first 3 lines):".into());
220        for (speaker, text) in &s.cold_open_lines {
221            let mut display = str_of(speaker);
222            if let Some(cm) = s
223                .cast
224                .iter()
225                .find(|c| Some(c.0.as_str()) == speaker.as_str())
226            {
227                display = str_of(&cm.1);
228            }
229            let mut t = str_of(text);
230            if t.chars().count() > 200 {
231                t = t.chars().take(197).collect::<String>() + "…";
232            }
233            lines.push(format!("  {display}: \"{t}\""));
234        }
235    }
236    lines.push(String::new());
237    if !s.cast.is_empty() {
238        lines.push("Cast:".into());
239        for (_, full, role) in &s.cast {
240            let role_str = if role.is_empty() {
241                String::new()
242            } else {
243                format!(" — {role}")
244            };
245            lines.push(format!("  {}{role_str}", str_of(full)));
246        }
247        lines.push(String::new());
248        let target = &s.cast[spotlight_index.rem_euclid(s.cast.len() as i64) as usize];
249        lines.push(format!(
250            "Spotlight post subject: {} ({})",
251            str_of(&target.1),
252            target.2
253        ));
254        lines.push(String::new());
255    }
256    lines.push(
257        "Write three Facebook post variants using exactly these markdown headings:\n## Hype Post\n## Quote Post\n## Spotlight Post\n\nHype: New episode announcement, teaser tone. Mention the show name, episode title, and Berkshire Talking Chronicle. No spoilers beyond the cold open setting.\nQuote: Pull a memorable line from the cold open excerpt above. Format as a blockquote or quoted text. Add a brief tune-in call to action.\nSpotlight: Feature the spotlight subject. Connect their character to the episode theme."
258            .into(),
259    );
260    lines.join("\n")
261}
262
263/// `publish_episode(...)` → success. `Err(SysExit)` for the key check.
264fn publish_episode(
265    slug: &str,
266    tag: &str,
267    platform: &str,
268    dry_run: bool,
269    model: &str,
270) -> anyhow::Result<bool> {
271    let p = derive_paths(slug, tag);
272    let posts_path = workspace_root()
273        .join("posts")
274        .join(slug)
275        .join(format!("{tag}_posts.md"));
276    if !p["parsed"].exists() {
277        log::warning(&format!(
278            "  Skipping {tag} — parsed JSON not found: {}",
279            p["parsed"].display()
280        ));
281        return Ok(false);
282    }
283    let parsed: Value = serde_json::from_str(&fs::read_to_string(&p["parsed"])?)?;
284    let cast_cfg: Option<Value> = if p["cast"].exists() {
285        Some(serde_json::from_str(&fs::read_to_string(&p["cast"])?)?)
286    } else {
287        log::warning(&format!(
288            "  Cast config not found: {} — cast list will be empty",
289            p["cast"].display()
290        ));
291        None
292    };
293    let summary = extract_summary(&parsed, cast_cfg.as_ref(), &p["master"])?;
294    let episode_number = summary.episode.as_i64().filter(|e| *e != 0).unwrap_or(0);
295    let cast_count = summary.cast.len().max(1) as i64;
296    let spotlight = (episode_number - 1).rem_euclid(cast_count);
297    let user_message = build_user_message(&summary, platform, spotlight);
298
299    if dry_run {
300        log::info(&format!("\n--- Dry run: {tag} ---"));
301        log::info(&format!("\n[SYSTEM PROMPT]\n{SYSTEM_PROMPT}"));
302        log::info(&format!("\n[USER MESSAGE]\n{user_message}"));
303        let sys_tokens = (SYSTEM_PROMPT.chars().count() as f64 / 4.0).ceil() as i64;
304        let user_tokens = (user_message.chars().count() as f64 / 4.0).ceil() as i64;
305        log::info(&format!(
306            "\nEstimated input tokens: ~{} (system: ~{sys_tokens}, user: ~{user_tokens})",
307            sys_tokens + user_tokens
308        ));
309        log::info(&format!(
310            "Output would be written to: {}",
311            posts_path.display()
312        ));
313        return Ok(true);
314    }
315
316    log::info(&format!("  Generating posts for {tag}..."));
317    let Some(api_key) = std::env::var("ANTHROPIC_API_KEY")
318        .ok()
319        .filter(|k| !k.is_empty())
320    else {
321        log::error("ANTHROPIC_API_KEY environment variable not set. Export your API key before running xil publish.");
322        return Err(super::SysExit(String::new()).into());
323    };
324    let body = json!({
325        "max_tokens": 1024,
326        "messages": [{"role": "user", "content": user_message}],
327        "model": model,
328        "system": [{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}],
329    });
330    let text = match xil_api::anthropic::Client::new(&api_key).messages_create(&body) {
331        Ok(resp) => resp["content"][0]["text"].as_str().map(str::to_string),
332        Err(e) => {
333            log::error(&format!("  API error for {tag}: {e}"));
334            return Ok(false);
335        }
336    };
337    let Some(posts) = text else {
338        log::error(&format!(
339            "  API error for {tag}: 'NoneType' object is not subscriptable"
340        ));
341        return Ok(false);
342    };
343    if let Some(parent) = posts_path.parent() {
344        fs::create_dir_all(parent)?;
345    }
346    let today = chrono::Local::now().format("%Y-%m-%d");
347    let mut out = format!(
348        "# {} — {} \"{}\" Social Posts\nGenerated: {today}  |  Platform: {platform}\n\n---\n\n",
349        str_of(&summary.show),
350        summary.tag,
351        str_of(&summary.title)
352    );
353    out.push_str(&posts);
354    if !posts.ends_with('\n') {
355        out.push('\n');
356    }
357    fs::write(&posts_path, out)?;
358    log::info(&format!("  Written: {}", posts_path.display()));
359    Ok(true)
360}
361
362pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
363    log::init("publish");
364    let result = {
365        let _banner = banner::begin(super::prog(), &super::argv_line(args));
366        execute(args)
367    };
368    match result {
369        // A bare sys.exit(1) after an error already logged.
370        Err(e)
371            if e.downcast_ref::<super::SysExit>()
372                .is_some_and(|s| s.0.is_empty()) =>
373        {
374            Ok(1)
375        }
376        other => super::finish(other),
377    }
378}
379
380fn execute(args: &[OsString]) -> anyhow::Result<i32> {
381    let a: Args = match super::parse_or_exit("xil-publish", args) {
382        Ok(a) => a,
383        Err(code) => return Ok(code),
384    };
385    let episode = a.episode.clone().filter(|s| !s.is_empty());
386    let tag = a.tag.clone().filter(|s| !s.is_empty());
387    if episode.is_none() && tag.is_none() && !a.all {
388        log::error("Specify --episode TAG, --tag TAG, or --all");
389        return Ok(1);
390    }
391    let slug = resolve_slug(a.show.as_deref(), "project.json");
392    if a.all {
393        let dir = Path::new("parsed").join(&slug);
394        let mut paths: Vec<String> = xil_core::fsutil::glob_children(&dir, "parsed_", ".json")
395            .into_iter()
396            .map(|p| p.to_string_lossy().into_owned())
397            .collect();
398        paths.sort();
399        if paths.is_empty() {
400            log::warning(&format!("No parsed JSON files found under parsed/{slug}/"));
401            return Ok(1);
402        }
403        log::info(&format!(
404            "Batch mode: {} episode(s) found for '{slug}'",
405            paths.len()
406        ));
407        let mut success = 0;
408        for path in &paths {
409            let base = xil_core::fsutil::basename(Path::new(path));
410            let ep = base.strip_prefix("parsed_").unwrap_or(&base);
411            let ep = ep.strip_suffix(".json").unwrap_or(ep);
412            if publish_episode(&slug, ep, &a.platform, a.dry_run, &a.model)? {
413                success += 1;
414            }
415        }
416        log::info(&format!("\n{success}/{} episodes processed.", paths.len()));
417        return Ok(0);
418    }
419    let t = episode.or(tag).unwrap_or_default();
420    if publish_episode(&slug, &t, &a.platform, a.dry_run, &a.model)? {
421        Ok(0)
422    } else {
423        Ok(1)
424    }
425}
426
427/// The clap definition behind `--help`, for man pages.
428pub fn command() -> clap::Command {
429    <Args as clap::CommandFactory>::command()
430}