feat(memory): Phase 2 memory tree - preprocessing, scoring, admission gate (#708) (#733)

* feat(memory): phase 1 memory tree - multi-source ingestion & canonical chunks (#707)

Adds an isolated memory tree layer under src/openhuman/memory/tree/
implementing Phase 1 of the new memory architecture (umbrella #711).
Zero edits to existing memory/*.rs files - the new layer coexists
with the legacy TinyHumans-backed client.

- Source adapters: chat / email / document -> canonical Markdown
- Token-bounded chunker with deterministic SHA-256 chunk IDs
- SQLite persistence at <workspace>/memory_tree/chunks.db with full
  provenance metadata (source_kind, source_id, owner, timestamps,
  tags, time_range) and back-pointer to raw source
- Unified JSON-RPC ingest (dispatches on source_kind + JSON payload):
  openhuman.memory_tree_ingest, _list_chunks, _get_chunk
- DataSource enum covering the 8 providers from m.excalidraw step 1
  (Discord/Telegram/Whatsapp/Gmail/OtherEmail/Notion/MeetingNotes/DriveDocs)
- ~40 unit tests (chunk ID stability, UTF-8-safe splitting,
  canonicalisation idempotence, store round-trip, filter behavior)

Additive only: new tables in a new DB file, new JSON-RPC namespace,
no existing behavior changes. Feeds #708 (scoring), #709 (summary
trees), #710 (query tools).

Closes #707. Parent: #711.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(memory): phase 2 memory tree - preprocessing, scoring, admission gate (#708)

Adds the scoring / admission layer between Phase 1's chunker and store.
Stacked on feat/707-memory-ingestion (PR #732) - depends on Phase 1's
chunk substrate.

- Pluggable EntityExtractor trait + CompositeExtractor chain
- RegexEntityExtractor: mechanical entities (emails, URLs, @handles, #hashtags)
  - Always on, deterministic, zero deps, UTF-8-safe char spans
- Five weighted signals: token count, unique-word ratio, metadata weight,
  source weight (per-DataSource), interaction (reply/sent/mention/dm tags),
  entity density
- Exact-match entity canonicalisation (email lowercased, @ and # stripped)
- Admission gate drops chunks below configurable threshold (default 0.3)
  - Score rationale persists for EVERY chunk (kept or dropped) for debugging
  - Entities indexed for KEPT chunks only
- Two new SQLite tables added to the memory_tree DB:
  - mem_tree_score: per-chunk score rationale with all signal values
  - mem_tree_entity_index: inverted index entity_id -> node_id
- Idempotent ALTER TABLE migration adds embedding BLOB column to
  mem_tree_chunks (used in Phase 3 retrieval, wired but not populated here)
- Ingest pipeline converted to async to accommodate the extractor trait;
  blocking SQLite work isolated on spawn_blocking; JSON-RPC surface
  unchanged (same memory_tree_ingest / list / get methods)
- Phase 2 deliberately ships without GLiNER/semantic NER - per-chunk
  semantic entities land later behind a cargo feature flag; the composite
  extractor interface keeps that drop-in trivial

Additive only: new tables, new columns, new module. Existing Phase 1
behavior unchanged except that low-signal chunks are now dropped before
reaching mem_tree_chunks. Raise score_drop_threshold to 0 to disable
the gate and restore Phase-1-identical behavior.

Closes #708. Parent: #711. Depends on: #707 (#732).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix memory tree scoring persistence issues

* Fix memory tree scoring robustness issues from PR review

- ingest: fail fast if scorer returns fewer/more results than chunks
  (silent zip truncation would drop chunks or their score rationale)
- score::persist_score{,_tx}: clear stale entity-index rows before
  re-indexing a re-scored chunk, since INSERT OR REPLACE never deletes
  rows whose entity_id is no longer in the new extraction
- score::store::lookup_entity: clamp limit to i64::MAX before casting
  to prevent a large usize wrapping into a negative LIMIT

Adds clear_entity_index_drops_stale_rows regression test.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
sanil-23
2026-04-21 16:17:27 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Steven Enamakel
parent 0ce1253fe1
commit 5fff5568d3
22 changed files with 2539 additions and 121 deletions
Generated
+1 -1
View File
@@ -4420,7 +4420,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openhuman"
version = "0.52.26"
version = "0.52.27"
dependencies = [
"aes-gcm",
"anyhow",
+177 -70
View File
@@ -1,7 +1,12 @@
//! Ingest orchestrator: canonicalise → chunk → persist (Phase 1 / #707).
//! Ingest orchestrator (Phase 1 + Phase 2):
//!
//! Consumers call one `ingest_*` function per source kind. Each returns an
//! [`IngestResult`] so the RPC layer can report how many chunks landed.
//! canonicalise → chunk → score → admission gate → persist (chunks + scores + entity index)
//!
//! Phase 2 inserts scoring between chunker and persistence. Low-scoring
//! chunks are dropped (their rationale is still persisted to
//! `mem_tree_score` for diagnostics); surviving chunks get their entities
//! indexed so later phases can resolve "which chunks mention Alice?" in
//! O(lookup).
use anyhow::Result;
use serde::{Deserialize, Serialize};
@@ -11,31 +16,39 @@ use crate::openhuman::memory::tree::canonicalize::{
chat::{self, ChatBatch},
document::{self, DocumentInput},
email::{self, EmailThread},
CanonicalisedSource,
};
use crate::openhuman::memory::tree::chunker::{chunk_markdown, ChunkerInput, ChunkerOptions};
use crate::openhuman::memory::tree::score::{self, ScoreResult, ScoringConfig};
use crate::openhuman::memory::tree::store;
use crate::openhuman::memory::tree::types::Chunk;
/// Outcome of one ingest call.
/// Outcome of one ingest call — extended with per-chunk admission info.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct IngestResult {
pub source_id: String,
/// Number of chunks that passed the admission gate and were persisted.
pub chunks_written: usize,
/// Number of chunks that failed the admission gate and were NOT persisted
/// (their score rationale IS persisted for diagnostics).
pub chunks_dropped: usize,
/// IDs of all chunks that were persisted (in source order).
pub chunk_ids: Vec<String>,
}
impl IngestResult {
fn from_chunks(source_id: String, chunks: &[Chunk], chunks_written: usize) -> Self {
fn empty(source_id: &str) -> Self {
Self {
source_id,
chunks_written,
chunk_ids: chunks.iter().map(|c| c.id.clone()).collect(),
source_id: source_id.to_string(),
chunks_written: 0,
chunks_dropped: 0,
chunk_ids: Vec::new(),
}
}
}
/// Ingest a batch of chat messages scoped to one channel/group.
pub fn ingest_chat(
pub async fn ingest_chat(
config: &Config,
source_id: &str,
owner: &str,
@@ -50,19 +63,13 @@ pub fn ingest_chat(
let canonical =
match chat::canonicalise(source_id, owner, &tags, batch).map_err(anyhow::Error::msg)? {
Some(c) => c,
None => {
return Ok(IngestResult {
source_id: source_id.to_string(),
chunks_written: 0,
chunk_ids: Vec::new(),
});
}
None => return Ok(IngestResult::empty(source_id)),
};
persist(config, source_id, canonical)
persist(config, source_id, canonical).await
}
/// Ingest a single email thread.
pub fn ingest_email(
pub async fn ingest_email(
config: &Config,
source_id: &str,
owner: &str,
@@ -77,19 +84,13 @@ pub fn ingest_email(
let canonical =
match email::canonicalise(source_id, owner, &tags, thread).map_err(anyhow::Error::msg)? {
Some(c) => c,
None => {
return Ok(IngestResult {
source_id: source_id.to_string(),
chunks_written: 0,
chunk_ids: Vec::new(),
});
}
None => return Ok(IngestResult::empty(source_id)),
};
persist(config, source_id, canonical)
persist(config, source_id, canonical).await
}
/// Ingest a single standalone document.
pub fn ingest_document(
pub async fn ingest_document(
config: &Config,
source_id: &str,
owner: &str,
@@ -106,22 +107,17 @@ pub fn ingest_document(
let canonical =
match document::canonicalise(source_id, owner, &tags, doc).map_err(anyhow::Error::msg)? {
Some(c) => c,
None => {
return Ok(IngestResult {
source_id: source_id.to_string(),
chunks_written: 0,
chunk_ids: Vec::new(),
});
}
None => return Ok(IngestResult::empty(source_id)),
};
persist(config, source_id, canonical)
persist(config, source_id, canonical).await
}
fn persist(
async fn persist(
config: &Config,
source_id: &str,
canonical: crate::openhuman::memory::tree::canonicalize::CanonicalisedSource,
canonical: CanonicalisedSource,
) -> Result<IngestResult> {
// 1. Chunk
let input = ChunkerInput {
source_kind: canonical.metadata.source_kind,
source_id: source_id.to_string(),
@@ -129,23 +125,76 @@ fn persist(
metadata: canonical.metadata,
};
let chunks = chunk_markdown(&input, &ChunkerOptions::default());
let written = store::upsert_chunks(config, &chunks)?;
if chunks.is_empty() {
return Ok(IngestResult::empty(source_id));
}
// 2. Score (async; uses configured extractor)
let scoring_cfg = ScoringConfig::default_regex_only();
let scores = score::score_chunks(&chunks, &scoring_cfg).await?;
// Fail fast on scorer length mismatch — silently truncating via zip would
// drop chunks (or their score rationale) without trace.
if scores.len() != chunks.len() {
anyhow::bail!(
"[memory_tree::ingest] scorer length mismatch: chunks={} scores={}",
chunks.len(),
scores.len()
);
}
// 3. Partition kept vs dropped
let mut kept_chunks: Vec<Chunk> = Vec::new();
let mut all_results: Vec<(ScoreResult, i64)> = Vec::new();
for (chunk, result) in chunks.iter().zip(scores.into_iter()) {
let ts_ms = chunk.metadata.timestamp.timestamp_millis();
if result.kept {
kept_chunks.push(chunk.clone());
}
all_results.push((result, ts_ms));
}
let dropped = all_results.iter().filter(|(r, _)| !r.kept).count();
log::debug!(
"[memory_tree::ingest] persisted source_id={} chunks={}",
"[memory_tree::ingest] scoring source_id={} kept={} dropped={}",
source_id,
written
kept_chunks.len(),
dropped
);
Ok(IngestResult::from_chunks(
source_id.to_string(),
&chunks,
written,
))
// 4. Persist (blocking SQLite — isolate on a dedicated thread)
let config_owned = config.clone();
let kept_for_store = kept_chunks.clone();
let results_for_store = all_results.clone();
let written = tokio::task::spawn_blocking(move || -> Result<usize> {
store::with_connection(&config_owned, |conn| {
let tx = conn.unchecked_transaction()?;
let n = store::upsert_chunks_tx(&tx, &kept_for_store)?;
for (result, ts_ms) in &results_for_store {
// Persist rationale for EVERY chunk (kept or dropped).
// Index entities only for kept chunks (handled inside persist_score_tx).
score::persist_score_tx(&tx, result, *ts_ms, None)?;
}
tx.commit()?;
Ok(n)
})
})
.await
.map_err(|e| anyhow::anyhow!("persist join error: {e}"))??;
Ok(IngestResult {
source_id: source_id.to_string(),
chunks_written: written,
chunks_dropped: dropped,
chunk_ids: kept_chunks.iter().map(|c| c.id.clone()).collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory::tree::canonicalize::chat::ChatMessage;
use crate::openhuman::memory::tree::score::store::{count_scores, lookup_entity};
use crate::openhuman::memory::tree::store::{count_chunks, list_chunks, ListChunksQuery};
use crate::openhuman::memory::tree::types::SourceKind;
use chrono::{TimeZone, Utc};
@@ -158,75 +207,133 @@ mod tests {
(tmp, cfg)
}
#[test]
fn ingest_chat_writes_chunks() {
let (_tmp, cfg) = test_config();
let batch = ChatBatch {
/// Build a substantive batch that reliably passes the admission gate.
fn substantive_batch() -> ChatBatch {
ChatBatch {
platform: "slack".into(),
channel_label: "#eng".into(),
messages: vec![
ChatMessage {
author: "alice".into(),
timestamp: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
text: "hello".into(),
text: "We are planning to ship the Phoenix migration on Friday \
after reviewing the runbook and staging results. Please \
confirm availability by replying here. alice@example.com"
.into(),
source_ref: Some("slack://m1".into()),
},
ChatMessage {
author: "bob".into(),
timestamp: Utc.timestamp_millis_opt(1_700_000_010_000).unwrap(),
text: "world".into(),
text: "Confirmed — I'll handle the coordination and cut a release \
candidate tonight. #launch-q2 will be tracked in Notion."
.into(),
source_ref: None,
},
],
};
let out = ingest_chat(&cfg, "slack:#eng", "alice", vec![], batch).unwrap();
assert_eq!(out.chunks_written, 1);
assert_eq!(count_chunks(&cfg).unwrap(), 1);
let rows = list_chunks(&cfg, &ListChunksQuery::default()).unwrap();
assert_eq!(rows[0].metadata.source_kind, SourceKind::Chat);
assert_eq!(rows[0].metadata.source_id, "slack:#eng");
}
}
#[test]
fn ingest_chat_empty_batch_is_noop() {
#[tokio::test]
async fn ingest_chat_writes_substantive_chunks() {
let (_tmp, cfg) = test_config();
let out = ingest_chat(&cfg, "slack:#eng", "alice", vec![], substantive_batch())
.await
.unwrap();
assert_eq!(out.chunks_written, 1);
assert_eq!(out.chunks_dropped, 0);
assert_eq!(count_chunks(&cfg).unwrap(), 1);
// Score row persisted for the kept chunk
assert_eq!(count_scores(&cfg).unwrap(), 1);
// Entity index populated from regex extraction (alice@example.com + hashtag)
let alice_hits = lookup_entity(&cfg, "email:alice@example.com", None).unwrap();
assert_eq!(alice_hits.len(), 1);
let rows = list_chunks(&cfg, &ListChunksQuery::default()).unwrap();
assert_eq!(rows[0].metadata.source_kind, SourceKind::Chat);
}
#[tokio::test]
async fn low_signal_chunks_are_dropped_but_score_persists() {
let (_tmp, cfg) = test_config();
let batch = ChatBatch {
platform: "slack".into(),
channel_label: "#eng".into(),
messages: vec![ChatMessage {
author: "alice".into(),
timestamp: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
text: "+1".into(), // extremely low-signal
source_ref: None,
}],
};
let out = ingest_chat(&cfg, "slack:#eng", "alice", vec![], batch)
.await
.unwrap();
assert_eq!(out.chunks_written, 0);
assert_eq!(out.chunks_dropped, 1);
// Chunk NOT in chunks table
assert_eq!(count_chunks(&cfg).unwrap(), 0);
// Score row IS persisted for diagnostics
assert_eq!(count_scores(&cfg).unwrap(), 1);
}
#[tokio::test]
async fn ingest_chat_empty_batch_is_noop() {
let (_tmp, cfg) = test_config();
let batch = ChatBatch {
platform: "slack".into(),
channel_label: "#eng".into(),
messages: vec![],
};
let out = ingest_chat(&cfg, "slack:#eng", "alice", vec![], batch).unwrap();
let out = ingest_chat(&cfg, "slack:#eng", "alice", vec![], batch)
.await
.unwrap();
assert_eq!(out.chunks_written, 0);
assert_eq!(out.chunks_dropped, 0);
assert_eq!(count_chunks(&cfg).unwrap(), 0);
assert_eq!(count_scores(&cfg).unwrap(), 0);
}
#[test]
fn re_ingest_is_idempotent() {
#[tokio::test]
async fn re_ingest_is_idempotent_on_chunks_and_scores() {
let (_tmp, cfg) = test_config();
let doc = DocumentInput {
provider: "notion".into(),
title: "Launch plan".into(),
body: "content here".into(),
body: "We are planning to ship Phoenix on Friday after review. \
Coordination is via email and the launch thread tracks \
the relevant decisions. alice@example.com owns this."
.into(),
modified_at: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
source_ref: Some("notion://page/abc".into()),
};
ingest_document(&cfg, "notion:abc", "alice", vec![], doc.clone()).unwrap();
ingest_document(&cfg, "notion:abc", "alice", vec![], doc).unwrap();
ingest_document(&cfg, "notion:abc", "alice", vec![], doc.clone())
.await
.unwrap();
ingest_document(&cfg, "notion:abc", "alice", vec![], doc)
.await
.unwrap();
assert_eq!(count_chunks(&cfg).unwrap(), 1);
assert_eq!(count_scores(&cfg).unwrap(), 1);
}
#[test]
fn chunks_preserve_source_ref() {
#[tokio::test]
async fn chunks_preserve_source_ref_when_kept() {
let (_tmp, cfg) = test_config();
let doc = DocumentInput {
provider: "notion".into(),
title: "t".into(),
body: "b".into(),
body: "Phoenix launch plan with enough substance to pass the admission \
gate: we are reviewing the migration runbook alice@example.com \
on Friday evening."
.into(),
modified_at: Utc::now(),
source_ref: Some("notion://x".into()),
};
ingest_document(&cfg, "notion:x", "alice", vec![], doc).unwrap();
ingest_document(&cfg, "notion:x", "alice", vec![], doc)
.await
.unwrap();
let rows = list_chunks(&cfg, &ListChunksQuery::default()).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].metadata.source_ref.as_ref().unwrap().value,
"notion://x"
+1
View File
@@ -27,6 +27,7 @@ pub mod chunker;
pub mod ingest;
pub mod rpc;
pub mod schemas;
pub mod score;
pub mod store;
pub mod types;
+25 -26
View File
@@ -63,33 +63,32 @@ pub async fn ingest_rpc(
source_id
);
let result = tokio::task::spawn_blocking({
let config = config.clone();
let source_id = source_id.clone();
let owner = owner.clone();
move || -> anyhow::Result<IngestResult> {
match source_kind {
SourceKind::Chat => {
let batch: ChatBatch = serde_json::from_value(payload)
.map_err(|e| anyhow::anyhow!("invalid chat payload: {e}"))?;
do_ingest_chat(&config, &source_id, &owner, tags, batch)
}
SourceKind::Email => {
let thread: EmailThread = serde_json::from_value(payload)
.map_err(|e| anyhow::anyhow!("invalid email payload: {e}"))?;
do_ingest_email(&config, &source_id, &owner, tags, thread)
}
SourceKind::Document => {
let doc: DocumentInput = serde_json::from_value(payload)
.map_err(|e| anyhow::anyhow!("invalid document payload: {e}"))?;
do_ingest_document(&config, &source_id, &owner, tags, doc)
}
}
// Phase 2: ingest functions are async. Their scoring stage awaits the
// extractor (cheap for regex, not-cheap for future GLiNER/LLM impls)
// and the DB work is isolated on `spawn_blocking` inside `persist`.
let result = match source_kind {
SourceKind::Chat => {
let batch: ChatBatch = serde_json::from_value(payload)
.map_err(|e| format!("invalid chat payload: {e}"))?;
do_ingest_chat(config, &source_id, &owner, tags, batch)
.await
.map_err(|e| format!("ingest: {e}"))?
}
})
.await
.map_err(|e| format!("ingest join error: {e}"))?
.map_err(|e| format!("ingest: {e}"))?;
SourceKind::Email => {
let thread: EmailThread = serde_json::from_value(payload)
.map_err(|e| format!("invalid email payload: {e}"))?;
do_ingest_email(config, &source_id, &owner, tags, thread)
.await
.map_err(|e| format!("ingest: {e}"))?
}
SourceKind::Document => {
let doc: DocumentInput = serde_json::from_value(payload)
.map_err(|e| format!("invalid document payload: {e}"))?;
do_ingest_document(config, &source_id, &owner, tags, doc)
.await
.map_err(|e| format!("ingest: {e}"))?
}
};
Ok(RpcOutcome::single_log(
result,
+8 -2
View File
@@ -98,13 +98,19 @@ pub fn schemas(function: &str) -> ControllerSchema {
FieldSchema {
name: "chunks_written",
ty: TypeSchema::U64,
comment: "Number of chunks persisted (including idempotent rewrites).",
comment: "Number of chunks persisted after admission.",
required: true,
},
FieldSchema {
name: "chunks_dropped",
ty: TypeSchema::U64,
comment: "Number of chunks rejected by the admission gate.",
required: true,
},
FieldSchema {
name: "chunk_ids",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "IDs of all chunks produced.",
comment: "IDs of all chunks persisted after admission.",
required: true,
},
],
@@ -0,0 +1,106 @@
use async_trait::async_trait;
use super::regex;
use super::types::ExtractedEntities;
/// Interface for anything that can read a chunk's text and emit entities.
#[async_trait]
pub trait EntityExtractor: Send + Sync {
/// Human-readable name for logs and diagnostics.
fn name(&self) -> &'static str;
/// Run extraction. Implementations should be idempotent per input.
async fn extract(&self, text: &str) -> anyhow::Result<ExtractedEntities>;
}
/// Synchronous regex extractor adapted to the async [`EntityExtractor`] trait.
pub struct RegexEntityExtractor;
#[async_trait]
impl EntityExtractor for RegexEntityExtractor {
fn name(&self) -> &'static str {
"regex"
}
async fn extract(&self, text: &str) -> anyhow::Result<ExtractedEntities> {
Ok(regex::extract(text))
}
}
/// Runs a sequence of extractors and merges their results.
///
/// An extractor returning an error is logged and skipped — one bad extractor
/// does not abort ingestion.
pub struct CompositeExtractor {
inner: Vec<Box<dyn EntityExtractor>>,
}
impl CompositeExtractor {
pub fn new(inner: Vec<Box<dyn EntityExtractor>>) -> Self {
Self { inner }
}
/// Convenience constructor: regex-only (the Phase 2 default).
pub fn regex_only() -> Self {
Self::new(vec![Box::new(RegexEntityExtractor)])
}
}
#[async_trait]
impl EntityExtractor for CompositeExtractor {
fn name(&self) -> &'static str {
"composite"
}
async fn extract(&self, text: &str) -> anyhow::Result<ExtractedEntities> {
let mut out = ExtractedEntities::default();
for ex in &self.inner {
match ex.extract(text).await {
Ok(batch) => out.merge(batch),
Err(e) => {
log::warn!(
"[memory_tree::extract] extractor `{}` failed: {e} — continuing",
ex.name()
);
}
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory::tree::score::extract::EntityKind;
#[tokio::test]
async fn regex_only_extractor_works() {
let c = CompositeExtractor::regex_only();
let out = c.extract("hi @alice a@b.com #launch").await.unwrap();
assert!(out.entities.iter().any(|e| e.kind == EntityKind::Handle));
assert!(out.entities.iter().any(|e| e.kind == EntityKind::Email));
assert!(out.entities.iter().any(|e| e.kind == EntityKind::Hashtag));
}
struct FailingExtractor;
#[async_trait]
impl EntityExtractor for FailingExtractor {
fn name(&self) -> &'static str {
"failing"
}
async fn extract(&self, _: &str) -> anyhow::Result<ExtractedEntities> {
Err(anyhow::anyhow!("boom"))
}
}
#[tokio::test]
async fn composite_survives_one_failing_extractor() {
let c = CompositeExtractor::new(vec![
Box::new(FailingExtractor),
Box::new(RegexEntityExtractor),
]);
let out = c.extract("@alice").await.unwrap();
assert!(out.entities.iter().any(|e| e.kind == EntityKind::Handle));
}
}
@@ -0,0 +1,13 @@
//! Entity extraction (Phase 2 / #708).
//!
//! Exposes [`EntityExtractor`] as a pluggable interface and a default
//! [`CompositeExtractor`] that runs a chain of extractors and merges their
//! output. Phase 2 ships with the mechanical regex extractor only; semantic
//! NER (GLiNER / LLM) plugs in later without changing any call sites.
mod extractor;
pub mod regex;
pub mod types;
pub use extractor::{CompositeExtractor, EntityExtractor, RegexEntityExtractor};
pub use types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic};
@@ -0,0 +1,209 @@
//! Deterministic mechanical-entity extraction via regex.
//!
//! Catches the shapes regex handles cleanly and that are genuinely useful
//! as cross-platform identity anchors (email appearing in Slack + Gmail =
//! same person):
//!
//! - **Email** — RFC-ish pattern, boundary-guarded
//! - **URL** — `http(s)://…` up to whitespace or trailing punctuation
//! - **Handle** — `@alice`, `@alice.bsky.social`, or `alice#1234`
//! - **Hashtag** — `#launch-q2`
//!
//! Every match has `score = 1.0` (regex is deterministic). Spans are
//! char-offsets (not bytes) for UTF-8 safety.
use once_cell::sync::Lazy;
use regex::Regex;
use super::types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic};
// ── Compiled regexes (once per process) ──────────────────────────────────
static RE_EMAIL: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b").unwrap());
static RE_URL: Lazy<Regex> = Lazy::new(|| {
// up-to trailing punctuation; avoids catastrophic backtracking
Regex::new(r"https?://[^\s<>\]\[()]+[^\s<>\]\[()\.\,;:\!\?]").unwrap()
});
static RE_HANDLE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|[\s(])@([A-Za-z0-9_][A-Za-z0-9_.\-]{1,})").unwrap());
static RE_DISCRIM: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b([A-Za-z0-9_.\-]{2,32})#\d{4}\b").unwrap());
static RE_HASHTAG: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|[\s(])#([A-Za-z][A-Za-z0-9_\-]{1,})").unwrap());
/// Extract all mechanical entities from `text`.
pub fn extract(text: &str) -> ExtractedEntities {
let mut entities: Vec<ExtractedEntity> = Vec::new();
let mut topics: Vec<ExtractedTopic> = Vec::new();
for m in RE_EMAIL.find_iter(text) {
entities.push(to_entity(text, m.start(), m.end(), EntityKind::Email));
}
for m in RE_URL.find_iter(text) {
entities.push(to_entity(text, m.start(), m.end(), EntityKind::Url));
}
for cap in RE_HANDLE.captures_iter(text) {
if let Some(m) = cap.get(1) {
entities.push(to_entity(text, m.start(), m.end(), EntityKind::Handle));
}
}
for cap in RE_DISCRIM.captures_iter(text) {
if let Some(m) = cap.get(0) {
entities.push(to_entity(text, m.start(), m.end(), EntityKind::Handle));
}
}
for cap in RE_HASHTAG.captures_iter(text) {
if let Some(m) = cap.get(1) {
entities.push(to_entity(text, m.start(), m.end(), EntityKind::Hashtag));
topics.push(ExtractedTopic {
label: text[m.start()..m.end()].to_lowercase(),
score: 1.0,
});
}
}
ExtractedEntities { entities, topics }
}
fn to_entity(text: &str, start: usize, end: usize, kind: EntityKind) -> ExtractedEntity {
ExtractedEntity {
kind,
text: text[start..end].to_string(),
span_start: char_index(text, start),
span_end: char_index(text, end),
score: 1.0,
}
}
fn char_index(s: &str, byte_idx: usize) -> u32 {
let byte_idx = byte_idx.min(s.len());
s[..byte_idx].chars().count() as u32
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(e: &ExtractedEntities) -> Vec<EntityKind> {
let mut k: Vec<_> = e.entities.iter().map(|x| x.kind).collect();
k.sort_by_key(|k| *k as u8);
k
}
#[test]
fn email_basic() {
let o = extract("contact alice@example.com please");
assert_eq!(o.entities.len(), 1);
assert_eq!(o.entities[0].kind, EntityKind::Email);
assert_eq!(o.entities[0].text, "alice@example.com");
}
#[test]
fn url_stops_at_trailing_punct() {
let o = extract("see https://example.com/x?y=1 now.");
let urls: Vec<_> = o
.entities
.iter()
.filter(|e| e.kind == EntityKind::Url)
.collect();
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].text, "https://example.com/x?y=1");
}
#[test]
fn handle_vs_email_boundary() {
let o = extract("@alice met alice@example.com and @bob");
let handles: Vec<_> = o
.entities
.iter()
.filter(|e| e.kind == EntityKind::Handle)
.map(|e| e.text.as_str())
.collect();
let emails: Vec<_> = o
.entities
.iter()
.filter(|e| e.kind == EntityKind::Email)
.map(|e| e.text.as_str())
.collect();
assert_eq!(handles, vec!["alice", "bob"]);
assert_eq!(emails, vec!["alice@example.com"]);
}
#[test]
fn discord_style_handle() {
let o = extract("ping alice#1234");
let h: Vec<_> = o
.entities
.iter()
.filter(|e| e.kind == EntityKind::Handle)
.collect();
assert_eq!(h.len(), 1);
assert_eq!(h[0].text, "alice#1234");
}
#[test]
fn hashtag_emits_topic() {
let o = extract("tracking #launch-q2 updates");
assert_eq!(
o.entities
.iter()
.filter(|e| e.kind == EntityKind::Hashtag)
.count(),
1
);
assert_eq!(o.topics.len(), 1);
assert_eq!(o.topics[0].label, "launch-q2");
}
#[test]
fn hashtag_requires_leading_letter() {
let o = extract("#123 no, #x1 yes");
let tags: Vec<_> = o
.entities
.iter()
.filter(|e| e.kind == EntityKind::Hashtag)
.collect();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].text, "x1");
}
#[test]
fn utf8_span_is_char_not_byte() {
let o = extract("中 a@b.com");
let email = o
.entities
.iter()
.find(|e| e.kind == EntityKind::Email)
.unwrap();
assert_eq!(email.span_start, 2);
}
#[test]
fn all_mechanical_kinds_in_one_pass() {
let o = extract("email a@b.com, url https://x.com, @alice, #topic1");
let k = kinds(&o);
assert!(k.contains(&EntityKind::Email));
assert!(k.contains(&EntityKind::Url));
assert!(k.contains(&EntityKind::Handle));
assert!(k.contains(&EntityKind::Hashtag));
}
#[test]
fn scores_always_one() {
let o = extract("a@b.com #x @y https://q.com");
for e in &o.entities {
assert!((e.score - 1.0).abs() < f32::EPSILON);
}
}
#[test]
fn empty_input_no_matches() {
let o = extract("plain prose with no identifiers");
assert!(o.entities.is_empty());
}
}
@@ -0,0 +1,259 @@
//! Types produced by entity extractors (Phase 2 / #708).
//!
//! The pipeline runs one or more [`super::EntityExtractor`] impls over each
//! admitted chunk and collects all their output into [`ExtractedEntities`].
use serde::{Deserialize, Serialize};
/// Classification of an extracted span.
///
/// Split into two categories:
/// - **Mechanical** — regex finds these deterministically. Stable, high precision,
/// limited recall. These are "identifiers" (pointers), not "entities"
/// in the semantic sense.
/// - **Semantic** — model-based (future GLiNER / LLM). Named references to
/// real-world objects: Person, Organization, Location, Event, Product.
///
/// Phase 2 ships with mechanical-only; semantic variants are populated in
/// Phase 3+ either at seal time by the summariser LLM or by a dedicated
/// per-chunk NER step if added later.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EntityKind {
// Mechanical
Email,
Url,
Handle,
Hashtag,
// Semantic (reserved — not emitted in Phase 2)
Person,
Organization,
Location,
Event,
Product,
Misc,
}
impl EntityKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Email => "email",
Self::Url => "url",
Self::Handle => "handle",
Self::Hashtag => "hashtag",
Self::Person => "person",
Self::Organization => "organization",
Self::Location => "location",
Self::Event => "event",
Self::Product => "product",
Self::Misc => "misc",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"email" => Ok(Self::Email),
"url" => Ok(Self::Url),
"handle" => Ok(Self::Handle),
"hashtag" => Ok(Self::Hashtag),
"person" => Ok(Self::Person),
"organization" => Ok(Self::Organization),
"location" => Ok(Self::Location),
"event" => Ok(Self::Event),
"product" => Ok(Self::Product),
"misc" => Ok(Self::Misc),
other => Err(format!("unknown entity kind: {other}")),
}
}
/// Whether this kind comes from deterministic extraction.
pub fn is_mechanical(self) -> bool {
matches!(self, Self::Email | Self::Url | Self::Handle | Self::Hashtag)
}
}
/// One extracted span from a chunk's content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExtractedEntity {
pub kind: EntityKind,
/// Surface form as it appears in the chunk.
pub text: String,
/// Character offsets `[start, end)` into the chunk text.
pub span_start: u32,
pub span_end: u32,
/// Extractor confidence `[0.0, 1.0]`. Regex = 1.0; model-based = output.
pub score: f32,
}
/// Topic candidate (hashtag-style or summariser-labeled).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExtractedTopic {
/// Normalised topic text (lowercase, no leading `#`).
pub label: String,
pub score: f32,
}
/// Aggregate output of one or more extractors on a single chunk.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ExtractedEntities {
pub entities: Vec<ExtractedEntity>,
pub topics: Vec<ExtractedTopic>,
}
impl ExtractedEntities {
pub fn is_empty(&self) -> bool {
self.entities.is_empty() && self.topics.is_empty()
}
/// Count of unique `(kind, text)` pairs, case-insensitive. Used as a scoring signal.
pub fn unique_entity_count(&self) -> usize {
use std::collections::BTreeSet;
self.entities
.iter()
.map(|e| (e.kind, e.text.to_lowercase()))
.collect::<BTreeSet<_>>()
.len()
}
/// 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.
pub fn merge(&mut self, other: ExtractedEntities) {
use std::collections::BTreeSet;
let mut seen: BTreeSet<(EntityKind, String, u32)> = self
.entities
.iter()
.map(|e| (e.kind, e.text.to_lowercase(), e.span_start))
.collect();
for e in other.entities {
let key = (e.kind, e.text.to_lowercase(), e.span_start);
if seen.insert(key) {
self.entities.push(e);
}
}
let mut topic_seen: BTreeSet<String> =
self.topics.iter().map(|t| t.label.clone()).collect();
for t in other.topics {
if topic_seen.insert(t.label.clone()) {
self.topics.push(t);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entity_kind_round_trip() {
for k in [
EntityKind::Email,
EntityKind::Url,
EntityKind::Handle,
EntityKind::Hashtag,
EntityKind::Person,
EntityKind::Organization,
EntityKind::Location,
EntityKind::Event,
EntityKind::Product,
EntityKind::Misc,
] {
assert_eq!(EntityKind::parse(k.as_str()).unwrap(), k);
}
}
#[test]
fn mechanical_classification() {
assert!(EntityKind::Email.is_mechanical());
assert!(EntityKind::Url.is_mechanical());
assert!(EntityKind::Handle.is_mechanical());
assert!(EntityKind::Hashtag.is_mechanical());
assert!(!EntityKind::Person.is_mechanical());
}
#[test]
fn unique_entity_count_dedups_case_insensitive() {
let e = ExtractedEntities {
entities: vec![
ExtractedEntity {
kind: EntityKind::Person,
text: "Alice".into(),
span_start: 0,
span_end: 5,
score: 1.0,
},
ExtractedEntity {
kind: EntityKind::Person,
text: "alice".into(),
span_start: 10,
span_end: 15,
score: 1.0,
},
],
topics: vec![],
};
assert_eq!(e.unique_entity_count(), 1);
}
#[test]
fn unique_entity_count_keeps_different_kinds_distinct() {
let e = ExtractedEntities {
entities: vec![
ExtractedEntity {
kind: EntityKind::Handle,
text: "alice".into(),
span_start: 0,
span_end: 5,
score: 1.0,
},
ExtractedEntity {
kind: EntityKind::Hashtag,
text: "alice".into(),
span_start: 10,
span_end: 15,
score: 1.0,
},
],
topics: vec![],
};
assert_eq!(e.unique_entity_count(), 2);
}
#[test]
fn merge_dedups_by_kind_text_span() {
let mut a = ExtractedEntities {
entities: vec![ExtractedEntity {
kind: EntityKind::Email,
text: "x@y.com".into(),
span_start: 0,
span_end: 7,
score: 1.0,
}],
topics: vec![],
};
let b = ExtractedEntities {
entities: vec![
ExtractedEntity {
kind: EntityKind::Email,
text: "x@y.com".into(),
span_start: 0,
span_end: 7,
score: 1.0,
}, // dup
ExtractedEntity {
kind: EntityKind::Email,
text: "x@y.com".into(),
span_start: 50,
span_end: 57,
score: 1.0,
}, // different span — keep
],
topics: vec![],
};
a.merge(b);
assert_eq!(a.entities.len(), 2);
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Phase 2: scoring / admission / enrichment pipeline (#708).
//!
//! Wraps extraction, signal computation, admission gate, canonicalisation,
//! and persistence into one call per chunk. Phase 1 `_ingest_one_chunk`
//! passes each chunk through [`score_chunk`] after chunking and before
//! storing.
pub mod extract;
pub mod resolver;
pub mod signals;
pub mod store;
use std::sync::Arc;
use anyhow::Result;
use chrono::Utc;
use futures_util::future::try_join_all;
use rusqlite::Transaction;
use serde::{Deserialize, Serialize};
use self::extract::{EntityExtractor, ExtractedEntities};
use self::resolver::{canonicalise, CanonicalEntity};
use self::signals::{ScoreSignals, SignalWeights};
use crate::openhuman::memory::tree::types::{approx_token_count, Chunk, SourceKind};
/// Default drop threshold. Chunks with `total < DEFAULT_DROP_THRESHOLD`
/// are tombstoned and never reach the chunk store.
pub const DEFAULT_DROP_THRESHOLD: f32 = 0.3;
/// Whole outcome of [`score_chunk`].
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ScoreResult {
pub chunk_id: String,
pub total: f32,
pub signals: ScoreSignals,
pub kept: bool,
pub drop_reason: Option<String>,
pub extracted: ExtractedEntities,
pub canonical_entities: Vec<CanonicalEntity>,
}
/// Configuration passed through the ingest pipeline for Phase 2 behaviour.
///
/// 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.
pub struct ScoringConfig {
pub extractor: Arc<dyn EntityExtractor>,
pub weights: SignalWeights,
pub drop_threshold: f32,
}
impl ScoringConfig {
/// Phase 2 default: regex-only extractor, default weights, default threshold.
pub fn default_regex_only() -> Self {
Self {
extractor: Arc::new(extract::CompositeExtractor::regex_only()),
weights: SignalWeights::default(),
drop_threshold: DEFAULT_DROP_THRESHOLD,
}
}
}
/// Compute the score for one chunk.
///
/// Pure function — does not touch the store. Callers decide what to persist
/// based on [`ScoreResult::kept`].
pub async fn score_chunk(chunk: &Chunk, cfg: &ScoringConfig) -> Result<ScoreResult> {
log::debug!(
"[memory_tree::score] score_chunk chunk_id={} tokens={}",
chunk.id,
chunk.token_count
);
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?;
// 2. Compute signals
let signals = self::signals::compute(
&chunk.metadata,
&scoring_content,
scoring_token_count,
&extracted,
);
// 3. Weighted combine
let total = self::signals::combine(&signals, &cfg.weights);
// 4. 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 =
scoring_token_count < self::signals::token_count::TOKEN_MIN && extracted.is_empty();
let kept = !tiny_entity_free && total >= cfg.drop_threshold;
let drop_reason = if kept {
None
} else if tiny_entity_free {
Some(format!(
"token_count {} < minimum {} and no entities extracted",
scoring_token_count,
self::signals::token_count::TOKEN_MIN
))
} else {
Some(format!(
"total {total:.3} < threshold {:.3}",
cfg.drop_threshold
))
};
// 5. 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={:?}",
chunk.id,
total,
drop_reason
);
}
Ok(ScoreResult {
chunk_id: chunk.id.clone(),
total,
signals,
kept,
drop_reason,
extracted,
canonical_entities,
})
}
fn scoring_content_for_chunk(chunk: &Chunk) -> String {
if chunk.metadata.source_kind != SourceKind::Chat {
return chunk.content.clone();
}
chunk
.content
.lines()
.filter(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with("# Chat transcript") && !trimmed.starts_with("## ")
})
.collect::<Vec<_>>()
.join("\n")
}
/// Score a batch of chunks. Errors from any single chunk fail the batch —
/// scoring is pure-ish (only the extractor may error) and a failure here is
/// a real bug, not a per-chunk issue to tolerate silently.
pub async fn score_chunks(chunks: &[Chunk], cfg: &ScoringConfig) -> Result<Vec<ScoreResult>> {
try_join_all(chunks.iter().map(|chunk| score_chunk(chunk, cfg))).await
}
// ── Persistence helpers used by the ingest orchestrator ─────────────────
/// Persist the score row + entity-index rows for one kept chunk.
///
/// The caller is responsible for having already written the chunk itself
/// into `mem_tree_chunks` (so the FK-like relation is satisfied). Dropped
/// chunks still get a score row persisted for diagnostics — callers should
/// pass `None` for `tree_id` in that case, since the chunk won't appear in
/// a tree.
pub fn persist_score(
config: &crate::openhuman::config::Config,
result: &ScoreResult,
timestamp_ms: i64,
tree_id: Option<&str>,
) -> Result<()> {
let row = score_row(result);
store::upsert_score(config, &row)?;
if result.kept {
// Clear any stale entity-index rows for this chunk before re-indexing.
// INSERT OR REPLACE on (entity_id, node_id) never deletes rows whose
// entity_id is no longer present in the new extraction — so a re-score
// that drops an entity would otherwise leave a phantom index row.
store::clear_entity_index_for_node(config, &result.chunk_id)?;
if !result.canonical_entities.is_empty() {
store::index_entities(
config,
&result.canonical_entities,
&result.chunk_id,
"leaf",
timestamp_ms,
tree_id,
)?;
}
}
Ok(())
}
pub(crate) fn persist_score_tx(
tx: &Transaction<'_>,
result: &ScoreResult,
timestamp_ms: i64,
tree_id: Option<&str>,
) -> Result<()> {
let row = score_row(result);
store::upsert_score_tx(tx, &row)?;
if result.kept {
// See persist_score for why we clear before re-indexing.
store::clear_entity_index_for_node_tx(tx, &result.chunk_id)?;
if !result.canonical_entities.is_empty() {
store::index_entities_tx(
tx,
&result.canonical_entities,
&result.chunk_id,
"leaf",
timestamp_ms,
tree_id,
)?;
}
}
Ok(())
}
fn score_row(result: &ScoreResult) -> store::ScoreRow {
// Score rows keep wall-clock scoring time; the separate timestamp_ms
// argument used for entity indexes is the source/ingest ordering time.
store::ScoreRow {
chunk_id: result.chunk_id.clone(),
total: result.total,
signals: result.signals.clone(),
dropped: !result.kept,
reason: result.drop_reason.clone(),
computed_at_ms: Utc::now().timestamp_millis(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind};
use chrono::Utc;
fn test_chunk(content: &str) -> Chunk {
let meta = Metadata::point_in_time(SourceKind::Email, "t1", "alice", Utc::now());
Chunk {
id: chunk_id(SourceKind::Email, "t1", 0),
content: content.to_string(),
token_count: crate::openhuman::memory::tree::types::approx_token_count(content),
metadata: meta,
seq_in_source: 0,
created_at: Utc::now(),
}
}
#[tokio::test]
async fn substantive_chunk_is_kept() {
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.",
);
let cfg = ScoringConfig::default_regex_only();
let r = score_chunk(&c, &cfg).await.unwrap();
assert!(r.kept, "expected kept, got total={}", r.total);
assert!(r.drop_reason.is_none());
assert!(!r.extracted.entities.is_empty());
assert!(!r.canonical_entities.is_empty());
}
#[tokio::test]
async fn noise_chunk_is_dropped() {
// Very short — below TOKEN_MIN — and no entities.
let c = test_chunk("lol");
let cfg = ScoringConfig::default_regex_only();
let r = score_chunk(&c, &cfg).await.unwrap();
assert!(!r.kept);
assert!(r.drop_reason.is_some());
}
#[tokio::test]
async fn threshold_override_respected() {
let c = test_chunk("just ok content, mid-signal");
let mut cfg = ScoringConfig::default_regex_only();
cfg.drop_threshold = 0.99; // unreasonably high
let r = score_chunk(&c, &cfg).await.unwrap();
assert!(!r.kept);
}
#[tokio::test]
async fn entities_are_canonicalised() {
let c = test_chunk("ping Alice@Example.com — she @alice replied to thread");
let cfg = ScoringConfig::default_regex_only();
let r = score_chunk(&c, &cfg).await.unwrap();
// Email (lowercased) and handle canonical ids should both appear
let ids: Vec<_> = r
.canonical_entities
.iter()
.map(|e| e.canonical_id.as_str())
.collect();
assert!(ids.iter().any(|id| *id == "email:alice@example.com"));
assert!(ids.iter().any(|id| *id == "handle:alice"));
}
}
+136
View File
@@ -0,0 +1,136 @@
//! Entity canonicalisation / cross-platform merge (Phase 2 / #708, V1).
//!
//! Exact-match only: normalises surface forms (lowercase emails, strip
//! leading `@` on handles) and assigns a canonical `entity_id` string.
//!
//! Fuzzy matching (alice-slack ≡ Alice-Discord by soft match) is deferred
//! until we have real entity-graph data — the current implementation
//! handles the mechanical cases cleanly without producing false merges.
use serde::{Deserialize, Serialize};
use crate::openhuman::memory::tree::score::extract::{EntityKind, ExtractedEntities};
/// Canonicalised entity — same shape as [`ExtractedEntity`] plus a stable
/// `canonical_id` suitable for indexing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CanonicalEntity {
pub canonical_id: String,
pub kind: EntityKind,
pub surface: String,
pub span_start: u32,
pub span_end: u32,
pub score: f32,
}
/// Canonicalise a batch of extracted entities.
///
/// Same surface form (after normalisation) → same `canonical_id` regardless
/// of how many times it appears in a chunk. Preserves source spans by
/// emitting one [`CanonicalEntity`] per occurrence.
pub fn canonicalise(extracted: &ExtractedEntities) -> Vec<CanonicalEntity> {
extracted
.entities
.iter()
.map(|e| CanonicalEntity {
canonical_id: canonical_id_for(e.kind, &e.text),
kind: e.kind,
surface: e.text.clone(),
span_start: e.span_start,
span_end: e.span_end,
score: e.score,
})
.collect()
}
/// Canonical id form per kind. Deterministic so the same surface always
/// maps to the same id.
///
/// - Email: `email:lowercased`
/// - Handle: `handle:lowercased` with leading `@` stripped
/// - Hashtag: `hashtag:lowercased` with leading `#` stripped
/// - URL: `url:trimmed` with case preserved for path/query exact matching
/// - Semantic kinds: `kind:lowercased-surface` (V1; fuzzy merge deferred)
pub fn canonical_id_for(kind: EntityKind, surface: &str) -> String {
let trimmed = surface.trim();
let clean = if kind == EntityKind::Url {
trimmed.to_string()
} else {
trimmed
.to_lowercase()
.trim_start_matches('@')
.trim_start_matches('#')
.to_string()
};
format!("{}:{}", kind.as_str(), clean)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory::tree::score::extract::ExtractedEntity;
fn entity(kind: EntityKind, text: &str) -> ExtractedEntity {
ExtractedEntity {
kind,
text: text.to_string(),
span_start: 0,
span_end: text.chars().count() as u32,
score: 1.0,
}
}
#[test]
fn email_case_insensitive_canonicalises() {
let a = canonical_id_for(EntityKind::Email, "Alice@Example.com");
let b = canonical_id_for(EntityKind::Email, "alice@example.com");
assert_eq!(a, b);
assert_eq!(a, "email:alice@example.com");
}
#[test]
fn handle_strips_leading_at() {
let a = canonical_id_for(EntityKind::Handle, "@alice");
let b = canonical_id_for(EntityKind::Handle, "alice");
assert_eq!(a, b);
assert_eq!(a, "handle:alice");
}
#[test]
fn hashtag_strips_leading_hash() {
let a = canonical_id_for(EntityKind::Hashtag, "#launch");
let b = canonical_id_for(EntityKind::Hashtag, "launch");
assert_eq!(a, b);
}
#[test]
fn url_preserves_case() {
let id = canonical_id_for(EntityKind::Url, " https://example.com/Path?Token=ABC ");
assert_eq!(id, "url:https://example.com/Path?Token=ABC");
}
#[test]
fn canonicalise_batch_preserves_spans() {
let ex = ExtractedEntities {
entities: vec![
entity(EntityKind::Email, "Alice@Example.com"),
entity(EntityKind::Email, "alice@example.com"),
],
topics: vec![],
};
let out = canonicalise(&ex);
assert_eq!(out.len(), 2);
// Both map to the same canonical id (merge-equivalent)
assert_eq!(out[0].canonical_id, out[1].canonical_id);
// But surface forms remain distinct
assert_ne!(out[0].surface, out[1].surface);
}
#[test]
fn different_kinds_produce_different_ids_for_same_text() {
assert_ne!(
canonical_id_for(EntityKind::Handle, "alice"),
canonical_id_for(EntityKind::Person, "alice")
);
}
}
@@ -0,0 +1,101 @@
//! Interaction-weight signal — boosts chunks the user actively engaged with.
//!
//! Direct engagement is one of the strongest retention signals — "a message
//! you replied to" is almost always worth remembering, even if its content
//! looks noisy by other signals.
//!
//! Phase 2 infers engagement from a small set of reserved **tags**:
//! - `reply` — the user replied to this message/thread
//! - `sent` — the user authored this content
//! - `mention` — the user was @-mentioned
//! - `dm` — this arrived in a direct-message channel
//!
//! Ingest adapters can attach these tags during canonicalisation when the
//! upstream source supports the distinction. Absent tags → neutral score.
use crate::openhuman::memory::tree::types::Metadata;
pub const TAG_REPLY: &str = "reply";
pub const TAG_SENT: &str = "sent";
pub const TAG_MENTION: &str = "mention";
pub const TAG_DM: &str = "dm";
/// Score in `[0.0, 1.0]` based on engagement tags present on the chunk.
///
/// Multiple tags stack (capped at 1.0):
/// - `sent` → +0.6 (author)
/// - `reply` → +0.5 (active dialogue)
/// - `dm` → +0.3 (scoped audience)
/// - `mention` → +0.2 (addressed)
///
/// Absent any of these → 0.5 (neutral — don't drop the chunk on this signal
/// alone since most content lacks explicit engagement tags).
pub fn score(meta: &Metadata) -> f32 {
let mut any_tag = false;
let mut total: f32 = 0.0;
for t in &meta.tags {
match t.as_str() {
TAG_SENT => {
total += 0.6;
any_tag = true;
}
TAG_REPLY => {
total += 0.5;
any_tag = true;
}
TAG_DM => {
total += 0.3;
any_tag = true;
}
TAG_MENTION => {
total += 0.2;
any_tag = true;
}
_ => {}
}
}
if !any_tag {
return 0.5;
}
total.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory::tree::types::SourceKind;
use chrono::Utc;
fn meta(tags: &[&str]) -> Metadata {
let mut m = Metadata::point_in_time(SourceKind::Chat, "x", "owner", Utc::now());
m.tags = tags.iter().map(|s| s.to_string()).collect();
m
}
#[test]
fn no_tags_neutral() {
assert_eq!(score(&meta(&[])), 0.5);
assert_eq!(score(&meta(&["unrelated"])), 0.5);
}
#[test]
fn sent_tag_high_score() {
assert!((score(&meta(&["sent"])) - 0.6).abs() < 1e-6);
}
#[test]
fn stacking_capped_at_one() {
// sent (0.6) + reply (0.5) + mention (0.2) = 1.3 → clamp to 1.0
assert!((score(&meta(&["sent", "reply", "mention"])) - 1.0).abs() < 1e-6);
}
#[test]
fn reply_only() {
assert!((score(&meta(&["reply"])) - 0.5).abs() < 1e-6);
}
#[test]
fn dm_plus_mention() {
assert!((score(&meta(&["dm", "mention"])) - 0.5).abs() < 1e-6);
}
}
@@ -0,0 +1,49 @@
//! Metadata-weight signal — base weight from the source kind's grouping.
//!
//! The idea: a 1:1 email thread is inherently higher-signal than a broadcast
//! Slack channel, regardless of content. This signal captures the "shape"
//! of the interaction: how scoped is the audience?
//!
//! Phase 2 keeps this simple: one weight per `SourceKind`. Per-grouping
//! context (e.g., channel size, thread participant count) is a future
//! refinement when we actually have that metadata at ingest.
use crate::openhuman::memory::tree::types::{Metadata, SourceKind};
/// Base weight for each source kind.
///
/// Email threads are typically scoped (1:1 or small groups, directed).
/// Documents are single-author outputs — high intentionality per chunk.
/// Chats vary widely; base weight is lower because the channel could be
/// a 200-person broadcast or a tight DM — the interaction signal disambiguates.
pub fn score(meta: &Metadata) -> f32 {
match meta.source_kind {
SourceKind::Email => 0.8,
SourceKind::Document => 0.9,
SourceKind::Chat => 0.5,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn meta(kind: SourceKind) -> Metadata {
Metadata::point_in_time(kind, "x", "owner", Utc::now())
}
#[test]
fn per_kind_weights() {
assert!(score(&meta(SourceKind::Document)) > score(&meta(SourceKind::Email)));
assert!(score(&meta(SourceKind::Email)) > score(&meta(SourceKind::Chat)));
}
#[test]
fn bounded_zero_one() {
for k in [SourceKind::Chat, SourceKind::Email, SourceKind::Document] {
let s = score(&meta(k));
assert!((0.0..=1.0).contains(&s));
}
}
}
@@ -0,0 +1,20 @@
//! Score signals + weighted combine (Phase 2 / #708).
//!
//! Each submodule computes one scoring signal in `[0.0, 1.0]`. [`combine`]
//! aggregates them into a total score using per-signal weights. The output
//! is still `[0.0, 1.0]` after normalisation by total weight.
//!
//! Storing per-signal values alongside the total (via [`ScoreSignals`]) is
//! what makes admission decisions debuggable — when a chunk is dropped, we
//! persist *which* signals fired at what values.
pub mod interaction;
pub mod metadata_weight;
mod ops;
pub mod source_weight;
pub mod token_count;
mod types;
pub mod unique_words;
pub use ops::{combine, compute, entity_density_score};
pub use types::{ScoreSignals, SignalWeights};
@@ -0,0 +1,142 @@
use super::{interaction, metadata_weight, source_weight, token_count, unique_words};
use super::{ScoreSignals, SignalWeights};
use crate::openhuman::memory::tree::score::extract::ExtractedEntities;
use crate::openhuman::memory::tree::types::Metadata;
/// Compute all signals for a chunk.
pub fn compute(
meta: &Metadata,
content: &str,
token_count: u32,
ex: &ExtractedEntities,
) -> ScoreSignals {
ScoreSignals {
token_count: token_count::score(token_count),
unique_words: unique_words::score(content),
metadata_weight: metadata_weight::score(meta),
source_weight: source_weight::score(meta),
interaction: interaction::score(meta),
entity_density: entity_density_score(token_count, ex),
}
}
/// Entity-density signal: entities per token, capped.
///
/// More distinct entities per unit of content → more substantive. Calibrated
/// so ~1 entity per 100 tokens maxes out the signal.
pub fn entity_density_score(token_count: u32, ex: &ExtractedEntities) -> f32 {
let unique = ex.unique_entity_count() as f32;
if token_count == 0 {
return 0.0;
}
let per_token = unique / token_count as f32;
// cap at 0.01 entities/token = 1 entity per 100 tokens
(per_token / 0.01).min(1.0)
}
/// Weighted sum of signals, normalised to `[0.0, 1.0]`.
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;
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;
(weighted / total_weight).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory::tree::score::extract::{
EntityKind, ExtractedEntities, ExtractedEntity,
};
use crate::openhuman::memory::tree::types::SourceKind;
use chrono::Utc;
fn meta(tags: &[&str], kind: SourceKind) -> Metadata {
let mut m = Metadata::point_in_time(kind, "x", "owner", Utc::now());
m.tags = tags.iter().map(|s| s.to_string()).collect();
m
}
fn make_entities(n: usize) -> ExtractedEntities {
ExtractedEntities {
entities: (0..n)
.map(|i| ExtractedEntity {
kind: EntityKind::Email,
text: format!("user{i}@example.com"),
span_start: 0,
span_end: 10,
score: 1.0,
})
.collect(),
topics: vec![],
}
}
#[test]
fn combine_all_zeros_is_zero() {
let s = ScoreSignals::default();
assert!(combine(&s, &SignalWeights::default()) < 0.01);
}
#[test]
fn combine_all_ones_is_one() {
let s = ScoreSignals {
token_count: 1.0,
unique_words: 1.0,
metadata_weight: 1.0,
source_weight: 1.0,
interaction: 1.0,
entity_density: 1.0,
};
assert!((combine(&s, &SignalWeights::default()) - 1.0).abs() < 1e-6);
}
#[test]
fn weights_influence_total() {
let s = ScoreSignals {
token_count: 0.0,
unique_words: 0.0,
metadata_weight: 0.0,
source_weight: 0.0,
interaction: 1.0,
entity_density: 0.0,
};
let total = combine(&s, &SignalWeights::default());
assert!((total - (3.0 / 9.0)).abs() < 1e-6);
}
#[test]
fn compute_wires_all_signals() {
let m = meta(&["reply"], SourceKind::Email);
let ex = make_entities(3);
let s = compute(
&m,
"Some substantive text about Phoenix launch planning.",
12,
&ex,
);
assert!(s.interaction > 0.0);
assert!(s.metadata_weight > 0.0);
assert!(s.source_weight > 0.0);
}
#[test]
fn entity_density_scales() {
let ex = make_entities(1);
assert!((entity_density_score(100, &ex) - 1.0).abs() < 1e-6);
assert!((entity_density_score(1000, &ex) - 0.1).abs() < 1e-6);
assert_eq!(entity_density_score(0, &ex), 0.0);
}
}
@@ -0,0 +1,110 @@
//! Source-weight signal — per-provider base weight derived from the
//! `DataSource` when it can be inferred from a chunk's tags.
//!
//! Rationale from `Memory Architecture.md` (Step 2.3 "Source scoring"):
//! - High-intentionality messaging (direct DMs, personal emails) scores higher
//! - Broadcast/channel content scores lower
//! - Documents authored by the user score higher than shared-but-unmodified drops
//!
//! Phase 2 takes a conservative approach: per-[`DataSource`] base weight.
//! Finer distinction (DM vs channel on Slack specifically) requires richer
//! ingest-time metadata and is deferred.
use crate::openhuman::memory::tree::types::{DataSource, Metadata, SourceKind};
const PROVIDER_PREFIX: &str = "provider:";
/// Best-effort map from `Metadata` to a [`DataSource`] — checks the `tags`
/// list for a stable `provider:<snake_case>` provider tag. If not present,
/// falls back to kind-based defaults.
///
/// The ingestion pipeline can (and should) add a provider tag on the
/// canonicalised output so this signal fires deterministically. Until that's
/// wired everywhere, we fall back to the kind-level default.
pub fn infer_data_source(meta: &Metadata) -> Option<DataSource> {
for tag in &meta.tags {
let Some(provider) = tag.strip_prefix(PROVIDER_PREFIX) else {
continue;
};
if let Ok(ds) = DataSource::parse(provider) {
return Some(ds);
}
}
None
}
/// Score in `[0.0, 1.0]` for the chunk's originating provider.
pub fn score(meta: &Metadata) -> f32 {
if let Some(ds) = infer_data_source(meta) {
return weight_for(ds);
}
// Fallback: kind-level defaults consistent with per-provider averages.
match meta.source_kind {
SourceKind::Email => 0.75,
SourceKind::Document => 0.7,
SourceKind::Chat => 0.5,
}
}
fn weight_for(ds: DataSource) -> f32 {
match ds {
// Personal email providers score high — typically small, directed audiences
DataSource::Gmail => 0.8,
DataSource::OtherEmail => 0.7,
// Chat providers differ: WhatsApp is typically DM-heavy, Discord
// can be broadcast-heavy, Telegram mixes both
DataSource::Whatsapp => 0.75,
DataSource::Telegram => 0.6,
DataSource::Discord => 0.5,
// Documents: Notion = structured, Drive = mixed, Meeting notes = high value
DataSource::Notion => 0.75,
DataSource::DriveDocs => 0.6,
DataSource::MeetingNotes => 0.85,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn meta_with_tag(kind: SourceKind, tag: &str) -> Metadata {
let mut m = Metadata::point_in_time(kind, "x", "owner", Utc::now());
m.tags.push(tag.to_string());
m
}
#[test]
fn data_source_inferred_from_tags() {
let m = meta_with_tag(SourceKind::Chat, "provider:whatsapp");
assert_eq!(infer_data_source(&m), Some(DataSource::Whatsapp));
}
#[test]
fn plain_user_label_does_not_infer_provider() {
let m = meta_with_tag(SourceKind::Email, "notion");
assert_eq!(infer_data_source(&m), None);
assert!((score(&m) - 0.75).abs() < 1e-6);
}
#[test]
fn unknown_tag_falls_back_to_kind_default() {
let m = meta_with_tag(SourceKind::Email, "not-a-data-source");
let s = score(&m);
assert!((s - 0.75).abs() < 1e-6);
}
#[test]
fn provider_specific_weights_applied() {
let m = meta_with_tag(SourceKind::Document, "provider:meeting_notes");
assert!((score(&m) - 0.85).abs() < 1e-6);
}
#[test]
fn all_data_sources_bounded() {
for ds in DataSource::all() {
let w = weight_for(*ds);
assert!((0.0..=1.0).contains(&w));
}
}
}
@@ -0,0 +1,79 @@
//! Token-count signal — penalises very short or very long chunks.
//!
//! Rationale: "+1", "lol", "👍" are usually noise; multi-page walls of text
//! are often pasted logs or attachments that overwhelm summarisation.
//! The signal is strongest in a middle band that corresponds to substantive
//! prose/discussion.
//!
//! Output is a score in `[0.0, 1.0]` shaped as a plateau between
//! `TOKEN_MIN` and `TOKEN_MAX` with linear ramps on both sides.
pub const TOKEN_MIN: u32 = 10; // below this → score 0
pub const TOKEN_RAMP_LOW: u32 = 30; // 10..30 → linear 0→1
pub const TOKEN_RAMP_HIGH: u32 = 3_000; // 3000..8000 → linear 1→0.5
pub const TOKEN_MAX: u32 = 8_000; // above → score 0.5 (not zero — still has content)
/// Score for a chunk's token count. See module docs for shape.
pub fn score(token_count: u32) -> f32 {
if token_count < TOKEN_MIN {
return 0.0;
}
if token_count <= TOKEN_RAMP_LOW {
// linear 0..1 over [MIN, RAMP_LOW]
let span = (TOKEN_RAMP_LOW - TOKEN_MIN) as f32;
return (token_count - TOKEN_MIN) as f32 / span;
}
if token_count <= TOKEN_RAMP_HIGH {
return 1.0;
}
if token_count <= TOKEN_MAX {
// linear 1.0..0.5 over [RAMP_HIGH, MAX]
let span = (TOKEN_MAX - TOKEN_RAMP_HIGH) as f32;
let t = (token_count - TOKEN_RAMP_HIGH) as f32 / span;
return 1.0 - 0.5 * t;
}
0.5
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tiny_is_zero() {
assert_eq!(score(0), 0.0);
assert_eq!(score(5), 0.0);
assert_eq!(score(9), 0.0);
}
#[test]
fn ramp_up_linear() {
// score(MIN) = 0, score(RAMP_LOW) = 1.0
assert!((score(TOKEN_MIN) - 0.0).abs() < 1e-4);
assert!((score(TOKEN_RAMP_LOW) - 1.0).abs() < 1e-4);
// midpoint ~0.5
let mid = TOKEN_MIN + (TOKEN_RAMP_LOW - TOKEN_MIN) / 2;
assert!((score(mid) - 0.5).abs() < 0.05);
}
#[test]
fn plateau_is_one() {
assert_eq!(score(200), 1.0);
assert_eq!(score(1000), 1.0);
assert_eq!(score(TOKEN_RAMP_HIGH), 1.0);
}
#[test]
fn ramp_down_to_half() {
assert!((score(TOKEN_MAX) - 0.5).abs() < 1e-4);
assert_eq!(score(TOKEN_MAX + 10_000), 0.5);
}
#[test]
fn monotonic_in_bands() {
// Strictly increasing on the up-ramp
assert!(score(TOKEN_MIN + 1) < score(TOKEN_RAMP_LOW - 1));
// Strictly decreasing on the down-ramp
assert!(score(TOKEN_RAMP_HIGH + 1) > score(TOKEN_MAX - 1));
}
}
@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
/// Per-signal score breakdown for one chunk. Persisted alongside the total
/// for diagnostics.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ScoreSignals {
pub token_count: f32,
pub unique_words: f32,
pub metadata_weight: f32,
pub source_weight: f32,
pub interaction: f32,
pub entity_density: f32,
}
/// Default weights applied to each signal in `combine`.
#[derive(Clone, Debug)]
pub struct SignalWeights {
pub token_count: f32,
pub unique_words: f32,
pub metadata_weight: f32,
pub source_weight: f32,
pub interaction: f32,
pub entity_density: f32,
}
impl Default for SignalWeights {
fn default() -> Self {
Self {
token_count: 1.0,
unique_words: 1.0,
metadata_weight: 1.5,
source_weight: 1.5,
interaction: 3.0, // strongest signal — direct user engagement
entity_density: 1.0,
}
}
}
@@ -0,0 +1,85 @@
//! Unique-word-ratio signal — noise detector that fires on low-diversity text.
//!
//! Example: "yay yay yay yay lol lol lol" has high repetition = low diversity.
//! A substantive message has high type-token ratio (roughly, unique words /
//! total words).
//!
//! For very short messages the ratio is naturally ~1.0, so we require a
//! minimum total count before this signal contributes — otherwise "hi bob"
//! would score identically to a real message.
pub const MIN_TOTAL_WORDS: usize = 5;
/// Score in `[0.0, 1.0]` from the type-token ratio of `text`.
///
/// - Too few total words → `0.5` (indeterminate — defer to other signals)
/// - Ratio < 0.3 (heavy repetition) → 0.0
/// - Ratio >= 0.7 (substantive) → 1.0
/// - Linear in between
pub fn score(text: &str) -> f32 {
let mut total: usize = 0;
let mut uniq: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for raw in text.split_whitespace() {
let w: String = raw
.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase();
if w.is_empty() {
continue;
}
total += 1;
uniq.insert(w);
}
if total < MIN_TOTAL_WORDS {
return 0.5;
}
let ratio = uniq.len() as f32 / total as f32;
if ratio <= 0.3 {
0.0
} else if ratio >= 0.7 {
1.0
} else {
(ratio - 0.3) / 0.4
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_text_returns_neutral() {
assert_eq!(score(""), 0.5);
assert_eq!(score("hi bob"), 0.5);
}
#[test]
fn high_repetition_scored_low() {
let noisy = "yay yay yay yay yay yay yay yay yay yay lol lol lol lol";
assert!(score(noisy) < 0.2);
}
#[test]
fn substantive_text_scored_high() {
let good =
"We decided to ship Phoenix on Friday after reviewing the migration plan carefully.";
assert!(score(good) >= 0.9);
}
#[test]
fn medium_repetition_ramps() {
// ~50% unique ratio should score around 0.5
let med = "alpha beta alpha beta gamma alpha delta beta gamma alpha";
let s = score(med);
assert!(s > 0.2 && s < 0.8);
}
#[test]
fn punctuation_stripped() {
let s1 = score("ship phoenix friday ship phoenix friday ship phoenix");
let s2 = score("ship! phoenix, friday. ship! phoenix, friday. ship! phoenix.");
assert!((s1 - s2).abs() < 0.05);
}
}
+473
View File
@@ -0,0 +1,473 @@
//! Persistence for Phase 2 artefacts (#708):
//!
//! - `mem_tree_score` — per-chunk score rationale (which signals fired, why
//! dropped/kept)
//! - `mem_tree_entity_index` — inverted index `entity_id → node_id` so
//! retrieval can resolve entity-scoped queries in O(lookup)
//!
//! Schema is declared in `memory/tree/store.rs::SCHEMA`; this file only
//! owns the CRUD operations.
use anyhow::Result;
use rusqlite::{params, Connection, OptionalExtension, Transaction};
use serde::{Deserialize, Serialize};
use crate::openhuman::config::Config;
use crate::openhuman::memory::tree::score::extract::EntityKind;
use crate::openhuman::memory::tree::score::resolver::CanonicalEntity;
use crate::openhuman::memory::tree::score::signals::ScoreSignals;
use crate::openhuman::memory::tree::store::with_connection;
/// Serialized per-chunk score rationale. Mirrors the `mem_tree_score` row.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ScoreRow {
pub chunk_id: String,
pub total: f32,
pub signals: ScoreSignals,
pub dropped: bool,
pub reason: Option<String>,
pub computed_at_ms: i64,
}
/// Upsert one score rationale row, replacing any existing entry for `chunk_id`.
pub fn upsert_score(config: &Config, row: &ScoreRow) -> Result<()> {
with_connection(config, |conn| {
upsert_score_on_connection(conn, row)?;
Ok(())
})
}
pub(crate) fn upsert_score_tx(tx: &Transaction<'_>, row: &ScoreRow) -> Result<()> {
tx.execute(
SCORE_UPSERT_SQL,
params![
row.chunk_id,
row.total,
row.signals.token_count,
row.signals.unique_words,
row.signals.metadata_weight,
row.signals.source_weight,
row.signals.interaction,
row.signals.entity_density,
i32::from(row.dropped),
row.reason,
row.computed_at_ms,
],
)?;
Ok(())
}
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,
dropped, reason, computed_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)";
fn upsert_score_on_connection(conn: &Connection, row: &ScoreRow) -> Result<()> {
conn.execute(
SCORE_UPSERT_SQL,
params![
row.chunk_id,
row.total,
row.signals.token_count,
row.signals.unique_words,
row.signals.metadata_weight,
row.signals.source_weight,
row.signals.interaction,
row.signals.entity_density,
i32::from(row.dropped),
row.reason,
row.computed_at_ms,
],
)?;
Ok(())
}
/// Fetch one chunk's score rationale.
pub fn get_score(config: &Config, chunk_id: &str) -> Result<Option<ScoreRow>> {
with_connection(config, |conn| {
conn.query_row(
"SELECT chunk_id, total,
token_count_signal, unique_words_signal,
metadata_weight, source_weight, interaction_weight, entity_density,
dropped, reason, computed_at_ms
FROM mem_tree_score WHERE chunk_id = ?1",
params![chunk_id],
|row| {
Ok(ScoreRow {
chunk_id: row.get(0)?,
total: row.get(1)?,
signals: ScoreSignals {
token_count: row.get(2)?,
unique_words: row.get(3)?,
metadata_weight: row.get(4)?,
source_weight: row.get(5)?,
interaction: row.get(6)?,
entity_density: row.get(7)?,
},
dropped: row.get::<_, i32>(8)? != 0,
reason: row.get(9)?,
computed_at_ms: row.get(10)?,
})
},
)
.optional()
.map_err(anyhow::Error::from)
})
}
/// Index one (entity, chunk) association.
///
/// Idempotent on the composite primary key `(entity_id, node_id)` so
/// re-indexing the same association is a no-op update.
pub fn index_entity(
config: &Config,
entity: &CanonicalEntity,
node_id: &str,
node_kind: &str,
timestamp_ms: i64,
tree_id: Option<&str>,
) -> Result<()> {
with_connection(config, |conn| {
conn.execute(
"INSERT OR REPLACE INTO mem_tree_entity_index (
entity_id, node_id, node_kind, entity_kind, surface,
score, timestamp_ms, tree_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
entity.canonical_id,
node_id,
node_kind,
entity.kind.as_str(),
entity.surface,
entity.score,
timestamp_ms,
tree_id,
],
)?;
Ok(())
})
}
/// Batch index all entities extracted from a chunk.
pub fn index_entities(
config: &Config,
entities: &[CanonicalEntity],
node_id: &str,
node_kind: &str,
timestamp_ms: i64,
tree_id: Option<&str>,
) -> Result<usize> {
if entities.is_empty() {
return Ok(0);
}
with_connection(config, |conn| {
let tx = conn.unchecked_transaction()?;
{
let mut stmt = tx.prepare(
"INSERT OR REPLACE INTO mem_tree_entity_index (
entity_id, node_id, node_kind, entity_kind, surface,
score, timestamp_ms, tree_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
for e in entities {
stmt.execute(params![
e.canonical_id,
node_id,
node_kind,
e.kind.as_str(),
e.surface,
e.score,
timestamp_ms,
tree_id,
])?;
}
}
tx.commit()?;
Ok(entities.len())
})
}
/// Remove all entity-index rows for a given node. Used before re-indexing
/// a re-scored chunk so entities dropped from the new extraction don't leak
/// through as stale `INSERT OR REPLACE` never deletes.
pub fn clear_entity_index_for_node(config: &Config, node_id: &str) -> Result<usize> {
with_connection(config, |conn| {
let n = conn.execute(
"DELETE FROM mem_tree_entity_index WHERE node_id = ?1",
params![node_id],
)?;
Ok(n)
})
}
pub(crate) fn clear_entity_index_for_node_tx(tx: &Transaction<'_>, node_id: &str) -> Result<usize> {
let n = tx.execute(
"DELETE FROM mem_tree_entity_index WHERE node_id = ?1",
params![node_id],
)?;
Ok(n)
}
pub(crate) fn index_entities_tx(
tx: &Transaction<'_>,
entities: &[CanonicalEntity],
node_id: &str,
node_kind: &str,
timestamp_ms: i64,
tree_id: Option<&str>,
) -> Result<usize> {
if entities.is_empty() {
return Ok(0);
}
let mut stmt = tx.prepare(
"INSERT OR REPLACE INTO mem_tree_entity_index (
entity_id, node_id, node_kind, entity_kind, surface,
score, timestamp_ms, tree_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
for e in entities {
stmt.execute(params![
e.canonical_id,
node_id,
node_kind,
e.kind.as_str(),
e.surface,
e.score,
timestamp_ms,
tree_id,
])?;
}
Ok(entities.len())
}
/// Result row from [`lookup_entity`].
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EntityHit {
pub entity_id: String,
pub node_id: String,
pub node_kind: String,
pub entity_kind: EntityKind,
pub surface: String,
pub score: f32,
pub timestamp_ms: i64,
pub tree_id: Option<String>,
}
/// Find all nodes indexed against `entity_id`, newest first.
pub fn lookup_entity(
config: &Config,
entity_id: &str,
limit: Option<usize>,
) -> Result<Vec<EntityHit>> {
// Clamp to i64::MAX before casting so callers can't wrap a large usize
// into a negative LIMIT and bypass it.
let limit = limit.unwrap_or(100).min(i64::MAX as usize) as i64;
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT entity_id, node_id, node_kind, entity_kind, surface,
score, timestamp_ms, tree_id
FROM mem_tree_entity_index
WHERE entity_id = ?1
ORDER BY timestamp_ms DESC
LIMIT ?2",
)?;
let rows = stmt
.query_map(params![entity_id, limit], |row| {
let kind_s: String = row.get(3)?;
let entity_kind = EntityKind::parse(&kind_s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
3,
rusqlite::types::Type::Text,
e.into(),
)
})?;
Ok(EntityHit {
entity_id: row.get(0)?,
node_id: row.get(1)?,
node_kind: row.get(2)?,
entity_kind,
surface: row.get(4)?,
score: row.get(5)?,
timestamp_ms: row.get(6)?,
tree_id: row.get(7)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
}
/// Count rows in the entity index (for tests / diagnostics).
pub fn count_entity_index(config: &Config) -> Result<u64> {
with_connection(config, |conn| {
let n: i64 = conn.query_row("SELECT COUNT(*) FROM mem_tree_entity_index", [], |r| {
r.get(0)
})?;
Ok(n.max(0) as u64)
})
}
/// Count score rows (for tests / diagnostics).
pub fn count_scores(config: &Config) -> Result<u64> {
with_connection(config, |conn| {
let n: i64 = conn.query_row("SELECT COUNT(*) FROM mem_tree_score", [], |r| r.get(0))?;
Ok(n.max(0) as u64)
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
(tmp, cfg)
}
fn sample_row(id: &str, dropped: bool) -> ScoreRow {
ScoreRow {
chunk_id: id.to_string(),
total: 0.7,
signals: ScoreSignals {
token_count: 1.0,
unique_words: 0.8,
metadata_weight: 0.9,
source_weight: 0.5,
interaction: 0.6,
entity_density: 0.3,
},
dropped,
reason: if dropped {
Some("below threshold".into())
} else {
None
},
computed_at_ms: 1_700_000_000_000,
}
}
fn sample_entity(id: &str) -> CanonicalEntity {
CanonicalEntity {
canonical_id: format!("email:{id}"),
kind: EntityKind::Email,
surface: format!("{id}@example.com"),
span_start: 0,
span_end: (id.len() + 12) as u32,
score: 1.0,
}
}
#[test]
fn upsert_then_get_score() {
let (_tmp, cfg) = test_config();
let row = sample_row("c1", false);
upsert_score(&cfg, &row).unwrap();
let got = get_score(&cfg, "c1").unwrap().expect("row exists");
assert_eq!(got.chunk_id, row.chunk_id);
assert!((got.total - row.total).abs() < 1e-6);
assert_eq!(got.dropped, row.dropped);
assert_eq!(got.reason, row.reason);
assert_eq!(got.computed_at_ms, row.computed_at_ms);
assert!((got.signals.token_count - row.signals.token_count).abs() < 1e-6);
}
#[test]
fn upsert_score_idempotent() {
let (_tmp, cfg) = test_config();
let r = sample_row("c1", false);
upsert_score(&cfg, &r).unwrap();
upsert_score(&cfg, &r).unwrap();
assert_eq!(count_scores(&cfg).unwrap(), 1);
}
#[test]
fn dropped_flag_persists() {
let (_tmp, cfg) = test_config();
let r = sample_row("c1", true);
upsert_score(&cfg, &r).unwrap();
let got = get_score(&cfg, "c1").unwrap().unwrap();
assert!(got.dropped);
assert_eq!(got.reason.as_deref(), Some("below threshold"));
}
#[test]
fn get_missing_score_is_none() {
let (_tmp, cfg) = test_config();
assert!(get_score(&cfg, "missing").unwrap().is_none());
}
#[test]
fn index_and_lookup_entity() {
let (_tmp, cfg) = test_config();
let e = sample_entity("alice");
index_entity(&cfg, &e, "chunk-1", "leaf", 1000, Some("source:chat")).unwrap();
index_entity(&cfg, &e, "chunk-2", "leaf", 2000, Some("source:chat")).unwrap();
let hits = lookup_entity(&cfg, "email:alice", None).unwrap();
assert_eq!(hits.len(), 2);
// newest first
assert_eq!(hits[0].node_id, "chunk-2");
assert_eq!(hits[1].node_id, "chunk-1");
}
#[test]
fn index_batch() {
let (_tmp, cfg) = test_config();
let entities = vec![sample_entity("a"), sample_entity("b"), sample_entity("c")];
let n = index_entities(&cfg, &entities, "chunk-1", "leaf", 1000, None).unwrap();
assert_eq!(n, 3);
assert_eq!(count_entity_index(&cfg).unwrap(), 3);
}
#[test]
fn clear_entity_index_drops_stale_rows() {
let (_tmp, cfg) = test_config();
let a = sample_entity("a");
let b = sample_entity("b");
index_entities(&cfg, &[a.clone(), b], "chunk-1", "leaf", 1000, None).unwrap();
assert_eq!(count_entity_index(&cfg).unwrap(), 2);
// Simulate a re-score that only keeps entity "a".
let cleared = clear_entity_index_for_node(&cfg, "chunk-1").unwrap();
assert_eq!(cleared, 2);
index_entities(&cfg, &[a], "chunk-1", "leaf", 1000, None).unwrap();
let hits = lookup_entity(&cfg, "email:b", None).unwrap();
assert!(hits.is_empty(), "stale entity should be removed");
let hits = lookup_entity(&cfg, "email:a", None).unwrap();
assert_eq!(hits.len(), 1);
}
#[test]
fn index_idempotent_per_entity_node_pair() {
let (_tmp, cfg) = test_config();
let e = sample_entity("alice");
index_entity(&cfg, &e, "chunk-1", "leaf", 1000, None).unwrap();
index_entity(&cfg, &e, "chunk-1", "leaf", 1000, None).unwrap();
assert_eq!(count_entity_index(&cfg).unwrap(), 1);
}
#[test]
fn lookup_limit_respected() {
let (_tmp, cfg) = test_config();
let e = sample_entity("alice");
for i in 0..5 {
index_entity(
&cfg,
&e,
&format!("chunk-{i}"),
"leaf",
1000 + i as i64,
None,
)
.unwrap();
}
let hits = lookup_entity(&cfg, "email:alice", Some(2)).unwrap();
assert_eq!(hits.len(), 2);
}
}
+201 -21
View File
@@ -9,7 +9,7 @@
use anyhow::{Context, Result};
use chrono::{DateTime, TimeZone, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use rusqlite::{params, Connection, OptionalExtension, Transaction};
use std::time::Duration;
use crate::openhuman::config::Config;
@@ -48,6 +48,46 @@ CREATE INDEX IF NOT EXISTS idx_mem_tree_chunks_owner
ON mem_tree_chunks(owner);
CREATE INDEX IF NOT EXISTS idx_mem_tree_chunks_source_seq
ON mem_tree_chunks(source_kind, source_id, seq_in_source);
-- Phase 2 (#708): per-chunk score rationale for admission debugging.
CREATE TABLE IF NOT EXISTS mem_tree_score (
chunk_id TEXT PRIMARY KEY,
total REAL NOT NULL,
token_count_signal REAL NOT NULL,
unique_words_signal REAL NOT NULL,
metadata_weight REAL NOT NULL,
source_weight REAL NOT NULL,
interaction_weight REAL NOT NULL,
entity_density REAL NOT NULL,
dropped INTEGER NOT NULL DEFAULT 0,
reason TEXT,
computed_at_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mem_tree_score_total
ON mem_tree_score(total);
CREATE INDEX IF NOT EXISTS idx_mem_tree_score_dropped
ON mem_tree_score(dropped);
-- Phase 2 (#708): inverted index entity_id -> node_id for retrieval.
CREATE TABLE IF NOT EXISTS mem_tree_entity_index (
entity_id TEXT NOT NULL,
node_id TEXT NOT NULL,
node_kind TEXT NOT NULL,
entity_kind TEXT NOT NULL,
surface TEXT NOT NULL,
score REAL NOT NULL,
timestamp_ms INTEGER NOT NULL,
tree_id TEXT,
PRIMARY KEY (entity_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_mem_tree_entity_index_entity
ON mem_tree_entity_index(entity_id);
CREATE INDEX IF NOT EXISTS idx_mem_tree_entity_index_node
ON mem_tree_entity_index(node_id);
CREATE INDEX IF NOT EXISTS idx_mem_tree_entity_index_timestamp
ON mem_tree_entity_index(timestamp_ms);
";
/// Upsert a batch of chunks atomically.
@@ -68,35 +108,85 @@ pub fn upsert_chunks(config: &Config, chunks: &[Chunk]) -> Result<usize> {
let tx = conn.unchecked_transaction()?;
{
let mut stmt = tx.prepare(
"INSERT OR REPLACE INTO mem_tree_chunks (
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner,
timestamp_ms, time_range_start_ms, time_range_end_ms,
tags_json, content, token_count, seq_in_source, created_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
ON CONFLICT(id) DO UPDATE SET
source_kind = excluded.source_kind,
source_id = excluded.source_id,
source_ref = excluded.source_ref,
owner = excluded.owner,
timestamp_ms = excluded.timestamp_ms,
time_range_start_ms = excluded.time_range_start_ms,
time_range_end_ms = excluded.time_range_end_ms,
tags_json = excluded.tags_json,
content = excluded.content,
token_count = excluded.token_count,
seq_in_source = excluded.seq_in_source,
created_at_ms = excluded.created_at_ms",
)?;
for chunk in chunks {
stmt.execute(params![
chunk.id,
chunk.metadata.source_kind.as_str(),
chunk.metadata.source_id,
chunk.metadata.source_ref.as_ref().map(|r| r.value.as_str()),
chunk.metadata.owner,
chunk.metadata.timestamp.timestamp_millis(),
chunk.metadata.time_range.0.timestamp_millis(),
chunk.metadata.time_range.1.timestamp_millis(),
serde_json::to_string(&chunk.metadata.tags)?,
chunk.content,
chunk.token_count,
chunk.seq_in_source,
chunk.created_at.timestamp_millis(),
])?;
}
upsert_chunks_with_statement(&mut stmt, chunks)?;
}
tx.commit()?;
Ok(chunks.len())
})
}
/// Upsert chunks using an existing transaction, preserving previously stored embeddings.
pub(crate) fn upsert_chunks_tx(tx: &Transaction<'_>, chunks: &[Chunk]) -> Result<usize> {
if chunks.is_empty() {
return Ok(0);
}
let mut stmt = tx.prepare(
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner,
timestamp_ms, time_range_start_ms, time_range_end_ms,
tags_json, content, token_count, seq_in_source, created_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
ON CONFLICT(id) DO UPDATE SET
source_kind = excluded.source_kind,
source_id = excluded.source_id,
source_ref = excluded.source_ref,
owner = excluded.owner,
timestamp_ms = excluded.timestamp_ms,
time_range_start_ms = excluded.time_range_start_ms,
time_range_end_ms = excluded.time_range_end_ms,
tags_json = excluded.tags_json,
content = excluded.content,
token_count = excluded.token_count,
seq_in_source = excluded.seq_in_source,
created_at_ms = excluded.created_at_ms",
)?;
upsert_chunks_with_statement(&mut stmt, chunks)?;
Ok(chunks.len())
}
fn upsert_chunks_with_statement(
stmt: &mut rusqlite::Statement<'_>,
chunks: &[Chunk],
) -> Result<()> {
for chunk in chunks {
stmt.execute(params![
chunk.id,
chunk.metadata.source_kind.as_str(),
chunk.metadata.source_id,
chunk.metadata.source_ref.as_ref().map(|r| r.value.as_str()),
chunk.metadata.owner,
chunk.metadata.timestamp.timestamp_millis(),
chunk.metadata.time_range.0.timestamp_millis(),
chunk.metadata.time_range.1.timestamp_millis(),
serde_json::to_string(&chunk.metadata.tags)?,
chunk.content,
chunk.token_count,
chunk.seq_in_source,
chunk.created_at.timestamp_millis(),
])?;
}
Ok(())
}
/// Fetch one chunk by its id.
pub fn get_chunk(config: &Config, id: &str) -> Result<Option<Chunk>> {
with_connection(config, |conn| {
@@ -238,7 +328,14 @@ fn ms_to_utc(ms: i64) -> rusqlite::Result<DateTime<Utc>> {
})
}
fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
/// Open the memory_tree SQLite DB and run a closure against it.
///
/// Visible to sibling modules (e.g. `score::store`) so Phase 2 can reuse
/// the same connection setup / schema initialisation without duplication.
pub(crate) fn with_connection<T>(
config: &Config,
f: impl FnOnce(&Connection) -> Result<T>,
) -> Result<T> {
let dir = config.workspace_dir.join(DB_DIR);
std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create memory_tree dir: {}", dir.display()))?;
@@ -251,6 +348,8 @@ fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>)
.context("Failed to enable memory_tree WAL mode")?;
conn.execute_batch(SCHEMA)
.context("Failed to initialize memory_tree schema")?;
// Phase 2 migrations — additive, idempotent.
add_column_if_missing(&conn, "mem_tree_chunks", "embedding", "BLOB")?;
f(&conn)
}
@@ -261,6 +360,69 @@ fn normalized_limit(requested: Option<usize>) -> i64 {
i64::try_from(clamped).unwrap_or(MAX_LIST_LIMIT as i64)
}
/// Idempotent `ALTER TABLE ADD COLUMN` — treats an existing column as success.
fn add_column_if_missing(conn: &Connection, table: &str, name: &str, sql_type: &str) -> Result<()> {
match conn.execute(
&format!("ALTER TABLE {table} ADD COLUMN {name} {sql_type}"),
[],
) {
Ok(_) => {
log::debug!("[memory_tree::store] migration: added column {table}.{name} ({sql_type})");
Ok(())
}
Err(err) if err.to_string().contains("duplicate column name") => Ok(()),
Err(err) => Err(err).with_context(|| format!("Failed to add column {table}.{name}")),
}
}
// ── Phase 2: embedding column accessors ─────────────────────────────────
/// Store a chunk's embedding as a packed little-endian `f32` blob.
///
/// Length is `embedding.len() * 4` bytes. The caller is responsible for
/// ensuring all embeddings in a given deployment share the same dimension.
pub fn set_chunk_embedding(config: &Config, chunk_id: &str, embedding: &[f32]) -> Result<()> {
let bytes: Vec<u8> = embedding.iter().flat_map(|f| f.to_le_bytes()).collect();
with_connection(config, |conn| {
let changed = conn.execute(
"UPDATE mem_tree_chunks SET embedding = ?1 WHERE id = ?2",
rusqlite::params![bytes, chunk_id],
)?;
if changed == 0 {
log::warn!("[memory_tree::store] set_chunk_embedding: no row for chunk_id={chunk_id}");
}
Ok(())
})
}
/// Fetch a chunk's embedding, decoding the stored little-endian `f32` blob.
///
/// Returns `Ok(None)` if the chunk doesn't exist or has no embedding stored.
pub fn get_chunk_embedding(config: &Config, chunk_id: &str) -> Result<Option<Vec<f32>>> {
with_connection(config, |conn| {
let blob: Option<Option<Vec<u8>>> = conn
.query_row(
"SELECT embedding FROM mem_tree_chunks WHERE id = ?1",
rusqlite::params![chunk_id],
|r| r.get::<_, Option<Vec<u8>>>(0),
)
.optional()?;
match blob.flatten() {
None => Ok(None),
Some(bytes) => {
if !bytes.len().is_multiple_of(4) {
anyhow::bail!("embedding blob length {} not a multiple of 4", bytes.len());
}
let floats: Vec<f32> = bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
Ok(Some(floats))
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -313,6 +475,24 @@ mod tests {
assert_eq!(count_chunks(&cfg).unwrap(), 1);
}
#[test]
fn reingest_preserves_existing_embedding() {
let (_tmp, cfg) = test_config();
let mut c = sample_chunk("slack:#eng", 0, 1_700_000_000_000);
upsert_chunks(&cfg, &[c.clone()]).unwrap();
set_chunk_embedding(&cfg, &c.id, &[0.1, 0.2, 0.3]).unwrap();
c.content = "updated content".into();
c.token_count = 99;
upsert_chunks(&cfg, &[c.clone()]).unwrap();
let embedding = get_chunk_embedding(&cfg, &c.id).unwrap().unwrap();
assert_eq!(embedding, vec![0.1, 0.2, 0.3]);
let got = get_chunk(&cfg, &c.id).unwrap().unwrap();
assert_eq!(got.content, "updated content");
assert_eq!(got.token_count, 99);
}
#[test]
fn list_filters_by_source_kind() {
let (_tmp, cfg) = test_config();
+2 -1
View File
@@ -819,7 +819,7 @@ async fn json_rpc_memory_tree_end_to_end() {
"payload": {
"provider": "notion",
"title": "Launch Plan",
"body": "Alpha\n\nBeta",
"body": "We decided to ship Phoenix on Friday after reviewing alice@example.com and the migration plan carefully. @bob will coordinate rollout, track #launch-q2 details, and update the Notion launch checklist with staging validation notes.",
"modified_at": 1700000000000_i64,
"source_ref": " notion://page/launch-plan "
}
@@ -833,6 +833,7 @@ async fn json_rpc_memory_tree_end_to_end() {
Some(&json!("notion:launch-plan"))
);
assert_eq!(ingest_result.get("chunks_written"), Some(&json!(1)));
assert_eq!(ingest_result.get("chunks_dropped"), Some(&json!(0)));
let chunk_ids = ingest_result
.get("chunk_ids")
.and_then(Value::as_array)