use serde::{Deserialize, Serialize};
use std::fmt;
use crate::llm::TokenUsage;
pub const MIN_EVIDENCE_COUNT: usize = 2;
pub const MIN_EVIDENCE_QUALITY: f64 = 0.3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AngleType {
Story,
Listicle,
HotTake,
}
impl fmt::Display for AngleType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Story => write!(f, "story"),
Self::Listicle => write!(f, "listicle"),
Self::HotTake => write!(f, "hot_take"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceType {
Contradiction,
DataPoint,
AhaMoment,
}
impl fmt::Display for EvidenceType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Contradiction => write!(f, "contradiction"),
Self::DataPoint => write!(f, "data_point"),
Self::AhaMoment => write!(f, "aha_moment"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceItem {
pub evidence_type: EvidenceType,
pub citation_text: String,
pub source_node_id: i64,
pub source_note_title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_heading_path: Option<String>,
pub confidence: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MinedAngle {
pub angle_type: AngleType,
pub seed_text: String,
pub char_count: usize,
pub evidence: Vec<EvidenceItem>,
pub confidence: String,
pub rationale: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AngleMiningOutput {
pub angles: Vec<MinedAngle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_reason: Option<String>,
pub evidence_quality_score: f64,
pub usage: TokenUsage,
pub model: String,
pub provider: String,
}
pub fn assign_angle_confidence(evidence: &[EvidenceItem]) -> String {
if evidence.len() >= 2 {
let avg = evidence.iter().map(|e| e.confidence).sum::<f64>() / evidence.len() as f64;
if avg >= 0.6 {
return "high".to_string();
}
}
"medium".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_evidence(confidence: f64) -> EvidenceItem {
EvidenceItem {
evidence_type: EvidenceType::DataPoint,
citation_text: "45% growth in Q3".to_string(),
source_node_id: 1,
source_note_title: "Metrics".to_string(),
source_heading_path: None,
confidence,
}
}
#[test]
fn angle_type_serde_roundtrip() {
for variant in [AngleType::Story, AngleType::Listicle, AngleType::HotTake] {
let json = serde_json::to_string(&variant).unwrap();
let back: AngleType = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back);
}
}
#[test]
fn evidence_type_serde_roundtrip() {
for variant in [
EvidenceType::Contradiction,
EvidenceType::DataPoint,
EvidenceType::AhaMoment,
] {
let json = serde_json::to_string(&variant).unwrap();
let back: EvidenceType = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back);
}
}
#[test]
fn angle_type_snake_case_serialization() {
assert_eq!(
serde_json::to_string(&AngleType::HotTake).unwrap(),
"\"hot_take\""
);
assert_eq!(
serde_json::to_string(&AngleType::Story).unwrap(),
"\"story\""
);
assert_eq!(
serde_json::to_string(&AngleType::Listicle).unwrap(),
"\"listicle\""
);
}
#[test]
fn evidence_type_snake_case_serialization() {
assert_eq!(
serde_json::to_string(&EvidenceType::AhaMoment).unwrap(),
"\"aha_moment\""
);
assert_eq!(
serde_json::to_string(&EvidenceType::DataPoint).unwrap(),
"\"data_point\""
);
assert_eq!(
serde_json::to_string(&EvidenceType::Contradiction).unwrap(),
"\"contradiction\""
);
}
#[test]
fn assign_confidence_high() {
let evidence = vec![sample_evidence(0.8), sample_evidence(0.7)];
assert_eq!(assign_angle_confidence(&evidence), "high");
}
#[test]
fn assign_confidence_medium_single_item() {
let evidence = vec![sample_evidence(0.9)];
assert_eq!(assign_angle_confidence(&evidence), "medium");
}
#[test]
fn assign_confidence_medium_low_avg() {
let evidence = vec![
sample_evidence(0.3),
sample_evidence(0.4),
sample_evidence(0.5),
];
assert_eq!(assign_angle_confidence(&evidence), "medium");
}
#[test]
fn mining_output_serialization() {
let output = AngleMiningOutput {
angles: vec![MinedAngle {
angle_type: AngleType::Story,
seed_text: "A test seed".to_string(),
char_count: 11,
evidence: vec![sample_evidence(0.8)],
confidence: "high".to_string(),
rationale: "Strong narrative".to_string(),
}],
fallback_reason: None,
evidence_quality_score: 0.8,
usage: TokenUsage::default(),
model: "gpt-4".to_string(),
provider: "openai".to_string(),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"angle_type\":\"story\""));
assert!(!json.contains("fallback_reason"));
}
#[test]
fn mining_output_fallback_serialization() {
let output = AngleMiningOutput {
angles: vec![],
fallback_reason: Some("insufficient_evidence".to_string()),
evidence_quality_score: 0.1,
usage: TokenUsage::default(),
model: "gpt-4".to_string(),
provider: "openai".to_string(),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"fallback_reason\":\"insufficient_evidence\""));
assert!(json.contains("\"angles\":[]"));
}
}