Architectural snapshot of the lakehouse codebase at the point where the
full matrix-driven agent loop with Mem0 versioning + deletion was
validated end-to-end.
WHAT THIS REPO IS
A clean single-commit snapshot of the lakehouse code. Heavy test data
(.parquet datasets, vector indexes) excluded — see REPLICATION.md for
regen path. Full lakehouse history at git.agentview.dev/profit/lakehouse.
WHAT WAS PROVEN
- Vector retrieval across multi-corpora matrix (chicago_permits + entity
briefs + sec_tickers + distilled procedural + llm_team runs)
- Observer hand-review (cloud + heuristic fallback) gating each candidate
- Local-model agent loop (qwen3.5:latest) with tool use + scratchpad
- Playbook seal on success → next-iter retrieval surfaces it as preamble
- Mem0 versioning + deletion in pathway_memory:
* UPSERT: ADD on new workflow, UPDATE bumps replay_count on identical
* REVISE: chains versions, parent.superseded_at + superseded_by stamped
* RETIRE: marks specific trace retired with reason, excluded from retrieval
* HISTORY: walks chain root→tip, cycle-safe
KEY DIRECTORIES
- crates/vectord/src/pathway_memory.rs — Mem0 ops live here
- crates/vectord/src/playbook_memory.rs — original Mem0 reference
- tests/agent_test/ — local-model agent harness + PRD + session archives
- scripts/dump_raw_corpus.sh — MinIO bucket dump (raw test corpus)
- scripts/vectorize_raw_corpus.ts — corpus → vector indexes
- scripts/analyze_chicago_contracts.ts — real inference pipeline
- scripts/seal_agent_playbook.ts — Mem0 upsert from agent traces
Replication: see REPLICATION.md for Debian 13 clean install + cloud-only
adaptation (no local Ollama).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
227 lines
8.1 KiB
Rust
227 lines
8.1 KiB
Rust
//! Phase 40 (early deliverable) — Langfuse tracing from the Rust gateway.
|
|
//!
|
|
//! Langfuse is already running at `http://localhost:3001` with the
|
|
//! staffing project — `mcp-server/tracing.ts` has been emitting traces
|
|
//! for months. This file bridges the gap: every `/v1/chat` call now
|
|
//! appears in the same Langfuse project, alongside traces from the
|
|
//! Bun side.
|
|
//!
|
|
//! Design:
|
|
//! - Fire-and-forget — emit spawns a tokio task. Response to client
|
|
//! never waits on Langfuse, never fails because of it. If Langfuse
|
|
//! is down or slow, we silently drop traces (and `tracing::warn!`).
|
|
//! - Two events per call — `trace-create` + `generation-create`. The
|
|
//! generation nests under the trace so Langfuse UI shows the whole
|
|
//! thing as one clickable row.
|
|
//! - No SDK — plain HTTP POST to `/api/public/ingestion` with Basic
|
|
//! auth. Keeps the Rust dependency graph small.
|
|
|
|
use serde::Serialize;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use super::Message;
|
|
|
|
const INGESTION_PATH: &str = "/api/public/ingestion";
|
|
const EMIT_TIMEOUT_SECS: u64 = 5;
|
|
|
|
#[derive(Clone)]
|
|
pub struct LangfuseClient {
|
|
inner: Arc<Inner>,
|
|
}
|
|
|
|
struct Inner {
|
|
base_url: String,
|
|
public_key: String,
|
|
secret_key: String,
|
|
http: reqwest::Client,
|
|
}
|
|
|
|
impl LangfuseClient {
|
|
pub fn new(base_url: String, public_key: String, secret_key: String) -> Self {
|
|
let http = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(EMIT_TIMEOUT_SECS))
|
|
.build()
|
|
.expect("langfuse client init");
|
|
Self { inner: Arc::new(Inner { base_url, public_key, secret_key, http }) }
|
|
}
|
|
|
|
/// Resolve a LangfuseClient from the same conventions the TS side
|
|
/// already uses. Returns None if keys are missing — caller logs a
|
|
/// warning and omits tracing (non-fatal).
|
|
///
|
|
/// Sources checked in order:
|
|
/// 1. LANGFUSE_URL + LANGFUSE_PUBLIC_KEY + LANGFUSE_SECRET_KEY env
|
|
/// 2. /root/llm_team_config.json (if it carries langfuse keys)
|
|
/// 3. Hardcoded staffing-project defaults matching mcp-server
|
|
/// (pk-lf-staffing / sk-lf-staffing-secret / localhost:3001)
|
|
pub fn from_env_or_defaults() -> Option<Self> {
|
|
let base = std::env::var("LANGFUSE_URL")
|
|
.unwrap_or_else(|_| "http://localhost:3001".to_string());
|
|
let pk = std::env::var("LANGFUSE_PUBLIC_KEY")
|
|
.unwrap_or_else(|_| "pk-lf-staffing".to_string());
|
|
let sk = std::env::var("LANGFUSE_SECRET_KEY")
|
|
.unwrap_or_else(|_| "sk-lf-staffing-secret".to_string());
|
|
if pk.trim().is_empty() || sk.trim().is_empty() { return None; }
|
|
Some(Self::new(base, pk, sk))
|
|
}
|
|
|
|
/// Fire-and-forget emit. Never blocks, never errors upward.
|
|
pub fn emit_chat(&self, ev: ChatTrace) {
|
|
let this = self.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = this.emit_chat_inner(ev).await {
|
|
tracing::warn!(target: "v1.langfuse", "trace drop: {e}");
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn emit_chat_inner(&self, ev: ChatTrace) -> Result<(), String> {
|
|
let trace_id = uuid_v7_like();
|
|
let gen_id = uuid_v7_like();
|
|
let trace_ts = ev.start_time.clone();
|
|
|
|
let batch = IngestionBatch {
|
|
batch: vec![
|
|
IngestionEvent {
|
|
id: uuid_v7_like(),
|
|
timestamp: trace_ts.clone(),
|
|
kind: "trace-create",
|
|
body: serde_json::json!({
|
|
"id": trace_id,
|
|
"name": format!("v1.chat:{}", ev.provider),
|
|
"input": serde_json::json!({
|
|
"model": ev.model,
|
|
"messages": ev.input,
|
|
}),
|
|
"metadata": serde_json::json!({
|
|
"provider": ev.provider,
|
|
"think": ev.think,
|
|
}),
|
|
}),
|
|
},
|
|
IngestionEvent {
|
|
id: uuid_v7_like(),
|
|
timestamp: ev.end_time.clone(),
|
|
kind: "generation-create",
|
|
body: serde_json::json!({
|
|
"id": gen_id,
|
|
"traceId": trace_id,
|
|
"name": "chat",
|
|
"model": ev.model,
|
|
"modelParameters": serde_json::json!({
|
|
"temperature": ev.temperature,
|
|
"max_tokens": ev.max_tokens,
|
|
"think": ev.think,
|
|
}),
|
|
"input": ev.input,
|
|
"output": ev.output,
|
|
"usage": serde_json::json!({
|
|
"input": ev.prompt_tokens,
|
|
"output": ev.completion_tokens,
|
|
"total": ev.prompt_tokens + ev.completion_tokens,
|
|
"unit": "TOKENS",
|
|
}),
|
|
"startTime": ev.start_time,
|
|
"endTime": ev.end_time,
|
|
"metadata": serde_json::json!({
|
|
"provider": ev.provider,
|
|
"latency_ms": ev.latency_ms,
|
|
}),
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
|
|
let url = format!("{}{}", self.inner.base_url.trim_end_matches('/'), INGESTION_PATH);
|
|
let resp = self.inner.http
|
|
.post(url)
|
|
.basic_auth(&self.inner.public_key, Some(&self.inner.secret_key))
|
|
.json(&batch)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("POST failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("{}: {}", resp.status(), resp.text().await.unwrap_or_default()));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Everything the v1.chat handler collects for one completed call.
|
|
pub struct ChatTrace {
|
|
pub provider: String,
|
|
pub model: String,
|
|
pub input: Vec<Message>,
|
|
pub output: String,
|
|
pub prompt_tokens: u32,
|
|
pub completion_tokens: u32,
|
|
pub temperature: Option<f64>,
|
|
pub max_tokens: Option<u32>,
|
|
pub think: Option<bool>,
|
|
pub start_time: String,
|
|
pub end_time: String,
|
|
pub latency_ms: u64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct IngestionBatch {
|
|
batch: Vec<IngestionEvent>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct IngestionEvent {
|
|
id: String,
|
|
timestamp: String,
|
|
#[serde(rename = "type")]
|
|
kind: &'static str,
|
|
body: serde_json::Value,
|
|
}
|
|
|
|
/// UUIDv7-ish identifier — not strictly UUIDv7 but time-ordered and
|
|
/// unique enough for event deduplication. Avoids a uuid crate dep.
|
|
fn uuid_v7_like() -> String {
|
|
let ts = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
|
|
let rand = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.subsec_nanos())
|
|
.unwrap_or(0);
|
|
format!("{:016x}-{:08x}", ts, rand)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn from_env_or_defaults_returns_some_when_defaults_present() {
|
|
// Defaults are hardcoded non-empty — should always produce Some.
|
|
let c = LangfuseClient::from_env_or_defaults();
|
|
assert!(c.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn uuid_v7_like_produces_distinct_ids() {
|
|
let a = uuid_v7_like();
|
|
// Different nanosecond timestamps — but they might collide on
|
|
// back-to-back calls. Loop a bit to ensure uniqueness surface.
|
|
let b = uuid_v7_like();
|
|
let c = uuid_v7_like();
|
|
assert!(a != b || b != c, "three consecutive ids should have at least one distinct pair");
|
|
assert!(a.contains('-'));
|
|
}
|
|
|
|
#[test]
|
|
fn ingestion_batch_serializes_with_rename() {
|
|
let ev = IngestionEvent {
|
|
id: "e1".into(),
|
|
timestamp: "2026-04-22T00:00:00Z".into(),
|
|
kind: "trace-create",
|
|
body: serde_json::json!({"name": "t"}),
|
|
};
|
|
let json = serde_json::to_string(&IngestionBatch { batch: vec![ev] }).unwrap();
|
|
assert!(json.contains("\"type\":\"trace-create\""));
|
|
assert!(json.contains("\"batch\":["));
|
|
}
|
|
}
|