tuitbot_core/workflow/
thread_plan.rs1use std::sync::Arc;
6
7use crate::config::Config;
8use crate::content::frameworks::ThreadStructure;
9use crate::llm::LlmProvider;
10
11use super::{make_content_gen, WorkflowError};
12
13#[derive(Debug, Clone)]
15pub struct ThreadPlanInput {
16 pub topic: String,
18 pub objective: Option<String>,
20 pub target_audience: Option<String>,
22 pub structure: Option<String>,
24}
25
26#[derive(Debug, Clone, serde::Serialize)]
28pub struct ThreadPlanOutput {
29 pub thread_tweets: Vec<String>,
31 pub tweet_count: usize,
33 pub structure_used: String,
35 pub hook_type: String,
37 pub first_tweet_preview: String,
39 pub estimated_performance: String,
41 pub objective_alignment: String,
43 pub target_audience: String,
45 pub topic_relevance: String,
47}
48
49fn parse_structure(s: &str) -> Option<ThreadStructure> {
51 match s.to_lowercase().as_str() {
52 "transformation" => Some(ThreadStructure::Transformation),
53 "framework" => Some(ThreadStructure::Framework),
54 "mistakes" => Some(ThreadStructure::Mistakes),
55 "analysis" => Some(ThreadStructure::Analysis),
56 _ => None,
57 }
58}
59
60fn analyze_hook(first_tweet: &str) -> &'static str {
62 let trimmed = first_tweet.trim();
63 if trimmed.ends_with('?') {
64 "question"
65 } else if trimmed.starts_with("Most people")
66 || trimmed.starts_with("Everyone")
67 || trimmed.starts_with("most people")
68 {
69 "contrarian"
70 } else if trimmed.starts_with("I ") || trimmed.starts_with("I'") {
71 "story"
72 } else {
73 "statement"
74 }
75}
76
77pub async fn execute(
79 llm: &Arc<dyn LlmProvider>,
80 config: &Config,
81 input: ThreadPlanInput,
82) -> Result<ThreadPlanOutput, WorkflowError> {
83 let structure_override = input.structure.as_deref().and_then(parse_structure);
84
85 let gen = make_content_gen(llm, &config.business);
86
87 let thread = gen
88 .generate_thread_with_structure(&input.topic, structure_override)
89 .await?;
90
91 let tweet_count = thread.tweets.len();
92 let hook_type = thread
93 .tweets
94 .first()
95 .map(|t| analyze_hook(t))
96 .unwrap_or("unknown");
97
98 let topic_lower = input.topic.to_lowercase();
100 let relevance = config.business.effective_industry_topics().iter().any(|t| {
101 topic_lower.contains(&t.to_lowercase()) || t.to_lowercase().contains(&topic_lower)
102 });
103
104 let estimated_performance = if relevance { "high" } else { "medium" };
105 let structure_used = input.structure.as_deref().unwrap_or("auto_selected");
106
107 Ok(ThreadPlanOutput {
108 first_tweet_preview: thread.tweets.first().cloned().unwrap_or_default(),
109 thread_tweets: thread.tweets,
110 tweet_count,
111 structure_used: structure_used.to_string(),
112 hook_type: hook_type.to_string(),
113 estimated_performance: estimated_performance.to_string(),
114 objective_alignment: input
115 .objective
116 .unwrap_or_else(|| "general engagement".to_string()),
117 target_audience: input
118 .target_audience
119 .unwrap_or_else(|| "general".to_string()),
120 topic_relevance: if relevance {
121 "matches_industry_topics"
122 } else {
123 "novel_topic"
124 }
125 .to_string(),
126 })
127}