mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
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:
co-authored by
Claude Opus 4.7
parent
8ab8c59961
commit
605a7fd5c3
Generated
+1
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user