1use crate::errors::Result;
2use crate::utils::{sanitize, SanitizeAction};
3use serde_json::Value;
4
5pub trait Sanitizer: Send + Sync {
12 fn sanitize(&self, content: &str) -> (String, SanitizeAction);
13}
14
15pub struct DefaultSanitizer;
17
18impl Sanitizer for DefaultSanitizer {
19 fn sanitize(&self, content: &str) -> (String, SanitizeAction) {
20 sanitize(content)
21 }
22}
23
24pub struct NoopSanitizer;
26
27impl Sanitizer for NoopSanitizer {
28 fn sanitize(&self, content: &str) -> (String, SanitizeAction) {
29 (content.to_string(), SanitizeAction::Allow)
30 }
31}
32
33pub trait Refiner: Send + Sync {
39 fn refine(&self, chunks: Vec<Value>, budget_tokens: Option<usize>) -> Result<Vec<Value>>;
40
41 fn trim(&self, _block: &[Value], _query: &str, _budget_tokens: usize) -> Option<Vec<Value>> {
45 None
46 }
47}
48
49pub struct NullRefiner;
51
52impl Refiner for NullRefiner {
53 fn refine(&self, chunks: Vec<Value>, _budget: Option<usize>) -> Result<Vec<Value>> {
54 Ok(chunks)
55 }
56}
57
58pub trait Distiller: Send + Sync {
60 fn distill(&self, log_entries: &[Value]) -> Result<Vec<DistilledChunk>>;
61
62 fn distill_with_context(
63 &self,
64 primary: &Value,
65 _related_logs: &[Value],
66 ) -> Result<Vec<DistilledChunk>> {
67 self.distill(std::slice::from_ref(primary))
68 }
69
70 fn provenance(&self) -> DistillProvenance {
71 DistillProvenance::default()
72 }
73}
74
75#[derive(Debug, Default, Clone)]
76pub struct DistillProvenance {
77 pub provider: Option<String>,
78 pub model: Option<String>,
79 pub prompt_version: Option<String>,
80}
81
82#[derive(Debug, Clone)]
83pub struct DistilledChunk {
84 pub content: String,
85 pub skill_name: Option<String>,
88 pub trigger_desc: Option<String>,
89 pub anti_trigger_desc: Option<String>,
90 pub source_log_id: String,
91 pub nomination: Option<String>,
92}
93
94pub struct HeuristicDistiller;
96
97impl Distiller for HeuristicDistiller {
98 fn distill(&self, log_entries: &[Value]) -> Result<Vec<DistilledChunk>> {
99 let mut out = Vec::new();
100 for entry in log_entries {
101 let id = entry["id"].as_str().unwrap_or("").to_string();
102 let nomination = entry["nomination"].as_str();
103 let text = nomination.or_else(|| entry["output_summary"].as_str());
104 if let Some(t) = text {
105 let t = t.trim();
106 if !t.is_empty() {
107 let query = entry["query"].as_str().map(str::trim).unwrap_or("");
108 let outcome = entry["outcome"].as_str().unwrap_or("");
109
110 let trigger_desc = entry["query"]
114 .as_str()
115 .map(|q| q.trim().chars().take(80).collect::<String>())
116 .filter(|q| !q.is_empty())
117 .or_else(|| {
118 t.lines()
119 .map(str::trim)
120 .find(|l| l.len() > 10)
121 .map(|l| l.chars().take(80).collect())
122 });
123
124 let content = if nomination.is_some() {
127 t.to_string()
128 } else if outcome == "fail" {
129 format!("Avoid: {t}")
130 } else {
131 t.to_string()
132 };
133
134 let anti_trigger_desc = if outcome == "fail" && !query.is_empty() {
136 Some(query.chars().take(60).collect::<String>())
137 } else {
138 None
139 };
140
141 let skill_name = trigger_desc
143 .as_deref()
144 .map(|t| t.split_whitespace().take(3).collect::<Vec<_>>().join(" "))
145 .filter(|s| !s.is_empty());
146
147 out.push(DistilledChunk {
148 content,
149 skill_name,
150 trigger_desc,
151 anti_trigger_desc,
152 source_log_id: id,
153 nomination: entry["nomination"].as_str().map(str::to_string),
154 });
155 }
156 }
157 }
158 Ok(out)
159 }
160
161 fn provenance(&self) -> DistillProvenance {
162 DistillProvenance {
163 provider: Some("heuristic".to_string()),
164 model: None,
165 prompt_version: Some("3".to_string()),
166 }
167 }
168}