Skip to main content

tuitbot_core/workflow/
thread_plan.rs

1//! Thread plan step: generate and analyze a multi-tweet thread via LLM.
2//!
3//! Produces a structured thread plan with hook analysis and performance estimates.
4
5use 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/// Input for the thread plan step.
14#[derive(Debug, Clone)]
15pub struct ThreadPlanInput {
16    /// The topic to write a thread about.
17    pub topic: String,
18    /// Optional objective (e.g., "establish expertise").
19    pub objective: Option<String>,
20    /// Optional target audience description.
21    pub target_audience: Option<String>,
22    /// Optional structure override (e.g., "transformation", "framework").
23    pub structure: Option<String>,
24}
25
26/// Output from the thread plan step.
27#[derive(Debug, Clone, serde::Serialize)]
28pub struct ThreadPlanOutput {
29    /// The generated tweets in thread order.
30    pub thread_tweets: Vec<String>,
31    /// Number of tweets in the thread.
32    pub tweet_count: usize,
33    /// The structure that was used.
34    pub structure_used: String,
35    /// Hook analysis for the first tweet.
36    pub hook_type: String,
37    /// First tweet preview.
38    pub first_tweet_preview: String,
39    /// Estimated performance based on topic relevance.
40    pub estimated_performance: String,
41    /// Objective alignment description.
42    pub objective_alignment: String,
43    /// Target audience description.
44    pub target_audience: String,
45    /// Topic relevance assessment.
46    pub topic_relevance: String,
47}
48
49/// Parse a structure string into a `ThreadStructure`.
50fn 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
60/// Analyze the hook type of the first tweet.
61fn 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
77/// Execute the thread plan step.
78pub 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    // Relevance heuristic: check if topic overlaps with configured industry topics
99    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}