Skip to main content

tuitbot_core/workflow/
mod.rs

1//! Workflow layer: stateful composite operations over toolkit primitives.
2//!
3//! Each workflow step defines explicit typed IO contracts and composes
4//! toolkit functions with DB and LLM state. Steps are the building blocks
5//! for both MCP composite tools and autopilot cycles.
6//!
7//! **Layer rules (charter §5):**
8//! - Workflow MAY access DB (`SqlitePool`) and LLM (`LlmProvider`).
9//! - Workflow MUST call X API operations through `toolkit::*`, never `XApiClient` directly.
10//! - Workflow MUST NOT import from `automation::`.
11
12pub mod discover;
13pub mod draft;
14pub mod orchestrate;
15pub mod publish;
16pub mod queue;
17pub mod thread_plan;
18
19#[cfg(test)]
20mod e2e_tests;
21#[cfg(test)]
22mod tests;
23
24use std::sync::Arc;
25
26use serde::Serialize;
27
28use crate::content::frameworks::ReplyArchetype;
29use crate::error::XApiError;
30use crate::llm::{GenerationParams, LlmProvider, LlmResponse};
31use crate::toolkit::ToolkitError;
32use crate::LlmError;
33
34// ── WorkflowError ────────────────────────────────────────────────────
35
36/// Errors from workflow operations.
37///
38/// Maps to existing `ErrorCode` variants in MCP responses (AD-10).
39#[derive(Debug, thiserror::Error)]
40pub enum WorkflowError {
41    /// Toolkit-level error (X API, validation).
42    #[error(transparent)]
43    Toolkit(#[from] ToolkitError),
44
45    /// Database error.
46    #[error("database error: {0}")]
47    Database(#[from] sqlx::Error),
48
49    /// Storage-layer error.
50    #[error("storage error: {0}")]
51    Storage(#[from] crate::error::StorageError),
52
53    /// LLM provider not configured.
54    #[error("LLM provider not configured")]
55    LlmNotConfigured,
56
57    /// LLM generation error.
58    #[error("LLM error: {0}")]
59    Llm(#[from] LlmError),
60
61    /// X API client not configured.
62    #[error("X API client not configured")]
63    XNotConfigured,
64
65    /// Invalid input parameter.
66    #[error("invalid input: {0}")]
67    InvalidInput(String),
68}
69
70// ── SharedProvider ───────────────────────────────────────────────────
71
72/// Bridge an `Arc<dyn LlmProvider>` into a `Box<dyn LlmProvider>` for
73/// `ContentGenerator`, which requires owned provider instances.
74///
75/// This adapter clones the Arc to construct ContentGenerator while allowing
76/// multiple workflow steps to share the same underlying LLM provider.
77pub(crate) struct SharedProvider(pub Arc<dyn LlmProvider>);
78
79#[async_trait::async_trait]
80impl LlmProvider for SharedProvider {
81    fn name(&self) -> &str {
82        self.0.name()
83    }
84
85    async fn complete(
86        &self,
87        system: &str,
88        user_message: &str,
89        params: &GenerationParams,
90    ) -> Result<LlmResponse, LlmError> {
91        self.0.complete(system, user_message, params).await
92    }
93
94    async fn health_check(&self) -> Result<(), LlmError> {
95        self.0.health_check().await
96    }
97}
98
99// ── Shared IO types ─────────────────────────────────────────────────
100
101/// A tweet candidate scored for reply-worthiness.
102#[derive(Debug, Clone, Serialize)]
103pub struct ScoredCandidate {
104    pub tweet_id: String,
105    pub author_username: String,
106    pub author_followers: u64,
107    pub text: String,
108    pub created_at: String,
109    pub score_total: f32,
110    pub score_breakdown: ScoreBreakdown,
111    pub matched_keywords: Vec<String>,
112    pub recommended_action: String,
113    pub already_replied: bool,
114}
115
116/// Per-signal score breakdown.
117#[derive(Debug, Clone, Serialize)]
118pub struct ScoreBreakdown {
119    pub keyword_relevance: f32,
120    pub follower: f32,
121    pub recency: f32,
122    pub engagement: f32,
123    pub reply_count: f32,
124    pub content_type: f32,
125}
126
127/// Result of drafting a reply for a single candidate.
128#[derive(Debug, Clone, Serialize)]
129#[serde(tag = "status")]
130pub enum DraftResult {
131    #[serde(rename = "success")]
132    Success {
133        candidate_id: String,
134        draft_text: String,
135        archetype: String,
136        char_count: usize,
137        confidence: String,
138        risks: Vec<String>,
139    },
140    #[serde(rename = "error")]
141    Error {
142        candidate_id: String,
143        error_code: String,
144        error_message: String,
145    },
146}
147
148/// Result of proposing/queueing a single reply.
149#[derive(Debug, Clone, Serialize)]
150#[serde(tag = "status")]
151pub enum ProposeResult {
152    #[serde(rename = "queued")]
153    Queued {
154        candidate_id: String,
155        approval_queue_id: i64,
156    },
157    #[serde(rename = "executed")]
158    Executed {
159        candidate_id: String,
160        reply_tweet_id: String,
161    },
162    #[serde(rename = "blocked")]
163    Blocked {
164        candidate_id: String,
165        reason: String,
166    },
167}
168
169/// Input item for the queue step.
170#[derive(Debug, Clone)]
171pub struct QueueItem {
172    /// The tweet ID to reply to.
173    pub candidate_id: String,
174    /// Pre-drafted reply text. If omitted, generates one via LLM.
175    pub pre_drafted_text: Option<String>,
176}
177
178// ── Helper: parse archetype string ──────────────────────────────────
179
180/// Parse an archetype string into a `ReplyArchetype`.
181pub fn parse_archetype(s: &str) -> Option<ReplyArchetype> {
182    match s.to_lowercase().replace(' ', "_").as_str() {
183        "agree_and_expand" | "agreeandexpand" => Some(ReplyArchetype::AgreeAndExpand),
184        "respectful_disagree" | "respectfuldisagree" => Some(ReplyArchetype::RespectfulDisagree),
185        "add_data" | "adddata" => Some(ReplyArchetype::AddData),
186        "ask_question" | "askquestion" => Some(ReplyArchetype::AskQuestion),
187        "share_experience" | "shareexperience" => Some(ReplyArchetype::ShareExperience),
188        _ => None,
189    }
190}
191
192// ── Helper: build content generator ─────────────────────────────────
193
194/// Build a `ContentGenerator` from a shared LLM provider.
195pub(crate) fn make_content_gen(
196    llm: &Arc<dyn LlmProvider>,
197    business: &crate::config::BusinessProfile,
198) -> crate::content::ContentGenerator {
199    let provider = Box::new(SharedProvider(Arc::clone(llm)));
200    crate::content::ContentGenerator::new(provider, business.clone())
201}
202
203// ── Helper: convert XApiError fields ────────────────────────────────
204
205impl WorkflowError {
206    /// Convenience: create from a toolkit error.
207    pub fn from_x_api(e: XApiError) -> Self {
208        Self::Toolkit(ToolkitError::XApi(e))
209    }
210}
211
212// ── Re-exports for convenience ──────────────────────────────────────
213
214pub use discover::{DiscoverInput, DiscoverOutput};
215pub use draft::DraftInput;
216pub use orchestrate::{CycleInput, CycleReport};
217pub use publish::PublishOutput;
218pub use queue::QueueInput;
219pub use thread_plan::{ThreadPlanInput, ThreadPlanOutput};