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:
256
crates/application/tests/embedder_usecases.rs
Normal file
256
crates/application/tests/embedder_usecases.rs
Normal file
@ -0,0 +1,256 @@
|
||||
//! L5 tests for the embedder-configuration use cases (LOT C2).
|
||||
//!
|
||||
//! Ports are faked in-memory so the use cases run without any I/O:
|
||||
//! - [`InMemoryEmbedderProfileStore`] — an [`EmbedderProfileStore`] backed by a
|
||||
//! `Vec` with the same upsert/delete-NotFound semantics as the real
|
||||
//! `FsEmbedderProfileStore`,
|
||||
//! - [`FixedEnvInspector`] — an [`EmbedderEnvInspector`] returning a constant
|
||||
//! [`EmbedderEnvReport`] (best-effort port, never errors).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{EmbedderEnvInspector, EmbedderEnvReport, EmbedderProfileStore, StoreError};
|
||||
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
||||
|
||||
use application::{
|
||||
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines,
|
||||
ListEmbedderProfiles, OnnxModelView, SaveEmbedderProfile, SaveEmbedderProfileInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// In-memory [`EmbedderProfileStore`] mirroring the real store's semantics:
|
||||
/// upsert-by-id (no duplicate), delete-absent ⇒ [`StoreError::NotFound`].
|
||||
#[derive(Default, Clone)]
|
||||
struct InMemoryEmbedderProfileStore(Arc<Mutex<Vec<EmbedderProfile>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedderProfileStore for InMemoryEmbedderProfileStore {
|
||||
async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
if let Some(slot) = v.iter_mut().find(|p| p.id == profile.id) {
|
||||
*slot = profile.clone();
|
||||
} else {
|
||||
v.push(profile.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), StoreError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
let before = v.len();
|
||||
v.retain(|p| p.id != id);
|
||||
if v.len() == before {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// An [`EmbedderEnvInspector`] returning a fixed report (best-effort, infallible).
|
||||
struct FixedEnvInspector(EmbedderEnvReport);
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedderEnvInspector for FixedEnvInspector {
|
||||
async fn inspect(&self) -> EmbedderEnvReport {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn save_input(id: &str, name: &str, dimension: usize) -> SaveEmbedderProfileInput {
|
||||
SaveEmbedderProfileInput {
|
||||
id: id.to_owned(),
|
||||
name: name.to_owned(),
|
||||
strategy: EmbedderStrategy::LocalOnnx,
|
||||
model: Some("multilingual-e5-small".to_owned()),
|
||||
endpoint: None,
|
||||
api_key_env: None,
|
||||
dimension,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListEmbedderProfiles / SaveEmbedderProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_is_empty_then_contains_saved_profile() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
|
||||
assert!(
|
||||
list.execute().await.unwrap().profiles.is_empty(),
|
||||
"no profile configured ⇒ empty list (default `none` posture)"
|
||||
);
|
||||
|
||||
let saved = save
|
||||
.execute(save_input("local-onnx", "Local ONNX", 384))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.profile.id, "local-onnx");
|
||||
assert_eq!(saved.profile.dimension, 384);
|
||||
|
||||
let listed = list.execute().await.unwrap().profiles;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0], saved.profile);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_upserts_by_id_without_duplicating() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
save.execute(save_input("e", "before", 384)).await.unwrap();
|
||||
let updated = save.execute(save_input("e", "after", 768)).await.unwrap();
|
||||
|
||||
let listed = list.execute().await.unwrap().profiles;
|
||||
assert_eq!(listed.len(), 1, "same id replaces, no duplicate");
|
||||
assert_eq!(listed[0], updated.profile);
|
||||
assert_eq!(listed[0].name, "after");
|
||||
assert_eq!(listed[0].dimension, 768);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_invalid_dimension_is_invalid_and_writes_nothing() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
let err = save
|
||||
.execute(save_input("bad", "Bad", 0))
|
||||
.await
|
||||
.expect_err("dimension 0 must be rejected");
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
|
||||
assert!(
|
||||
list.execute().await.unwrap().profiles.is_empty(),
|
||||
"a rejected profile must not be persisted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_empty_name_is_invalid_and_writes_nothing() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
let err = save
|
||||
.execute(save_input("id", "", 384))
|
||||
.await
|
||||
.expect_err("empty name must be rejected");
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
|
||||
assert!(
|
||||
list.execute().await.unwrap().profiles.is_empty(),
|
||||
"a rejected profile must not be persisted"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteEmbedderProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_existing_profile() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let delete = DeleteEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
save.execute(save_input("a", "A", 384)).await.unwrap();
|
||||
save.execute(save_input("b", "B", 384)).await.unwrap();
|
||||
|
||||
delete
|
||||
.execute(DeleteEmbedderProfileInput { id: "a".to_owned() })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let listed = list.execute().await.unwrap().profiles;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, "b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_unknown_id_is_not_found() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let delete = DeleteEmbedderProfile::new(Arc::new(store));
|
||||
|
||||
let err = delete
|
||||
.execute(DeleteEmbedderProfileInput {
|
||||
id: "ghost".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect_err("deleting an unknown id errors");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"NOT_FOUND",
|
||||
"StoreError::NotFound ⇒ AppError::NotFound, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DescribeEmbedderEngines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn onnx_view() -> OnnxModelView {
|
||||
OnnxModelView {
|
||||
id: "multilingual-e5-small".to_owned(),
|
||||
display_name: "Multilingual E5 Small".to_owned(),
|
||||
dimension: 384,
|
||||
approx_size_mb: 118,
|
||||
recommended: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_engines_merges_catalogue_report_and_flags() {
|
||||
let report = EmbedderEnvReport {
|
||||
ollama_detected: true,
|
||||
onnx_cached_models: vec!["multilingual-e5-small".to_owned()],
|
||||
};
|
||||
let inspector = Arc::new(FixedEnvInspector(report));
|
||||
let describe = DescribeEmbedderEngines::new(
|
||||
inspector,
|
||||
vec![onnx_view()],
|
||||
/* vector_http_enabled */ true,
|
||||
/* vector_onnx_enabled */ false,
|
||||
);
|
||||
|
||||
let view = describe.execute().await.unwrap();
|
||||
|
||||
// Catalogue injected verbatim.
|
||||
assert_eq!(view.recommended_onnx, vec![onnx_view()]);
|
||||
// Report fields surfaced.
|
||||
assert!(view.ollama_detected);
|
||||
assert_eq!(view.onnx_cached_models, vec!["multilingual-e5-small"]);
|
||||
// Compiled-capability flags surfaced as injected.
|
||||
assert!(view.vector_http_enabled);
|
||||
assert!(!view.vector_onnx_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_engines_with_nothing_detected() {
|
||||
let inspector = Arc::new(FixedEnvInspector(EmbedderEnvReport {
|
||||
ollama_detected: false,
|
||||
onnx_cached_models: vec![],
|
||||
}));
|
||||
let describe = DescribeEmbedderEngines::new(inspector, vec![], false, true);
|
||||
|
||||
let view = describe.execute().await.unwrap();
|
||||
|
||||
assert!(view.recommended_onnx.is_empty());
|
||||
assert!(!view.ollama_detected);
|
||||
assert!(view.onnx_cached_models.is_empty());
|
||||
assert!(!view.vector_http_enabled);
|
||||
assert!(view.vector_onnx_enabled);
|
||||
}
|
||||
Reference in New Issue
Block a user