diff --git a/src/openhuman/memory/tree/score/extract/llm.rs b/src/openhuman/memory/tree/score/extract/llm.rs new file mode 100644 index 000000000..33be3887b --- /dev/null +++ b/src/openhuman/memory/tree/score/extract/llm.rs @@ -0,0 +1,684 @@ +//! LLM-based entity + importance extractor. +//! +//! Talks to an Ollama-compatible chat-completions HTTP endpoint, asks for +//! NER + an importance rating in one structured-JSON response, and parses +//! the result into [`ExtractedEntities`]. +//! +//! ## Why this lives here +//! +//! Phase 2 ships a regex extractor only. Semantic NER (Person/Org/Loc/…) +//! requires a model. We use a small local LLM (Ollama default: +//! `qwen2.5:0.5b`) for two reasons: +//! +//! 1. **Reuse** — openhuman already runs Ollama for embeddings; no new +//! deps, no native ONNX runtime to ship. +//! 2. **Free importance signal** — extending the NER prompt with one extra +//! JSON field (`importance`) gives us an LLM-rated quality score per +//! chunk for the cost of one prompt instead of two LLM calls. +//! +//! ## Span recovery +//! +//! LLMs are unreliable about character offsets. We re-find each returned +//! entity surface in the source text via `text.find(...)` to recover spans. +//! Entities whose surface form can't be located in the source text are +//! dropped with a warn log (this catches model hallucinations). +//! +//! ## Soft fallback +//! +//! If the HTTP call fails (Ollama not running, model not pulled, timeout), +//! we log a warn and return [`ExtractedEntities::default()`]. The +//! [`super::CompositeExtractor`] already tolerates errors from individual +//! extractors; ingestion never blocks on LLM availability. + +use std::time::Duration; + +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +use super::types::{EntityKind, ExtractedEntities, ExtractedEntity}; +use super::EntityExtractor; + +// ── Configuration ──────────────────────────────────────────────────────── + +/// Configuration for [`LlmEntityExtractor`]. +#[derive(Clone, Debug)] +pub struct LlmExtractorConfig { + /// Base URL of the Ollama-compatible endpoint (e.g. `http://localhost:11434`). + /// Do NOT include `/api/chat` — the extractor appends it. + pub endpoint: String, + /// Model identifier as known to the endpoint (e.g. `qwen2.5:0.5b`). + pub model: String, + /// Per-request timeout. The default is generous because the first + /// request after a model swap may need to load weights. + pub timeout: Duration, + /// Which entity kinds the LLM is allowed to emit. Anything outside this + /// set is mapped to [`EntityKind::Misc`] or dropped depending on + /// `strict_kinds`. + pub allowed_kinds: Vec, + /// If true, drop entities whose declared kind isn't in `allowed_kinds` + /// instead of falling back to [`EntityKind::Misc`]. + pub strict_kinds: bool, +} + +impl Default for LlmExtractorConfig { + fn default() -> Self { + Self { + endpoint: "http://localhost:11434".to_string(), + model: "qwen2.5:0.5b".to_string(), + timeout: Duration::from_secs(15), + allowed_kinds: vec![ + EntityKind::Person, + EntityKind::Organization, + EntityKind::Location, + EntityKind::Event, + EntityKind::Product, + ], + strict_kinds: false, + } + } +} + +// ── Extractor ──────────────────────────────────────────────────────────── + +/// LLM-backed entity + importance extractor. +pub struct LlmEntityExtractor { + cfg: LlmExtractorConfig, + http: Client, +} + +impl LlmEntityExtractor { + pub fn new(cfg: LlmExtractorConfig) -> anyhow::Result { + let http = Client::builder() + .timeout(cfg.timeout) + .build() + .map_err(anyhow::Error::from)?; + Ok(Self { cfg, http }) + } + + /// Build the Ollama `/api/chat` request body. + fn build_request(&self, text: &str) -> OllamaChatRequest { + OllamaChatRequest { + model: self.cfg.model.clone(), + messages: vec![ + OllamaMessage { + role: "system".to_string(), + content: SYSTEM_PROMPT.to_string(), + }, + OllamaMessage { + role: "user".to_string(), + content: format!("Text:\n{text}\n\nReturn JSON only."), + }, + ], + format: "json".to_string(), + stream: false, + options: OllamaOptions { temperature: 0.0 }, + } + } +} + +#[async_trait] +impl EntityExtractor for LlmEntityExtractor { + fn name(&self) -> &'static str { + "llm-ollama" + } + + async fn extract(&self, text: &str) -> anyhow::Result { + // Soft-fallback contract: every failure path (transport, HTTP status, + // JSON parse) is logged as a warn and returns an empty + // `ExtractedEntities` rather than `Err`. This makes the extractor + // safe to call from any context, not just `score_chunk` (which + // separately catches errors from its own extractor chain). A caller + // distinguishes "LLM had nothing to say" from "LLM ran and returned + // zero entities" by inspecting `llm_importance` — `None` means the + // call didn't complete successfully. + Ok(self.extract_or_empty(text).await) + } +} + +impl LlmEntityExtractor { + /// Internal: wraps the actual HTTP call and returns `ExtractedEntities` + /// for every failure mode via soft-fallback. Split out of `extract` so + /// the error branches can share logging without `?`-propagation. + async fn extract_or_empty(&self, text: &str) -> ExtractedEntities { + let url = format!("{}/api/chat", self.cfg.endpoint.trim_end_matches('/')); + let body = self.build_request(text); + log::debug!( + "[memory_tree::extract::llm] POST {url} model={} text_chars={}", + self.cfg.model, + text.chars().count() + ); + + let resp = match self.http.post(&url).json(&body).send().await { + Ok(r) => r, + Err(e) => { + log::warn!( + "[memory_tree::extract::llm] transport failure to {url}: {e} — \ + returning empty extraction" + ); + return ExtractedEntities::default(); + } + }; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + log::warn!( + "[memory_tree::extract::llm] ollama non-success status {status}: {} — \ + returning empty extraction", + truncate_for_log(&body, 200) + ); + return ExtractedEntities::default(); + } + + let envelope: OllamaChatResponse = match resp.json().await { + Ok(v) => v, + Err(e) => { + log::warn!( + "[memory_tree::extract::llm] response body not Ollama-shaped JSON: {e} — \ + returning empty extraction" + ); + return ExtractedEntities::default(); + } + }; + log::debug!( + "[memory_tree::extract::llm] response chars={}", + envelope.message.content.len() + ); + + let parsed: LlmExtractionOutput = match serde_json::from_str(&envelope.message.content) { + Ok(v) => v, + Err(e) => { + log::warn!( + "[memory_tree::extract::llm] LLM returned non-JSON or wrong-shape \ + response: {e}; content was: {} — returning empty extraction", + truncate_for_log(&envelope.message.content, 400) + ); + return ExtractedEntities::default(); + } + }; + + parsed.into_extracted_entities(text, &self.cfg) + } +} + +// ── Prompt ─────────────────────────────────────────────────────────────── + +const SYSTEM_PROMPT: &str = "\ +You are a named-entity extractor and importance rater. Return JSON only — \ +no prose, no markdown, no commentary. Do not summarize. Extract every named \ +entity mention you find, including duplicates, and rate the chunk's overall \ +importance as a float in [0.0, 1.0]. + +Schema: +{ + \"entities\": [ + { \"kind\": \"person|organization|location|event|product\", + \"text\": \"\" } + ], + \"importance\": 0.0, + \"importance_reason\": \"\" +} + +Importance guide: + 0.9+ actionable decisions, key information, explicit commitments + 0.6+ substantive discussion, factual content, named entities + 0.3+ ambient context, low-density prose + <0.3 reactions, acknowledgments, bots, trivial exchanges +"; + +// ── Wire types (Ollama API) ────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct OllamaChatRequest { + model: String, + messages: Vec, + format: String, + stream: bool, + options: OllamaOptions, +} + +#[derive(Debug, Serialize)] +struct OllamaMessage { + role: String, + content: String, +} + +#[derive(Debug, Serialize)] +struct OllamaOptions { + temperature: f32, +} + +#[derive(Debug, Deserialize)] +struct OllamaChatResponse { + message: OllamaResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct OllamaResponseMessage { + content: String, +} + +// ── LLM JSON output ────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct LlmExtractionOutput { + #[serde(default)] + entities: Vec, + #[serde(default)] + importance: Option, + #[serde(default)] + importance_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct LlmEntity { + kind: String, + text: String, +} + +impl LlmExtractionOutput { + fn into_extracted_entities( + self, + source_text: &str, + cfg: &LlmExtractorConfig, + ) -> ExtractedEntities { + let mut entities = Vec::with_capacity(self.entities.len()); + + // Per-surface search cursor (char offset). When the LLM returns the + // same surface text twice (deliberately — the prompt asks for + // duplicates), we resume searching AFTER the previous occurrence so + // each emitted entity points at a distinct span. Byte indices are + // tracked separately from char indices because `str::find` returns + // byte offsets while the rest of the pipeline uses char spans. + use std::collections::HashMap; + let mut cursors: HashMap = HashMap::new(); + + for raw in self.entities { + let surface = raw.text.trim(); + if surface.is_empty() { + continue; + } + + let kind = match parse_kind(&raw.kind) { + Some(k) => { + if cfg.allowed_kinds.contains(&k) { + k + } else if cfg.strict_kinds { + log::debug!( + "[memory_tree::extract::llm] dropping entity with disallowed kind: {}", + raw.kind + ); + continue; + } else { + EntityKind::Misc + } + } + None => { + if cfg.strict_kinds { + log::debug!( + "[memory_tree::extract::llm] dropping entity with unknown kind: {}", + raw.kind + ); + continue; + } + EntityKind::Misc + } + }; + + // Recover spans by string search, advancing the cursor for this + // surface so repeated mentions get distinct spans. If the model + // hallucinated a surface (or we've exhausted all of its + // occurrences), drop the entity. + let (byte_from, char_from) = cursors.get(surface).copied().unwrap_or((0, 0)); + let (span_start, span_end, byte_after) = + match find_char_span_from(source_text, surface, byte_from, char_from) { + Some(s) => s, + None => { + log::debug!( + "[memory_tree::extract::llm] dropping hallucinated or exhausted \ + entity (not found beyond cursor): {surface:?}" + ); + continue; + } + }; + cursors.insert(surface.to_string(), (byte_after, span_end)); + + entities.push(ExtractedEntity { + kind, + text: surface.to_string(), + span_start, + span_end, + score: 0.85, // LLM-derived; lower confidence than regex + }); + } + + let llm_importance = self.importance.map(|v| v.clamp(0.0, 1.0)); + + ExtractedEntities { + entities, + topics: Vec::new(), + llm_importance, + llm_importance_reason: self.importance_reason, + } + } +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +fn parse_kind(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "person" | "people" => Some(EntityKind::Person), + "organization" | "organisation" | "org" => Some(EntityKind::Organization), + "location" | "place" | "loc" => Some(EntityKind::Location), + "event" => Some(EntityKind::Event), + "product" => Some(EntityKind::Product), + "misc" | "miscellaneous" | "other" => Some(EntityKind::Misc), + _ => None, + } +} + +/// Find `needle` in `haystack` and return its `(char_start, char_end)`. +/// +/// Uses byte-level `find` then translates to char offsets so spans align +/// with the rest of the extractor pipeline (which is char-based). +fn find_char_span(haystack: &str, needle: &str) -> Option<(u32, u32)> { + find_char_span_from(haystack, needle, 0, 0).map(|(s, e, _)| (s, e)) +} + +/// Find `needle` in `haystack` starting from `byte_from` and return +/// `(char_start, char_end, byte_after_needle)`. +/// +/// The byte-offset return is so the caller can chain successive searches +/// without re-walking the prefix every time: pass the returned +/// `byte_after_needle` as the next call's `byte_from`. +/// +/// `char_from` must correspond to `byte_from` in the same `haystack` — +/// i.e. `haystack[..byte_from].chars().count() == char_from as usize`. +/// The caller maintains this invariant (cheap: it's the return from the +/// previous call). +fn find_char_span_from( + haystack: &str, + needle: &str, + byte_from: usize, + char_from: u32, +) -> Option<(u32, u32, usize)> { + if needle.is_empty() || byte_from > haystack.len() { + return None; + } + // Guard against `byte_from` landing inside a multi-byte UTF-8 sequence. + if !haystack.is_char_boundary(byte_from) { + return None; + } + let rel = haystack[byte_from..].find(needle)?; + let byte_start = byte_from + rel; + let byte_end = byte_start + needle.len(); + // Walk forward from the previous char position to build the new char + // offset — avoids re-walking the full prefix. + let char_start = char_from + haystack[byte_from..byte_start].chars().count() as u32; + let char_end = char_start + needle.chars().count() as u32; + Some((char_start, char_end, byte_end)) +} + +fn truncate_for_log(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + return s.to_string(); + } + let truncated: String = s.chars().take(max_chars).collect(); + format!("{truncated}…") +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_kind_normalisation() { + assert_eq!(parse_kind("Person"), Some(EntityKind::Person)); + assert_eq!(parse_kind("organisation"), Some(EntityKind::Organization)); + assert_eq!(parse_kind(" PRODUCT "), Some(EntityKind::Product)); + assert!(parse_kind("Spaceship").is_none()); + } + + #[test] + fn find_char_span_handles_unicode() { + let text = "中 Alice met Bob"; + let span = find_char_span(text, "Alice").unwrap(); + assert_eq!(span, (2, 7)); + } + + #[test] + fn find_char_span_returns_none_for_missing() { + assert!(find_char_span("hello world", "absent").is_none()); + } + + #[test] + fn find_char_span_from_advances_past_prior_match() { + let text = "Alice met Bob then Alice left"; + let (s1, e1, byte_after) = find_char_span_from(text, "Alice", 0, 0).unwrap(); + assert_eq!((s1, e1), (0, 5)); + // Resuming from the cursor must find the second Alice. + let (s2, e2, _) = find_char_span_from(text, "Alice", byte_after, e1).unwrap(); + assert_eq!((s2, e2), (19, 24)); + } + + #[test] + fn find_char_span_from_returns_none_after_exhaustion() { + let text = "Alice met Bob"; + let (_, _, byte_after) = find_char_span_from(text, "Alice", 0, 0).unwrap(); + // No second Alice → None. + assert!(find_char_span_from(text, "Alice", byte_after, 5).is_none()); + } + + #[test] + fn find_char_span_from_preserves_utf8() { + // Two "中" characters (3 bytes each in UTF-8); "Alice" between. + let text = "中 Alice 中 Alice"; + let (s1, e1, byte_after) = find_char_span_from(text, "Alice", 0, 0).unwrap(); + assert_eq!((s1, e1), (2, 7)); + let (s2, e2, _) = find_char_span_from(text, "Alice", byte_after, e1).unwrap(); + // First "中 Alice " = 2 + 5 + 1 + 1 + 1 chars; second Alice starts at char 10. + assert_eq!((s2, e2), (10, 15)); + } + + #[test] + fn find_char_span_from_rejects_non_char_boundary() { + // "中" is 3 bytes; offsets 1 and 2 are mid-codepoint. + let text = "中Alice"; + assert!(find_char_span_from(text, "Alice", 1, 0).is_none()); + } + + #[test] + fn into_extracted_entities_gives_distinct_spans_to_duplicate_mentions() { + // Two "Alice" mentions in source → two distinct ExtractedEntity rows + // with non-overlapping spans. Previously both got (0, 5). + let out = LlmExtractionOutput { + entities: vec![ + LlmEntity { + kind: "person".into(), + text: "Alice".into(), + }, + LlmEntity { + kind: "person".into(), + text: "Alice".into(), + }, + ], + importance: None, + importance_reason: None, + }; + let cfg = LlmExtractorConfig::default(); + let e = out.into_extracted_entities("Alice met Bob then Alice left", &cfg); + assert_eq!(e.entities.len(), 2); + assert_eq!((e.entities[0].span_start, e.entities[0].span_end), (0, 5)); + assert_eq!((e.entities[1].span_start, e.entities[1].span_end), (19, 24)); + } + + #[test] + fn into_extracted_entities_drops_extra_duplicate_when_source_only_has_one() { + // Three "Alice" mentions returned by LLM, only one in source → keep + // one, drop the rest as exhausted-duplicate. + let out = LlmExtractionOutput { + entities: vec![ + LlmEntity { + kind: "person".into(), + text: "Alice".into(), + }, + LlmEntity { + kind: "person".into(), + text: "Alice".into(), + }, + LlmEntity { + kind: "person".into(), + text: "Alice".into(), + }, + ], + importance: None, + importance_reason: None, + }; + let cfg = LlmExtractorConfig::default(); + let e = out.into_extracted_entities("Alice met Bob", &cfg); + assert_eq!(e.entities.len(), 1); + } + + #[tokio::test] + async fn extract_soft_fallback_on_unreachable_endpoint() { + // Point at an unreachable port so the transport fails. extract() + // must NOT return Err — it must return an empty ExtractedEntities + // with a warn log. + let cfg = LlmExtractorConfig { + endpoint: "http://127.0.0.1:1".to_string(), + timeout: std::time::Duration::from_millis(100), + ..LlmExtractorConfig::default() + }; + let ex = LlmEntityExtractor::new(cfg).unwrap(); + let out = ex.extract("some text").await.unwrap(); + assert!(out.entities.is_empty()); + assert!(out.topics.is_empty()); + assert!(out.llm_importance.is_none()); + } + + #[test] + fn into_extracted_entities_drops_hallucinations() { + let out = LlmExtractionOutput { + entities: vec![ + LlmEntity { + kind: "person".into(), + text: "Alice".into(), + }, + LlmEntity { + kind: "person".into(), + text: "ImaginaryPerson".into(), + }, + ], + importance: Some(0.7), + importance_reason: Some("substantive".into()), + }; + let cfg = LlmExtractorConfig::default(); + let e = out.into_extracted_entities("Alice met Bob today.", &cfg); + // Hallucinated "ImaginaryPerson" dropped; "Alice" kept. + assert_eq!(e.entities.len(), 1); + assert_eq!(e.entities[0].text, "Alice"); + assert_eq!(e.llm_importance, Some(0.7)); + assert_eq!(e.llm_importance_reason.as_deref(), Some("substantive")); + } + + #[test] + fn into_extracted_entities_clamps_importance() { + let out = LlmExtractionOutput { + entities: vec![], + importance: Some(1.5), + importance_reason: None, + }; + let cfg = LlmExtractorConfig::default(); + let e = out.into_extracted_entities("text", &cfg); + assert_eq!(e.llm_importance, Some(1.0)); + } + + #[test] + fn into_extracted_entities_strict_drops_unknown_kinds() { + let out = LlmExtractionOutput { + entities: vec![LlmEntity { + kind: "spaceship".into(), + text: "Enterprise".into(), + }], + importance: None, + importance_reason: None, + }; + let cfg = LlmExtractorConfig { + strict_kinds: true, + ..LlmExtractorConfig::default() + }; + let e = out.into_extracted_entities("Enterprise launched.", &cfg); + assert!(e.entities.is_empty()); + } + + #[test] + fn into_extracted_entities_lenient_falls_back_to_misc() { + let out = LlmExtractionOutput { + entities: vec![LlmEntity { + kind: "spaceship".into(), + text: "Enterprise".into(), + }], + importance: None, + importance_reason: None, + }; + let cfg = LlmExtractorConfig::default(); // strict_kinds = false + let e = out.into_extracted_entities("Enterprise launched.", &cfg); + assert_eq!(e.entities.len(), 1); + assert_eq!(e.entities[0].kind, EntityKind::Misc); + } + + #[test] + fn into_extracted_entities_disallowed_known_kind_falls_back_to_misc() { + // "person" is a known kind but might be excluded by allowed_kinds. + let out = LlmExtractionOutput { + entities: vec![LlmEntity { + kind: "person".into(), + text: "Alice".into(), + }], + importance: None, + importance_reason: None, + }; + let cfg = LlmExtractorConfig { + allowed_kinds: vec![EntityKind::Organization], // Person not allowed + strict_kinds: false, + ..LlmExtractorConfig::default() + }; + let e = out.into_extracted_entities("Alice met Bob.", &cfg); + assert_eq!(e.entities.len(), 1); + assert_eq!(e.entities[0].kind, EntityKind::Misc); + } + + #[test] + fn build_request_uses_configured_model() { + let cfg = LlmExtractorConfig { + model: "test-model".into(), + ..LlmExtractorConfig::default() + }; + let ex = LlmEntityExtractor::new(cfg).unwrap(); + let req = ex.build_request("hello"); + assert_eq!(req.model, "test-model"); + assert_eq!(req.format, "json"); + assert!(!req.stream); + assert_eq!(req.options.temperature, 0.0); + assert_eq!(req.messages.len(), 2); + assert_eq!(req.messages[0].role, "system"); + assert_eq!(req.messages[1].role, "user"); + assert!(req.messages[1].content.contains("hello")); + } + + #[test] + fn truncate_for_log_short_input_unchanged() { + assert_eq!(truncate_for_log("hi", 10), "hi"); + } + + #[test] + fn truncate_for_log_long_input_appends_ellipsis() { + let long = "x".repeat(500); + let out = truncate_for_log(&long, 10); + assert_eq!(out.chars().count(), 11); // 10 + "…" + assert!(out.ends_with('…')); + } +} diff --git a/src/openhuman/memory/tree/score/extract/mod.rs b/src/openhuman/memory/tree/score/extract/mod.rs index 802d73a3c..f79a97b19 100644 --- a/src/openhuman/memory/tree/score/extract/mod.rs +++ b/src/openhuman/memory/tree/score/extract/mod.rs @@ -6,8 +6,10 @@ //! NER (GLiNER / LLM) plugs in later without changing any call sites. mod extractor; +pub mod llm; pub mod regex; pub mod types; pub use extractor::{CompositeExtractor, EntityExtractor, RegexEntityExtractor}; +pub use llm::{LlmEntityExtractor, LlmExtractorConfig}; pub use types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic}; diff --git a/src/openhuman/memory/tree/score/extract/regex.rs b/src/openhuman/memory/tree/score/extract/regex.rs index 64308a49a..6abc3e122 100644 --- a/src/openhuman/memory/tree/score/extract/regex.rs +++ b/src/openhuman/memory/tree/score/extract/regex.rs @@ -67,7 +67,13 @@ pub fn extract(text: &str) -> ExtractedEntities { } } - ExtractedEntities { entities, topics } + ExtractedEntities { + entities, + topics, + // Regex extractor never produces an LLM importance signal. + llm_importance: None, + llm_importance_reason: None, + } } fn to_entity(text: &str, start: usize, end: usize, kind: EntityKind) -> ExtractedEntity { diff --git a/src/openhuman/memory/tree/score/extract/types.rs b/src/openhuman/memory/tree/score/extract/types.rs index 883aed584..ca737c50d 100644 --- a/src/openhuman/memory/tree/score/extract/types.rs +++ b/src/openhuman/memory/tree/score/extract/types.rs @@ -95,10 +95,25 @@ pub struct ExtractedTopic { } /// Aggregate output of one or more extractors on a single chunk. +/// +/// `llm_importance` and `llm_importance_reason` are populated by extractors +/// that piggyback an importance rating on their NER call (see +/// [`super::llm::LlmEntityExtractor`]). Cheap regex extractors leave them +/// `None`; downstream signal compute treats `None` as "no LLM signal" and +/// the weighted combine zeroes that contribution out so behaviour matches +/// pre-LLM Phase 2 exactly when LLM is disabled. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ExtractedEntities { pub entities: Vec, pub topics: Vec, + /// Optional LLM-rated importance in `[0.0, 1.0]` for this chunk. + /// `None` means no LLM signal is available. + #[serde(default)] + pub llm_importance: Option, + /// One-line audit trail from the LLM explaining the importance rating. + /// Used purely for diagnostics; never feeds back into scoring. + #[serde(default)] + pub llm_importance_reason: Option, } impl ExtractedEntities { @@ -118,8 +133,14 @@ impl ExtractedEntities { /// Merge another extractor's output into this one. /// - /// Deduplicates by `(kind, normalised_text, span_start)` so the same - /// match from two extractors doesn't get double-counted. + /// Deduplicates entities by `(kind, normalised_text, span_start)` and + /// topics by `label` so the same match from two extractors doesn't get + /// double-counted. + /// + /// LLM importance signals merge by **maximum** — if either side rated + /// the chunk as important, the merged result keeps that higher rating. + /// The reason from whichever side won the max wins; if they tied or + /// both are absent, the non-empty one (if any) is kept. pub fn merge(&mut self, other: ExtractedEntities) { use std::collections::BTreeSet; let mut seen: BTreeSet<(EntityKind, String, u32)> = self @@ -140,6 +161,24 @@ impl ExtractedEntities { self.topics.push(t); } } + + // Merge LLM importance: max wins, reason follows the max. + match (self.llm_importance, other.llm_importance) { + (Some(a), Some(b)) if b > a => { + self.llm_importance = Some(b); + self.llm_importance_reason = other.llm_importance_reason; + } + (None, Some(b)) => { + self.llm_importance = Some(b); + self.llm_importance_reason = other.llm_importance_reason; + } + // self.a >= other.b OR other has nothing — keep self + _ => { + if self.llm_importance_reason.is_none() { + self.llm_importance_reason = other.llm_importance_reason; + } + } + } } } @@ -194,6 +233,8 @@ mod tests { }, ], topics: vec![], + llm_importance: None, + llm_importance_reason: None, }; assert_eq!(e.unique_entity_count(), 1); } @@ -218,6 +259,8 @@ mod tests { }, ], topics: vec![], + llm_importance: None, + llm_importance_reason: None, }; assert_eq!(e.unique_entity_count(), 2); } @@ -233,6 +276,8 @@ mod tests { score: 1.0, }], topics: vec![], + llm_importance: None, + llm_importance_reason: None, }; let b = ExtractedEntities { entities: vec![ @@ -252,6 +297,8 @@ mod tests { }, // different span — keep ], topics: vec![], + llm_importance: None, + llm_importance_reason: None, }; a.merge(b); assert_eq!(a.entities.len(), 2); diff --git a/src/openhuman/memory/tree/score/mod.rs b/src/openhuman/memory/tree/score/mod.rs index 6ec12565c..f56d0b1e6 100644 --- a/src/openhuman/memory/tree/score/mod.rs +++ b/src/openhuman/memory/tree/score/mod.rs @@ -27,6 +27,18 @@ use crate::openhuman::memory::tree::types::{approx_token_count, Chunk, SourceKin /// are tombstoned and never reach the chunk store. pub const DEFAULT_DROP_THRESHOLD: f32 = 0.3; +/// If the deterministic (cheap-signals-only) total is at or above this, +/// the chunk is admitted without consulting the LLM extractor. +/// +/// Tuned to leave a generous "borderline" band where the LLM signal is +/// most informative while skipping LLM cost on obviously substantive +/// content. +pub const DEFAULT_DEFINITE_KEEP: f32 = 0.85; + +/// If the deterministic total is at or below this, the chunk is dropped +/// without consulting the LLM extractor. Catches obvious noise cheaply. +pub const DEFAULT_DEFINITE_DROP: f32 = 0.15; + /// Whole outcome of [`score_chunk`]. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ScoreResult { @@ -44,10 +56,25 @@ pub struct ScoreResult { /// Held as a struct (vs config struct fields) so callers can override per-run /// without mutating global config — useful for tests and explicit threshold /// tuning. +/// +/// The `extractor` field always runs (typically a regex-based composite +/// for cheap mechanical entities). `llm_extractor` is consulted **only +/// when the cheap-signals total falls in the band** +/// `(definite_drop_threshold, definite_keep_threshold)` — chunks that are +/// obviously trash or obviously substantive don't pay the LLM cost. pub struct ScoringConfig { pub extractor: Arc, pub weights: SignalWeights, pub drop_threshold: f32, + /// Optional second-pass extractor whose output is **merged** into the + /// regex output before the final combine. Designed for LLM-based NER + + /// importance signal (see [`extract::LlmEntityExtractor`]). `None` + /// means LLM augmentation is disabled. + pub llm_extractor: Option>, + /// Cheap-signals total ≥ this → admit without consulting LLM. + pub definite_keep_threshold: f32, + /// Cheap-signals total ≤ this → drop without consulting LLM. + pub definite_drop_threshold: f32, } impl ScoringConfig { @@ -57,6 +84,23 @@ impl ScoringConfig { extractor: Arc::new(extract::CompositeExtractor::regex_only()), weights: SignalWeights::default(), drop_threshold: DEFAULT_DROP_THRESHOLD, + llm_extractor: None, + definite_keep_threshold: DEFAULT_DEFINITE_KEEP, + definite_drop_threshold: DEFAULT_DEFINITE_DROP, + } + } + + /// Convenience constructor: regex always + LLM extractor on borderline + /// chunks. The `llm_importance` weight is enabled in [`SignalWeights`] + /// so the LLM signal actually influences the final total. + pub fn with_llm_extractor(llm: Arc) -> Self { + Self { + extractor: Arc::new(extract::CompositeExtractor::regex_only()), + weights: SignalWeights::with_llm_enabled(), + drop_threshold: DEFAULT_DROP_THRESHOLD, + llm_extractor: Some(llm), + definite_keep_threshold: DEFAULT_DEFINITE_KEEP, + definite_drop_threshold: DEFAULT_DEFINITE_DROP, } } } @@ -65,6 +109,16 @@ impl ScoringConfig { /// /// Pure function — does not touch the store. Callers decide what to persist /// based on [`ScoreResult::kept`]. +/// +/// Pipeline: +/// 1. Run the always-on extractor (typically regex). +/// 2. Compute cheap signals; combine **excluding** `llm_importance` weight. +/// 3. Short-circuit: +/// - If cheap total ≥ `definite_keep_threshold`: admit without LLM. +/// - If cheap total ≤ `definite_drop_threshold`: drop without LLM. +/// - Else: borderline — run the LLM extractor (if configured), merge +/// its output, recompute signals, recombine with full weights. +/// 4. Apply final admission gate against `drop_threshold`. pub async fn score_chunk(chunk: &Chunk, cfg: &ScoringConfig) -> Result { log::debug!( "[memory_tree::score] score_chunk chunk_id={} tokens={}", @@ -75,21 +129,86 @@ pub async fn score_chunk(chunk: &Chunk, cfg: &ScoringConfig) -> Result cfg.definite_drop_threshold && cheap_total < cfg.definite_keep_threshold; + let llm_consulted = if in_band { + if let Some(llm) = cfg.llm_extractor.as_ref() { + log::debug!( + "[memory_tree::score] borderline chunk_id={} cheap_total={:.3} — consulting LLM", + chunk.id, + cheap_total + ); + match llm.extract(&scoring_content).await { + Ok(more) => { + extracted.merge(more); + // Recompute signals so llm_importance flows in. + signals = self::signals::compute( + &chunk.metadata, + &scoring_content, + scoring_token_count, + &extracted, + ); + true + } + Err(e) => { + log::warn!( + "[memory_tree::score] LLM extractor `{}` failed: {e} — \ + falling back to cheap signals only", + llm.name() + ); + false + } + } + } else { + false + } + } else { + log::debug!( + "[memory_tree::score] short-circuit chunk_id={} cheap_total={:.3} \ + ({}, skipping LLM)", + chunk.id, + cheap_total, + if cheap_total >= cfg.definite_keep_threshold { + "definite_keep" + } else { + "definite_drop" + } + ); + false + }; - // 4. Admission gate. Source and interaction priors are deliberately + // 4. Final weighted combine. + // + // If the LLM ran, its importance signal is populated → use the full + // `combine` which includes the `llm_importance` weight. + // + // If the LLM was skipped (short-circuited or not configured) OR failed + // (caught above, sets `llm_consulted=false`), using the full combine + // would pin `llm_importance * w.llm_importance = 0 * 2.0` into the + // numerator while still dividing by the full denominator — artificially + // dragging the total down. Fall back to `combine_cheap_only` which + // excludes that term from both numerator and denominator, so the cheap + // signals alone produce the total. + let total = if llm_consulted { + self::signals::combine(&signals, &cfg.weights) + } else { + self::signals::combine_cheap_only(&signals, &cfg.weights) + }; + + // 5. Admission gate. Source and interaction priors are deliberately // non-zero, so guard against very short entity-free chatter being kept by // metadata alone. let tiny_entity_free = @@ -110,16 +229,17 @@ pub async fn score_chunk(chunk: &Chunk, cfg: &ScoringConfig) -> Result store::ScoreRow { dropped: !result.kept, reason: result.drop_reason.clone(), computed_at_ms: Utc::now().timestamp_millis(), + llm_importance_reason: result.extracted.llm_importance_reason.clone(), } } @@ -302,4 +423,172 @@ mod tests { assert!(ids.iter().any(|id| *id == "email:alice@example.com")); assert!(ids.iter().any(|id| *id == "handle:alice")); } + + // ── Short-circuit / LLM-extractor tests ───────────────────────────── + + /// Test extractor that returns a fixed importance value and records call count. + struct FakeLlm { + importance: f32, + call_count: std::sync::atomic::AtomicUsize, + } + + impl FakeLlm { + fn new(importance: f32) -> std::sync::Arc { + std::sync::Arc::new(Self { + importance, + call_count: std::sync::atomic::AtomicUsize::new(0), + }) + } + fn calls(&self) -> usize { + self.call_count.load(std::sync::atomic::Ordering::Relaxed) + } + } + + #[async_trait::async_trait] + impl extract::EntityExtractor for FakeLlm { + fn name(&self) -> &'static str { + "fake-llm" + } + async fn extract(&self, _text: &str) -> Result { + self.call_count + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Ok(extract::ExtractedEntities { + entities: vec![], + topics: vec![], + llm_importance: Some(self.importance), + llm_importance_reason: Some("fake".into()), + }) + } + } + + #[tokio::test] + async fn short_circuit_skips_llm_when_cheap_total_is_definite_keep() { + // A substantive chunk with high cheap-total should bypass the LLM. + let c = test_chunk( + "We decided to ship Phoenix on Friday after reviewing alice@example.com and \ + the migration plan carefully. @bob will coordinate and we discussed \ + #launch-q2 details extensively in the email thread.", + ); + let llm = FakeLlm::new(0.5); + let mut cfg = ScoringConfig::with_llm_extractor(llm.clone()); + // Force the cheap total well above the keep threshold by lowering + // the keep threshold so this test is robust to weight tuning. + cfg.definite_keep_threshold = 0.10; + let r = score_chunk(&c, &cfg).await.unwrap(); + assert!(r.kept); + assert_eq!(llm.calls(), 0, "LLM should not be consulted"); + // signals.llm_importance stays at 0 (no LLM call happened) + assert_eq!(r.signals.llm_importance, 0.0); + } + + #[tokio::test] + async fn short_circuit_skips_llm_when_cheap_total_is_definite_drop() { + // A noisy chunk with very low cheap total should bypass the LLM + // and be dropped. + let c = test_chunk("ok"); + let llm = FakeLlm::new(0.99); + let mut cfg = ScoringConfig::with_llm_extractor(llm.clone()); + // Force the cheap total to look like definite_drop. + cfg.definite_drop_threshold = 0.99; + let r = score_chunk(&c, &cfg).await.unwrap(); + assert!(!r.kept); + assert_eq!( + llm.calls(), + 0, + "LLM should not be consulted on definite_drop" + ); + } + + #[tokio::test] + async fn borderline_chunk_consults_llm() { + // Pick content that will land in the borderline band and verify the LLM + // gets called. Use generous band edges so the test isn't sensitive + // to weight nudges. + let c = test_chunk("This is a moderately interesting note about a project."); + let llm = FakeLlm::new(0.9); + let mut cfg = ScoringConfig::with_llm_extractor(llm.clone()); + cfg.definite_drop_threshold = 0.0; + cfg.definite_keep_threshold = 1.0; + let r = score_chunk(&c, &cfg).await.unwrap(); + assert_eq!(llm.calls(), 1, "LLM should be consulted exactly once"); + assert!(r.signals.llm_importance > 0.0); + assert_eq!(r.extracted.llm_importance_reason.as_deref(), Some("fake")); + } + + #[tokio::test] + async fn llm_failure_falls_back_gracefully() { + struct FailingLlm; + #[async_trait::async_trait] + impl extract::EntityExtractor for FailingLlm { + fn name(&self) -> &'static str { + "failing-llm" + } + async fn extract(&self, _text: &str) -> Result { + Err(anyhow::anyhow!("simulated failure")) + } + } + let c = test_chunk("This is a moderately interesting note about a project."); + let mut cfg = ScoringConfig::with_llm_extractor(std::sync::Arc::new(FailingLlm)); + cfg.definite_drop_threshold = 0.0; + cfg.definite_keep_threshold = 1.0; + // Should not error out; should produce a result based on cheap signals only. + let r = score_chunk(&c, &cfg).await.unwrap(); + assert_eq!(r.signals.llm_importance, 0.0); + } + + /// When LLM is skipped (short-circuit or failure), the reported `total` + /// must equal `combine_cheap_only(signals, weights)` — not the + /// LLM-weighted `combine` (which would drag `llm_importance=0` through + /// a 2.0 weight and artificially lower the total). + #[tokio::test] + async fn short_circuit_reports_cheap_only_total() { + let c = test_chunk( + "We decided to ship Phoenix on Friday after reviewing alice@example.com and \ + the migration plan carefully. @bob will coordinate and we discussed \ + #launch-q2 details extensively in the email thread.", + ); + let llm = FakeLlm::new(0.99); + let mut cfg = ScoringConfig::with_llm_extractor(llm.clone()); + cfg.definite_keep_threshold = 0.10; // force short-circuit keep + let r = score_chunk(&c, &cfg).await.unwrap(); + assert_eq!(llm.calls(), 0); + let expected = self::signals::combine_cheap_only(&r.signals, &cfg.weights); + assert!( + (r.total - expected).abs() < 1e-6, + "total={} expected(cheap_only)={}", + r.total, + expected + ); + // And explicitly NOT the full combine (which would include a 0-value + // llm_importance term in a 0..1-clamped weighted average, dragging + // the total down). + let with_llm = self::signals::combine(&r.signals, &cfg.weights); + assert!( + r.total > with_llm, + "cheap-only total ({}) should exceed LLM-weighted total \ + ({}) when llm_importance is zero", + r.total, + with_llm + ); + } + + /// When the LLM *does* run, the reported total uses the full combine — + /// the llm_importance contribution is actually in the sum. + #[tokio::test] + async fn llm_consulted_reports_full_total() { + let c = test_chunk("This is a moderately interesting note about a project."); + let llm = FakeLlm::new(0.9); + let mut cfg = ScoringConfig::with_llm_extractor(llm.clone()); + cfg.definite_drop_threshold = 0.0; + cfg.definite_keep_threshold = 1.0; + let r = score_chunk(&c, &cfg).await.unwrap(); + assert_eq!(llm.calls(), 1); + let expected = self::signals::combine(&r.signals, &cfg.weights); + assert!( + (r.total - expected).abs() < 1e-6, + "total={} expected(full combine)={}", + r.total, + expected + ); + } } diff --git a/src/openhuman/memory/tree/score/resolver.rs b/src/openhuman/memory/tree/score/resolver.rs index c3b1d1fd2..14cc56faf 100644 --- a/src/openhuman/memory/tree/score/resolver.rs +++ b/src/openhuman/memory/tree/score/resolver.rs @@ -117,6 +117,8 @@ mod tests { entity(EntityKind::Email, "alice@example.com"), ], topics: vec![], + llm_importance: None, + llm_importance_reason: None, }; let out = canonicalise(&ex); assert_eq!(out.len(), 2); diff --git a/src/openhuman/memory/tree/score/signals/mod.rs b/src/openhuman/memory/tree/score/signals/mod.rs index 321e13437..78ace044d 100644 --- a/src/openhuman/memory/tree/score/signals/mod.rs +++ b/src/openhuman/memory/tree/score/signals/mod.rs @@ -16,5 +16,5 @@ pub mod token_count; mod types; pub mod unique_words; -pub use ops::{combine, compute, entity_density_score}; +pub use ops::{combine, combine_cheap_only, compute, entity_density_score}; pub use types::{ScoreSignals, SignalWeights}; diff --git a/src/openhuman/memory/tree/score/signals/ops.rs b/src/openhuman/memory/tree/score/signals/ops.rs index 900de969b..5421f6dcd 100644 --- a/src/openhuman/memory/tree/score/signals/ops.rs +++ b/src/openhuman/memory/tree/score/signals/ops.rs @@ -4,6 +4,9 @@ use crate::openhuman::memory::tree::score::extract::ExtractedEntities; use crate::openhuman::memory::tree::types::Metadata; /// Compute all signals for a chunk. +/// +/// `llm_importance` is sourced from `ex.llm_importance` (defaults to `0.0` +/// when the extractor didn't produce one — equivalent to "no LLM signal"). pub fn compute( meta: &Metadata, content: &str, @@ -17,6 +20,7 @@ pub fn compute( source_weight: source_weight::score(meta), interaction: interaction::score(meta), entity_density: entity_density_score(token_count, ex), + llm_importance: ex.llm_importance.unwrap_or(0.0).clamp(0.0, 1.0), } } @@ -35,7 +39,38 @@ pub fn entity_density_score(token_count: u32, ex: &ExtractedEntities) -> f32 { } /// Weighted sum of signals, normalised to `[0.0, 1.0]`. +/// +/// When `w.llm_importance == 0.0` (the default) the LLM signal contributes +/// nothing to either the numerator or the denominator — output is identical +/// to pre-LLM Phase 2. pub fn combine(signals: &ScoreSignals, w: &SignalWeights) -> f32 { + let total_weight = w.token_count + + w.unique_words + + w.metadata_weight + + w.source_weight + + w.interaction + + w.entity_density + + w.llm_importance; + if total_weight <= 0.0 { + return 0.0; + } + let weighted = signals.token_count * w.token_count + + signals.unique_words * w.unique_words + + signals.metadata_weight * w.metadata_weight + + signals.source_weight * w.source_weight + + signals.interaction * w.interaction + + signals.entity_density * w.entity_density + + signals.llm_importance * w.llm_importance; + (weighted / total_weight).clamp(0.0, 1.0) +} + +/// Weighted sum **excluding the `llm_importance` signal**. +/// +/// Used by the short-circuit logic in `score_chunk`: if the deterministic +/// (cheap-signals-only) total is already firmly above or below the +/// admission band, we skip the LLM call entirely. The LLM signal only +/// participates in the *final* `combine` once it's been computed. +pub fn combine_cheap_only(signals: &ScoreSignals, w: &SignalWeights) -> f32 { let total_weight = w.token_count + w.unique_words + w.metadata_weight @@ -80,7 +115,7 @@ mod tests { score: 1.0, }) .collect(), - topics: vec![], + ..Default::default() } } @@ -99,6 +134,7 @@ mod tests { source_weight: 1.0, interaction: 1.0, entity_density: 1.0, + llm_importance: 0.0, // default weight is 0 → contribution is zero }; assert!((combine(&s, &SignalWeights::default()) - 1.0).abs() < 1e-6); } @@ -112,6 +148,7 @@ mod tests { source_weight: 0.0, interaction: 1.0, entity_density: 0.0, + llm_importance: 0.0, }; let total = combine(&s, &SignalWeights::default()); assert!((total - (3.0 / 9.0)).abs() < 1e-6); diff --git a/src/openhuman/memory/tree/score/signals/types.rs b/src/openhuman/memory/tree/score/signals/types.rs index 7954fd510..b20bbc167 100644 --- a/src/openhuman/memory/tree/score/signals/types.rs +++ b/src/openhuman/memory/tree/score/signals/types.rs @@ -10,9 +10,19 @@ pub struct ScoreSignals { pub source_weight: f32, pub interaction: f32, pub entity_density: f32, + /// LLM-derived importance rating in `[0.0, 1.0]`. `0.0` when no LLM + /// signal is available — combined with `SignalWeights::llm_importance = 0.0` + /// (the default) this produces a no-op contribution to the total, keeping + /// behaviour identical to pre-LLM Phase 2. + #[serde(default)] + pub llm_importance: f32, } /// Default weights applied to each signal in `combine`. +/// +/// `llm_importance` defaults to `0.0` (disabled). Callers who configure an +/// LLM extractor should bump it (typical: 2.0 — comparable to the +/// metadata/source weights, well below the interaction-direct signal). #[derive(Clone, Debug)] pub struct SignalWeights { pub token_count: f32, @@ -21,6 +31,7 @@ pub struct SignalWeights { pub source_weight: f32, pub interaction: f32, pub entity_density: f32, + pub llm_importance: f32, } impl Default for SignalWeights { @@ -32,6 +43,19 @@ impl Default for SignalWeights { source_weight: 1.5, interaction: 3.0, // strongest signal — direct user engagement entity_density: 1.0, + llm_importance: 0.0, // disabled until LLM extractor is configured + } + } +} + +impl SignalWeights { + /// Same as [`Default::default`] but with a non-zero `llm_importance` weight. + /// Use when an LLM extractor is wired in and you want its importance + /// signal to influence the admission decision. + pub fn with_llm_enabled() -> Self { + Self { + llm_importance: 2.0, + ..Self::default() } } } diff --git a/src/openhuman/memory/tree/score/store.rs b/src/openhuman/memory/tree/score/store.rs index fb47306a2..bed44b455 100644 --- a/src/openhuman/memory/tree/score/store.rs +++ b/src/openhuman/memory/tree/score/store.rs @@ -27,6 +27,11 @@ pub struct ScoreRow { pub dropped: bool, pub reason: Option, pub computed_at_ms: i64, + /// One-line LLM-supplied explanation for the importance rating; useful + /// for tuning prompts and thresholds. The numeric value lives on + /// `signals.llm_importance`. + #[serde(default)] + pub llm_importance_reason: Option, } /// Upsert one score rationale row, replacing any existing entry for `chunk_id`. @@ -49,6 +54,8 @@ pub(crate) fn upsert_score_tx(tx: &Transaction<'_>, row: &ScoreRow) -> Result<() row.signals.source_weight, row.signals.interaction, row.signals.entity_density, + row.signals.llm_importance, + row.llm_importance_reason, i32::from(row.dropped), row.reason, row.computed_at_ms, @@ -61,8 +68,9 @@ const SCORE_UPSERT_SQL: &str = "INSERT OR REPLACE INTO mem_tree_score ( chunk_id, total, token_count_signal, unique_words_signal, metadata_weight, source_weight, interaction_weight, entity_density, + llm_importance, llm_importance_reason, dropped, reason, computed_at_ms - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"; + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"; fn upsert_score_on_connection(conn: &Connection, row: &ScoreRow) -> Result<()> { conn.execute( @@ -76,6 +84,8 @@ fn upsert_score_on_connection(conn: &Connection, row: &ScoreRow) -> Result<()> { row.signals.source_weight, row.signals.interaction, row.signals.entity_density, + row.signals.llm_importance, + row.llm_importance_reason, i32::from(row.dropped), row.reason, row.computed_at_ms, @@ -91,6 +101,7 @@ pub fn get_score(config: &Config, chunk_id: &str) -> Result> { "SELECT chunk_id, total, token_count_signal, unique_words_signal, metadata_weight, source_weight, interaction_weight, entity_density, + llm_importance, llm_importance_reason, dropped, reason, computed_at_ms FROM mem_tree_score WHERE chunk_id = ?1", params![chunk_id], @@ -105,10 +116,12 @@ pub fn get_score(config: &Config, chunk_id: &str) -> Result> { source_weight: row.get(5)?, interaction: row.get(6)?, entity_density: row.get(7)?, + llm_importance: row.get::<_, Option>(8)?.unwrap_or(0.0), }, - dropped: row.get::<_, i32>(8)? != 0, - reason: row.get(9)?, - computed_at_ms: row.get(10)?, + llm_importance_reason: row.get::<_, Option>(9)?, + dropped: row.get::<_, i32>(10)? != 0, + reason: row.get(11)?, + computed_at_ms: row.get(12)?, }) }, ) @@ -340,6 +353,7 @@ mod tests { source_weight: 0.5, interaction: 0.6, entity_density: 0.3, + llm_importance: 0.0, }, dropped, reason: if dropped { @@ -348,6 +362,7 @@ mod tests { None }, computed_at_ms: 1_700_000_000_000, + llm_importance_reason: None, } } diff --git a/src/openhuman/memory/tree/store.rs b/src/openhuman/memory/tree/store.rs index bcb4a6ec1..5c686dcc6 100644 --- a/src/openhuman/memory/tree/store.rs +++ b/src/openhuman/memory/tree/store.rs @@ -350,6 +350,11 @@ pub(crate) fn with_connection( .context("Failed to initialize memory_tree schema")?; // Phase 2 migrations — additive, idempotent. add_column_if_missing(&conn, "mem_tree_chunks", "embedding", "BLOB")?; + // Phase 2 LLM-NER follow-up: per-chunk LLM importance signal + + // human-readable reason. Both nullable; absence is treated as + // "no LLM signal available" by readers. + add_column_if_missing(&conn, "mem_tree_score", "llm_importance", "REAL")?; + add_column_if_missing(&conn, "mem_tree_score", "llm_importance_reason", "TEXT")?; f(&conn) }