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:
@ -23,10 +23,27 @@
|
||||
//! repeatable vectors from a hashing bag-of-words, good enough for tests and for a
|
||||
//! trivial offline fallback).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{Embedder, EmbedderError};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ports::{
|
||||
Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderPromptDismissal,
|
||||
EmbedderPromptStore, FileSystem, FsError, RemotePath, StoreError,
|
||||
};
|
||||
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
/// Whether this binary was compiled with the HTTP embedding capability
|
||||
/// (`localServer`/`api` real engines + Ollama detection). Reported honestly to the
|
||||
/// C2/C3 UI so it only offers strategies actually wired into *this* build.
|
||||
pub const VECTOR_HTTP_ENABLED: bool = cfg!(feature = "vector-http");
|
||||
|
||||
/// Whether this binary was compiled with the in-process ONNX embedding capability
|
||||
/// (`localOnnx` real engine via `fastembed`). Reported honestly to the C2/C3 UI.
|
||||
pub const VECTOR_ONNX_ENABLED: bool = cfg!(feature = "vector-onnx");
|
||||
|
||||
/// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is
|
||||
/// [`EmbedderStrategy::None`] (recall stays naïve).
|
||||
@ -50,7 +67,10 @@ pub fn embedder_from_profile(
|
||||
EmbedderStrategy::LocalOnnx => {
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
{
|
||||
Some(Box::new(OnnxEmbedder::from_profile(profile, onnx_cache_dir)))
|
||||
Some(Box::new(OnnxEmbedder::from_profile(
|
||||
profile,
|
||||
onnx_cache_dir,
|
||||
)))
|
||||
}
|
||||
#[cfg(not(feature = "vector-onnx"))]
|
||||
{
|
||||
@ -325,10 +345,9 @@ impl Embedder for HttpEmbedder {
|
||||
}
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint)))?;
|
||||
let response = request.send().await.map_err(|e| {
|
||||
EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(EmbedderError::Unavailable(format!(
|
||||
@ -464,6 +483,189 @@ pub fn onnx_model_is_cached(cache_dir: &std::path::Path, model: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmbedderEnvProbe — best-effort local-environment inspector (LOT C2/C3).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Default base URL of a local Ollama-style embedding server, probed by
|
||||
/// [`EmbedderEnvProbe`] to detect an already-installed local engine (the
|
||||
/// Linux-spirit "detect the existing first" rule). The base (no path): the probe
|
||||
/// appends `/api/tags` via [`detect_ollama`].
|
||||
pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434";
|
||||
|
||||
/// Concrete [`EmbedderEnvInspector`]: a **best-effort, never-failing** probe of the
|
||||
/// local embedding environment.
|
||||
///
|
||||
/// - `onnx_cached_models`: for each model in [`RECOMMENDED_ONNX_MODELS`], reports
|
||||
/// the ids already present in `onnx_cache_dir` (pure FS, no `fastembed`
|
||||
/// dependency). The blocking `read_dir` runs on a `spawn_blocking` thread so it
|
||||
/// never stalls the async runtime.
|
||||
/// - `ollama_detected`: `true` only when built with the `vector-http` capability
|
||||
/// **and** an Ollama-style server answers at `ollama_base_url`; `false` otherwise.
|
||||
///
|
||||
/// Construction is cheap and infallible; nothing touches disk or the network until
|
||||
/// [`inspect`](EmbedderEnvInspector::inspect) is called.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmbedderEnvProbe {
|
||||
onnx_cache_dir: PathBuf,
|
||||
/// Only read under `vector-http` (by [`detect_ollama`]); retained unconditionally
|
||||
/// so the constructor signature is stable across build configs.
|
||||
#[cfg_attr(not(feature = "vector-http"), allow(dead_code))]
|
||||
ollama_base_url: String,
|
||||
}
|
||||
|
||||
impl EmbedderEnvProbe {
|
||||
/// Builds the probe from the ONNX cache directory (`<app_data_dir>/embedders/onnx`,
|
||||
/// see [`ONNX_CACHE_SUBDIR`]) and the base URL of the local Ollama-style server.
|
||||
#[must_use]
|
||||
pub fn new(onnx_cache_dir: PathBuf, ollama_base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
onnx_cache_dir,
|
||||
ollama_base_url: ollama_base_url.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedderEnvInspector for EmbedderEnvProbe {
|
||||
async fn inspect(&self) -> EmbedderEnvReport {
|
||||
// Pure-FS cache scan off the runtime: `onnx_model_is_cached` calls blocking
|
||||
// `read_dir`, so run the whole scan on a blocking thread.
|
||||
let cache_dir = self.onnx_cache_dir.clone();
|
||||
let onnx_cached_models = tokio::task::spawn_blocking(move || {
|
||||
RECOMMENDED_ONNX_MODELS
|
||||
.iter()
|
||||
.filter(|m| onnx_model_is_cached(&cache_dir, m.id))
|
||||
.map(|m| m.id.to_owned())
|
||||
.collect::<Vec<String>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
#[cfg(feature = "vector-http")]
|
||||
let ollama_detected = detect_ollama(&self.ollama_base_url).await;
|
||||
#[cfg(not(feature = "vector-http"))]
|
||||
let ollama_detected = false;
|
||||
|
||||
EmbedderEnvReport {
|
||||
ollama_detected,
|
||||
onnx_cached_models,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FsEmbedderPromptStore — per-project embedder-suggestion state (LOT C3).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `.ideai/` directory name inside a project root.
|
||||
const PROMPT_IDEAI_DIR: &str = ".ideai";
|
||||
/// Memory sub-dir (the suggestion state lives beside the memory it concerns).
|
||||
const PROMPT_MEMORY_DIR: &str = "memory";
|
||||
/// State file name (dot-prefixed: derived/local state, like the vector index).
|
||||
const PROMPT_FILE: &str = ".embedder-prompt.json";
|
||||
|
||||
/// Wire form of the suggestion-dismissal choice (camelCase).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum DismissalWire {
|
||||
Later,
|
||||
Never,
|
||||
}
|
||||
|
||||
impl From<EmbedderPromptDismissal> for DismissalWire {
|
||||
fn from(d: EmbedderPromptDismissal) -> Self {
|
||||
match d {
|
||||
EmbedderPromptDismissal::Later => Self::Later,
|
||||
EmbedderPromptDismissal::Never => Self::Never,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DismissalWire> for EmbedderPromptDismissal {
|
||||
fn from(w: DismissalWire) -> Self {
|
||||
match w {
|
||||
DismissalWire::Later => Self::Later,
|
||||
DismissalWire::Never => Self::Never,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk shape of `.ideai/memory/.embedder-prompt.json`:
|
||||
/// `{ "dismissed": "later" | "never" }`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
struct PromptDoc {
|
||||
dismissed: DismissalWire,
|
||||
}
|
||||
|
||||
/// File-backed [`EmbedderPromptStore`] (LOT C3): persists the per-project response
|
||||
/// to the embedder suggestion under `.ideai/memory/.embedder-prompt.json`, through
|
||||
/// the [`FileSystem`] port (so it is location-neutral, SSH/WSL unchanged).
|
||||
///
|
||||
/// Reads are **best-effort**: a missing file ⇒ `Ok(None)` (never answered); a
|
||||
/// malformed file also degrades to `Ok(None)` rather than surfacing an error, so a
|
||||
/// hand-corrupted state never blocks the suggestion logic.
|
||||
#[derive(Clone)]
|
||||
pub struct FsEmbedderPromptStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl FsEmbedderPromptStore {
|
||||
/// Builds the store from the [`FileSystem`] port.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { fs }
|
||||
}
|
||||
|
||||
/// `<root>/.ideai/memory`.
|
||||
fn memory_dir(root: &ProjectPath) -> String {
|
||||
let base = root.as_str().trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{PROMPT_IDEAI_DIR}/{PROMPT_MEMORY_DIR}")
|
||||
}
|
||||
|
||||
/// `<memory-dir>/.embedder-prompt.json`.
|
||||
fn prompt_path(root: &ProjectPath) -> RemotePath {
|
||||
RemotePath::new(format!("{}/{PROMPT_FILE}", Self::memory_dir(root)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedderPromptStore for FsEmbedderPromptStore {
|
||||
async fn read(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
) -> Result<Option<EmbedderPromptDismissal>, StoreError> {
|
||||
match self.fs.read(&Self::prompt_path(root)).await {
|
||||
Ok(bytes) => Ok(serde_json::from_slice::<PromptDoc>(&bytes)
|
||||
.ok()
|
||||
.map(|doc| doc.dismissed.into())),
|
||||
// Never answered (or unreadable) ⇒ no recorded dismissal.
|
||||
Err(FsError::NotFound(_)) | Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
dismissal: EmbedderPromptDismissal,
|
||||
) -> Result<(), StoreError> {
|
||||
let dir = RemotePath::new(Self::memory_dir(root));
|
||||
self.fs
|
||||
.create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
let doc = PromptDoc {
|
||||
dismissed: dismissal.into(),
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&doc)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
self.fs
|
||||
.write(&Self::prompt_path(root), &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
mod onnx {
|
||||
use std::path::{Path, PathBuf};
|
||||
@ -546,9 +748,7 @@ mod onnx {
|
||||
.get_or_try_init(|| async {
|
||||
let cache_dir = self.cache_dir.clone();
|
||||
let built = tokio::task::spawn_blocking(move || {
|
||||
TextEmbedding::try_new(
|
||||
InitOptions::new(model).with_cache_dir(cache_dir),
|
||||
)
|
||||
TextEmbedding::try_new(InitOptions::new(model).with_cache_dir(cache_dir))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| EmbedderError::Io(format!("onnx init task failed: {e}")))?
|
||||
|
||||
Reference in New Issue
Block a user