1use 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 #[arg(long)]
25 episode: Option<String>,
26 #[arg(long)]
28 tag: Option<String>,
29 #[arg(long)]
31 all: bool,
32 #[arg(long)]
34 show: Option<String>,
35 #[arg(long, default_value = "facebook", value_parser = ["facebook", "instagram"])]
37 platform: String,
38 #[arg(long)]
40 dry_run: bool,
41 #[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
54fn 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
263fn 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 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
427pub fn command() -> clap::Command {
429 <Args as clap::CommandFactory>::command()
430}