use anyhow::Result;
use serde::{Deserialize, Serialize};
use brainwires_core::{ChatOptions, Message, Provider};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedFact {
pub content: String,
pub category: FactCategory,
pub confidence: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactCategory {
UserPreference,
ProjectDetail,
BehavioralPattern,
ToolKnowledge,
TechnicalDecision,
}
pub struct FactExtractor;
impl FactExtractor {
pub async fn extract_facts(
summary: &str,
provider: &dyn Provider,
) -> Result<Vec<ExtractedFact>> {
let prompt = format!(
"You are a knowledge extractor. Given the following conversation summary, \
extract durable facts that would be useful to remember long-term.\n\n\
For each fact, provide:\n\
- \"content\": the fact text\n\
- \"category\": one of: user_preference, project_detail, behavioral_pattern, \
tool_knowledge, technical_decision\n\
- \"confidence\": a float 0.0-1.0\n\n\
Return ONLY a JSON array of objects. No markdown fences.\n\n\
Summary:\n{summary}"
);
let messages = vec![Message::user(&prompt)];
let options = ChatOptions {
temperature: Some(0.2),
max_tokens: Some(2048),
..Default::default()
};
let response = provider.chat(&messages, None, &options).await?;
let text = response.message.text_or_summary();
let facts: Vec<ExtractedFact> = serde_json::from_str(text.trim()).unwrap_or_else(|_| {
tracing::warn!("Failed to parse fact extraction JSON; creating fallback fact");
vec![ExtractedFact {
content: text.trim().to_string(),
category: FactCategory::ProjectDetail,
confidence: 0.5,
}]
});
Ok(facts)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fact_category_serde_roundtrip() {
let categories = vec![
FactCategory::UserPreference,
FactCategory::ProjectDetail,
FactCategory::BehavioralPattern,
FactCategory::ToolKnowledge,
FactCategory::TechnicalDecision,
];
for cat in categories {
let json = serde_json::to_string(&cat).unwrap();
let parsed: FactCategory = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cat);
}
}
#[test]
fn test_extracted_fact_serde_roundtrip() {
let fact = ExtractedFact {
content: "User prefers Rust over Python".to_string(),
category: FactCategory::UserPreference,
confidence: 0.9,
};
let json = serde_json::to_string(&fact).unwrap();
let parsed: ExtractedFact = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.content, fact.content);
assert_eq!(parsed.category, FactCategory::UserPreference);
assert!((parsed.confidence - 0.9).abs() < f32::EPSILON);
}
#[test]
fn test_fact_category_json_names() {
assert_eq!(
serde_json::to_string(&FactCategory::UserPreference).unwrap(),
"\"user_preference\""
);
assert_eq!(
serde_json::to_string(&FactCategory::TechnicalDecision).unwrap(),
"\"technical_decision\""
);
}
}