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:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View File

@ -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"