1use serde::{Deserialize, Serialize};
7use std::fmt;
8
9use crate::llm::TokenUsage;
10
11pub const MIN_EVIDENCE_COUNT: usize = 2;
14
15pub const MIN_EVIDENCE_QUALITY: f64 = 0.3;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AngleType {
23 Story,
25 Listicle,
27 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum EvidenceType {
45 Contradiction,
47 DataPoint,
49 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#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct EvidenceItem {
66 pub evidence_type: EvidenceType,
68 pub citation_text: String,
70 pub source_node_id: i64,
72 pub source_note_title: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub source_heading_path: Option<String>,
77 pub confidence: f64,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct MinedAngle {
84 pub angle_type: AngleType,
86 pub seed_text: String,
88 pub char_count: usize,
90 pub evidence: Vec<EvidenceItem>,
92 pub confidence: String,
94 pub rationale: String,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct AngleMiningOutput {
101 pub angles: Vec<MinedAngle>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub fallback_reason: Option<String>,
106 pub evidence_quality_score: f64,
108 pub usage: TokenUsage,
110 pub model: String,
112 pub provider: String,
114}
115
116pub 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}