diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e663209..322a6d2 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -52,3 +52,4 @@ {"id":"int-ca7d767e","kind":"field_change","created_at":"2026-06-28T16:16:07.053939933Z","actor":"dawn","issue_id":"klbr-8dq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed: LongMemEval pipeline run now defaults --out under benchmarks/runs."}} {"id":"int-fb2d145f","kind":"field_change","created_at":"2026-06-28T16:16:07.11528074Z","actor":"dawn","issue_id":"klbr-oba","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed: added reproducible random LongMemEval sampling with question_type stratification."}} {"id":"int-fdca5c0e","kind":"field_change","created_at":"2026-06-28T19:54:14.601069054Z","actor":"dawn","issue_id":"klbr-f0q.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented OpPlan/QueryOp classification, serialized op_plan into retrieval traces, preserved lookup defaults, added deterministic planner and pipeline trace tests."}} +{"id":"int-9125728a","kind":"field_change","created_at":"2026-06-28T21:21:26.576608546Z","actor":"dawn","issue_id":"klbr-h4l","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented project-local dense/sparse embedding cache, ran 25-sample QA bench, inspected failures, tried bounded planner/high-budget experiments, and wrote report packet."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 236054f..d4c6f25 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"klbr-h4l","title":"Cache bench embeddings locally and run 25-sample QA bench","description":"Make LongMemEval bench embeddings use a project-local gitignored sqlite cache so reruns do not pay for the same embeddings twice. Then run a 25-sample stratified QA bench, inspect failures, try bounded non-overfit tweaks if evidence supports them, and prepare a report packet if results remain weak or tuning would be overfit.","design":"Prefer a global project cache path such as benchmarks/cache/*.db over temp/run-local caches. Keep benchmark polling sparse: start the run, wait for artifacts or process completion, then inspect once.","acceptance_criteria":"Embedding cache lives under the project and is ignored by git; cache hits are reused across bench runs; a 25-sample bench artifact exists with failure analysis; any code changes are tested, committed, and pushed.","notes":"Implemented project-local embedding cache at benchmarks/cache/embeddings.db and wired LongMemEval bench LlmClient construction through it. Ran stratified 25-sample QA on seed 4937553249516211047: current-code run benchmarks/runs/longmemeval-s/klbr-full/2026-06-28_210327.693783Z scored official accuracy 0.6800. Report packet written to report_packet.md in that run dir. High-budget targeted reruns fixed only dd2973ad and a1cc6108; remaining failures point to fact/timeline synthesis rather than safe cue tuning.","status":"closed","priority":1,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T20:08:32Z","created_by":"dawn","updated_at":"2026-06-28T21:21:27Z","started_at":"2026-06-28T20:08:35Z","closed_at":"2026-06-28T21:21:27Z","close_reason":"Implemented project-local dense/sparse embedding cache, ran 25-sample QA bench, inspected failures, tried bounded planner/high-budget experiments, and wrote report packet.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"klbr-f0q.1","title":"Add operation-aware query planning","description":"Introduce a small OpPlan/QueryOp classifier for memory QA queries: lookup, aggregate_count, aggregate_sum, aggregate_avg, order_or_rank, update_resolution, preference_recommendation, and abstain_or_false_premise_check. Use it to switch only high-value classes away from plain lookup behavior.","design":"Keep the label set intentionally small and rule-based first; avoid growing English lexical hacks into the main ranking decision boundary.","acceptance_criteria":"Production memory retrieval traces expose the selected query op and plan; existing lookup behavior remains unchanged by default; aggregate/order/update/preference queries can be detected deterministically in tests.","status":"closed","priority":1,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T19:44:46Z","created_by":"dawn","updated_at":"2026-06-28T19:54:15Z","started_at":"2026-06-28T19:45:04Z","closed_at":"2026-06-28T19:54:15Z","close_reason":"Implemented OpPlan/QueryOp classification, serialized op_plan into retrieval traces, preserved lookup defaults, added deterministic planner and pipeline trace tests.","dependencies":[{"issue_id":"klbr-f0q.1","depends_on_id":"klbr-f0q","type":"parent-child","created_at":"2026-06-28T22:44:46Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"klbr-f0q.2","title":"Add structured synthesis for planned QA operations","description":"Add a fact-table synthesis path for aggregate count/sum/avg, ordering, previous/latest update resolution, and preference-grounded recommendations. Do not route ordinary conversation lookup through the structured path unless the planner selects it.","design":"Stage A extracts/normalizes fact rows with refs; Stage B computes or renders the answer according to the OpPlan.","acceptance_criteria":"Planned synthesis computes counts/averages/order/update answers from cited fact rows; preference recommendations require personal support and avoid generic distractor answers; lookup questions keep current direct reader path.","status":"open","priority":1,"issue_type":"feature","owner":"90008@klbr.net","created_at":"2026-06-28T19:44:46Z","created_by":"dawn","updated_at":"2026-06-28T19:44:46Z","dependencies":[{"issue_id":"klbr-f0q.2","depends_on_id":"klbr-f0q","type":"parent-child","created_at":"2026-06-28T22:44:46Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-f0q.2","depends_on_id":"klbr-f0q.4","type":"blocks","created_at":"2026-06-28T22:44:56Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"klbr-f0q.3","title":"Implement collect-mode evidence assembly","description":"For aggregate, ordering, and update-resolution plans, retrieve and rank evidence by fact-group coverage instead of fixed top-k alone. Add sufficiency/gap diagnosis and clustered packet assembly by entity/task/session where feasible.","design":"Use relevance plus marginal coverage gain, with hard budget/stopping controls based on fact-group sufficiency rather than packet count.","acceptance_criteria":"Collect mode can cover multi-session fact groups without one high-scoring session monopolizing context; packet traces expose coverage/gap state; focused tests cover six-way count/order style cases.","notes":"Started implementation: collect-mode plans now widen candidate/packet limits and use collect-aware packet capping that prefers distinct sessions before second packets from the same session. Remaining: explicit fact-group sufficiency and gap diagnosis.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T19:44:46Z","created_by":"dawn","updated_at":"2026-06-28T19:54:15Z","started_at":"2026-06-28T19:54:15Z","dependencies":[{"issue_id":"klbr-f0q.3","depends_on_id":"klbr-f0q","type":"parent-child","created_at":"2026-06-28T22:44:46Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-f0q.3","depends_on_id":"klbr-f0q.1","type":"blocks","created_at":"2026-06-28T22:44:56Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} diff --git a/.gitignore b/.gitignore index bb149fb..d26124c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /agent.db /.codex /benchmarks/runs/* +/benchmarks/cache/ /benchmarks/inputs/datasets/longmemeval* !/benchmarks/runs/README.md klbr.json diff --git a/docs/memory-benches.md b/docs/memory-benches.md index d3bd12c..31f1527 100644 --- a/docs/memory-benches.md +++ b/docs/memory-benches.md @@ -252,6 +252,12 @@ rtk cargo run -p klbr-bench -- run \ --out benchmarks/runs/lme-s/klbr-reflink-v2 ``` +longmemeval bench runs cache dense and sparse embedding calls in +`benchmarks/cache/embeddings.db`. the cache is project-local and gitignored; its +keys include embedding kind, embedder url, resolved model, dimension, embedder +options, and text, so reruns do not reuse incompatible vectors after model/dim +changes. + and internally expand that into phases: ```bash diff --git a/klbr-bench/src/longmemeval.rs b/klbr-bench/src/longmemeval.rs index d55180c..bad8f0a 100644 --- a/klbr-bench/src/longmemeval.rs +++ b/klbr-bench/src/longmemeval.rs @@ -15,7 +15,7 @@ use klbr_core::{ config::{Config, MemoryConfig}, context::{Context as AgentContext, ProvenanceHint, RecalledMemory}, memory::{to_base36, MemoryStore}, - models::{LlmClient, Message}, + models::{LlmClient, Message, ModelsConfig}, mvp::{MemoryEdgeType, MemoryLayer, MemoryRecordInput, MemoryStatus, SimilarityMetric}, pipeline::{ AssembledContext, BenchQuery, BenchRun, BenchSession, BenchTurn, ContextBudget, @@ -24,6 +24,8 @@ use klbr_core::{ retrieval::{self, RetrievalConfig}, support::SupportScorer, }; + +const BENCH_EMBED_CACHE_PATH: &str = "benchmarks/cache/embeddings.db"; use rusqlite::OptionalExtension; #[derive(Debug, Clone, Deserialize, Serialize)] @@ -290,6 +292,10 @@ fn default_question_date() -> String { "9999/01/01 (Fri) 00:00".to_string() } +fn bench_llm_client(models: ModelsConfig) -> Result { + LlmClient::new(models).with_embedding_cache_path(BENCH_EMBED_CACHE_PATH) +} + fn suite_protocol_note(suite: &str) -> Option<&'static str> { if suite.eq_ignore_ascii_case("locomo") { Some( @@ -1355,7 +1361,7 @@ pub async fn run_pipeline_command(args: &[String]) -> Result<()> { config.memory.rerank = true; } - let llm = LlmClient::new(config.models.clone()); + let llm = bench_llm_client(config.models.clone())?; let db_path = out_dir.join("memory.db"); let store = MemoryStore::open( db_path @@ -1703,6 +1709,7 @@ pub async fn run_pipeline_command(args: &[String]) -> Result<()> { "top_k": top_k, "graph_depth": graph_depth, }, + "embed_cache": BENCH_EMBED_CACHE_PATH, "diagnostic": diagnostic.clone(), "packet_rerank": config.memory.rerank, "protocol_note": protocol_note, @@ -2168,8 +2175,7 @@ async fn run_ingest(data_path: &str, db_dir_path: &str) -> Result<()> { fs::create_dir_all(db_dir_path)?; let config = Config::load_bench()?; - let llm = LlmClient::new(config.models.clone()); - let cache = GlobalEmbedCache::new()?; + let llm = bench_llm_client(config.models.clone())?; let total = dataset.len(); for (idx, item) in dataset.iter().enumerate() { @@ -2252,25 +2258,19 @@ async fn run_ingest(data_path: &str, db_dir_path: &str) -> Result<()> { } if turn.role == "user" || turn.role == "assistant" { - let emb = if let Some(cached) = cache.get(&text)? { - cached - } else { - let fresh = match llm.embed(&text).await { - Ok(e) => e, - Err(err) => { - eprintln!("Warning: Failed to embed chunk (len {}): {}; attempting with truncated text...", text.len(), err); - let truncated: String = text.chars().take(2000).collect(); - match llm.embed(&truncated).await { - Ok(e) => e, - Err(err2) => { - eprintln!("Warning: Failed to embed truncated chunk: {}; using zero embedding.", err2); - vec![0.0; config.models.embed_dim] - } + let emb = match llm.embed(&text).await { + Ok(e) => e, + Err(err) => { + eprintln!("Warning: Failed to embed chunk (len {}): {}; attempting with truncated text...", text.len(), err); + let truncated: String = text.chars().take(2000).collect(); + match llm.embed(&truncated).await { + Ok(e) => e, + Err(err2) => { + eprintln!("Warning: Failed to embed truncated chunk: {}; using zero embedding.", err2); + vec![0.0; config.models.embed_dim] } } - }; - cache.insert(&text, &fresh)?; - fresh + } }; let input = MemoryRecordInput { memory_id: None, @@ -2322,7 +2322,7 @@ async fn run_retrieve( let dataset: Vec = serde_json::from_reader(dataset_file)?; let config = Config::load_bench()?; - let llm = LlmClient::new(config.models.clone()); + let llm = bench_llm_client(config.models.clone())?; let mut memory_config = config.memory.clone(); memory_config.top_k = top_k; @@ -2553,7 +2553,7 @@ async fn run_answer( let dataset: Vec = serde_json::from_reader(dataset_file)?; let config = Config::load_bench()?; - let llm = LlmClient::new(config.models.clone()); + let llm = bench_llm_client(config.models.clone())?; let mut memory_config = config.memory.clone(); memory_config.top_k = top_k; @@ -2971,7 +2971,7 @@ async fn run_synth_reflink(data_path: &str, db_dir_path: &str, out_path: &str) - let dataset: Vec = serde_json::from_reader(dataset_file)?; let config = Config::load_bench()?; - let llm = LlmClient::new(config.models.clone()); + let llm = bench_llm_client(config.models.clone())?; let mut synthetic_questions = Vec::new(); @@ -3307,54 +3307,3 @@ async fn run_bench_exact( Ok(()) } - -struct GlobalEmbedCache { - conn: rusqlite::Connection, -} - -impl GlobalEmbedCache { - fn new() -> Result { - let cache_path = "benchmarks/runs/klbr_embed_cache.db"; - if let Some(parent) = Path::new(cache_path).parent() { - fs::create_dir_all(parent)?; - } - let conn = rusqlite::Connection::open(cache_path)?; - conn.execute( - "CREATE TABLE IF NOT EXISTS cache ( - hash TEXT PRIMARY KEY, - embedding TEXT - )", - [], - )?; - Ok(Self { conn }) - } - - fn get(&self, text: &str) -> Result>> { - let hash = klbr_core::memory::simple_hash(text); - let row: Option = self - .conn - .query_row( - "SELECT embedding FROM cache WHERE hash = ?1", - rusqlite::params![hash], - |row| row.get(0), - ) - .optional()?; - - if let Some(json_str) = row { - let vec: Vec = serde_json::from_str(&json_str)?; - Ok(Some(vec)) - } else { - Ok(None) - } - } - - fn insert(&self, text: &str, embedding: &[f32]) -> Result<()> { - let hash = klbr_core::memory::simple_hash(text); - let json_str = serde_json::to_string(embedding)?; - self.conn.execute( - "INSERT OR REPLACE INTO cache (hash, embedding) VALUES (?1, ?2)", - rusqlite::params![hash, json_str], - )?; - Ok(()) - } -} diff --git a/klbr-core/src/models.rs b/klbr-core/src/models.rs index dd8e19a..e804248 100644 --- a/klbr-core/src/models.rs +++ b/klbr-core/src/models.rs @@ -1,9 +1,12 @@ use anyhow::Result; use futures::StreamExt; use reqwest::{Client, RequestBuilder}; +use rusqlite::OptionalExtension; use serde::{de::Error as _, Deserialize, Deserializer, Serialize}; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, HashMap}; +use std::path::Path; +use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; // ── message types ───────────────────────────────────────────────────────────── @@ -339,9 +342,99 @@ pub struct LlmClient { reranker_client: Client, embedder_clients: Vec, embedder_index: std::sync::Arc, + embedding_cache: Option>>, pub config: ModelsConfig, } +struct EmbeddingCache { + conn: rusqlite::Connection, +} + +impl EmbeddingCache { + fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let conn = rusqlite::Connection::open(path)?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS embedding_cache ( + namespace TEXT NOT NULL, + text_hash TEXT NOT NULL, + text TEXT NOT NULL, + dim INTEGER NOT NULL, + embedding TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (namespace, text_hash, text, dim) + );", + )?; + Ok(Self { conn }) + } + + fn get(&self, namespace: &str, text: &str, dim: usize) -> Result>> { + let text_hash = crate::memory::simple_hash(text); + let row = self + .conn + .query_row( + "SELECT embedding FROM embedding_cache + WHERE namespace = ?1 AND text_hash = ?2 AND text = ?3 AND dim = ?4", + rusqlite::params![namespace, text_hash, text, dim as i64], + |row| row.get::<_, String>(0), + ) + .optional()?; + row.map(|json| serde_json::from_str(&json).map_err(Into::into)) + .transpose() + } + + fn insert(&self, namespace: &str, text: &str, embedding: &[f32]) -> Result<()> { + let text_hash = crate::memory::simple_hash(text); + let json = serde_json::to_string(embedding)?; + self.conn.execute( + "INSERT OR REPLACE INTO embedding_cache + (namespace, text_hash, text, dim, embedding) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![namespace, text_hash, text, embedding.len() as i64, json], + )?; + Ok(()) + } + + fn get_sparse( + &self, + namespace: &str, + text: &str, + ) -> Result>> { + let text_hash = crate::memory::simple_hash(text); + let row = self + .conn + .query_row( + "SELECT embedding FROM embedding_cache + WHERE namespace = ?1 AND text_hash = ?2 AND text = ?3 AND dim = 0", + rusqlite::params![namespace, text_hash, text], + |row| row.get::<_, String>(0), + ) + .optional()?; + row.map(|json| serde_json::from_str(&json).map_err(Into::into)) + .transpose() + } + + fn insert_sparse( + &self, + namespace: &str, + text: &str, + embedding: &std::collections::HashMap, + ) -> Result<()> { + let text_hash = crate::memory::simple_hash(text); + let json = serde_json::to_string(embedding)?; + self.conn.execute( + "INSERT OR REPLACE INTO embedding_cache + (namespace, text_hash, text, dim, embedding) + VALUES (?1, ?2, ?3, 0, ?4)", + rusqlite::params![namespace, text_hash, text, json], + )?; + Ok(()) + } +} + // accumulator for streaming tool call assembly #[derive(Default)] struct PartialCall { @@ -372,10 +465,16 @@ impl LlmClient { reranker_client, embedder_clients, embedder_index: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), + embedding_cache: None, config, } } + pub fn with_embedding_cache_path(mut self, path: impl AsRef) -> Result { + self.embedding_cache = Some(Arc::new(Mutex::new(EmbeddingCache::open(path)?))); + Ok(self) + } + fn is_auto_model(name: &str) -> bool { let normalized = name.trim(); normalized.is_empty() || normalized.eq_ignore_ascii_case("auto") @@ -967,6 +1066,17 @@ impl LlmClient { cleaned_text: &str, ) -> Result> { let embed_model = self.resolve_model(config, true).await?; + let cache_namespace = self.embedding_cache_namespace(config, &embed_model); + if let Some(cache) = &self.embedding_cache { + if let Some(cached) = + cache + .lock() + .unwrap() + .get(&cache_namespace, cleaned_text, self.config.embed_dim)? + { + return Ok(cached); + } + } let mut body = json!({ "model": embed_model, "input": cleaned_text }); body.as_object_mut() @@ -1010,9 +1120,34 @@ impl LlmClient { )); } + if let Some(cache) = &self.embedding_cache { + cache + .lock() + .unwrap() + .insert(&cache_namespace, cleaned_text, &embedding)?; + } + Ok(embedding) } + fn embedding_cache_namespace(&self, config: &ModelConfig, embed_model: &str) -> String { + let options = serde_json::to_string(&config.extra_options_json()) + .unwrap_or_else(|_| "{}".to_string()); + format!( + "kind=dense|url={}|model={}|dim={}|options={}", + config.url, embed_model, self.config.embed_dim, options + ) + } + + fn sparse_embedding_cache_namespace(&self, config: &ModelConfig, embed_model: &str) -> String { + let options = serde_json::to_string(&config.extra_options_json()) + .unwrap_or_else(|_| "{}".to_string()); + format!( + "kind=sparse|url={}|model={}|options={}", + config.url, embed_model, options + ) + } + fn parse_embedding_item(item: &Value) -> Result> { let raw_embedding = item .get("embedding") @@ -1124,6 +1259,16 @@ impl LlmClient { let config = &self.config.embedders[0]; let client = &self.embedder_clients[0]; let embed_model = self.resolve_model(config, true).await?; + let cache_namespace = self.sparse_embedding_cache_namespace(config, &embed_model); + if let Some(cache) = &self.embedding_cache { + if let Some(cached) = cache + .lock() + .unwrap() + .get_sparse(&cache_namespace, &cleaned_text)? + { + return Ok(cached); + } + } let endpoint = Self::endpoint(&config.url, "/sparse_embeddings"); let body = json!({ @@ -1134,7 +1279,7 @@ impl LlmClient { let req = client.post(&endpoint).json(&body); let res = req.send().await; - match res { + let weights = match res { Ok(response) if response.status().is_success() => { if let Ok(json) = response.json::().await { if let Some(sparse_vals) = json @@ -1147,14 +1292,25 @@ impl LlmClient { map.insert(k.clone(), val as f32); } } - return Ok(map); + map + } else { + std::collections::HashMap::new() } + } else { + std::collections::HashMap::new() } } - _ => {} + _ => std::collections::HashMap::new(), + }; + + if let Some(cache) = &self.embedding_cache { + cache + .lock() + .unwrap() + .insert_sparse(&cache_namespace, &cleaned_text, &weights)?; } - Ok(std::collections::HashMap::new()) + Ok(weights) } async fn embed_chunk_with_config( @@ -1713,6 +1869,46 @@ mod tests { assert_eq!(embedding, vec![0.1, 0.2, 0.3]); } + #[tokio::test] + async fn embedding_cache_reuses_single_embeddings() { + let base_url = spawn_http_response( + "200 OK", + "application/json", + r#"{"data": [{"index": 0, "embedding": [0.1, 0.2, 0.3]}]}"#, + ) + .await; + let cache_file = tempfile::NamedTempFile::new().unwrap(); + let client = LlmClient::new(test_models_config(base_url)) + .with_embedding_cache_path(cache_file.path()) + .unwrap(); + + let first = client.embed("hello").await.unwrap(); + let second = client.embed("hello").await.unwrap(); + + assert_eq!(first, vec![0.1, 0.2, 0.3]); + assert_eq!(second, first); + } + + #[tokio::test] + async fn embedding_cache_reuses_sparse_embeddings() { + let base_url = spawn_http_response( + "200 OK", + "application/json", + r#"{"data": [{"sparse_values": {"hello": 1.5, "world": 0.5}}]}"#, + ) + .await; + let cache_file = tempfile::NamedTempFile::new().unwrap(); + let client = LlmClient::new(test_models_config(base_url)) + .with_embedding_cache_path(cache_file.path()) + .unwrap(); + + let first = client.embed_sparse("hello world").await.unwrap(); + let second = client.embed_sparse("hello world").await.unwrap(); + + assert_eq!(first.get("hello").copied(), Some(1.5)); + assert_eq!(first, second); + } + #[tokio::test] async fn embedder_accepts_llama_legacy_embedding_shape() { let base_url = spawn_http_response( diff --git a/klbr-core/src/planner.rs b/klbr-core/src/planner.rs index a6c9066..1d63ed6 100644 --- a/klbr-core/src/planner.rs +++ b/klbr-core/src/planner.rs @@ -104,10 +104,23 @@ pub fn classify_query_op(query: &str) -> OpPlan { "current", "most recent", "last time", + "last week", + "last month", + "last year", + "last saturday", + "last sunday", + "last monday", + "last tuesday", + "last wednesday", + "last thursday", + "last friday", "newer", "older", "before", "after", + "ago", + "a month ago", + "a week ago", ], ) { return OpPlan { @@ -186,6 +199,16 @@ pub fn classify_query_op(query: &str) -> OpPlan { }; } + if lower.contains("how old") && lower.contains("when") { + return OpPlan { + op: QueryOp::AggregateCount, + collect_all: true, + require_personal_support: false, + max_passes: 2, + reason: "age_at_event_cue".to_string(), + }; + } + if contains_any( &lower, &[ @@ -193,6 +216,9 @@ pub fn classify_query_op(query: &str) -> OpPlan { "recommendation", "suggest", "suggestion", + "tips", + "advice", + "ideas", "best fit", "would i like", "should i try", @@ -299,6 +325,32 @@ mod tests { assert_eq!(plan.candidate_limit(5), 10); } + #[test] + fn planner_detects_advice_as_preference_recommendation() { + let plan = classify_query_op( + "I was thinking about rearranging bedroom furniture this weekend. Any tips?", + ); + + assert_eq!(plan.op, QueryOp::PreferenceRecommendation); + assert!(plan.require_personal_support); + } + + #[test] + fn planner_detects_relative_time_prompts() { + let plan = classify_query_op("who did I go with to the music event last Saturday?"); + + assert_eq!(plan.op, QueryOp::UpdateResolution); + assert!(plan.collect_all); + } + + #[test] + fn planner_detects_age_at_event_as_collect_mode() { + let plan = classify_query_op("how old was I when Alex was born?"); + + assert_eq!(plan.op, QueryOp::AggregateCount); + assert!(plan.collect_all); + } + #[test] fn planner_leaves_plain_lookup_alone() { let plan = classify_query_op("where did I buy coffee creamer?");