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