feat(memory): LLM-based NER + importance signal with cheap-signals short-circuit (#708)

Adds an Ollama-backed entity extractor and an LLM-derived importance
score as a new signal in the admission gate. Builds on merged Phase 2
(#733) without changing its surface — additive only.

Why LLM (not GLiNER): zero new deps (reuses existing reqwest + async
infra), reuses the same Ollama setup openhuman uses for embeddings,
better per-entity quality scales with model choice, no native ONNX
runtime to ship. Latency cost (~100-300ms per chunk on a small model
like qwen2.5:0.5b) is amortised by the short-circuit.

The admission gate stays a hybrid - cheap deterministic signals
always run; the LLM is one signal among many, never the sole arbiter.
Backwards-compatible: with default SignalWeights the LLM weight is 0.0
and behaviour matches pre-LLM Phase 2 exactly.

What's new:

- src/openhuman/memory/tree/score/extract/llm.rs: LlmEntityExtractor
  implementing the existing EntityExtractor trait. Posts a structured
  JSON request to an Ollama-compatible /api/chat endpoint asking for
  NER + an importance rating in one call. Span recovery via string
  search; hallucinated entities (surface not in source text) dropped.
  Soft fallback: HTTP failures log a warn and return empty extraction.
- ExtractedEntities gains llm_importance (Option<f32>) +
  llm_importance_reason (Option<String>); merge() takes max importance.
- ScoreSignals gains llm_importance (f32, defaults to 0.0).
- SignalWeights gains llm_importance (default 0.0; with_llm_enabled()
  helper sets it to 2.0 - comparable to metadata/source weights).
- signals::combine_cheap_only(): variant that excludes the LLM signal,
  used for the short-circuit decision in score_chunk.
- ScoringConfig gains llm_extractor (Option<Arc<dyn EntityExtractor>>),
  definite_keep_threshold (default 0.85), definite_drop_threshold
  (default 0.15). New with_llm_extractor() constructor.
- score_chunk() pipeline:
  1. Always-on regex extraction
  2. Cheap signals + combine_cheap_only -> cheap_total
  3. If cheap_total >= definite_keep OR <= definite_drop: skip LLM
  4. Else (borderline band): run LLM extractor, merge results, recompute
  5. Final combine + admission gate against drop_threshold
- mem_tree_score table gains nullable llm_importance + llm_importance_reason
  columns via idempotent ALTER TABLE migration.
- ScoreRow + upsert/get persist the new column.

What's not changed:
- Existing JSON-RPC surface
- Default behaviour (LLM weight=0, no llm_extractor configured = same
  outputs as before)
- mem_tree_chunks / mem_tree_entity_index schemas

Tests added:
- LLM extractor: prompt construction, JSON parsing, hallucination
  drop, importance clamping, strict vs lenient unknown-kind handling
- Score pipeline: short-circuit on definite_keep/definite_drop skips
  LLM call (verified via FakeLlm call counter); borderline band
  consults LLM once; LLM failure falls back gracefully without erroring

LLM-NER work follows up on #708. Parent: #711.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-04-22 15:10:27 +05:30
co-authored by Claude Opus 4.7
parent 8ab8c59961
commit 605a7fd5c3
11 changed files with 858 additions and 18 deletions
Generated
+1 -1
View File
@@ -4420,7 +4420,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openhuman"
version = "0.52.27"
version = "0.52.28"
dependencies = [
"aes-gcm",
"anyhow",
@@ -0,0 +1,497 @@
//! 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<EntityKind>,
/// 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<Self> {
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<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 = self
.http
.post(&url)
.json(&body)
.send()
.await
.map_err(anyhow::Error::from)?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("ollama status {status}: {body}");
}
let envelope: OllamaChatResponse = resp.json().await.map_err(anyhow::Error::from)?;
log::debug!(
"[memory_tree::extract::llm] response chars={}",
envelope.message.content.len()
);
let parsed: LlmExtractionOutput =
serde_json::from_str(&envelope.message.content).map_err(|e| {
anyhow::anyhow!(
"LLM returned non-JSON or wrong-shape response: {e}; \
content was: {}",
truncate_for_log(&envelope.message.content, 400)
)
})?;
Ok(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\": \"<exact surface form as it appears in the text>\" }
],
\"importance\": 0.0,
\"importance_reason\": \"<one short sentence explaining the rating>\"
}
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<OllamaMessage>,
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<LlmEntity>,
#[serde(default)]
importance: Option<f32>,
#[serde(default)]
importance_reason: Option<String>,
}
#[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());
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. If the model hallucinated a
// surface that doesn't appear in the source text, drop it.
let (span_start, span_end) = match find_char_span(source_text, surface) {
Some(s) => s,
None => {
log::debug!(
"[memory_tree::extract::llm] dropping hallucinated entity (not found \
in source): {surface:?}"
);
continue;
}
};
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<EntityKind> {
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)> {
let byte_idx = haystack.find(needle)?;
let char_start = haystack[..byte_idx].chars().count() as u32;
let char_end = char_start + needle.chars().count() as u32;
Some((char_start, char_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 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('…'));
}
}
@@ -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};
@@ -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 {
@@ -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<ExtractedEntity>,
pub topics: Vec<ExtractedTopic>,
/// 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<f32>,
/// 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<String>,
}
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;
}
}
}
}
}
+228 -9
View File
@@ -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<dyn EntityExtractor>,
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<Arc<dyn EntityExtractor>>,
/// 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<dyn EntityExtractor>) -> 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<ScoreResult> {
log::debug!(
"[memory_tree::score] score_chunk chunk_id={} tokens={}",
@@ -75,21 +129,72 @@ pub async fn score_chunk(chunk: &Chunk, cfg: &ScoringConfig) -> Result<ScoreResu
let scoring_content = scoring_content_for_chunk(chunk);
let scoring_token_count = approx_token_count(&scoring_content);
// 1. Extract entities (regex + any configured semantic extractors)
let extracted = cfg.extractor.extract(&scoring_content).await?;
// 1. Always-on extraction (regex / mechanical).
let mut extracted = cfg.extractor.extract(&scoring_content).await?;
// 2. Compute signals
let signals = self::signals::compute(
// 2. Compute cheap signals + combine excluding LLM importance.
let mut signals = self::signals::compute(
&chunk.metadata,
&scoring_content,
scoring_token_count,
&extracted,
);
let cheap_total = self::signals::combine_cheap_only(&signals, &cfg.weights);
// 3. Weighted combine
// 3. Short-circuit decision.
let in_band =
cheap_total > 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. Final weighted combine — includes llm_importance whether it was
// populated by the LLM call (set to its rating) or not (still 0.0).
let total = self::signals::combine(&signals, &cfg.weights);
// 4. Admission gate. Source and interaction priors are deliberately
// 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 +215,17 @@ pub async fn score_chunk(chunk: &Chunk, cfg: &ScoringConfig) -> Result<ScoreResu
))
};
// 5. Canonicalise for indexing (only meaningful when kept — but we
// 6. Canonicalise for indexing (only meaningful when kept — but we
// canonicalise unconditionally so the result is inspectable in tests)
let canonical_entities = canonicalise(&extracted);
if !kept {
log::debug!(
"[memory_tree::score] drop chunk_id={} total={:.3} reason={:?}",
"[memory_tree::score] drop chunk_id={} total={:.3} reason={:?} llm_consulted={}",
chunk.id,
total,
drop_reason
drop_reason,
llm_consulted
);
}
@@ -233,6 +339,7 @@ fn score_row(result: &ScoreResult) -> 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 +409,116 @@ 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<Self> {
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<extract::ExtractedEntities> {
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<extract::ExtractedEntities> {
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);
}
}
@@ -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};
@@ -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
@@ -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()
}
}
}
+17 -4
View File
@@ -27,6 +27,11 @@ pub struct ScoreRow {
pub dropped: bool,
pub reason: Option<String>,
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<String>,
}
/// 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<Option<ScoreRow>> {
"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<Option<ScoreRow>> {
source_weight: row.get(5)?,
interaction: row.get(6)?,
entity_density: row.get(7)?,
llm_importance: row.get::<_, Option<f32>>(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<String>>(9)?,
dropped: row.get::<_, i32>(10)? != 0,
reason: row.get(11)?,
computed_at_ms: row.get(12)?,
})
},
)
+5
View File
@@ -350,6 +350,11 @@ pub(crate) fn with_connection<T>(
.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)
}