Skip to main content

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}