11fc95629c
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>
183 lines
5.7 KiB
Rust
183 lines
5.7 KiB
Rust
//! `nouser-nous-real` — proveedor Nous con LLM real (gated por feature).
|
|
//!
|
|
//! ## Build modes
|
|
//!
|
|
//! - `cargo build -p nouser-nous-real`
|
|
//! Compila como **stub**: bin que arranca, sidecarea al brahman-init
|
|
//! pero rechaza toda request con un error explicando que falta la
|
|
//! feature. Útil para que `cargo build --workspace` no requiera ML
|
|
//! deps.
|
|
//!
|
|
//! - `cargo build -p nouser-nous-real --features embeddings`
|
|
//! Compila con `fastembed` + ONNX Runtime descargado por Cargo.
|
|
//! Modelo default: `all-MiniLM-L6-v2` (384-d, ~80 MB descargado al
|
|
//! primer run y cacheado en `~/.cache/fastembed`).
|
|
//!
|
|
//! ## Diseño
|
|
//!
|
|
//! Mismo contrato wire que `nouser-nous-mock` (`nouser-nous` crate). La
|
|
//! diferencia operativa: real produce 384-d con semantic content
|
|
//! (text-embedding del modelo); mock produce 32-d con metadata-hashing.
|
|
//! No son intercambiables a media-deployment — los centroides de
|
|
//! Mónadas calculadas con uno NO matchean con el otro.
|
|
//!
|
|
//! La Card declara `priority_contexts.prod = { priority_offset: +1 }`,
|
|
//! contrapeso del mock que tiene `+1 en test`. Así el broker brahman
|
|
//! elige automáticamente:
|
|
//! - `BRAHMAN_BROKER_CONTEXT=test` → mock gana.
|
|
//! - `BRAHMAN_BROKER_CONTEXT=prod` → real gana.
|
|
//! - sin contexto → empate por label alfabético.
|
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use brahman_card::{
|
|
ulid::Ulid, Card, CardKind, ContextBias, Flow, Flows, Lifecycle, Payload, Priority,
|
|
Supervision, TypeRef,
|
|
};
|
|
use nouser_nous::{transport, FLOW_EMBED_REQUEST, FLOW_EMBED_RESULT, FLOW_TYPE_NAME};
|
|
use tokio::net::UnixListener;
|
|
use tracing::info;
|
|
|
|
#[cfg(feature = "embeddings")]
|
|
mod embeddings;
|
|
#[cfg(not(feature = "embeddings"))]
|
|
mod stub;
|
|
|
|
#[cfg(feature = "embeddings")]
|
|
const MODEL_ID: &str = "real-fastembed-allMiniLML6V2-384d";
|
|
#[cfg(not(feature = "embeddings"))]
|
|
const MODEL_ID: &str = "real-stub-no-feature";
|
|
|
|
#[cfg(feature = "embeddings")]
|
|
const EMBED_DIM: u32 = 384;
|
|
#[cfg(not(feature = "embeddings"))]
|
|
const EMBED_DIM: u32 = 0;
|
|
|
|
#[tokio::main(flavor = "current_thread")]
|
|
async fn main() -> std::io::Result<()> {
|
|
init_tracing();
|
|
|
|
#[cfg(not(feature = "embeddings"))]
|
|
info!(
|
|
"nouser-nous-real corriendo en modo STUB (compilá con \
|
|
--features embeddings para activar el modelo)"
|
|
);
|
|
|
|
// 1. Sidecar al brahman-init (mismo patrón que el mock).
|
|
let card = build_card();
|
|
info!(label = %card.label, mode = MODEL_ID, "publicando Card al brahman-init");
|
|
brahman_sidecar::spawn(card);
|
|
|
|
// 2. Bind del socket Nous (mismo path que el mock — son swappable).
|
|
let sock_path = transport::default_socket_path();
|
|
if sock_path.exists() {
|
|
std::fs::remove_file(&sock_path)?;
|
|
}
|
|
if let Some(parent) = sock_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let listener = UnixListener::bind(&sock_path)?;
|
|
info!(socket = %sock_path.display(), "nouser-nous-real escuchando");
|
|
|
|
// 3. Inicializar el modelo (sólo en modo embeddings).
|
|
#[cfg(feature = "embeddings")]
|
|
let backend = embeddings::Backend::init().map_err(|e| {
|
|
std::io::Error::other(format!("init modelo: {e}"))
|
|
})?;
|
|
#[cfg(feature = "embeddings")]
|
|
let backend = std::sync::Arc::new(backend);
|
|
|
|
// 4. Accept loop.
|
|
loop {
|
|
let (stream, _addr) = listener.accept().await?;
|
|
|
|
#[cfg(feature = "embeddings")]
|
|
{
|
|
let backend = backend.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = embeddings::handle_conn(stream, backend).await {
|
|
tracing::warn!(error = %e, "conn falló");
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(not(feature = "embeddings"))]
|
|
{
|
|
tokio::spawn(async move {
|
|
if let Err(e) = stub::handle_conn(stream).await {
|
|
tracing::warn!(error = %e, "conn falló");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn init_tracing() {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "info".into()),
|
|
)
|
|
.with_target(false)
|
|
.compact()
|
|
.init();
|
|
}
|
|
|
|
/// Card que real-nous anuncia. Idéntica al mock excepto por:
|
|
/// - label distinto (`nouser.nous_real`) para que coexistan en el broker.
|
|
/// - `priority_contexts.prod = +1` (gana en contexto prod).
|
|
fn build_card() -> Card {
|
|
let mut priority_contexts = BTreeMap::new();
|
|
priority_contexts.insert(
|
|
"prod".into(),
|
|
ContextBias {
|
|
pin_to: None,
|
|
priority_offset: 1,
|
|
},
|
|
);
|
|
|
|
Card {
|
|
schema_version: brahman_card::CARD_SCHEMA_VERSION,
|
|
id: Ulid::new(),
|
|
label: "nouser.nous_real".into(),
|
|
payload: Payload::Virtual,
|
|
supervision: Supervision::Delegate,
|
|
lifecycle: Lifecycle::Daemon,
|
|
priority: Priority::Normal,
|
|
kind: CardKind::Ente,
|
|
flow: Flows {
|
|
input: vec![Flow {
|
|
name: FLOW_EMBED_REQUEST.into(),
|
|
ty: TypeRef::Primitive {
|
|
name: FLOW_TYPE_NAME.into(),
|
|
},
|
|
pin_to: None,
|
|
}],
|
|
output: vec![Flow {
|
|
name: FLOW_EMBED_RESULT.into(),
|
|
ty: TypeRef::Primitive {
|
|
name: FLOW_TYPE_NAME.into(),
|
|
},
|
|
pin_to: None,
|
|
}],
|
|
},
|
|
priority_contexts,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
// Helpers compartidos. Anotados allow(dead_code) porque en stub mode
|
|
// algunos quedan sin uso pero los queremos disponibles consistentemente.
|
|
|
|
#[allow(dead_code)]
|
|
pub(crate) fn model_id() -> &'static str {
|
|
MODEL_ID
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub(crate) fn embed_dim() -> u32 {
|
|
EMBED_DIM
|
|
}
|