feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -152,7 +152,10 @@ fn query(text: &str, budget: usize) -> MemoryQuery {
|
||||
}
|
||||
|
||||
fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> {
|
||||
entries.iter().map(|e| e.slug.as_str().to_string()).collect()
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| e.slug.as_str().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn store_with(notes: &[Memory], fs: Arc<MemFs>) -> Arc<FsMemoryStore> {
|
||||
@ -215,16 +218,16 @@ async fn hash_embedder_dimension_is_consistent() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.len(), 2, "one vector per input, order preserved");
|
||||
assert!(out.iter().all(|v| v.len() == 48), "every vector is dimension-long");
|
||||
assert!(
|
||||
out.iter().all(|v| v.len() == 48),
|
||||
"every vector is dimension-long"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_embedder_vectors_are_l2_normalised() {
|
||||
let e = HashEmbedder::new("h", 64);
|
||||
let out = e
|
||||
.embed(&["one two three four".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
let out = e.embed(&["one two three four".to_string()]).await.unwrap();
|
||||
let norm = out[0].iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "norm ≈ 1, got {norm}");
|
||||
}
|
||||
@ -332,7 +335,12 @@ async fn vector_recall_ranks_closest_note_first() {
|
||||
.recall(&root(), &query("beta kiwi mango papaya", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.first().map(|e| e.slug.as_str()), Some("beta"), "ranked: {:?}", slugs(&out));
|
||||
assert_eq!(
|
||||
out.first().map(|e| e.slug.as_str()),
|
||||
Some("beta"),
|
||||
"ranked: {:?}",
|
||||
slugs(&out)
|
||||
);
|
||||
// All three notes fit the ample budget.
|
||||
assert_eq!(out.len(), 3);
|
||||
}
|
||||
@ -352,8 +360,15 @@ async fn vector_recall_truncates_to_budget() {
|
||||
let recall = vector_recall(embedder, store, fs);
|
||||
|
||||
// Budget 2 fits only the top-ranked `beta` (cost 2); the next would exceed.
|
||||
let out = recall.recall(&root(), &query("beta kiwi", 2)).await.unwrap();
|
||||
assert_eq!(slugs(&out), vec!["beta"], "budget must keep only the closest");
|
||||
let out = recall
|
||||
.recall(&root(), &query("beta kiwi", 2))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
slugs(&out),
|
||||
vec!["beta"],
|
||||
"budget must keep only the closest"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -389,20 +404,32 @@ async fn vector_recall_writes_and_reuses_the_derived_cache() {
|
||||
let recall = VectorMemoryRecall::new(spy.clone(), store, fs.clone());
|
||||
|
||||
// First recall: embeds 2 note texts + 1 query text = 3.
|
||||
let _ = recall.recall(&root(), &query("alpha apple", 100_000)).await.unwrap();
|
||||
let _ = recall
|
||||
.recall(&root(), &query("alpha apple", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
let after_first = spy.embedded_texts.load(Ordering::SeqCst);
|
||||
assert_eq!(after_first, 3, "2 notes + 1 query embedded on a cold cache");
|
||||
|
||||
// The derived cache file now exists with both slugs and the embedder id.
|
||||
let doc = fs.raw(VECTORS_PATH).expect("vectors.json written");
|
||||
assert!(doc.contains("\"embedderId\": \"spy\""), "tagged with embedder id: {doc}");
|
||||
assert!(
|
||||
doc.contains("\"embedderId\": \"spy\""),
|
||||
"tagged with embedder id: {doc}"
|
||||
);
|
||||
assert!(doc.contains("\"alpha\""), "alpha vector cached: {doc}");
|
||||
assert!(doc.contains("\"beta\""), "beta vector cached: {doc}");
|
||||
|
||||
// Second recall: note vectors come from the cache; only the query is embedded.
|
||||
let _ = recall.recall(&root(), &query("beta banana", 100_000)).await.unwrap();
|
||||
let _ = recall
|
||||
.recall(&root(), &query("beta banana", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
let delta = spy.embedded_texts.load(Ordering::SeqCst) - after_first;
|
||||
assert_eq!(delta, 1, "cache reuse ⇒ only the query is re-embedded, got {delta}");
|
||||
assert_eq!(
|
||||
delta, 1,
|
||||
"cache reuse ⇒ only the query is re-embedded, got {delta}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -417,7 +444,10 @@ async fn vector_recall_invalidates_cache_on_embedder_id_change() {
|
||||
.recall(&root(), &query("alpha apple", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"first\""));
|
||||
assert!(fs
|
||||
.raw(VECTORS_PATH)
|
||||
.unwrap()
|
||||
.contains("\"embedderId\": \"first\""));
|
||||
|
||||
// A different embedder id must invalidate ⇒ recompute all note vectors.
|
||||
let second = Arc::new(SpyEmbedder::new("second", 128));
|
||||
@ -432,7 +462,10 @@ async fn vector_recall_invalidates_cache_on_embedder_id_change() {
|
||||
"id change must invalidate and recompute note vectors"
|
||||
);
|
||||
// The file is now tagged with the new embedder id.
|
||||
assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"second\""));
|
||||
assert!(fs
|
||||
.raw(VECTORS_PATH)
|
||||
.unwrap()
|
||||
.contains("\"embedderId\": \"second\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -472,7 +505,10 @@ async fn vector_recall_with_stub_embedder_errors_without_panic() {
|
||||
let stub = Arc::new(StubEmbedder::new("stub", 64, "localOnnx"));
|
||||
let recall = VectorMemoryRecall::new(stub, store, fs);
|
||||
let res = recall.recall(&root(), &query("alpha apple", 1000)).await;
|
||||
assert!(res.is_err(), "stub embedder ⇒ Err on the vector path, got {res:?}");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"stub embedder ⇒ Err on the vector path, got {res:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@ -520,7 +556,10 @@ async fn adaptive_none_strategy_matches_naive_exactly() {
|
||||
let q = query("kiwi mango papaya", budget);
|
||||
let expected = naive.recall(&root(), &q).await.unwrap();
|
||||
let got = adaptive.recall(&root(), &q).await.unwrap();
|
||||
assert_eq!(got, expected, "strategy none must equal naive at budget {budget}");
|
||||
assert_eq!(
|
||||
got, expected,
|
||||
"strategy none must equal naive at budget {budget}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -546,7 +585,11 @@ async fn adaptive_small_memory_routes_to_naive_order() {
|
||||
.recall(&root(), &query("gamma grape", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(slugs(&out), vec!["alpha", "beta", "gamma"], "naïve keeps index order");
|
||||
assert_eq!(
|
||||
slugs(&out),
|
||||
vec!["alpha", "beta", "gamma"],
|
||||
"naïve keeps index order"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -614,9 +657,15 @@ async fn adaptive_falls_back_to_naive_when_embedder_fails() {
|
||||
let total = infrastructure::index_token_size(&store.read_index(&root()).await.unwrap());
|
||||
let q = query("carrot potato onion", total - 1);
|
||||
|
||||
let got = adaptive.recall(&root(), &q).await.expect("fallback must not error");
|
||||
let got = adaptive
|
||||
.recall(&root(), &q)
|
||||
.await
|
||||
.expect("fallback must not error");
|
||||
let expected = naive_ref.recall(&root(), &q).await.unwrap();
|
||||
assert_eq!(got, expected, "embedder failure must degrade to the naïve result");
|
||||
assert_eq!(
|
||||
got, expected,
|
||||
"embedder failure must degrade to the naïve result"
|
||||
);
|
||||
assert!(!got.is_empty(), "the degraded result still returns entries");
|
||||
}
|
||||
|
||||
@ -646,11 +695,18 @@ impl Drop for TempDir {
|
||||
|
||||
#[test]
|
||||
fn recommended_onnx_models_advertise_e5_small_384() {
|
||||
assert_eq!(RECOMMENDED_ONNX_MODELS.len(), 1, "exactly one curated model");
|
||||
assert_eq!(
|
||||
RECOMMENDED_ONNX_MODELS.len(),
|
||||
1,
|
||||
"exactly one curated model"
|
||||
);
|
||||
let m = RECOMMENDED_ONNX_MODELS[0];
|
||||
assert_eq!(m.id, "multilingual-e5-small");
|
||||
assert_eq!(m.dimension, 384);
|
||||
assert!(m.recommended, "the single curated model is the recommended default");
|
||||
assert!(
|
||||
m.recommended,
|
||||
"the single curated model is the recommended default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -681,7 +737,9 @@ fn onnx_model_is_cached_true_when_model_subdir_present_and_nonempty() {
|
||||
// *contains* the model token (slashes/underscores normalised to `-`), mirroring
|
||||
// hf-hub's `models--<org>--<name>` layout.
|
||||
let tmp = TempDir::new();
|
||||
let model_dir = tmp.path().join("models--Qdrant--multilingual-e5-small-onnx");
|
||||
let model_dir = tmp
|
||||
.path()
|
||||
.join("models--Qdrant--multilingual-e5-small-onnx");
|
||||
std::fs::create_dir_all(&model_dir).unwrap();
|
||||
// The subdir must be non-empty to count as "cached".
|
||||
std::fs::write(model_dir.join("model.onnx"), b"not really a model").unwrap();
|
||||
@ -696,7 +754,11 @@ fn onnx_model_is_cached_true_when_model_subdir_present_and_nonempty() {
|
||||
fn onnx_model_is_cached_false_when_matching_subdir_is_empty() {
|
||||
// A matching but *empty* subdir does not count as a present cached model.
|
||||
let tmp = TempDir::new();
|
||||
std::fs::create_dir_all(tmp.path().join("models--Qdrant--multilingual-e5-small-onnx")).unwrap();
|
||||
std::fs::create_dir_all(
|
||||
tmp.path()
|
||||
.join("models--Qdrant--multilingual-e5-small-onnx"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!onnx_model_is_cached(tmp.path(), "multilingual-e5-small"),
|
||||
"an empty matching subdir is not a cached model"
|
||||
|
||||
Reference in New Issue
Block a user