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:
236
crates/infrastructure/tests/embedder_config.rs
Normal file
236
crates/infrastructure/tests/embedder_config.rs
Normal file
@ -0,0 +1,236 @@
|
||||
//! L5 integration tests for the LOT C2 embedder-config adapters:
|
||||
//! - [`FsEmbedderProfileStore`] driven through the [`EmbedderProfileStore`] port
|
||||
//! against a real [`LocalFileSystem`] + temp dir (round-trip, `embedder.json`
|
||||
//! persistence, delete-absent ⇒ `NotFound`),
|
||||
//! - [`EmbedderEnvProbe::inspect`] without the HTTP feature (`ollama_detected`
|
||||
//! always `false`) and reflecting a prepared ONNX cache.
|
||||
//!
|
||||
//! No network is required. The temp-dir convention mirrors `tests/project_store.rs`
|
||||
//! (`std::env::temp_dir()` + a UUID, self-cleaning on drop) — no new dependency.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
EmbedderEnvInspector, EmbedderProfileStore, FileSystem, RemotePath, StoreError,
|
||||
};
|
||||
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
||||
use infrastructure::{
|
||||
EmbedderEnvProbe, FsEmbedderProfileStore, LocalFileSystem, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l5-embedder-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn app_data_dir(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
fn path(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
fn child(&self, name: &str) -> RemotePath {
|
||||
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store(tmp: &TempDir) -> FsEmbedderProfileStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsEmbedderProfileStore::new(fs, tmp.app_data_dir())
|
||||
}
|
||||
|
||||
fn sample(id: &str, name: &str, dimension: usize) -> EmbedderProfile {
|
||||
EmbedderProfile::new(
|
||||
id,
|
||||
name,
|
||||
EmbedderStrategy::LocalOnnx,
|
||||
Some("multilingual-e5-small".to_owned()),
|
||||
None,
|
||||
None,
|
||||
dimension,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FsEmbedderProfileStore via the EmbedderProfileStore port
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_list_roundtrips() {
|
||||
let tmp = TempDir::new();
|
||||
let store: &dyn EmbedderProfileStore = &store(&tmp);
|
||||
|
||||
assert!(
|
||||
store.list().await.unwrap().is_empty(),
|
||||
"no embedder.json yet ⇒ empty list"
|
||||
);
|
||||
|
||||
let p = sample("local-onnx", "Local ONNX", 384);
|
||||
store.save(&p).await.unwrap();
|
||||
|
||||
assert_eq!(store.list().await.unwrap(), vec![p]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_upserts_by_id_without_duplicating() {
|
||||
let tmp = TempDir::new();
|
||||
let store: &dyn EmbedderProfileStore = &store(&tmp);
|
||||
|
||||
store.save(&sample("e", "before", 384)).await.unwrap();
|
||||
let updated = sample("e", "after", 768);
|
||||
store.save(&updated).await.unwrap();
|
||||
|
||||
let listed = store.list().await.unwrap();
|
||||
assert_eq!(listed.len(), 1, "upsert must not duplicate by id");
|
||||
assert_eq!(listed[0], updated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_profile() {
|
||||
let tmp = TempDir::new();
|
||||
let store: &dyn EmbedderProfileStore = &store(&tmp);
|
||||
|
||||
store.save(&sample("a", "A", 384)).await.unwrap();
|
||||
store.save(&sample("b", "B", 384)).await.unwrap();
|
||||
|
||||
store.delete("a").await.unwrap();
|
||||
|
||||
let listed = store.list().await.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, "b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_unknown_is_not_found() {
|
||||
let tmp = TempDir::new();
|
||||
let store: &dyn EmbedderProfileStore = &store(&tmp);
|
||||
store.save(&sample("a", "A", 384)).await.unwrap();
|
||||
|
||||
let err = store
|
||||
.delete("ghost")
|
||||
.await
|
||||
.expect_err("deleting unknown id fails");
|
||||
assert!(matches!(err, StoreError::NotFound), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedder_file_is_camelcase_versioned() {
|
||||
let tmp = TempDir::new();
|
||||
let store: &dyn EmbedderProfileStore = &store(&tmp);
|
||||
|
||||
store
|
||||
.save(
|
||||
&EmbedderProfile::new(
|
||||
"api-openai",
|
||||
"OpenAI",
|
||||
EmbedderStrategy::Api,
|
||||
Some("text-embedding-3-small".to_owned()),
|
||||
Some("https://api.openai.com/v1/embeddings".to_owned()),
|
||||
Some("OPENAI_API_KEY".to_owned()),
|
||||
1536,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs.read(&tmp.child("embedder.json")).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
assert_eq!(json["version"], 1);
|
||||
let profiles = json
|
||||
.get("profiles")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("top-level `profiles` array");
|
||||
assert_eq!(profiles.len(), 1);
|
||||
let entry = &profiles[0];
|
||||
assert_eq!(entry["id"], "api-openai");
|
||||
assert_eq!(entry["strategy"], "api");
|
||||
// camelCase field, never the secret itself.
|
||||
assert_eq!(entry["apiKeyEnv"], "OPENAI_API_KEY");
|
||||
assert!(entry.get("api_key_env").is_none(), "no snake_case leak");
|
||||
assert_eq!(entry["dimension"], 1536);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmbedderEnvProbe::inspect — pure-FS cache scan, no network
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_empty_cache_reports_nothing() {
|
||||
let tmp = TempDir::new();
|
||||
// An ONNX cache subdir that exists but is empty ⇒ no cached models.
|
||||
let cache = tmp.path().join(ONNX_CACHE_SUBDIR);
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
|
||||
let probe = EmbedderEnvProbe::new(cache, "http://127.0.0.1:1"); // unreachable host
|
||||
let report = probe.inspect().await;
|
||||
|
||||
assert!(report.onnx_cached_models.is_empty(), "empty cache ⇒ none");
|
||||
assert!(
|
||||
!report.ollama_detected,
|
||||
"without vector-http (or with an unreachable host) ⇒ never detected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_missing_cache_dir_is_best_effort_empty() {
|
||||
// A cache dir that does not exist must not panic — best-effort ⇒ empty.
|
||||
let cache = std::env::temp_dir().join(format!("idea-no-such-cache-{}", Uuid::new_v4()));
|
||||
assert!(!cache.exists());
|
||||
|
||||
let probe = EmbedderEnvProbe::new(cache, "http://127.0.0.1:1");
|
||||
let report = probe.inspect().await;
|
||||
|
||||
assert!(report.onnx_cached_models.is_empty());
|
||||
assert!(!report.ollama_detected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_detects_prepared_onnx_cache() {
|
||||
let tmp = TempDir::new();
|
||||
let cache = tmp.path().join(ONNX_CACHE_SUBDIR);
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
|
||||
// Recreate the hf-hub-style cache layout for the recommended model:
|
||||
// a non-empty subdirectory whose name contains the model token
|
||||
// (`onnx_model_is_cached`: needle = id with `_`/`/`→`-`, lowercased).
|
||||
let model = RECOMMENDED_ONNX_MODELS
|
||||
.iter()
|
||||
.find(|m| m.recommended)
|
||||
.expect("a recommended model exists");
|
||||
let model_dir = cache.join(format!("models--intfloat--{}", model.id));
|
||||
std::fs::create_dir_all(&model_dir).unwrap();
|
||||
// Non-empty: the probe treats an empty dir as "not present".
|
||||
std::fs::write(model_dir.join("model.onnx"), b"stub").unwrap();
|
||||
|
||||
let probe = EmbedderEnvProbe::new(cache, "http://127.0.0.1:1");
|
||||
let report = probe.inspect().await;
|
||||
|
||||
assert_eq!(
|
||||
report.onnx_cached_models,
|
||||
vec![model.id.to_owned()],
|
||||
"the prepared cached model must be detected"
|
||||
);
|
||||
assert!(!report.ollama_detected, "no real Ollama required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_capability_flags_match_features() {
|
||||
// The consts must mirror the build's features exactly (honest reporting to the UI).
|
||||
assert_eq!(VECTOR_HTTP_ENABLED, cfg!(feature = "vector-http"));
|
||||
assert_eq!(VECTOR_ONNX_ENABLED, cfg!(feature = "vector-onnx"));
|
||||
}
|
||||
Reference in New Issue
Block a user