Skip to main content

tuitbot_core/content/
angles.rs

1//! Domain types for the Hook Miner angle mining system.
2//!
3//! Defines the taxonomy of content angles and evidence types,
4//! plus output containers for the mining pipeline.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9use crate::llm::TokenUsage;
10
11/// Minimum number of evidence items required to generate angles.
12/// Below this threshold, the pipeline returns a fallback state.
13pub const MIN_EVIDENCE_COUNT: usize = 2;
14
15/// Minimum average confidence across evidence items.
16/// Below this threshold, evidence is considered noise.
17pub const MIN_EVIDENCE_QUALITY: f64 = 0.3;
18
19/// Content angle archetype for social media posts.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AngleType {
23    /// Narrative-driven angle grounded in a personal or observed story.
24    Story,
25    /// Structured list angle (e.g., "3 things I learned…").
26    Listicle,
27    /// Bold opinion angle backed by evidence.
28    HotTake,
29}
30
31impl fmt::Display for AngleType {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::Story => write!(f, "story"),
35            Self::Listicle => write!(f, "listicle"),
36            Self::HotTake => write!(f, "hot_take"),
37        }
38    }
39}
40
41/// Type of evidence extracted from vault notes.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum EvidenceType {
45    /// Opposing claims found across different notes.
46    Contradiction,
47    /// A specific statistic, metric, or quantified claim.
48    DataPoint,
49    /// A non-obvious insight or surprising connection.
50    AhaMoment,
51}
52
53impl fmt::Display for EvidenceType {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            Self::Contradiction => write!(f, "contradiction"),
57            Self::DataPoint => write!(f, "data_point"),
58            Self::AhaMoment => write!(f, "aha_moment"),
59        }
60    }
61}
62
63/// A single piece of evidence extracted from a vault note.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct EvidenceItem {
66    /// The category of evidence.
67    pub evidence_type: EvidenceType,
68    /// Short excerpt or paraphrase (max 120 chars).
69    pub citation_text: String,
70    /// ID of the source content node.
71    pub source_node_id: i64,
72    /// Title of the source note.
73    pub source_note_title: String,
74    /// Optional heading hierarchy within the note.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub source_heading_path: Option<String>,
77    /// Extraction confidence (0.0–1.0).
78    pub confidence: f64,
79}
80
81/// A content angle synthesized from extracted evidence.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct MinedAngle {
84    /// The angle archetype.
85    pub angle_type: AngleType,
86    /// Opening tweet text (max 280 chars).
87    pub seed_text: String,
88    /// Character count of `seed_text`.
89    pub char_count: usize,
90    /// Evidence items supporting this angle.
91    pub evidence: Vec<EvidenceItem>,
92    /// Confidence heuristic: "high" or "medium".
93    pub confidence: String,
94    /// One-sentence explanation of why this angle works.
95    pub rationale: String,
96}
97
98/// Output from the angle mining pipeline.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct AngleMiningOutput {
101    /// Generated angles (0–3).
102    pub angles: Vec<MinedAngle>,
103    /// If set, explains why full angle generation was skipped.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub fallback_reason: Option<String>,
106    /// Average confidence across all extracted evidence.
107    pub evidence_quality_score: f64,
108    /// Token usage for the pipeline (extraction + generation).
109    pub usage: TokenUsage,
110    /// Model that produced the output.
111    pub model: String,
112    /// Provider name (e.g., "openai", "anthropic").
113    pub provider: String,
114}
115
116/// Assign confidence label based on evidence strength.
117///
118/// Returns "high" if there are 2+ evidence items with average
119/// confidence >= 0.6, otherwise "medium".
120pub fn assign_angle_confidence(evidence: &[EvidenceItem]) -> String {
121    if evidence.len() >= 2 {
122        let avg = evidence.iter().map(|e| e.confidence).sum::<f64>() / evidence.len() as f64;
123        if avg >= 0.6 {
124            return "high".to_string();
125        }
126    }
127    "medium".to_string()
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn sample_evidence(confidence: f64) -> EvidenceItem {
135        EvidenceItem {
136            evidence_type: EvidenceType::DataPoint,
137            citation_text: "45% growth in Q3".to_string(),
138            source_node_id: 1,
139            source_note_title: "Metrics".to_string(),
140            source_heading_path: None,
141            confidence,
142        }
143    }
144
145    #[test]
146    fn angle_type_serde_roundtrip() {
147        for variant in [AngleType::Story, AngleType::Listicle, AngleType::HotTake] {
148            let json = serde_json::to_string(&variant).unwrap();
149            let back: AngleType = serde_json::from_str(&json).unwrap();
150            assert_eq!(variant, back);
151        }
152    }
153
154    #[test]
155    fn evidence_type_serde_roundtrip() {
156        for variant in [
157            EvidenceType::Contradiction,
158            EvidenceType::DataPoint,
159            EvidenceType::AhaMoment,
160        ] {
161            let json = serde_json::to_string(&variant).unwrap();
162            let back: EvidenceType = serde_json::from_str(&json).unwrap();
163            assert_eq!(variant, back);
164        }
165    }
166
167    #[test]
168    fn angle_type_snake_case_serialization() {
169        assert_eq!(
170            serde_json::to_string(&AngleType::HotTake).unwrap(),
171            "\"hot_take\""
172        );
173        assert_eq!(
174            serde_json::to_string(&AngleType::Story).unwrap(),
175            "\"story\""
176        );
177        assert_eq!(
178            serde_json::to_string(&AngleType::Listicle).unwrap(),
179            "\"listicle\""
180        );
181    }
182
183    #[test]
184    fn evidence_type_snake_case_serialization() {
185        assert_eq!(
186            serde_json::to_string(&EvidenceType::AhaMoment).unwrap(),
187            "\"aha_moment\""
188        );
189        assert_eq!(
190            serde_json::to_string(&EvidenceType::DataPoint).unwrap(),
191            "\"data_point\""
192        );
193        assert_eq!(
194            serde_json::to_string(&EvidenceType::Contradiction).unwrap(),
195            "\"contradiction\""
196        );
197    }
198
199    #[test]
200    fn assign_confidence_high() {
201        let evidence = vec![sample_evidence(0.8), sample_evidence(0.7)];
202        assert_eq!(assign_angle_confidence(&evidence), "high");
203    }
204
205    #[test]
206    fn assign_confidence_medium_single_item() {
207        let evidence = vec![sample_evidence(0.9)];
208        assert_eq!(assign_angle_confidence(&evidence), "medium");
209    }
210
211    #[test]
212    fn assign_confidence_medium_low_avg() {
213        let evidence = vec![
214            sample_evidence(0.3),
215            sample_evidence(0.4),
216            sample_evidence(0.5),
217        ];
218        assert_eq!(assign_angle_confidence(&evidence), "medium");
219    }
220
221    #[test]
222    fn mining_output_serialization() {
223        let output = AngleMiningOutput {
224            angles: vec![MinedAngle {
225                angle_type: AngleType::Story,
226                seed_text: "A test seed".to_string(),
227                char_count: 11,
228                evidence: vec![sample_evidence(0.8)],
229                confidence: "high".to_string(),
230                rationale: "Strong narrative".to_string(),
231            }],
232            fallback_reason: None,
233            evidence_quality_score: 0.8,
234            usage: TokenUsage::default(),
235            model: "gpt-4".to_string(),
236            provider: "openai".to_string(),
237        };
238        let json = serde_json::to_string(&output).unwrap();
239        assert!(json.contains("\"angle_type\":\"story\""));
240        assert!(!json.contains("fallback_reason"));
241    }
242
243    #[test]
244    fn mining_output_fallback_serialization() {
245        let output = AngleMiningOutput {
246            angles: vec![],
247            fallback_reason: Some("insufficient_evidence".to_string()),
248            evidence_quality_score: 0.1,
249            usage: TokenUsage::default(),
250            model: "gpt-4".to_string(),
251            provider: "openai".to_string(),
252        };
253        let json = serde_json::to_string(&output).unwrap();
254        assert!(json.contains("\"fallback_reason\":\"insufficient_evidence\""));
255        assert!(json.contains("\"angles\":[]"));
256    }
257}