innate_core/refine.rs
1use crate::errors::Result;
2use crate::utils::{sanitize, SanitizeAction};
3use serde_json::Value;
4use std::sync::Arc;
5
6// ---------------------------------------------------------------------------
7// Sanitizer — injectable content sanitizer (§二·六)
8// ---------------------------------------------------------------------------
9
10/// Replaceable sanitizer. Inject via `KnowledgeBase::open_with`.
11/// Default: `DefaultSanitizer` (wraps built-in heuristics).
12pub trait Sanitizer: Send + Sync {
13 fn sanitize(&self, content: &str) -> (String, SanitizeAction);
14}
15
16/// Built-in sanitizer — wraps `utils::sanitize()`.
17pub struct DefaultSanitizer;
18
19impl Sanitizer for DefaultSanitizer {
20 fn sanitize(&self, content: &str) -> (String, SanitizeAction) {
21 sanitize(content)
22 }
23}
24
25/// No-op sanitizer — passes content through unchanged (use to disable sanitization).
26pub struct NoopSanitizer;
27
28impl Sanitizer for NoopSanitizer {
29 fn sanitize(&self, content: &str) -> (String, SanitizeAction) {
30 (content.to_string(), SanitizeAction::Allow)
31 }
32}
33
34// ---------------------------------------------------------------------------
35// Refiner — online trim / adapt
36// ---------------------------------------------------------------------------
37
38/// Online refiner — trims or adapts recalled chunks.
39pub trait Refiner: Send + Sync {
40 fn refine(&self, chunks: Vec<Value>, budget_tokens: Option<usize>) -> Result<Vec<Value>>;
41
42 /// Trim a block to fit within `budget_tokens` given the active `query`.
43 /// Returns `None` if trimming is not supported or the block cannot be trimmed while
44 /// preserving hard-dep closure integrity.
45 fn trim(&self, _block: &[Value], _query: &str, _budget_tokens: usize) -> Option<Vec<Value>> {
46 None
47 }
48}
49
50/// No-op refiner (default): returns chunks unchanged, trim is unsupported.
51pub struct NullRefiner;
52
53impl Refiner for NullRefiner {
54 fn refine(&self, chunks: Vec<Value>, _budget: Option<usize>) -> Result<Vec<Value>> {
55 Ok(chunks)
56 }
57}
58
59/// Reranker — part (d): an OPT-IN, OFFLINE reasoning pass over the top vector/lexical
60/// candidates. Returns the candidate ids in a new (more relevant) order. This is the
61/// "PageIndex-style reasoning over relevance" benefit applied as a re-ranker on a small
62/// shortlist, **never** in the latency-critical hook path (recall stays no-LLM unless a
63/// caller explicitly sets `rerank=true`). Errors must be non-fatal at the call site:
64/// recall falls back to the fused order so a flaky LLM never breaks retrieval.
65pub trait Reranker: Send + Sync {
66 /// Given the `query` and `candidates` (each a chunk JSON with at least `id`,
67 /// `content`, `trigger_desc`), return the ids in preferred order. Implementations
68 /// may return a subset/superset; the caller intersects with the known candidates.
69 fn rerank(&self, query: &str, candidates: &[Value]) -> Result<Vec<String>>;
70}
71
72/// No-op reranker (default): preserves the incoming fused order. Used whenever no LLM
73/// is configured or the caller did not opt in.
74pub struct NoopReranker;
75
76impl Reranker for NoopReranker {
77 fn rerank(&self, _query: &str, candidates: &[Value]) -> Result<Vec<String>> {
78 Ok(candidates
79 .iter()
80 .filter_map(|c| c.get("id").and_then(Value::as_str).map(str::to_string))
81 .collect())
82 }
83}
84
85/// Distiller — episodic logs → zero or more pending chunks per input log.
86pub trait Distiller: Send + Sync {
87 fn distill(&self, log_entries: &[Value]) -> Result<Vec<DistilledChunk>>;
88
89 fn distill_with_context(
90 &self,
91 primary: &Value,
92 _related_logs: &[Value],
93 ) -> Result<Vec<DistilledChunk>> {
94 self.distill(std::slice::from_ref(primary))
95 }
96
97 fn provenance(&self) -> DistillProvenance {
98 DistillProvenance::default()
99 }
100}
101
102#[derive(Debug, Default, Clone)]
103pub struct DistillProvenance {
104 pub provider: Option<String>,
105 pub model: Option<String>,
106 pub prompt_version: Option<String>,
107}
108
109#[derive(Debug, Clone, Default)]
110pub struct DistilledChunk {
111 pub content: String,
112 /// Short human-readable skill label (1-3 words) shown in the web UI's
113 /// `row-skill` slot. `None` falls back to `trigger_desc` at insert time.
114 pub skill_name: Option<String>,
115 pub trigger_desc: Option<String>,
116 pub anti_trigger_desc: Option<String>,
117 pub source_log_id: String,
118 pub nomination: Option<String>,
119 /// Per-chunk override for `ChunkRow.distill_provider`. Set by
120 /// [`ResilientDistiller`] to `"heuristic_fallback"` so operators can tell
121 /// which chunks were produced by the deterministic fallback rather than the
122 /// primary (LLM) distiller. `None` ⇒ use the batch-level `provenance()`.
123 pub provider_override: Option<String>,
124}
125
126/// Heuristic distiller: extracts chunks from log output / nomination fields.
127pub struct HeuristicDistiller;
128
129impl Distiller for HeuristicDistiller {
130 fn distill(&self, log_entries: &[Value]) -> Result<Vec<DistilledChunk>> {
131 let mut out = Vec::new();
132 for entry in log_entries {
133 let id = entry["id"].as_str().unwrap_or("").to_string();
134 let nomination = entry["nomination"].as_str();
135 let text = nomination.or_else(|| entry["output_summary"].as_str());
136 if let Some(t) = text {
137 let t = t.trim();
138 if !t.is_empty() {
139 let query = entry["query"].as_str().map(str::trim).unwrap_or("");
140 let outcome = entry["outcome"].as_str().unwrap_or("");
141
142 // Use query as trigger_desc for embedding — it caused this log and
143 // gives the chunk a useful retrieval signal without baking the query
144 // into the content (which creates retrieval-overfit chunks).
145 let trigger_desc = entry["query"]
146 .as_str()
147 .map(|q| q.trim().chars().take(80).collect::<String>())
148 .filter(|q| !q.is_empty())
149 .or_else(|| {
150 t.lines()
151 .map(str::trim)
152 .find(|l| l.len() > 10)
153 .map(|l| l.chars().take(80).collect())
154 });
155
156 // Keep content query-agnostic so the chunk is reusable across
157 // similar but not identical queries. Nominations are preserved as-is.
158 let content = if nomination.is_some() {
159 t.to_string()
160 } else if outcome == "fail" {
161 format!("Avoid: {t}")
162 } else {
163 t.to_string()
164 };
165
166 // For failed tasks, discourage re-triggering in the same query context.
167 let anti_trigger_desc = if outcome == "fail" && !query.is_empty() {
168 Some(query.chars().take(60).collect::<String>())
169 } else {
170 None
171 };
172
173 // Short skill label: first few words of the trigger phrase.
174 let skill_name = trigger_desc
175 .as_deref()
176 .map(|t| t.split_whitespace().take(3).collect::<Vec<_>>().join(" "))
177 .filter(|s| !s.is_empty());
178
179 out.push(DistilledChunk {
180 content,
181 skill_name,
182 trigger_desc,
183 anti_trigger_desc,
184 source_log_id: id,
185 nomination: entry["nomination"].as_str().map(str::to_string),
186 provider_override: None,
187 });
188 }
189 }
190 }
191 Ok(out)
192 }
193
194 fn provenance(&self) -> DistillProvenance {
195 DistillProvenance {
196 provider: Some("heuristic".to_string()),
197 model: None,
198 prompt_version: Some("3".to_string()),
199 }
200 }
201}
202
203/// Resilient distiller — wraps a `primary` distiller (e.g. the LLM `HttpDistiller`)
204/// with a deterministic `fallback` (e.g. `HeuristicDistiller`) so knowledge
205/// creation never depends on the primary staying available.
206///
207/// Policy (see `docs/Innate-设计-确定性兜底蒸馏-v1.md`): the primary gets the
208/// first `llm_attempt_budget` attempts at a log — measured by the log's
209/// `distill_attempts` counter, which only increments on a `failed` terminal
210/// state. While attempts remain, a primary error is **propagated** so the
211/// existing `failed → recover → retry` machinery gives the primary another
212/// chance (preserving quality). Once `distill_attempts >= llm_attempt_budget`,
213/// a primary error triggers the deterministic fallback instead of failing the
214/// log (guaranteeing eventual capture). A primary *success* — including a valid
215/// empty result (nothing worth distilling) — is always used as-is and never
216/// falls back.
217///
218/// This is **not** a second LLM: the fallback is a deterministic, local pass, so
219/// it stays within the single-LLM fault-tolerance contract.
220pub struct ResilientDistiller {
221 primary: Arc<dyn Distiller>,
222 fallback: Arc<dyn Distiller>,
223 llm_attempt_budget: i64,
224}
225
226impl ResilientDistiller {
227 pub fn new(
228 primary: Arc<dyn Distiller>,
229 fallback: Arc<dyn Distiller>,
230 llm_attempt_budget: i64,
231 ) -> Self {
232 Self {
233 primary,
234 fallback,
235 llm_attempt_budget,
236 }
237 }
238
239 fn budget_exhausted(&self, log: &Value) -> bool {
240 log.get("distill_attempts")
241 .and_then(Value::as_i64)
242 .unwrap_or(0)
243 >= self.llm_attempt_budget
244 }
245
246 fn tag_fallback(mut chunks: Vec<DistilledChunk>) -> Vec<DistilledChunk> {
247 for c in &mut chunks {
248 c.provider_override = Some("heuristic_fallback".to_string());
249 }
250 chunks
251 }
252}
253
254impl Distiller for ResilientDistiller {
255 fn distill(&self, log_entries: &[Value]) -> Result<Vec<DistilledChunk>> {
256 match self.primary.distill(log_entries) {
257 Ok(chunks) => Ok(chunks),
258 Err(e) => {
259 let exhausted = log_entries
260 .first()
261 .map(|l| self.budget_exhausted(l))
262 .unwrap_or(false);
263 if exhausted {
264 Ok(Self::tag_fallback(self.fallback.distill(log_entries)?))
265 } else {
266 Err(e)
267 }
268 }
269 }
270 }
271
272 fn distill_with_context(
273 &self,
274 primary: &Value,
275 related_logs: &[Value],
276 ) -> Result<Vec<DistilledChunk>> {
277 match self.primary.distill_with_context(primary, related_logs) {
278 Ok(chunks) => Ok(chunks),
279 Err(e) => {
280 if self.budget_exhausted(primary) {
281 Ok(Self::tag_fallback(
282 self.fallback.distill_with_context(primary, related_logs)?,
283 ))
284 } else {
285 Err(e)
286 }
287 }
288 }
289 }
290
291 fn provenance(&self) -> DistillProvenance {
292 self.primary.provenance()
293 }
294}