feat(nouser): Phase D-2 — proveedor Nous real (LLM) detrás de feature
Cierra el ciclo del módulo Nous: existe un proveedor que produce
embeddings reales con un modelo LLM, mientras que `cargo build` sin
features sigue siendo liviano (no descarga ni compila ML deps).
Crate nuevo crates/modules/nouser/nous-real con dos modos según feature:
- Sin feature (default): stub.
cargo build -p nouser-nous-real (~10s, sin ML deps).
Bin arranca, sidecarea a brahman-init declarando la Card,
escucha en el socket Nous, rechaza requests con un ErrorResponse
explicativo: "compilado sin la feature embeddings, rebuild con
cargo build -p nouser-nous-real --features embeddings".
cargo build --workspace SIGUE siendo limpio.
- Con --features embeddings: real.
Pulls fastembed = "4" → ort 2.0.0-rc.9 (ONNX Runtime con binarios
descargados por Cargo) + tokenizers 0.21 + ~30 transitive deps.
Compila en ~50s.
Modelo default: all-MiniLM-L6-v2 (384-d, descargado a
~/.cache/fastembed la primera vez).
EmbedText: pasa el texto al modelo → vector 384-d.
EmbedFile: lee primeros 8KiB UTF-8 lossy, embed como texto.
Ping: devuelve model_id + embed_dim reales.
Card declara label "nouser.nous_real" + priority_contexts.prod = +1.
En contexto prod gana sobre el mock; en test el mock gana por su +1
en test. Sin contexto, empate alfabético.
Validación end-to-end con modelo real:
$ ente-zero & nouser-nous-real &
$ python3 socket-probe '{"kind":"embed_text","payload":{"text":"..."}}'
model: real-fastembed-allMiniLML6V2-384d
elapsed_ms: 8
embed_dim: 384
Tradeoff: dim mock (32) vs real (384) son incompatibles. Cambiar
proveedor invalida centroides cacheados — documentar "limpiar DB al
swap".
Workspace state:
- cargo build --workspace limpio sin features (no ML deps pulled).
- cargo build -p nouser-nous-real --features embeddings funciona.
- 0 errores, 0 warnings en ambos modos.
Pendientes para D-3 / futuro:
- Discovery de socket: el consumer hoy usa NOUSER_NOUS_SOCKET hardcoded.
Para que el broker elija real vs mock per-contexto, falta o un campo
socket en el MatchEvent o un broker query "dame socket de session X".
- Coexistencia: ambos providers compiten por el mismo socket path por
default. Parametrizarlos cuando se quiera correrlos juntos.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
//! Modo embeddings: usa fastembed-rs (ONNX Runtime) para producir
|
||||
//! vectores reales de text-embedding.
|
||||
//!
|
||||
//! Modelo default: `all-MiniLM-L6-v2` (384-d). Se descarga al primer
|
||||
//! arranque a `~/.cache/fastembed` y queda cacheado.
|
||||
//!
|
||||
//! ## Mapeo del contrato
|
||||
//!
|
||||
//! - `EmbedText`: pasa el texto al modelo, devuelve el vector 384-d.
|
||||
//! - `EmbedFile`: lee hasta los primeros 8 KiB del archivo, los
|
||||
//! interpreta como UTF-8 con replacement-char, y los embeda como
|
||||
//! texto. Para archivos binarios el resultado no es semánticamente
|
||||
//! útil — caller decide qué hacer.
|
||||
//! - `Ping`: devuelve `model_id` y `embed_dim` reales.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
|
||||
use nouser_nous::{
|
||||
EmbedFilePayload, EmbedRequest, EmbedResponse, EmbedTextPayload, ErrorResponse, PingResponse,
|
||||
RequestKind,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const MAX_FILE_BYTES: usize = 8192;
|
||||
|
||||
/// Backend concreto: posee el modelo cargado.
|
||||
pub struct Backend {
|
||||
model: TextEmbedding,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn init() -> Result<Self, String> {
|
||||
info!("cargando modelo all-MiniLM-L6-v2 (puede descargar ~80MB la primera vez)");
|
||||
let opts = InitOptions::new(EmbeddingModel::AllMiniLML6V2)
|
||||
.with_show_download_progress(true);
|
||||
let model = TextEmbedding::try_new(opts).map_err(|e| format!("fastembed init: {e}"))?;
|
||||
info!("modelo listo");
|
||||
Ok(Self { model })
|
||||
}
|
||||
|
||||
fn embed_one(&self, text: &str) -> Result<Vec<f32>, String> {
|
||||
let out = self
|
||||
.model
|
||||
.embed(vec![text], None)
|
||||
.map_err(|e| format!("embed: {e}"))?;
|
||||
out.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "fastembed devolvió 0 vectores".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_conn(stream: UnixStream, backend: Arc<Backend>) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let req: EmbedRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return write_error(reader.into_inner(), format!("JSON inválido: {e}")).await;
|
||||
}
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
let result = match req.kind {
|
||||
RequestKind::EmbedFile => handle_file(req.payload, &backend, started),
|
||||
RequestKind::EmbedText => handle_text(req.payload, &backend, started),
|
||||
RequestKind::Ping => handle_ping(),
|
||||
};
|
||||
|
||||
let mut stream = reader.into_inner();
|
||||
match result {
|
||||
Ok(json) => {
|
||||
stream.write_all(json.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
}
|
||||
Err(msg) => return write_error(stream, msg).await,
|
||||
}
|
||||
stream.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_text(
|
||||
payload: serde_json::Value,
|
||||
backend: &Backend,
|
||||
started: Instant,
|
||||
) -> Result<String, String> {
|
||||
let p: EmbedTextPayload =
|
||||
serde_json::from_value(payload).map_err(|e| format!("payload: {e}"))?;
|
||||
info!(text_len = p.text.len(), "embed_text");
|
||||
let v = backend.embed_one(&p.text)?;
|
||||
let resp = EmbedResponse {
|
||||
embedding: v,
|
||||
model: super::model_id().to_string(),
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
fn handle_file(
|
||||
payload: serde_json::Value,
|
||||
backend: &Backend,
|
||||
started: Instant,
|
||||
) -> Result<String, String> {
|
||||
let p: EmbedFilePayload =
|
||||
serde_json::from_value(payload).map_err(|e| format!("payload: {e}"))?;
|
||||
info!(path = %p.path, "embed_file (lee contenido)");
|
||||
|
||||
let path = PathBuf::from(&p.path);
|
||||
let mut file = File::open(&path).map_err(|e| format!("abrir archivo: {e}"))?;
|
||||
let mut buf = vec![0u8; MAX_FILE_BYTES];
|
||||
let n = file.read(&mut buf).map_err(|e| format!("leer archivo: {e}"))?;
|
||||
buf.truncate(n);
|
||||
let text = String::from_utf8_lossy(&buf).to_string();
|
||||
|
||||
let v = backend.embed_one(&text)?;
|
||||
let resp = EmbedResponse {
|
||||
embedding: v,
|
||||
model: super::model_id().to_string(),
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
fn handle_ping() -> Result<String, String> {
|
||||
let resp = PingResponse {
|
||||
model: super::model_id().to_string(),
|
||||
embed_dim: super::embed_dim(),
|
||||
};
|
||||
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||
}
|
||||
|
||||
async fn write_error(mut stream: UnixStream, msg: String) -> std::io::Result<()> {
|
||||
warn!(error = %msg, "respuesta de error");
|
||||
let resp = ErrorResponse { error: msg };
|
||||
let json = serde_json::to_string(&resp).unwrap_or_else(|_| "{\"error\":\"encode\"}".into());
|
||||
stream.write_all(json.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
stream.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user