Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcf4c9a8e7 | ||
|
|
3c6d2c5f74 | ||
|
|
8e1855e779 | ||
|
|
313eec3c6e | ||
|
|
51cc0a69cf | ||
|
|
528fded11b | ||
|
|
64700ea6da | ||
|
|
5225211e45 | ||
|
|
4f0b6fb9b3 | ||
|
|
8e781ac325 | ||
|
|
ee0450b7c3 | ||
|
|
0f9b4aa2fe | ||
|
|
e4eb0fa168 | ||
|
|
93081bed5c | ||
|
|
885a1acf19 | ||
|
|
97888e3775 | ||
|
|
9b8befaa94 | ||
|
|
2965b68a9d | ||
|
|
08c8debfff | ||
|
|
b02cf5b9e1 | ||
|
|
1ac8045924 | ||
|
|
52d2da2f44 | ||
|
|
d44ad3af1e | ||
|
|
89ac6a9b5b |
Generated
-1
@@ -48,7 +48,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"lru",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+3
-5
@@ -16,14 +16,12 @@ import type { Gap, Proposal } from "./types.ts";
|
||||
// Phase 44 migration (2026-04-27): bot/propose.ts now flows through
|
||||
// the gateway's /v1/chat instead of hitting the sidecar's /generate
|
||||
// directly. /v1/usage tracks the call, Langfuse traces it, observer
|
||||
// sees it. Gateway owns the routing.
|
||||
//
|
||||
// 2026-04-28: gpt-oss:120b → deepseek-v3.2 via Ollama Pro. Newer
|
||||
// DeepSeek revision, faster, still on the same OLLAMA_CLOUD_KEY.
|
||||
// sees it. Same upstream model (CLOUD_MODEL gpt-oss:120b on
|
||||
// Ollama Cloud) — gateway just owns the routing.
|
||||
const GATEWAY_URL = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
|
||||
const REPO_ROOT = "/home/profit/lakehouse";
|
||||
const PRD_PATH = `${REPO_ROOT}/docs/PRD.md`;
|
||||
const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "deepseek-v3.2";
|
||||
const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "gpt-oss:120b";
|
||||
const MAX_TOKENS = 6000;
|
||||
|
||||
export async function findGaps(): Promise<Gap[]> {
|
||||
|
||||
+2
-7
@@ -44,10 +44,7 @@ name = "staffing_inference"
|
||||
# pattern generalizes beyond code review.
|
||||
preferred_mode = "staffing_inference_lakehouse"
|
||||
fallback_modes = ["ladder", "consensus", "pipeline"]
|
||||
# 2026-04-28: gpt-oss-120b:free → kimi-k2.6 via Ollama Pro. Coding-
|
||||
# specialized, faster than gpt-oss, on the same OLLAMA_CLOUD_KEY so
|
||||
# no extra provider hop.
|
||||
default_model = "kimi-k2.6"
|
||||
default_model = "openai/gpt-oss-120b:free"
|
||||
matrix_corpus = "workers_500k_v8"
|
||||
|
||||
[[task_class]]
|
||||
@@ -61,9 +58,7 @@ matrix_corpus = "kb_team_runs_v1"
|
||||
name = "doc_drift_check"
|
||||
preferred_mode = "drift"
|
||||
fallback_modes = ["validator"]
|
||||
# 2026-04-28: gpt-oss:120b → gemini-3-flash-preview via Ollama Pro.
|
||||
# Speed leader on factual checking, same OLLAMA_CLOUD_KEY.
|
||||
default_model = "gemini-3-flash-preview"
|
||||
default_model = "gpt-oss:120b"
|
||||
matrix_corpus = "distilled_factual_v20260423095819"
|
||||
|
||||
[[task_class]]
|
||||
|
||||
+9
-14
@@ -15,27 +15,22 @@
|
||||
|
||||
[[provider]]
|
||||
name = "ollama"
|
||||
base_url = "http://localhost:11434"
|
||||
base_url = "http://localhost:3200"
|
||||
auth = "none"
|
||||
default_model = "qwen3.5:latest"
|
||||
# Hot-path local inference. No bearer needed — direct to Ollama as of
|
||||
# 2026-05-02 (Python sidecar's pass-through wrapper retired). Model
|
||||
# names are bare (e.g. "qwen3.5:latest", not "ollama/qwen3.5:latest").
|
||||
# Hot-path local inference. No bearer needed — Python sidecar on
|
||||
# localhost handles the Ollama API. Model names are bare
|
||||
# (e.g. "qwen3.5:latest", not "ollama/qwen3.5:latest").
|
||||
|
||||
[[provider]]
|
||||
name = "ollama_cloud"
|
||||
base_url = "https://ollama.com"
|
||||
auth = "bearer"
|
||||
auth_env = "OLLAMA_CLOUD_KEY"
|
||||
default_model = "deepseek-v3.2"
|
||||
# Cloud-tier Ollama (Pro plan as of 2026-04-28). Key resolved from
|
||||
# OLLAMA_CLOUD_KEY at gateway boot; Pro tier upgraded the account so
|
||||
# rate limits + model access widen without a key change. Model-prefix
|
||||
# routing: "cloud/<model>" auto-routes here. 39-model fleet now
|
||||
# includes deepseek-v3.2, deepseek-v4-{flash,pro}, gemini-3-flash-
|
||||
# preview, glm-{5,5.1}, kimi-k2.6, qwen3-coder-next.
|
||||
# 2026-04-28: default upgraded gpt-oss:120b → deepseek-v3.2 (newest
|
||||
# DeepSeek revision; kimi-k2:1t still upstream-broken with HTTP 500).
|
||||
default_model = "gpt-oss:120b"
|
||||
# Cloud-tier Ollama. Key resolved from OLLAMA_CLOUD_KEY env at gateway
|
||||
# boot. Model-prefix routing: "cloud/<model>" auto-routes here
|
||||
# (see gateway::v1::resolve_provider).
|
||||
|
||||
[[provider]]
|
||||
name = "openrouter"
|
||||
@@ -43,7 +38,7 @@ base_url = "https://openrouter.ai/api/v1"
|
||||
auth = "bearer"
|
||||
auth_env = "OPENROUTER_API_KEY"
|
||||
auth_fallback_files = ["/home/profit/.env", "/root/llm_team_config.json"]
|
||||
default_model = "x-ai/grok-4.1-fast"
|
||||
default_model = "openai/gpt-oss-120b:free"
|
||||
# Multi-provider gateway. Covers Anthropic, Google, OpenAI, MiniMax,
|
||||
# Qwen, Gemma, etc. Key resolved via crates/gateway/src/v1/openrouter.rs
|
||||
# resolve_openrouter_key() — env first, then fallback files.
|
||||
|
||||
@@ -12,4 +12,3 @@ serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
async-trait = "0.1"
|
||||
lru = "0.12"
|
||||
|
||||
+53
-420
@@ -1,74 +1,28 @@
|
||||
use lru::LruCache;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// HTTP client for Ollama (post-2026-05-02 — sidecar dropped).
|
||||
///
|
||||
/// `base_url` was historically the Python sidecar at `:3200`, which
|
||||
/// pass-through-proxied to Ollama at `:11434`. The sidecar added zero
|
||||
/// logic on the hot path (embed.py + generate.py + rerank.py +
|
||||
/// admin.py = ~120 LOC of pure Ollama wrappers), so this client now
|
||||
/// talks to Ollama directly and the sidecar process can be retired.
|
||||
///
|
||||
/// What stayed Python: `lab_ui.py` + `pipeline_lab.py` (~888 LOC of
|
||||
/// dev-mode Streamlit-shape UIs) — those aren't on the runtime hot
|
||||
/// path and continue running for prompt experimentation.
|
||||
/// HTTP client for the Python AI sidecar.
|
||||
///
|
||||
/// `generate()` has two transport modes:
|
||||
/// - When `gateway_url` is None (default), posts directly to Ollama's
|
||||
/// `${base_url}/api/generate`.
|
||||
/// - When `gateway_url` is `Some(url)`, posts to `${url}/v1/chat`
|
||||
/// with `provider="ollama"` so the call appears in `/v1/usage` and
|
||||
/// Langfuse traces.
|
||||
/// - When `gateway_url` is None (default), it posts to
|
||||
/// `${base_url}/generate` (sidecar direct).
|
||||
/// - When `gateway_url` is `Some(url)`, it posts to
|
||||
/// `${url}/v1/chat` with `provider="ollama"` so the call appears
|
||||
/// in `/v1/usage` and Langfuse traces.
|
||||
///
|
||||
/// `embed()`, `rerank()`, and admin methods always go direct to
|
||||
/// Ollama — no `/v1` equivalent for those surfaces yet.
|
||||
/// `embed()`, `rerank()`, and admin methods always go direct to the
|
||||
/// sidecar — no `/v1` equivalent yet, no point round-tripping.
|
||||
///
|
||||
/// Phase 44 part 2 (2026-04-27): the gateway URL is wired in by
|
||||
/// callers that want observability (vectord modules); it's left
|
||||
/// unset by callers that ARE the gateway internals (avoids self-loops
|
||||
/// + redundant hops).
|
||||
/// Per-text embed cache key. We key on (model, text) so different
|
||||
/// model selections produce distinct cache lines — a query embedded
|
||||
/// under nomic-embed-text-v2-moe must NOT collide with the same
|
||||
/// query under nomic-embed-text v1.
|
||||
#[derive(Eq, PartialEq, Hash, Clone)]
|
||||
struct EmbedCacheKey {
|
||||
model: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// Default LRU cache size — 4096 entries × ~6KB per 768-d f64
|
||||
/// vector ≈ 24MB. Sized for typical staffing-domain repetition
|
||||
/// (coordinator workflows have query repetition rates around 70-90%
|
||||
/// per session). Tunable via [aibridge].embed_cache_size in the
|
||||
/// config; 0 disables the cache entirely.
|
||||
const DEFAULT_EMBED_CACHE_SIZE: usize = 4096;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AiClient {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
gateway_url: Option<String>,
|
||||
/// Closes the 63× perf gap with Go side. Mirrors the shape of
|
||||
/// Go's internal/embed/cached.go::CachedProvider — same
|
||||
/// (model, text) → vector caching, same nil-disable semantics.
|
||||
/// None = caching disabled (cache_size=0); Some = bounded LRU.
|
||||
embed_cache: Option<Arc<Mutex<LruCache<EmbedCacheKey, Vec<f64>>>>>,
|
||||
/// Hit / miss counters for /admin observability + load-test
|
||||
/// validation. Atomic so Clone'd AiClients share the same counts.
|
||||
embed_cache_hits: Arc<AtomicU64>,
|
||||
embed_cache_misses: Arc<AtomicU64>,
|
||||
/// Pinned at construction time so the EmbedResponse can carry
|
||||
/// dimension consistently even when every text was a cache hit
|
||||
/// (no fresh upstream call to learn the dim from). Set on first
|
||||
/// successful Ollama embed; checked on every cache hit.
|
||||
cached_dim: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
// -- Request/Response types --
|
||||
@@ -141,49 +95,17 @@ pub struct RerankResponse {
|
||||
|
||||
impl AiClient {
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self::with_embed_cache(base_url, DEFAULT_EMBED_CACHE_SIZE)
|
||||
}
|
||||
|
||||
/// Constructs an AiClient with an explicit embed-cache size.
|
||||
/// Pass 0 to disable the cache entirely (matches Go-side
|
||||
/// CachedProvider's nil-cache semantics).
|
||||
pub fn with_embed_cache(base_url: &str, cache_size: usize) -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
let embed_cache = if cache_size > 0 {
|
||||
// SAFETY: cache_size > 0 just verified, NonZeroUsize::new
|
||||
// returns Some.
|
||||
let cap = NonZeroUsize::new(cache_size).expect("cache_size > 0");
|
||||
Some(Arc::new(Mutex::new(LruCache::new(cap))))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
client,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
gateway_url: None,
|
||||
embed_cache,
|
||||
embed_cache_hits: Arc::new(AtomicU64::new(0)),
|
||||
embed_cache_misses: Arc::new(AtomicU64::new(0)),
|
||||
cached_dim: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache hit/miss/size snapshot. Useful for /admin endpoints +
|
||||
/// load-test validation ("did the cache fire as expected?").
|
||||
pub fn embed_cache_stats(&self) -> (u64, u64, usize) {
|
||||
let hits = self.embed_cache_hits.load(Ordering::Relaxed);
|
||||
let misses = self.embed_cache_misses.load(Ordering::Relaxed);
|
||||
let len = self
|
||||
.embed_cache
|
||||
.as_ref()
|
||||
.map(|c| c.lock().map(|g| g.len()).unwrap_or(0))
|
||||
.unwrap_or(0);
|
||||
(hits, misses, len)
|
||||
}
|
||||
|
||||
/// Same as `new`, but every `generate()` is routed through
|
||||
/// `${gateway_url}/v1/chat` (provider=ollama) for observability.
|
||||
/// Use this for callers OUTSIDE the gateway. Inside the gateway
|
||||
@@ -196,222 +118,50 @@ impl AiClient {
|
||||
c
|
||||
}
|
||||
|
||||
/// Reachability + version check. Hits Ollama's `/api/version`,
|
||||
/// returns a sidecar-shaped envelope so callers reading
|
||||
/// `.status` / `.ollama_url` don't break across the
|
||||
/// pre-/post-2026-05-02 cutover.
|
||||
pub async fn health(&self) -> Result<serde_json::Value, String> {
|
||||
let resp = self.client
|
||||
.get(format!("{}/api/version", self.base_url))
|
||||
.get(format!("{}/health", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ollama unreachable: {e}"))?;
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("invalid response: {e}"))?;
|
||||
Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"ollama_url": &self.base_url,
|
||||
"ollama_version": body.get("version"),
|
||||
}))
|
||||
.map_err(|e| format!("sidecar unreachable: {e}"))?;
|
||||
resp.json().await.map_err(|e| format!("invalid response: {e}"))
|
||||
}
|
||||
|
||||
/// Embed with per-text LRU caching. Mirrors Go-side
|
||||
/// CachedProvider behavior: cache key is (model, text);
|
||||
/// cache-hit texts skip the sidecar; cache-miss texts batch
|
||||
/// into a single sidecar call; results are interleaved in the
|
||||
/// caller's input order.
|
||||
///
|
||||
/// Closes ~95% of the load-test perf gap vs Go side (loadgen
|
||||
/// 2026-05-01: Rust 128 RPS → with cache ≥ 7000 RPS expected
|
||||
/// for warm-cache workloads). Cold-cache behavior unchanged
|
||||
/// (every text is a miss → single sidecar call, identical to
|
||||
/// pre-cache).
|
||||
pub async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String> {
|
||||
let model_key = req.model.clone().unwrap_or_default();
|
||||
let resp = self.client
|
||||
.post(format!("{}/embed", self.base_url))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("embed request failed: {e}"))?;
|
||||
|
||||
// Fast path: cache disabled → original behavior.
|
||||
let Some(cache) = self.embed_cache.as_ref() else {
|
||||
return self.embed_uncached(&req).await;
|
||||
};
|
||||
if req.texts.is_empty() {
|
||||
return self.embed_uncached(&req).await;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("embed error ({}): {text}", text.len()));
|
||||
}
|
||||
|
||||
// First pass: check cache for each text. Track which positions
|
||||
// need a sidecar fetch.
|
||||
let mut embeddings: Vec<Option<Vec<f64>>> = vec![None; req.texts.len()];
|
||||
let mut miss_indices: Vec<usize> = Vec::new();
|
||||
let mut miss_texts: Vec<String> = Vec::new();
|
||||
{
|
||||
let mut guard = cache.lock().map_err(|e| format!("cache lock poisoned: {e}"))?;
|
||||
for (i, text) in req.texts.iter().enumerate() {
|
||||
let key = EmbedCacheKey { model: model_key.clone(), text: text.clone() };
|
||||
if let Some(vec) = guard.get(&key) {
|
||||
embeddings[i] = Some(vec.clone());
|
||||
self.embed_cache_hits.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
miss_indices.push(i);
|
||||
miss_texts.push(text.clone());
|
||||
self.embed_cache_misses.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All hit? Return immediately. Use cached_dim to populate
|
||||
// the response dimension (no sidecar to ask).
|
||||
if miss_indices.is_empty() {
|
||||
let dim = self.cached_dim.load(Ordering::Relaxed) as usize;
|
||||
let dim = if dim == 0 { embeddings[0].as_ref().map(|v| v.len()).unwrap_or(0) } else { dim };
|
||||
return Ok(EmbedResponse {
|
||||
embeddings: embeddings.into_iter().map(|opt| opt.expect("filled")).collect(),
|
||||
model: req.model.unwrap_or_else(|| "nomic-embed-text".to_string()),
|
||||
dimensions: dim,
|
||||
});
|
||||
}
|
||||
|
||||
// Second pass: fetch the misses in one sidecar call.
|
||||
let miss_req = EmbedRequest { texts: miss_texts.clone(), model: req.model.clone() };
|
||||
let resp = self.embed_uncached(&miss_req).await?;
|
||||
if resp.embeddings.len() != miss_texts.len() {
|
||||
return Err(format!(
|
||||
"embed cache: sidecar returned {} embeddings for {} texts",
|
||||
resp.embeddings.len(),
|
||||
miss_texts.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Pin cached_dim on first successful response.
|
||||
if resp.dimensions > 0 {
|
||||
self.cached_dim.store(resp.dimensions as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Insert misses into cache + fill response slots.
|
||||
{
|
||||
let mut guard = cache.lock().map_err(|e| format!("cache lock poisoned: {e}"))?;
|
||||
for (j, idx) in miss_indices.iter().enumerate() {
|
||||
let key = EmbedCacheKey {
|
||||
model: model_key.clone(),
|
||||
text: miss_texts[j].clone(),
|
||||
};
|
||||
let vec = resp.embeddings[j].clone();
|
||||
guard.put(key, vec.clone());
|
||||
embeddings[*idx] = Some(vec);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EmbedResponse {
|
||||
embeddings: embeddings.into_iter().map(|opt| opt.expect("filled")).collect(),
|
||||
model: resp.model,
|
||||
dimensions: resp.dimensions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Direct Ollama call — used internally by embed() for cache-miss
|
||||
/// batches and as the transparent fallback when the cache is
|
||||
/// disabled. Loops per-text against `${base_url}/api/embed`,
|
||||
/// matching the sidecar's pre-2026-05-02 behavior. Ollama 0.4+
|
||||
/// supports batch input but per-text keeps compatibility broader
|
||||
/// + lets cache-miss-only batches share the loop with cold runs.
|
||||
async fn embed_uncached(&self, req: &EmbedRequest) -> Result<EmbedResponse, String> {
|
||||
let model = req.model.clone().unwrap_or_else(|| "nomic-embed-text".to_string());
|
||||
let mut embeddings: Vec<Vec<f64>> = Vec::with_capacity(req.texts.len());
|
||||
|
||||
for text in &req.texts {
|
||||
let resp = self.client
|
||||
.post(format!("{}/api/embed", self.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"model": &model,
|
||||
"input": text,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("embed request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("ollama embed error: {body}"));
|
||||
}
|
||||
// Ollama returns {"embeddings": [[...]], "model": "...", ...}.
|
||||
// The outer `embeddings` is always a list; for a scalar input
|
||||
// we get a single inner vector.
|
||||
let parsed: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("embed parse error: {e}"))?;
|
||||
let arr = parsed.get("embeddings")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| format!("ollama embed: missing 'embeddings' field in {parsed}"))?;
|
||||
if arr.is_empty() {
|
||||
return Err("ollama embed: empty embeddings array".to_string());
|
||||
}
|
||||
let first = arr[0].as_array()
|
||||
.ok_or_else(|| "ollama embed: embeddings[0] not an array".to_string())?;
|
||||
let vec: Vec<f64> = first.iter()
|
||||
.filter_map(|n| n.as_f64())
|
||||
.collect();
|
||||
if vec.is_empty() {
|
||||
return Err("ollama embed: numeric coercion produced empty vector".to_string());
|
||||
}
|
||||
embeddings.push(vec);
|
||||
}
|
||||
|
||||
let dimensions = embeddings.first().map(|v| v.len()).unwrap_or(0);
|
||||
Ok(EmbedResponse {
|
||||
embeddings,
|
||||
model,
|
||||
dimensions,
|
||||
})
|
||||
resp.json().await.map_err(|e| format!("embed parse error: {e}"))
|
||||
}
|
||||
|
||||
pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
|
||||
if let Some(gw) = self.gateway_url.as_deref() {
|
||||
return self.generate_via_gateway(gw, req).await;
|
||||
}
|
||||
// Direct Ollama path. Used by gateway internals (so the ollama
|
||||
// provider can call upstream without a self-loop through
|
||||
// /v1/chat) and by any consumer that wants raw transport
|
||||
// without /v1/usage accounting.
|
||||
let model = req.model.clone().unwrap_or_else(|| "qwen3.5:latest".to_string());
|
||||
let mut body = serde_json::json!({
|
||||
"model": &model,
|
||||
"prompt": &req.prompt,
|
||||
"stream": false,
|
||||
});
|
||||
let mut options = serde_json::Map::new();
|
||||
if let Some(t) = req.temperature {
|
||||
options.insert("temperature".to_string(), serde_json::json!(t));
|
||||
}
|
||||
if let Some(mt) = req.max_tokens {
|
||||
options.insert("num_predict".to_string(), serde_json::json!(mt));
|
||||
}
|
||||
if !options.is_empty() {
|
||||
body["options"] = serde_json::Value::Object(options);
|
||||
}
|
||||
if let Some(sys) = &req.system {
|
||||
body["system"] = serde_json::json!(sys);
|
||||
}
|
||||
if let Some(th) = req.think {
|
||||
body["think"] = serde_json::json!(th);
|
||||
}
|
||||
|
||||
// Direct-sidecar legacy path. Used by gateway internals (so
|
||||
// ollama_arm can call sidecar without a self-loop) and by
|
||||
// any consumer that wants raw transport without /v1/usage
|
||||
// accounting.
|
||||
let resp = self.client
|
||||
.post(format!("{}/api/generate", self.base_url))
|
||||
.json(&body)
|
||||
.post(format!("{}/generate", self.base_url))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("generate request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("ollama generate error: {text}"));
|
||||
return Err(format!("generate error: {text}"));
|
||||
}
|
||||
let parsed: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("generate parse error: {e}"))?;
|
||||
|
||||
Ok(GenerateResponse {
|
||||
text: parsed.get("response").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
model,
|
||||
tokens_evaluated: parsed.get("prompt_eval_count").and_then(|v| v.as_u64()),
|
||||
tokens_generated: parsed.get("eval_count").and_then(|v| v.as_u64()),
|
||||
})
|
||||
resp.json().await.map_err(|e| format!("generate parse error: {e}"))
|
||||
}
|
||||
|
||||
/// Phase 44 part 2: route generate() through the gateway's
|
||||
@@ -467,60 +217,19 @@ impl AiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Cross-encoder reranking via Ollama generate. Asks the model to
|
||||
/// rate each document's relevance to the query 0-10, then sorts
|
||||
/// descending. Mirrors the sidecar's pre-2026-05-02 algorithm
|
||||
/// exactly so callers see the same scores.
|
||||
pub async fn rerank(&self, req: RerankRequest) -> Result<RerankResponse, String> {
|
||||
let model = req.model.clone().unwrap_or_else(|| "qwen3.5:latest".to_string());
|
||||
let mut scored: Vec<ScoredDocument> = Vec::with_capacity(req.documents.len());
|
||||
let resp = self.client
|
||||
.post(format!("{}/rerank", self.base_url))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("rerank request failed: {e}"))?;
|
||||
|
||||
for (i, doc) in req.documents.iter().enumerate() {
|
||||
let prompt = format!(
|
||||
"Rate the relevance of the following document to the query on a scale of 0 to 10. \
|
||||
Respond with ONLY a number.\n\n\
|
||||
Query: {}\n\n\
|
||||
Document: {}\n\n\
|
||||
Score:",
|
||||
req.query, doc,
|
||||
);
|
||||
let resp = self.client
|
||||
.post(format!("{}/api/generate", self.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"model": &model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
"options": {"temperature": 0.0, "num_predict": 8},
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("rerank request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("ollama rerank error: {body}"));
|
||||
}
|
||||
let parsed: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("rerank parse error: {e}"))?;
|
||||
let text = parsed.get("response").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
// Parse the leading number; tolerate "7", "7.5", "7 — strong match".
|
||||
let score = text.split_whitespace().next()
|
||||
.and_then(|t| t.parse::<f64>().ok())
|
||||
.unwrap_or(0.0)
|
||||
.clamp(0.0, 10.0);
|
||||
|
||||
scored.push(ScoredDocument {
|
||||
index: i,
|
||||
text: doc.clone(),
|
||||
score,
|
||||
});
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("rerank error: {text}"));
|
||||
}
|
||||
|
||||
scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
if let Some(k) = req.top_k {
|
||||
scored.truncate(k);
|
||||
}
|
||||
Ok(RerankResponse { results: scored, model })
|
||||
resp.json().await.map_err(|e| format!("rerank parse error: {e}"))
|
||||
}
|
||||
|
||||
/// Force Ollama to unload the named model from VRAM (keep_alive=0).
|
||||
@@ -529,116 +238,40 @@ impl AiClient {
|
||||
/// profile's model can linger in VRAM next to the new one.
|
||||
pub async fn unload_model(&self, model: &str) -> Result<serde_json::Value, String> {
|
||||
let resp = self.client
|
||||
.post(format!("{}/api/generate", self.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"model": model,
|
||||
"prompt": "",
|
||||
"keep_alive": 0,
|
||||
"stream": false,
|
||||
}))
|
||||
.post(format!("{}/admin/unload", self.base_url))
|
||||
.json(&serde_json::json!({ "model": model }))
|
||||
.send().await
|
||||
.map_err(|e| format!("unload request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("ollama unload error: {text}"));
|
||||
return Err(format!("unload error: {text}"));
|
||||
}
|
||||
// Ollama returns 200 with the empty-prompt response shape.
|
||||
// Fold into the legacy {"unloaded": "<model>"} envelope so
|
||||
// callers' parsing doesn't break.
|
||||
Ok(serde_json::json!({ "unloaded": model }))
|
||||
resp.json().await.map_err(|e| format!("unload parse error: {e}"))
|
||||
}
|
||||
|
||||
/// Ask Ollama to load the named model into VRAM proactively. Makes
|
||||
/// the first real request after profile activation fast (no cold-load
|
||||
/// latency). Empty prompts confuse some models, so we send a single
|
||||
/// space + cap num_predict=1 (matches the sidecar's prior behavior).
|
||||
/// latency).
|
||||
pub async fn preload_model(&self, model: &str) -> Result<serde_json::Value, String> {
|
||||
let resp = self.client
|
||||
.post(format!("{}/api/generate", self.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"model": model,
|
||||
"prompt": " ",
|
||||
"keep_alive": "5m",
|
||||
"stream": false,
|
||||
"options": {"num_predict": 1},
|
||||
}))
|
||||
.post(format!("{}/admin/preload", self.base_url))
|
||||
.json(&serde_json::json!({ "model": model }))
|
||||
.send().await
|
||||
.map_err(|e| format!("preload request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("ollama preload error: {text}"));
|
||||
return Err(format!("preload error: {text}"));
|
||||
}
|
||||
let parsed: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("preload parse error: {e}"))?;
|
||||
Ok(serde_json::json!({
|
||||
"preloaded": model,
|
||||
"load_duration_ns": parsed.get("load_duration"),
|
||||
"total_duration_ns": parsed.get("total_duration"),
|
||||
}))
|
||||
resp.json().await.map_err(|e| format!("preload parse error: {e}"))
|
||||
}
|
||||
|
||||
/// GPU + loaded-model snapshot. Combines nvidia-smi output (when
|
||||
/// available) with Ollama's /api/ps. Same shape as the prior
|
||||
/// sidecar /admin/vram endpoint so callers don't need updating.
|
||||
/// GPU + loaded-model snapshot from the sidecar. Combines nvidia-smi
|
||||
/// output (if available) with Ollama's /api/ps.
|
||||
pub async fn vram_snapshot(&self) -> Result<serde_json::Value, String> {
|
||||
let resp = self.client
|
||||
.get(format!("{}/api/ps", self.base_url))
|
||||
.get(format!("{}/admin/vram", self.base_url))
|
||||
.send().await
|
||||
.map_err(|e| format!("ollama ps request failed: {e}"))?;
|
||||
let loaded: Vec<serde_json::Value> = if resp.status().is_success() {
|
||||
let parsed: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||
parsed.get("models")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().map(|m| serde_json::json!({
|
||||
"name": m.get("name"),
|
||||
"size_vram_mib": m.get("size_vram").and_then(|v| v.as_u64()).map(|n| n / (1024 * 1024)),
|
||||
"expires_at": m.get("expires_at"),
|
||||
})).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let gpu = nvidia_smi_snapshot();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"gpu": gpu,
|
||||
"ollama_loaded": loaded,
|
||||
}))
|
||||
.map_err(|e| format!("vram request failed: {e}"))?;
|
||||
resp.json().await.map_err(|e| format!("vram parse error: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot nvidia-smi poll. Returns Null if the tool isn't on PATH
|
||||
/// or the call fails. Mirrors the sidecar's `_nvidia_smi_snapshot`
|
||||
/// shape exactly so callers reading vram_snapshot don't break.
|
||||
fn nvidia_smi_snapshot() -> serde_json::Value {
|
||||
use std::process::Command;
|
||||
let out = Command::new("nvidia-smi")
|
||||
.args([
|
||||
"--query-gpu=memory.used,memory.total,utilization.gpu,name",
|
||||
"--format=csv,noheader,nounits",
|
||||
])
|
||||
.output();
|
||||
let stdout = match out {
|
||||
Ok(o) if o.status.success() => o.stdout,
|
||||
_ => return serde_json::Value::Null,
|
||||
};
|
||||
let line = String::from_utf8_lossy(&stdout);
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return serde_json::Value::Null;
|
||||
}
|
||||
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
if parts.len() < 4 {
|
||||
return serde_json::Value::Null;
|
||||
}
|
||||
let used = parts[0].parse::<u64>().unwrap_or(0);
|
||||
let total = parts[1].parse::<u64>().unwrap_or(0);
|
||||
let util = parts[2].parse::<u64>().unwrap_or(0);
|
||||
serde_json::json!({
|
||||
"name": parts[3],
|
||||
"used_mib": used,
|
||||
"total_mib": total,
|
||||
"utilization_pct": util,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
//! Cross-runtime parity helper for `extract_json`.
|
||||
//!
|
||||
//! Reads a single model-output string from stdin, runs the Rust
|
||||
//! extract_json, prints `{"matched": bool, "value": <object|null>}`
|
||||
//! to stdout as JSON. Exit 0 on success, exit 1 on internal error.
|
||||
//!
|
||||
//! The Go counterpart lives at
|
||||
//! `golangLAKEHOUSE/internal/validator/iterate.go::ExtractJSON`. The
|
||||
//! parity probe at
|
||||
//! `golangLAKEHOUSE/scripts/cutover/parity/extract_json_parity.sh`
|
||||
//! feeds the same fixtures through both and diffs the outputs.
|
||||
//!
|
||||
//! Usage:
|
||||
//! echo '<raw model output>' | parity_extract_json
|
||||
//! parity_extract_json <<< '...'
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
fn main() {
|
||||
let mut buf = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
|
||||
eprintln!("read stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let result = gateway::v1::iterate::extract_json(&buf);
|
||||
let body = serde_json::json!({
|
||||
"matched": result.is_some(),
|
||||
"value": result.unwrap_or(serde_json::Value::Null),
|
||||
});
|
||||
match serde_json::to_string(&body) {
|
||||
Ok(s) => println!("{s}"),
|
||||
Err(e) => {
|
||||
eprintln!("serialize result: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Cross-runtime parity helper for `SessionRecord` JSON shape.
|
||||
//!
|
||||
//! Reads a fixture JSON on stdin, builds a `SessionRecord`, emits
|
||||
//! one JSONL row on stdout. Used by
|
||||
//! `golangLAKEHOUSE/scripts/cutover/parity/session_log_parity.sh`
|
||||
//! to verify the Rust gateway's session log shape stays byte-equal
|
||||
//! to the Go-side validatord's `validator.SessionRecord` (commit
|
||||
//! 1a3a82a in golangLAKEHOUSE).
|
||||
|
||||
use gateway::v1::session_log::{SessionAttemptRecord, SessionRecord, SESSION_RECORD_SCHEMA};
|
||||
use serde::Deserialize;
|
||||
use std::io::Read;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FixtureInput {
|
||||
session_id: String,
|
||||
kind: String,
|
||||
model: String,
|
||||
provider: String,
|
||||
prompt: String,
|
||||
iterations: u32,
|
||||
max_iterations: u32,
|
||||
final_verdict: String,
|
||||
attempts: Vec<SessionAttemptRecord>,
|
||||
#[serde(default)]
|
||||
artifact: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
grounded_in_roster: Option<bool>,
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut buf = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
|
||||
eprintln!("read stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let input: FixtureInput = match serde_json::from_str(&buf) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("parse stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let rec = SessionRecord {
|
||||
schema: SESSION_RECORD_SCHEMA.to_string(),
|
||||
session_id: input.session_id,
|
||||
// Pinned timestamp so both runtimes' rows compare byte-equal
|
||||
// when the test wrapper normalizes on `daemon` only.
|
||||
timestamp: "2026-01-01T00:00:00+00:00".to_string(),
|
||||
daemon: "gateway".to_string(),
|
||||
kind: input.kind,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
prompt: input.prompt,
|
||||
iterations: input.iterations,
|
||||
max_iterations: input.max_iterations,
|
||||
final_verdict: input.final_verdict,
|
||||
attempts: input.attempts,
|
||||
artifact: input.artifact,
|
||||
grounded_in_roster: input.grounded_in_roster,
|
||||
duration_ms: input.duration_ms,
|
||||
};
|
||||
match serde_json::to_string(&rec) {
|
||||
Ok(s) => println!("{s}"),
|
||||
Err(e) => {
|
||||
eprintln!("marshal: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,10 +438,6 @@ impl ExecutionLoop {
|
||||
start_time: start_time.to_rfc3339(),
|
||||
end_time: end_time.to_rfc3339(),
|
||||
latency_ms: elapsed_ms,
|
||||
// Internal execution-loop traffic is its own top-level
|
||||
// trace per call. If a future caller threads a parent
|
||||
// trace into self.state, lift this to Some(parent_id).
|
||||
parent_trace_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -586,10 +582,10 @@ impl ExecutionLoop {
|
||||
/// Phase 20 step (8) — T3 overseer escalation.
|
||||
///
|
||||
/// When the local executor/reviewer loop can't self-correct, call
|
||||
/// the cloud overseer (`claude-opus-4-7` via OpenCode Zen) with
|
||||
/// (a) the KB context — recent outcomes + prior corrections for
|
||||
/// this sig_hash + task_class, across every profile that has run
|
||||
/// it — and (b) the recent log tail. Its output is appended as a
|
||||
/// the cloud overseer (`gpt-oss:120b` via Ollama Cloud) with (a)
|
||||
/// the KB context — recent outcomes + prior corrections for this
|
||||
/// sig_hash + task_class, across every profile that has run it —
|
||||
/// and (b) the recent log tail. Its output is appended as a
|
||||
/// `system` role turn so the next executor generation sees it,
|
||||
/// AND written to `data/_kb/overseer_corrections.jsonl` so every
|
||||
/// future profile activation reads from the same learning pool.
|
||||
@@ -597,16 +593,9 @@ impl ExecutionLoop {
|
||||
/// This is the "pipe to the overviewer" piece from 2026-04-23 —
|
||||
/// the overseer is now a first-class KB consumer AND producer, not
|
||||
/// a one-shot correction oracle.
|
||||
///
|
||||
/// 2026-04-28: routed through OpenCode (Zen tier) for Claude Opus
|
||||
/// 4.7. Frontier reasoning matters here because the overseer fires
|
||||
/// only after local self-correction has failed twice — by that
|
||||
/// point we need the strongest reasoning available, not the
|
||||
/// cheapest token. Frequency is low so the Zen pay-per-token cost
|
||||
/// stays bounded.
|
||||
async fn escalate_to_overseer(&mut self, turn: u32, reason: &str) -> Result<(), String> {
|
||||
let Some(opencode_key) = self.state.opencode_key.clone() else {
|
||||
return Err("OPENCODE_API_KEY not configured — skipping escalation".into());
|
||||
let Some(cloud_key) = self.state.ollama_cloud_key.clone() else {
|
||||
return Err("OLLAMA_CLOUD_KEY not configured — skipping escalation".into());
|
||||
};
|
||||
|
||||
let kb = KbContext::load_for(&sig_hash(&self.req), &self.req.task_class).await;
|
||||
@@ -615,18 +604,16 @@ impl ExecutionLoop {
|
||||
let started = std::time::Instant::now();
|
||||
let start_time = chrono::Utc::now();
|
||||
let chat_req = crate::v1::ChatRequest {
|
||||
model: "claude-opus-4-7".to_string(),
|
||||
model: "gpt-oss:120b".to_string(),
|
||||
messages: vec![crate::v1::Message::new_text("user", prompt.clone())],
|
||||
temperature: Some(0.1),
|
||||
max_tokens: None,
|
||||
stream: Some(false),
|
||||
// Anthropic models on opencode reject `think` (handled in
|
||||
// the adapter), but we keep the intent flag for parity.
|
||||
think: Some(true),
|
||||
provider: Some("opencode".into()),
|
||||
think: Some(true), // overseer KEEPS thinking (Phase 20 rule)
|
||||
provider: Some("ollama_cloud".into()),
|
||||
};
|
||||
let resp = crate::v1::opencode::chat(&opencode_key, &chat_req).await
|
||||
.map_err(|e| format!("opencode: {e}"))?;
|
||||
let resp = crate::v1::ollama_cloud::chat(&cloud_key, &chat_req).await
|
||||
.map_err(|e| format!("ollama_cloud: {e}"))?;
|
||||
let latency_ms = started.elapsed().as_millis() as u64;
|
||||
let end_time = chrono::Utc::now();
|
||||
let correction_text: String = resp.choices.into_iter().next()
|
||||
@@ -646,8 +633,8 @@ impl ExecutionLoop {
|
||||
if let Some(lf) = &self.state.langfuse {
|
||||
use crate::v1::langfuse_trace::ChatTrace;
|
||||
lf.emit_chat(ChatTrace {
|
||||
provider: "opencode".into(),
|
||||
model: "claude-opus-4-7".into(),
|
||||
provider: "ollama_cloud".into(),
|
||||
model: "gpt-oss:120b".into(),
|
||||
input: vec![crate::v1::Message::new_text("user", prompt.clone())],
|
||||
output: correction_text.clone(),
|
||||
prompt_tokens: resp.usage.prompt_tokens,
|
||||
@@ -658,13 +645,12 @@ impl ExecutionLoop {
|
||||
start_time: start_time.to_rfc3339(),
|
||||
end_time: end_time.to_rfc3339(),
|
||||
latency_ms,
|
||||
parent_trace_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Append to the transcript so the next executor turn sees it.
|
||||
self.append(LogEntry::new(
|
||||
turn, "system", "claude-opus-4-7", "overseer_correction",
|
||||
turn, "system", "gpt-oss:120b", "overseer_correction",
|
||||
serde_json::json!({
|
||||
"reason": reason,
|
||||
"correction": correction_text,
|
||||
@@ -686,7 +672,7 @@ impl ExecutionLoop {
|
||||
"task_class": self.req.task_class,
|
||||
"operation": self.req.operation,
|
||||
"reason": reason,
|
||||
"model": "claude-opus-4-7",
|
||||
"model": "gpt-oss:120b",
|
||||
"correction": correction_text,
|
||||
"applied_at_turn": turn,
|
||||
"kb_context_used": kb,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
//! Library facade for the gateway crate so sub-binaries (e.g.
|
||||
//! `parity_extract_json`) can reuse the same modules the gateway
|
||||
//! binary uses.
|
||||
//!
|
||||
//! Added 2026-05-02 to support the cross-runtime parity probe at
|
||||
//! `golangLAKEHOUSE/scripts/cutover/parity/extract_json_parity.sh`.
|
||||
//! `extract_json` is the load-bearing public surface for that probe.
|
||||
//!
|
||||
//! main.rs still uses local `mod foo;` declarations independently —
|
||||
//! adding this file is purely additive (the binary's module tree is
|
||||
//! unchanged).
|
||||
|
||||
pub mod access;
|
||||
pub mod access_service;
|
||||
pub mod auth;
|
||||
pub mod execution_loop;
|
||||
pub mod observability;
|
||||
pub mod tools;
|
||||
pub mod v1;
|
||||
@@ -362,22 +362,6 @@ async fn main() {
|
||||
}
|
||||
c
|
||||
},
|
||||
// Coordinator session JSONL — one row per /v1/iterate
|
||||
// session for offline DuckDB analysis. Cross-runtime
|
||||
// parity with Go-side validatord (commit 1a3a82a).
|
||||
session_log: {
|
||||
let path = &config.gateway.session_log_path;
|
||||
let s = v1::session_log::SessionLogger::from_path(path);
|
||||
if s.is_some() {
|
||||
tracing::info!(
|
||||
"v1: session log enabled — coordinator sessions written to {}",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
tracing::info!("v1: session log disabled (set [gateway].session_log_path to enable)");
|
||||
}
|
||||
s
|
||||
},
|
||||
}));
|
||||
|
||||
// Auth middleware (if enabled) — P5-001 fix 2026-04-23:
|
||||
|
||||
@@ -21,19 +21,12 @@
|
||||
//! re-implementation. Staffing executors, agent loops, and future
|
||||
//! validators all reach the same code path.
|
||||
|
||||
use axum::{extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, Json};
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_MAX_ITERATIONS: u32 = 3;
|
||||
const LOOPBACK_TIMEOUT_SECS: u64 = 240;
|
||||
|
||||
/// Header name used to propagate a Langfuse parent trace id across
|
||||
/// daemon boundaries. Matches Go's `shared.TraceIDHeader` constant
|
||||
/// byte-for-byte (commit d6d2fdf in golangLAKEHOUSE) — same wire
|
||||
/// format means a Go caller can hit Rust's /v1/iterate (or vice
|
||||
/// versa) and the resulting Langfuse trees nest correctly.
|
||||
pub const TRACE_ID_HEADER: &str = "x-lakehouse-trace-id";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct IterateRequest {
|
||||
/// "fill" | "email" | "playbook" — picks which validator runs.
|
||||
@@ -87,14 +80,6 @@ pub struct IterateResponse {
|
||||
pub validation: serde_json::Value,
|
||||
pub iterations: u32,
|
||||
pub history: Vec<IterateAttempt>,
|
||||
/// Echoes the resolved trace id (caller-forwarded header, body
|
||||
/// field, langfuse-middleware mint, or local fallback). Operators
|
||||
/// pivot from this id straight into Langfuse + the
|
||||
/// coordinator_sessions.jsonl join key. Cross-runtime parity with
|
||||
/// Go's `validator.IterateResponse` (commit 6847bbc in
|
||||
/// golangLAKEHOUSE).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -102,52 +87,29 @@ pub struct IterateFailure {
|
||||
pub error: String,
|
||||
pub iterations: u32,
|
||||
pub history: Vec<IterateAttempt>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn iterate(
|
||||
State(state): State<super::V1State>,
|
||||
headers: HeaderMap,
|
||||
State(_state): State<super::V1State>,
|
||||
Json(req): Json<IterateRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let max_iter = req.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS).max(1);
|
||||
let temperature = req.temperature.unwrap_or(0.2);
|
||||
let max_tokens = req.max_tokens.unwrap_or(4096);
|
||||
let mut history: Vec<IterateAttempt> = Vec::with_capacity(max_iter as usize);
|
||||
let mut attempt_records: Vec<super::session_log::SessionAttemptRecord> = Vec::with_capacity(max_iter as usize);
|
||||
let mut current_prompt = req.prompt.clone();
|
||||
|
||||
// Resolve the parent Langfuse trace id. Caller-forwarded header
|
||||
// wins (cross-daemon tree linkage); otherwise mint a fresh id so
|
||||
// the iterate session is its own tree. Same shape as the Go-side
|
||||
// validatord trace propagation.
|
||||
let trace_id: String = headers
|
||||
.get(TRACE_ID_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(new_trace_id);
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(LOOPBACK_TIMEOUT_SECS))
|
||||
.build() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
// Even infrastructure failures get a session row so a
|
||||
// missing /v1/iterate event never silently disappears
|
||||
// from the longitudinal log.
|
||||
write_infra_error(&state, &req, &trace_id, max_iter, 0, format!("client build: {e}")).await;
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("client build: {e}")).into_response();
|
||||
}
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("client build: {e}")).into_response(),
|
||||
};
|
||||
// Self-loopback to the gateway port. Carries gateway internal
|
||||
// calls through /v1/chat + /v1/validate so /v1/usage tracks them.
|
||||
let gateway = "http://127.0.0.1:3100";
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
for iteration in 0..max_iter {
|
||||
let attempt_started = chrono::Utc::now();
|
||||
// ── Generate ──
|
||||
let mut messages = Vec::with_capacity(2);
|
||||
if let Some(sys) = &req.system {
|
||||
@@ -161,33 +123,20 @@ pub async fn iterate(
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
});
|
||||
let raw = match call_chat(&client, gateway, &chat_body, &trace_id).await {
|
||||
let raw = match call_chat(&client, gateway, &chat_body).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
write_infra_error(&state, &req, &trace_id, max_iter, t0.elapsed().as_millis() as u64, format!("/v1/chat hop failed at iter {iteration}: {e}")).await;
|
||||
return (StatusCode::BAD_GATEWAY, format!("/v1/chat hop failed at iter {iteration}: {e}")).into_response();
|
||||
}
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("/v1/chat hop failed at iter {iteration}: {e}")).into_response(),
|
||||
};
|
||||
|
||||
// ── Extract JSON ──
|
||||
let artifact = match extract_json(&raw) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let span_id = emit_attempt_span(
|
||||
&state, &trace_id, iteration, &req, ¤t_prompt, &raw, "no_json", None,
|
||||
attempt_started, chrono::Utc::now(),
|
||||
);
|
||||
history.push(IterateAttempt {
|
||||
iteration,
|
||||
raw: raw.chars().take(2000).collect(),
|
||||
status: AttemptStatus::NoJson,
|
||||
});
|
||||
attempt_records.push(super::session_log::SessionAttemptRecord {
|
||||
iteration,
|
||||
verdict_kind: "no_json".to_string(),
|
||||
error: None,
|
||||
span_id,
|
||||
});
|
||||
current_prompt = format!(
|
||||
"{}\n\nYour previous attempt did not contain a JSON object. Reply with ONLY a valid JSON object matching the requested artifact shape.",
|
||||
req.prompt,
|
||||
@@ -202,41 +151,22 @@ pub async fn iterate(
|
||||
"artifact": artifact,
|
||||
"context": req.context.clone().unwrap_or(serde_json::Value::Null),
|
||||
});
|
||||
match call_validate(&client, gateway, &validate_body, &trace_id).await {
|
||||
match call_validate(&client, gateway, &validate_body).await {
|
||||
Ok(report) => {
|
||||
let span_id = emit_attempt_span(
|
||||
&state, &trace_id, iteration, &req, ¤t_prompt, &raw, "accepted", None,
|
||||
attempt_started, chrono::Utc::now(),
|
||||
);
|
||||
history.push(IterateAttempt {
|
||||
iteration,
|
||||
raw: raw.chars().take(2000).collect(),
|
||||
status: AttemptStatus::Accepted,
|
||||
});
|
||||
attempt_records.push(super::session_log::SessionAttemptRecord {
|
||||
iteration,
|
||||
verdict_kind: "accepted".to_string(),
|
||||
error: None,
|
||||
span_id,
|
||||
});
|
||||
let duration_ms = t0.elapsed().as_millis() as u64;
|
||||
let grounded = grounded_in_roster(&state, &req.kind, &artifact);
|
||||
write_session_accepted(&state, &req, &trace_id, iteration + 1, max_iter, attempt_records, &artifact, grounded, duration_ms).await;
|
||||
return (StatusCode::OK, Json(IterateResponse {
|
||||
artifact,
|
||||
validation: report,
|
||||
iterations: iteration + 1,
|
||||
history,
|
||||
trace_id: Some(trace_id.clone()),
|
||||
})).into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
let err_summary = err.to_string();
|
||||
let span_id = emit_attempt_span(
|
||||
&state, &trace_id, iteration, &req, ¤t_prompt, &raw, "validation_failed",
|
||||
Some(err_summary.clone()),
|
||||
attempt_started, chrono::Utc::now(),
|
||||
);
|
||||
history.push(IterateAttempt {
|
||||
iteration,
|
||||
raw: raw.chars().take(2000).collect(),
|
||||
@@ -244,12 +174,6 @@ pub async fn iterate(
|
||||
error: serde_json::to_value(&err_summary).unwrap_or(serde_json::Value::Null),
|
||||
},
|
||||
});
|
||||
attempt_records.push(super::session_log::SessionAttemptRecord {
|
||||
iteration,
|
||||
verdict_kind: "validation_failed".to_string(),
|
||||
error: Some(err_summary.clone()),
|
||||
span_id,
|
||||
});
|
||||
// Append validation feedback to prompt for next iter.
|
||||
// The model sees concrete failure mode + retries with
|
||||
// corrective context. This is the "observer correction"
|
||||
@@ -264,167 +188,19 @@ pub async fn iterate(
|
||||
}
|
||||
}
|
||||
|
||||
let duration_ms = t0.elapsed().as_millis() as u64;
|
||||
write_session_failure(&state, &req, &trace_id, max_iter, max_iter, attempt_records, duration_ms).await;
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(IterateFailure {
|
||||
error: format!("max iterations reached ({max_iter}) without passing validation"),
|
||||
iterations: max_iter,
|
||||
history,
|
||||
trace_id: Some(trace_id.clone()),
|
||||
})).into_response()
|
||||
}
|
||||
|
||||
// ─── Helpers — Langfuse spans + session log + roster check ─────────
|
||||
|
||||
fn emit_attempt_span(
|
||||
state: &super::V1State,
|
||||
trace_id: &str,
|
||||
iteration: u32,
|
||||
req: &IterateRequest,
|
||||
prompt: &str,
|
||||
raw: &str,
|
||||
verdict: &str,
|
||||
error: Option<String>,
|
||||
started: chrono::DateTime<chrono::Utc>,
|
||||
ended: chrono::DateTime<chrono::Utc>,
|
||||
) -> Option<String> {
|
||||
let lf = state.langfuse.as_ref()?;
|
||||
Some(lf.emit_attempt_span(super::langfuse_trace::AttemptSpan {
|
||||
trace_id: trace_id.to_string(),
|
||||
iteration,
|
||||
model: req.model.clone(),
|
||||
provider: req.provider.clone(),
|
||||
prompt: prompt.to_string(),
|
||||
raw: raw.to_string(),
|
||||
verdict: verdict.to_string(),
|
||||
error,
|
||||
start_time: started.to_rfc3339(),
|
||||
end_time: ended.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Verify every fill artifact's candidate IDs exist in the roster.
|
||||
/// Returns Some(true)/Some(false) on the fill kind, None otherwise
|
||||
/// (other kinds don't have worker IDs to ground). Same semantics as
|
||||
/// Go's `handlers.rosterCheckFor("fill")`.
|
||||
fn grounded_in_roster(
|
||||
state: &super::V1State,
|
||||
kind: &str,
|
||||
artifact: &serde_json::Value,
|
||||
) -> Option<bool> {
|
||||
if kind != "fill" {
|
||||
return None;
|
||||
}
|
||||
let fills = artifact.get("fills").and_then(|v| v.as_array())?;
|
||||
for f in fills {
|
||||
let id = match f.get("candidate_id").and_then(|v| v.as_str()) {
|
||||
Some(s) if !s.is_empty() => s,
|
||||
_ => return Some(false),
|
||||
};
|
||||
if state.validate_workers.find(id).is_none() {
|
||||
return Some(false);
|
||||
}
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
|
||||
async fn write_session_accepted(
|
||||
state: &super::V1State,
|
||||
req: &IterateRequest,
|
||||
trace_id: &str,
|
||||
iterations: u32,
|
||||
max_iter: u32,
|
||||
attempts: Vec<super::session_log::SessionAttemptRecord>,
|
||||
artifact: &serde_json::Value,
|
||||
grounded: Option<bool>,
|
||||
duration_ms: u64,
|
||||
) {
|
||||
let Some(logger) = state.session_log.as_ref() else { return };
|
||||
let rec = build_session_record(req, trace_id, "accepted", iterations, max_iter, attempts, Some(artifact.clone()), grounded, duration_ms);
|
||||
logger.append(rec).await;
|
||||
}
|
||||
|
||||
async fn write_session_failure(
|
||||
state: &super::V1State,
|
||||
req: &IterateRequest,
|
||||
trace_id: &str,
|
||||
iterations: u32,
|
||||
max_iter: u32,
|
||||
attempts: Vec<super::session_log::SessionAttemptRecord>,
|
||||
duration_ms: u64,
|
||||
) {
|
||||
let Some(logger) = state.session_log.as_ref() else { return };
|
||||
let rec = build_session_record(req, trace_id, "max_iter_exhausted", iterations, max_iter, attempts, None, None, duration_ms);
|
||||
logger.append(rec).await;
|
||||
}
|
||||
|
||||
async fn write_infra_error(
|
||||
state: &super::V1State,
|
||||
req: &IterateRequest,
|
||||
trace_id: &str,
|
||||
max_iter: u32,
|
||||
duration_ms: u64,
|
||||
error: String,
|
||||
) {
|
||||
let Some(logger) = state.session_log.as_ref() else { return };
|
||||
let attempts = vec![super::session_log::SessionAttemptRecord {
|
||||
iteration: 0,
|
||||
verdict_kind: "infra_error".to_string(),
|
||||
error: Some(error),
|
||||
span_id: None,
|
||||
}];
|
||||
let rec = build_session_record(req, trace_id, "infra_error", 0, max_iter, attempts, None, None, duration_ms);
|
||||
logger.append(rec).await;
|
||||
}
|
||||
|
||||
fn build_session_record(
|
||||
req: &IterateRequest,
|
||||
trace_id: &str,
|
||||
final_verdict: &str,
|
||||
iterations: u32,
|
||||
max_iter: u32,
|
||||
attempts: Vec<super::session_log::SessionAttemptRecord>,
|
||||
artifact: Option<serde_json::Value>,
|
||||
grounded: Option<bool>,
|
||||
duration_ms: u64,
|
||||
) -> super::session_log::SessionRecord {
|
||||
super::session_log::SessionRecord {
|
||||
schema: super::session_log::SESSION_RECORD_SCHEMA.to_string(),
|
||||
session_id: trace_id.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
daemon: "gateway".to_string(),
|
||||
kind: req.kind.clone(),
|
||||
model: req.model.clone(),
|
||||
provider: req.provider.clone(),
|
||||
prompt: super::session_log::truncate(&req.prompt, 4000),
|
||||
iterations,
|
||||
max_iterations: max_iter,
|
||||
final_verdict: final_verdict.to_string(),
|
||||
attempts,
|
||||
artifact,
|
||||
grounded_in_roster: grounded,
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a fresh trace id when no parent was forwarded. Same
|
||||
/// time-ordered hex shape Langfuse already accepts elsewhere in this
|
||||
/// crate (see `langfuse_trace::uuid_v7_like`).
|
||||
fn new_trace_id() -> 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)
|
||||
}
|
||||
|
||||
async fn call_chat(client: &reqwest::Client, gateway: &str, body: &serde_json::Value, trace_id: &str) -> Result<String, String> {
|
||||
let mut req = client.post(format!("{gateway}/v1/chat")).json(body);
|
||||
if !trace_id.is_empty() {
|
||||
req = req.header(TRACE_ID_HEADER, trace_id);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| format!("chat hop: {e}"))?;
|
||||
async fn call_chat(client: &reqwest::Client, gateway: &str, body: &serde_json::Value) -> Result<String, String> {
|
||||
let resp = client.post(format!("{gateway}/v1/chat"))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("chat hop: {e}"))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
@@ -437,12 +213,12 @@ async fn call_chat(client: &reqwest::Client, gateway: &str, body: &serde_json::V
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_validate(client: &reqwest::Client, gateway: &str, body: &serde_json::Value, trace_id: &str) -> Result<serde_json::Value, String> {
|
||||
let mut req = client.post(format!("{gateway}/v1/validate")).json(body);
|
||||
if !trace_id.is_empty() {
|
||||
req = req.header(TRACE_ID_HEADER, trace_id);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| format!("validate hop: {e}"))?;
|
||||
async fn call_validate(client: &reqwest::Client, gateway: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let resp = client.post(format!("{gateway}/v1/validate"))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("validate hop: {e}"))?;
|
||||
let status = resp.status();
|
||||
let parsed: serde_json::Value = resp.json().await.map_err(|e| format!("validate parse: {e}"))?;
|
||||
if status.is_success() {
|
||||
@@ -458,13 +234,7 @@ async fn call_validate(client: &reqwest::Client, gateway: &str, body: &serde_jso
|
||||
/// Extract the first JSON object from a model's output. Handles
|
||||
/// fenced code blocks (```json ... ```), bare braces, and stray
|
||||
/// prose around the JSON. Returns None on no extractable object.
|
||||
///
|
||||
/// Made `pub` 2026-05-02 to support the cross-runtime parity probe
|
||||
/// at `golangLAKEHOUSE/scripts/cutover/parity/extract_json_parity.sh`.
|
||||
/// The Go counterpart lives at `internal/validator/iterate.go::ExtractJSON`;
|
||||
/// when either runtime's algorithm changes the parity probe surfaces
|
||||
/// the divergence.
|
||||
pub fn extract_json(raw: &str) -> Option<serde_json::Value> {
|
||||
fn extract_json(raw: &str) -> Option<serde_json::Value> {
|
||||
// Try fenced first.
|
||||
let candidates: Vec<String> = {
|
||||
let mut out = vec![];
|
||||
|
||||
@@ -76,54 +76,63 @@ impl LangfuseClient {
|
||||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget per-iteration span emit. Returns the generated
|
||||
/// span id synchronously so the caller can stamp it on
|
||||
/// `IterateAttempt.span_id` before the network round-trip resolves.
|
||||
/// Mirrors Go's `validator.Tracer` callback shape.
|
||||
pub fn emit_attempt_span(&self, sp: AttemptSpan) -> String {
|
||||
let span_id = uuid_v7_like();
|
||||
let span_id_for_caller = span_id.clone();
|
||||
let this = self.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = this.emit_attempt_span_inner(span_id, sp).await {
|
||||
tracing::warn!(target: "v1.langfuse", "iterate span drop: {e}");
|
||||
}
|
||||
});
|
||||
span_id_for_caller
|
||||
}
|
||||
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();
|
||||
|
||||
async fn emit_attempt_span_inner(&self, span_id: String, sp: AttemptSpan) -> Result<(), String> {
|
||||
let level = if sp.verdict == "accepted" { "DEFAULT" } else { "WARNING" };
|
||||
let batch = IngestionBatch {
|
||||
batch: vec![IngestionEvent {
|
||||
id: uuid_v7_like(),
|
||||
timestamp: sp.end_time.clone(),
|
||||
kind: "span-create",
|
||||
body: serde_json::json!({
|
||||
"id": span_id,
|
||||
"traceId": sp.trace_id,
|
||||
"name": format!("iterate.attempt[{}]", sp.iteration),
|
||||
"input": serde_json::json!({
|
||||
"iteration": sp.iteration,
|
||||
"model": sp.model,
|
||||
"provider": sp.provider,
|
||||
"prompt": truncate(&sp.prompt, 4000),
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
"output": serde_json::json!({
|
||||
"verdict": sp.verdict,
|
||||
"error": sp.error,
|
||||
"raw": truncate(&sp.raw, 4000),
|
||||
},
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
"level": level,
|
||||
"startTime": sp.start_time,
|
||||
"endTime": sp.end_time,
|
||||
}),
|
||||
}],
|
||||
},
|
||||
],
|
||||
};
|
||||
self.post_batch(batch).await
|
||||
}
|
||||
|
||||
async fn post_batch(&self, batch: IngestionBatch) -> Result<(), String> {
|
||||
let url = format!("{}{}", self.inner.base_url.trim_end_matches('/'), INGESTION_PATH);
|
||||
let resp = self.inner.http
|
||||
.post(url)
|
||||
@@ -137,81 +146,6 @@ impl LangfuseClient {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn emit_chat_inner(&self, ev: ChatTrace) -> Result<(), String> {
|
||||
// When the caller forwarded a parent trace id (via the
|
||||
// X-Lakehouse-Trace-Id header → V1State plumbing), attach the
|
||||
// generation as a child of that trace. Without a parent we
|
||||
// mint a new top-level trace per call (Phase 40 default).
|
||||
let trace_id = ev.parent_trace_id.clone().unwrap_or_else(uuid_v7_like);
|
||||
let nested = ev.parent_trace_id.is_some();
|
||||
let gen_id = uuid_v7_like();
|
||||
let trace_ts = ev.start_time.clone();
|
||||
|
||||
let mut events = Vec::with_capacity(2);
|
||||
if !nested {
|
||||
// Only mint a fresh trace-create when we don't have a parent.
|
||||
// Reusing a parent trace id without re-creating it is the
|
||||
// contract that lets validatord's iterate-session show up
|
||||
// as one tree in Langfuse.
|
||||
events.push(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,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
events.push(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,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
self.post_batch(IngestionBatch { batch: events }).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `n` chars (NOT bytes). Matches the Go
|
||||
/// `trim` helper used in session log + attempt-span emission so an
|
||||
/// operator reading two cross-runtime traces sees the same boundary.
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
s.chars().take(n).collect()
|
||||
}
|
||||
|
||||
/// Everything the v1.chat handler collects for one completed call.
|
||||
@@ -228,32 +162,6 @@ pub struct ChatTrace {
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub latency_ms: u64,
|
||||
/// When set, attach this chat trace as a child of the named
|
||||
/// Langfuse trace instead of starting a new top-level trace. Used
|
||||
/// by `/v1/iterate` to nest its inner /v1/chat hops under the
|
||||
/// iterate-session trace so a multi-call session shows in
|
||||
/// Langfuse as ONE trace tree, not N+1 disconnected traces.
|
||||
/// Matches the Go-side `X-Lakehouse-Trace-Id` propagation
|
||||
/// (commit d6d2fdf in golangLAKEHOUSE).
|
||||
pub parent_trace_id: Option<String>,
|
||||
}
|
||||
|
||||
/// One iteration attempt inside `/v1/iterate`'s loop. Becomes one
|
||||
/// span on the parent trace when emitted via `emit_attempt_span`.
|
||||
/// Matches Go's `validator.AttemptSpan` shape so the cross-runtime
|
||||
/// observability surface is consistent.
|
||||
pub struct AttemptSpan {
|
||||
pub trace_id: String,
|
||||
pub iteration: u32,
|
||||
pub model: String,
|
||||
pub provider: String,
|
||||
pub prompt: String,
|
||||
pub raw: String,
|
||||
/// Verdict kind: "no_json" | "validation_failed" | "accepted"
|
||||
pub verdict: String,
|
||||
pub error: Option<String>,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -21,7 +21,6 @@ pub mod opencode;
|
||||
pub mod validate;
|
||||
pub mod iterate;
|
||||
pub mod langfuse_trace;
|
||||
pub mod session_log;
|
||||
pub mod mode;
|
||||
pub mod respond;
|
||||
pub mod truth;
|
||||
@@ -84,13 +83,6 @@ pub struct V1State {
|
||||
/// disabled (keys missing or container unreachable). Traces are
|
||||
/// fire-and-forget: never block the response path.
|
||||
pub langfuse: Option<langfuse_trace::LangfuseClient>,
|
||||
/// Coordinator session JSONL writer (path from
|
||||
/// `[gateway].session_log_path`). One row per `/v1/iterate`
|
||||
/// session for offline DuckDB analysis. None = disabled.
|
||||
/// Cross-runtime parity with the Go-side `validatord`
|
||||
/// `[validatord].session_log_path` (commit 1a3a82a in
|
||||
/// golangLAKEHOUSE).
|
||||
pub session_log: Option<session_log::SessionLogger>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize)]
|
||||
@@ -369,7 +361,6 @@ mod resolve_provider_tests {
|
||||
|
||||
async fn chat(
|
||||
State(state): State<V1State>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<ChatRequest>,
|
||||
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
||||
if req.messages.is_empty() {
|
||||
@@ -499,17 +490,6 @@ async fn chat(
|
||||
let output = resp.choices.first()
|
||||
.map(|c| c.message.text())
|
||||
.unwrap_or_default();
|
||||
// Cross-runtime trace linkage. When a caller (validatord on
|
||||
// Go side, /v1/iterate on Rust side) forwards a parent trace
|
||||
// id via X-Lakehouse-Trace-Id, attach this generation to that
|
||||
// trace so the iterate session and its inner chat hops show
|
||||
// up as ONE trace tree in Langfuse. Header name matches the
|
||||
// Go-side `shared.TraceIDHeader` constant byte-for-byte.
|
||||
let parent_trace_id = headers
|
||||
.get(crate::v1::iterate::TRACE_ID_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
lf.emit_chat(langfuse_trace::ChatTrace {
|
||||
provider: used_provider.clone(),
|
||||
model: resp.model.clone(),
|
||||
@@ -523,7 +503,6 @@ async fn chat(
|
||||
start_time: start_time.to_rfc3339(),
|
||||
end_time: end_time.to_rfc3339(),
|
||||
latency_ms,
|
||||
parent_trace_id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
//! Coordinator session JSONL writer — Rust parity with the Go-side
|
||||
//! `internal/validator/session_log.go` (commit 1a3a82a in
|
||||
//! golangLAKEHOUSE). Same schema, same field names, same producer
|
||||
//! semantics, so a unified longitudinal log can pull from either
|
||||
//! runtime via DuckDB.
|
||||
//!
|
||||
//! Schema: `session.iterate.v1`. One row per `/v1/iterate` session.
|
||||
//! Append-only. Best-effort posture: errors warn and the iterate
|
||||
//! response always ships.
|
||||
//!
|
||||
//! See `golangLAKEHOUSE/docs/SESSION_LOG.md` for the full schema
|
||||
//! reference + DuckDB query examples. This module produces rows
|
||||
//! with `daemon: "gateway"`; the Go side produces `daemon:
|
||||
//! "validatord"`. Operators who want a unified stream can point both
|
||||
//! to the same path (the OS write-append is atomic for the row sizes
|
||||
//! we produce) or query both files together via duckdb's `read_json`
|
||||
//! glob support.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub const SESSION_RECORD_SCHEMA: &str = "session.iterate.v1";
|
||||
|
||||
/// One row in coordinator_sessions.jsonl. Field names are the on-wire
|
||||
/// names — must stay byte-equal to the Go side's
|
||||
/// `validator.SessionRecord` (proven by the cross-runtime parity
|
||||
/// probe at golangLAKEHOUSE/scripts/cutover/parity/).
|
||||
// Deserialize is supported so the parity helper binary can round-trip
|
||||
// fixture inputs through serde without hand-rolling a parser. Production
|
||||
// emit path uses Serialize only; SessionRecord rows are written by the
|
||||
// gateway and consumed by DuckDB / external tooling, never re-read by us.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SessionRecord {
|
||||
pub schema: String,
|
||||
pub session_id: String,
|
||||
pub timestamp: String,
|
||||
pub daemon: String,
|
||||
pub kind: String,
|
||||
pub model: String,
|
||||
pub provider: String,
|
||||
pub prompt: String,
|
||||
pub iterations: u32,
|
||||
pub max_iterations: u32,
|
||||
pub final_verdict: String, // "accepted" | "max_iter_exhausted" | "infra_error"
|
||||
pub attempts: Vec<SessionAttemptRecord>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub artifact: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grounded_in_roster: Option<bool>,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SessionAttemptRecord {
|
||||
pub iteration: u32,
|
||||
pub verdict_kind: String, // "no_json" | "validation_failed" | "accepted" | "infra_error"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub span_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Append-only writer. Cloneable handle — internal state is Arc'd so
|
||||
/// V1State can keep its own clone and per-request clones are cheap.
|
||||
#[derive(Clone)]
|
||||
pub struct SessionLogger {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
path: String,
|
||||
/// tokio::Mutex (not std) because we hold it across the async
|
||||
/// fs write. Contention is low (one row per /v1/iterate session).
|
||||
mu: Mutex<()>,
|
||||
}
|
||||
|
||||
impl SessionLogger {
|
||||
/// Construct a logger writing to `path`. Empty path → None
|
||||
/// (skip the wiring in the iterate handler entirely).
|
||||
pub fn from_path(path: &str) -> Option<Self> {
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
inner: Arc::new(Inner {
|
||||
path: path.to_string(),
|
||||
mu: Mutex::new(()),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Append one record. Best-effort: failures land in `tracing::warn!`
|
||||
/// and the caller sees Ok(()) — observability is a witness, never
|
||||
/// a gate. Returns Err only on impossible cases the type system
|
||||
/// can't rule out (here: serde_json::to_string failing on a
|
||||
/// well-formed struct, which shouldn't happen).
|
||||
pub async fn append(&self, rec: SessionRecord) {
|
||||
let body = match serde_json::to_string(&rec) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "v1.session_log", "marshal: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _guard = self.inner.mu.lock().await;
|
||||
if let Err(e) = self.write(&body).await {
|
||||
tracing::warn!(target: "v1.session_log", "write {}: {e}", self.inner.path);
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(&self, body: &str) -> std::io::Result<()> {
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
// Lazy mkdir on first write so a not-yet-mounted volume at
|
||||
// startup doesn't kill the daemon.
|
||||
if let Some(parent) = std::path::Path::new(&self.inner.path).parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
}
|
||||
let mut f = OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(&self.inner.path)
|
||||
.await?;
|
||||
f.write_all(body.as_bytes()).await?;
|
||||
f.write_all(b"\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort UTF-8 char truncation. Matches Go's `trim` helper so
|
||||
/// rows produced by either runtime cap fields at the same boundaries.
|
||||
pub fn truncate(s: &str, n: usize) -> String {
|
||||
s.chars().take(n).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
fn fixture_record(session_id: &str) -> SessionRecord {
|
||||
SessionRecord {
|
||||
schema: SESSION_RECORD_SCHEMA.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
timestamp: "2026-05-02T08:00:00Z".to_string(),
|
||||
daemon: "gateway".to_string(),
|
||||
kind: "fill".to_string(),
|
||||
model: "qwen3.5:latest".to_string(),
|
||||
provider: "ollama".to_string(),
|
||||
prompt: "produce a fill artifact".to_string(),
|
||||
iterations: 1,
|
||||
max_iterations: 3,
|
||||
final_verdict: "accepted".to_string(),
|
||||
attempts: vec![SessionAttemptRecord {
|
||||
iteration: 0,
|
||||
verdict_kind: "accepted".to_string(),
|
||||
error: None,
|
||||
span_id: Some("span-0".to_string()),
|
||||
}],
|
||||
artifact: Some(serde_json::json!({"fills":[{"candidate_id":"W-1"}]})),
|
||||
grounded_in_roster: Some(true),
|
||||
duration_ms: 50,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_path_empty_returns_none() {
|
||||
assert!(SessionLogger::from_path("").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_writes_jsonl_row_with_schema_field() {
|
||||
let dir = tempdir();
|
||||
let path = dir.join("sessions.jsonl");
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let logger = SessionLogger::from_path(&path_str).unwrap();
|
||||
logger.append(fixture_record("trace-a")).await;
|
||||
|
||||
let body = fs::read_to_string(&path).await.unwrap();
|
||||
assert!(body.contains("\"schema\":\"session.iterate.v1\""));
|
||||
assert!(body.contains("\"session_id\":\"trace-a\""));
|
||||
assert!(body.contains("\"grounded_in_roster\":true"));
|
||||
assert!(body.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_concurrent_safe() {
|
||||
let dir = tempdir();
|
||||
let path = dir.join("sessions.jsonl");
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let logger = SessionLogger::from_path(&path_str).unwrap();
|
||||
|
||||
let n = 32;
|
||||
let mut handles = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let l = logger.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
l.append(fixture_record(&format!("trace-{i}"))).await;
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.await.unwrap();
|
||||
}
|
||||
|
||||
let body = fs::read_to_string(&path).await.unwrap();
|
||||
let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(lines.len(), n, "expected {n} rows, got {}", lines.len());
|
||||
// Every row must round-trip through serde — a torn write
|
||||
// would surface as a parse error.
|
||||
for line in lines {
|
||||
let _: serde_json::Value = serde_json::from_str(line).expect("valid json per row");
|
||||
}
|
||||
}
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
// Per-test unique path so prior runs don't pollute the next.
|
||||
// The static counter increments across the whole test binary,
|
||||
// so back-to-back tests in the same module get distinct dirs.
|
||||
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"session_log_test_{}_{}_{}",
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0),
|
||||
n,
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
}
|
||||
@@ -62,15 +62,6 @@ pub struct GatewayConfig {
|
||||
pub host: String,
|
||||
#[serde(default = "default_gateway_port")]
|
||||
pub port: u16,
|
||||
/// Coordinator session JSONL output path. One row per
|
||||
/// `/v1/iterate` session, schema=`session.iterate.v1`. Empty =
|
||||
/// disabled. Cross-runtime parity with the Go side's
|
||||
/// `[validatord].session_log_path` (added 2026-05-02). Default
|
||||
/// empty so existing deployments aren't perturbed; production
|
||||
/// sets `/var/lib/lakehouse/gateway/sessions.jsonl`. See
|
||||
/// `golangLAKEHOUSE/docs/SESSION_LOG.md` for query examples.
|
||||
#[serde(default)]
|
||||
pub session_log_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -158,13 +149,7 @@ fn default_gateway_port() -> u16 { 3100 }
|
||||
fn default_storage_root() -> String { "./data".to_string() }
|
||||
fn default_profile_root() -> String { "./data/_profiles".to_string() }
|
||||
fn default_manifest_prefix() -> String { "_catalog/manifests".to_string() }
|
||||
// Post-2026-05-02: AiClient talks directly to Ollama; the Python
|
||||
// sidecar's hot-path role was retired. The config field name
|
||||
// `[sidecar].url` is preserved for migration compatibility (operators
|
||||
// with existing TOMLs don't need to rename anything), but the value
|
||||
// now points at Ollama. Lab UI / pipeline_lab Python remains as a
|
||||
// dev-only tool; not on this URL.
|
||||
fn default_sidecar_url() -> String { "http://localhost:11434".to_string() }
|
||||
fn default_sidecar_url() -> String { "http://localhost:3200".to_string() }
|
||||
fn default_embed_model() -> String { "nomic-embed-text".to_string() }
|
||||
fn default_gen_model() -> String { "qwen2.5".to_string() }
|
||||
fn default_rerank_model() -> String { "qwen2.5".to_string() }
|
||||
@@ -199,11 +184,7 @@ impl Config {
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gateway: GatewayConfig {
|
||||
host: default_host(),
|
||||
port: default_gateway_port(),
|
||||
session_log_path: String::new(),
|
||||
},
|
||||
gateway: GatewayConfig { host: default_host(), port: default_gateway_port() },
|
||||
storage: StorageConfig {
|
||||
root: default_storage_root(),
|
||||
profile_root: default_profile_root(),
|
||||
|
||||
@@ -603,200 +603,3 @@ fn row_from_batch(batch: &RecordBatch, row: usize) -> Result<Row, String> {
|
||||
|
||||
Ok(Row { doc_id, chunk_text, vector: v, source, chunk_idx })
|
||||
}
|
||||
|
||||
// =================== Tests ===================
|
||||
//
|
||||
// All tests run against a temp directory — never the production
|
||||
// data/lance/ tree. Lance reads/writes are async + filesystem-bound,
|
||||
// so we use #[tokio::test]. Each test uses a unique per-pid + per-
|
||||
// nanosecond temp dir so concurrent runs don't collide and a re-run
|
||||
// of a single test doesn't see prior state.
|
||||
//
|
||||
// Surfaced 2026-05-02 audit: vectord-lance had ZERO tests despite
|
||||
// being on the live HTTP path. These are the load-bearing locks for
|
||||
// the public API contract.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_path(label: &str) -> String {
|
||||
let n = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
let pid = std::process::id();
|
||||
std::env::temp_dir()
|
||||
.join(format!("vlance_test_{label}_{pid}_{n}"))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Build a minimal in-memory Parquet file matching vectord's
|
||||
/// binary-blob schema. Used as input to migrate_from_parquet_bytes.
|
||||
fn synth_parquet_bytes(n_rows: usize, dims: usize) -> Vec<u8> {
|
||||
use parquet::arrow::ArrowWriter;
|
||||
use std::io::Cursor;
|
||||
|
||||
let schema = Arc::new(Schema::new(vec![
|
||||
Field::new("source", DataType::Utf8, true),
|
||||
Field::new("doc_id", DataType::Utf8, false),
|
||||
Field::new("chunk_idx", DataType::Int32, true),
|
||||
Field::new("chunk_text", DataType::Utf8, true),
|
||||
Field::new("vector", DataType::Binary, false),
|
||||
]));
|
||||
|
||||
let sources: Vec<Option<&str>> = (0..n_rows).map(|_| Some("test")).collect();
|
||||
let doc_ids: Vec<String> = (0..n_rows).map(|i| format!("DOC-{i:04}")).collect();
|
||||
let chunk_idxs: Vec<Option<i32>> = (0..n_rows).map(|i| Some(i as i32)).collect();
|
||||
let chunk_texts: Vec<String> = (0..n_rows).map(|i| format!("synth chunk {i}")).collect();
|
||||
let vectors: Vec<Vec<u8>> = (0..n_rows).map(|i| {
|
||||
let v: Vec<f32> = (0..dims).map(|j| (i * dims + j) as f32 * 0.01).collect();
|
||||
let mut bytes = Vec::with_capacity(dims * 4);
|
||||
for f in v { bytes.extend_from_slice(&f.to_le_bytes()); }
|
||||
bytes
|
||||
}).collect();
|
||||
|
||||
let batch = RecordBatch::try_new(schema.clone(), vec![
|
||||
Arc::new(StringArray::from(sources)),
|
||||
Arc::new(StringArray::from(doc_ids)),
|
||||
Arc::new(Int32Array::from(chunk_idxs)),
|
||||
Arc::new(StringArray::from(chunk_texts)),
|
||||
Arc::new(BinaryArray::from(vectors.iter().map(|v| v.as_slice()).collect::<Vec<_>>())),
|
||||
]).expect("synth parquet batch");
|
||||
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).expect("arrow writer");
|
||||
writer.write(&batch).expect("write batch");
|
||||
writer.close().expect("close writer");
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_store_reports_no_state() {
|
||||
let path = temp_path("fresh");
|
||||
let store = LanceVectorStore::new(path.clone());
|
||||
assert_eq!(store.path(), path);
|
||||
assert_eq!(store.count().await.unwrap_or(0), 0);
|
||||
assert!(!store.has_vector_index().await.unwrap_or(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrate_then_count_and_fetch() {
|
||||
let path = temp_path("migrate_fetch");
|
||||
let store = LanceVectorStore::new(path.clone());
|
||||
let bytes = synth_parquet_bytes(8, 4);
|
||||
|
||||
let stats = store.migrate_from_parquet_bytes(&bytes).await.expect("migrate");
|
||||
assert_eq!(stats.rows_written, 8);
|
||||
assert_eq!(stats.dimensions, 4);
|
||||
assert!(stats.disk_bytes > 0, "lance dataset should occupy disk");
|
||||
|
||||
assert_eq!(store.count().await.unwrap(), 8);
|
||||
|
||||
let row = store.get_by_doc_id("DOC-0003").await
|
||||
.expect("get_by_doc_id Ok").expect("DOC-0003 exists");
|
||||
assert_eq!(row.doc_id, "DOC-0003");
|
||||
assert_eq!(row.chunk_text, "synth chunk 3");
|
||||
assert_eq!(row.vector.len(), 4);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
|
||||
/// Load-bearing contract: get_by_doc_id distinguishes "dataset
|
||||
/// missing" (Err) from "id missing" (Ok(None)) so the HTTP
|
||||
/// handler can return 404 without inspecting error strings.
|
||||
#[tokio::test]
|
||||
async fn get_by_doc_id_missing_returns_none() {
|
||||
let path = temp_path("missing_id");
|
||||
let store = LanceVectorStore::new(path.clone());
|
||||
store.migrate_from_parquet_bytes(&synth_parquet_bytes(4, 4)).await.expect("migrate");
|
||||
|
||||
let row = store.get_by_doc_id("DOC-NEVER-EXISTS").await.expect("Ok");
|
||||
assert!(row.is_none(), "missing id must return Ok(None), not Err");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
|
||||
/// Verifies the load-bearing structural-difference claim of
|
||||
/// ADR-019: Lance appends without rewriting the whole file. Row
|
||||
/// count grows; new rows are fetchable by their doc_ids.
|
||||
#[tokio::test]
|
||||
async fn append_grows_count_and_new_rows_fetchable() {
|
||||
let path = temp_path("append");
|
||||
let store = LanceVectorStore::new(path.clone());
|
||||
store.migrate_from_parquet_bytes(&synth_parquet_bytes(4, 4)).await.expect("migrate");
|
||||
assert_eq!(store.count().await.unwrap(), 4);
|
||||
|
||||
let stats = store.append(
|
||||
Some("appended".into()),
|
||||
vec!["NEW-A".into(), "NEW-B".into()],
|
||||
vec![0, 0],
|
||||
vec!["new chunk a".into(), "new chunk b".into()],
|
||||
vec![vec![0.1, 0.2, 0.3, 0.4], vec![0.5, 0.6, 0.7, 0.8]],
|
||||
).await.expect("append");
|
||||
|
||||
assert_eq!(stats.rows_appended, 2);
|
||||
assert_eq!(store.count().await.unwrap(), 6);
|
||||
|
||||
let new_a = store.get_by_doc_id("NEW-A").await.unwrap().expect("NEW-A");
|
||||
assert_eq!(new_a.chunk_text, "new chunk a");
|
||||
assert_eq!(new_a.source.as_deref(), Some("appended"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
|
||||
/// Without this guard a dim-mismatch row would land on disk and
|
||||
/// silently break search at query time.
|
||||
#[tokio::test]
|
||||
async fn append_dim_mismatch_errors() {
|
||||
let path = temp_path("dim_mismatch");
|
||||
let store = LanceVectorStore::new(path.clone());
|
||||
store.migrate_from_parquet_bytes(&synth_parquet_bytes(4, 4)).await.expect("migrate");
|
||||
|
||||
let err = store.append(
|
||||
None, vec!["X".into(), "Y".into()], vec![0, 0],
|
||||
vec!["a".into(), "b".into()],
|
||||
vec![vec![1.0, 2.0, 3.0, 4.0], vec![1.0, 2.0]],
|
||||
).await;
|
||||
assert!(err.is_err(), "dim mismatch must error");
|
||||
let msg = err.unwrap_err();
|
||||
assert!(msg.contains("dim") || msg.contains("expected"),
|
||||
"error must mention the dimension problem; got: {msg}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
|
||||
/// Search round-trip: query the exact vector for one row, top-1
|
||||
/// must be that row. Verifies the search path works on small
|
||||
/// datasets where IVF training would normally be skipped.
|
||||
#[tokio::test]
|
||||
async fn search_returns_nearest() {
|
||||
let path = temp_path("search");
|
||||
let store = LanceVectorStore::new(path.clone());
|
||||
store.migrate_from_parquet_bytes(&synth_parquet_bytes(8, 4)).await.expect("migrate");
|
||||
|
||||
let target: Vec<f32> = (0..4).map(|j| (5 * 4 + j) as f32 * 0.01).collect();
|
||||
let hits = store.search(&target, 3, None, None).await.expect("search");
|
||||
assert!(!hits.is_empty(), "search must return at least 1 hit");
|
||||
assert_eq!(hits[0].doc_id, "DOC-0005",
|
||||
"exact-vector match should be top-1; got {hits:?}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
|
||||
/// stats() summarizes the dataset state in one call. Locks the
|
||||
/// field shape so downstream consumers don't break on a rename.
|
||||
#[tokio::test]
|
||||
async fn stats_reports_post_migrate_state() {
|
||||
let path = temp_path("stats");
|
||||
let store = LanceVectorStore::new(path.clone());
|
||||
store.migrate_from_parquet_bytes(&synth_parquet_bytes(5, 4)).await.expect("migrate");
|
||||
|
||||
let s = store.stats().await.expect("stats");
|
||||
assert_eq!(s.rows, 5);
|
||||
assert!(s.disk_bytes > 0);
|
||||
assert!(!s.has_vector_index, "no vector index built yet");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +163,7 @@ pub async fn query(
|
||||
// production caller of the Phase 21 primitives — see audit finding
|
||||
// "Phase 21 Rust primitives are wired but not CALLED by any
|
||||
// production surface" from 2026-04-21.
|
||||
// 2026-04-30 model bump: qwen2.5:latest → qwen3.5:latest to match
|
||||
// the small-model-pipeline local-tier default. Same JSON-clean
|
||||
// property, more capacity. think=Some(false) preserved — RAG hot
|
||||
// path doesn't need reasoning traces; direct answers only.
|
||||
let mut cont_opts = ContinuableOpts::new("qwen3.5:latest");
|
||||
let mut cont_opts = ContinuableOpts::new("qwen2.5:latest");
|
||||
cont_opts.max_tokens = Some(512);
|
||||
cont_opts.temperature = Some(0.2);
|
||||
cont_opts.shape = ResponseShape::Text;
|
||||
@@ -180,7 +176,7 @@ pub async fn query(
|
||||
// echoes whatever Ollama loaded). Use the configured tier model
|
||||
// for now; if RAG needs to report the actual resolved model,
|
||||
// the runner can add a post-call ps probe later.
|
||||
model: "qwen3.5:latest".to_string(),
|
||||
model: "qwen2.5:latest".to_string(),
|
||||
sources: results,
|
||||
tokens_generated: None,
|
||||
})
|
||||
|
||||
@@ -1855,10 +1855,10 @@ async fn lance_migrate(
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, format!("read parquet: {e}")))?;
|
||||
|
||||
let lance_store = state.lance.store_for_new(&index_name, &bucket).await
|
||||
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let stats = lance_store.migrate_from_parquet_bytes(&bytes).await
|
||||
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
tracing::info!(
|
||||
"lance migrate '{}': {} rows, {}d, {} bytes on disk, {:.2}s",
|
||||
@@ -1888,38 +1888,6 @@ fn default_partitions() -> u32 { 316 } // ≈√100K — sane for the referenc
|
||||
fn default_bits() -> u32 { 8 }
|
||||
fn default_subvectors() -> u32 { 48 } // 768/48 = 16 dims per subvector
|
||||
|
||||
/// Sanitize a Lance backend error before returning it to the HTTP
|
||||
/// caller. Two responsibilities:
|
||||
///
|
||||
/// 1. Map "dataset not found" patterns to HTTP 404 instead of 500.
|
||||
/// A missing index isn't an internal failure — it's a resource
|
||||
/// lookup miss, and the response code should reflect that.
|
||||
/// 2. Strip server-side filesystem paths and Rust crate registry
|
||||
/// paths (`/root/.cargo/registry/src/index.crates.io-...`) from
|
||||
/// the message body. An attacker probing the surface shouldn't
|
||||
/// learn the server's directory layout or our exact dep versions.
|
||||
///
|
||||
/// Surfaced 2026-05-02 by the Lance backend audit: missing-index
|
||||
/// search returned 500 + leaked the lakehouse data path AND the
|
||||
/// .cargo/registry path with crate versions.
|
||||
fn sanitize_lance_err(err: String, index_name: &str) -> (StatusCode, String) {
|
||||
let lower = err.to_lowercase();
|
||||
let is_not_found = lower.contains("not found") || lower.contains("no such file");
|
||||
let msg = if is_not_found {
|
||||
format!("lance dataset not found: {index_name}")
|
||||
} else {
|
||||
// Generic 500 with the Lance error body trimmed of paths.
|
||||
let cleaned = err
|
||||
.split("/root/.cargo/").next().unwrap_or(&err)
|
||||
.split("/home/").next().unwrap_or(&err)
|
||||
.trim_end_matches([',', ' ', '\n', '\t'])
|
||||
.to_string();
|
||||
if cleaned.is_empty() { format!("lance backend error on {index_name}") } else { cleaned }
|
||||
};
|
||||
let status = if is_not_found { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR };
|
||||
(status, msg)
|
||||
}
|
||||
|
||||
/// Build the IVF_PQ index on the Lance dataset.
|
||||
async fn lance_build_index(
|
||||
State(state): State<VectorState>,
|
||||
@@ -1927,10 +1895,10 @@ async fn lance_build_index(
|
||||
Json(req): Json<LanceIndexRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let lance_store = state.lance.store_for(&index_name).await
|
||||
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
match lance_store.build_index(req.num_partitions, req.num_bits, req.num_sub_vectors).await {
|
||||
Ok(stats) => Ok(Json(stats)),
|
||||
Err(e) => Err(sanitize_lance_err(e, &index_name)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1979,13 +1947,13 @@ async fn lance_search(
|
||||
let qv: Vec<f32> = embed_resp.embeddings[0].iter().map(|&x| x as f32).collect();
|
||||
|
||||
let lance_store = state.lance.store_for(&index_name).await
|
||||
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let nprobes = req.nprobes.or(Some(LANCE_DEFAULT_NPROBES));
|
||||
let refine = req.refine_factor.or(Some(LANCE_DEFAULT_REFINE_FACTOR));
|
||||
let hits = lance_store.search(&qv, req.top_k, nprobes, refine).await
|
||||
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"index_name": index_name,
|
||||
@@ -2003,7 +1971,7 @@ async fn lance_get_doc(
|
||||
Path((index_name, doc_id)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let lance_store = state.lance.store_for(&index_name).await
|
||||
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let t0 = std::time::Instant::now();
|
||||
match lance_store.get_by_doc_id(&doc_id).await {
|
||||
Ok(Some(row)) => Ok(Json(serde_json::json!({
|
||||
@@ -2013,7 +1981,7 @@ async fn lance_get_doc(
|
||||
"row": row,
|
||||
}))),
|
||||
Ok(None) => Err((StatusCode::NOT_FOUND, format!("doc_id not found: {doc_id}"))),
|
||||
Err(e) => Err(sanitize_lance_err(e, &index_name)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2045,7 +2013,7 @@ async fn lance_append(
|
||||
return Err((StatusCode::BAD_REQUEST, "rows array is empty".into()));
|
||||
}
|
||||
let lance_store = state.lance.store_for(&index_name).await
|
||||
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let mut doc_ids = Vec::with_capacity(req.rows.len());
|
||||
let mut chunk_idxs = Vec::with_capacity(req.rows.len());
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Lakehouse: Rust vs Go architecture comparison
|
||||
|
||||
> **Source of truth lives in the golangLAKEHOUSE repo:**
|
||||
> [`/home/profit/golangLAKEHOUSE/docs/ARCHITECTURE_COMPARISON.md`](file:///home/profit/golangLAKEHOUSE/docs/ARCHITECTURE_COMPARISON.md)
|
||||
>
|
||||
> J's living document — pulled from there into this repo's docs as
|
||||
> a pointer so the comparison is reachable from either side.
|
||||
|
||||
## Why the source lives in golangLAKEHOUSE
|
||||
|
||||
The Go rewrite was the trigger for the comparison. The doc updates as
|
||||
J ships fixes on either side, and most of the open backlog items
|
||||
(materializer port, replay port, validators network surface) land in
|
||||
the Go repo. Keeping the source there means PR auditing on Go
|
||||
commits also catches doc drift.
|
||||
|
||||
## When to update from this side
|
||||
|
||||
If a fix lands in the Rust repo that changes a comparison value
|
||||
(e.g. embed cache change, sidecar drop, new validator), update both:
|
||||
|
||||
1. The source at `/home/profit/golangLAKEHOUSE/docs/ARCHITECTURE_COMPARISON.md`
|
||||
2. The change log section at the bottom of the same file
|
||||
|
||||
This file is a pointer — **do not put authoritative content here.**
|
||||
Drift between two copies wastes the discipline.
|
||||
|
||||
## Quick links
|
||||
|
||||
- **Decisions tracker** — section near the top of the source file.
|
||||
Lists actioned items + open backlog with LOC estimates.
|
||||
- **Performance numbers** — Python dependency section. Updated each
|
||||
time a load test is rerun.
|
||||
- **Distillation porting status** — table of phase-by-phase port
|
||||
state across runtimes.
|
||||
- **Recommendation** — current working hypothesis on Go-primary vs
|
||||
Rust-primary. Subject to change as fixes ship.
|
||||
|
||||
## Last known state
|
||||
|
||||
- **2026-05-01**: Rust embed cache shipped (`150cc3b`), 236× RPS gain.
|
||||
- **2026-05-01**: Go validator port shipped (`b03521a`), production
|
||||
safety net now on Go side.
|
||||
- **Open**: Drop Rust Python sidecar (~200 LOC, universal-win).
|
||||
- **Open**: Port Rust materializer to Go (~500-800 LOC, unblocks
|
||||
Go-only end-to-end pipeline).
|
||||
+5
-26
@@ -3,15 +3,6 @@
|
||||
[gateway]
|
||||
host = "0.0.0.0"
|
||||
port = 3100
|
||||
# Coordinator session JSONL — one row per /v1/iterate session for
|
||||
# offline DuckDB analysis. Cross-runtime parity with the Go-side
|
||||
# [validatord].session_log_path. Set to the SAME path Go validatord
|
||||
# writes to so DuckDB queries see one unified longitudinal stream
|
||||
# across both runtimes (rows are tagged daemon="gateway" vs
|
||||
# daemon="validatord" so producers stay distinguishable). Append-write
|
||||
# is atomic at the row sizes both runtimes produce — both daemons
|
||||
# co-writing is safe.
|
||||
session_log_path = "/tmp/lakehouse-validator/sessions.jsonl"
|
||||
|
||||
[storage]
|
||||
root = "./data"
|
||||
@@ -53,22 +44,12 @@ manifest_prefix = "_catalog/manifests"
|
||||
# max_rows_per_query = 10000
|
||||
|
||||
[sidecar]
|
||||
# Post-2026-05-02: AiClient talks directly to Ollama; the Python
|
||||
# sidecar's hot-path role (~120 LOC of pure Ollama wrappers) was
|
||||
# retired. Field name kept for migration compat — value now points
|
||||
# at Ollama on :11434. Lab UI + pipeline_lab Python remains as a
|
||||
# dev-only tool, NOT on this URL.
|
||||
url = "http://localhost:11434"
|
||||
url = "http://localhost:3200"
|
||||
|
||||
[ai]
|
||||
embed_model = "nomic-embed-text"
|
||||
# Local-tier defaults bumped 2026-04-30: qwen3.5:latest is the
|
||||
# stronger local rung in the 5-loop substrate (per
|
||||
# project_small_model_pipeline_vision.md). Same JSON-clean property
|
||||
# as qwen2.5, more capacity. Ollama still serves both — bump back
|
||||
# in this file if a workload regressed.
|
||||
gen_model = "qwen3.5:latest"
|
||||
rerank_model = "qwen3.5:latest"
|
||||
gen_model = "qwen2.5"
|
||||
rerank_model = "qwen2.5"
|
||||
|
||||
[auth]
|
||||
enabled = false
|
||||
@@ -91,9 +72,7 @@ min_recall = 0.9 # never promote below this
|
||||
max_trials_per_hour = 20 # hard budget cap
|
||||
|
||||
# Model roster — available for profile hot-swap
|
||||
# qwen3.5:latest: stronger local rung — JSON-clean, 8K+ context,
|
||||
# default for gen_model and rerank_model
|
||||
# qwen3: 8.2B, 40K context, thinking+tools, best for reasoning tasks
|
||||
# qwen2.5: 7B, 8K context, fast — kept loaded for the 2026-04 era
|
||||
# comparison runs; new defaults use qwen3.5:latest
|
||||
# qwen2.5: 7B, 8K context, fast, good for SQL generation
|
||||
# mistral: 7B, 8K context, good for general generation
|
||||
# nomic-embed-text: 137M, embedding-only, used by all profiles
|
||||
|
||||
+2
-29
@@ -23,13 +23,6 @@ import { roleBand, SCENES, SCENES_VERSION, FACE_RENDER_DIM, type RoleBand } from
|
||||
import { ICONS, ICONS_VERSION, DEFAULT_NEGATIVE, certToSlug, type IconRecipe } from "./icon_recipes.js";
|
||||
|
||||
const BASE = process.env.LAKEHOUSE_URL || "http://localhost:3100";
|
||||
// G5 cutover prep (2026-05-01): when GO_LAKEHOUSE_URL is set, the
|
||||
// `/_go/*` pass-through routes to the Go gateway. Off-by-default
|
||||
// (empty = disabled, returns 503 on /_go/*). Reversible via unset.
|
||||
// Doesn't modify any existing tool — additive parallel path so
|
||||
// operators can validate Go-side handlers under real Bun-frontend
|
||||
// shape without touching the production Rust path.
|
||||
const GO_BASE = process.env.GO_LAKEHOUSE_URL || "";
|
||||
const PORT = parseInt(process.env.MCP_PORT || "3700");
|
||||
|
||||
// ─── Staffer roster — used by the per-staffer hot-swap index (G). ────
|
||||
@@ -320,9 +313,9 @@ ${(buckets as any[] || []).map((b: any) => `- ${b.name}: ${b.backend} (${b.reach
|
||||
- Ollama: :11434
|
||||
|
||||
## Available Models
|
||||
- qwen3.5:latest: stronger local rung, JSON-clean (default for gen + rerank)
|
||||
- qwen3: 8.2B, 40K context, thinking+tools (best for reasoning)
|
||||
- qwen2.5: 7B, 8K context (legacy — 2026-04 era comparison runs only)
|
||||
- qwen2.5: 7B, 8K context (best for fast SQL generation)
|
||||
- mistral: 7B, 8K context (general generation)
|
||||
- nomic-embed-text: 137M (embedding, automatic)
|
||||
`;
|
||||
return { contents: [{ uri: uri.href, mimeType: "text/plain", text }] };
|
||||
@@ -718,26 +711,6 @@ async function main() {
|
||||
return new Response(await r.text(), { status: r.status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// G5 cutover slice: pass-through to GO_BASE (Go gateway). Same
|
||||
// shape as /api/* but points at the Go side. Returns 503 when
|
||||
// GO_LAKEHOUSE_URL isn't set so callers know the cutover slice
|
||||
// is off rather than silently routing to Rust. No body
|
||||
// transformation — caller responsible for sending Go-shaped
|
||||
// requests (e.g. /v1/embed not /ai/embed).
|
||||
if (url.pathname.startsWith("/_go/")) {
|
||||
if (!GO_BASE) {
|
||||
return err("GO_LAKEHOUSE_URL not set; /_go/* cutover slice is disabled", 503);
|
||||
}
|
||||
const path = url.pathname.replace("/_go", "");
|
||||
const body = req.method !== "GET" ? await req.text() : undefined;
|
||||
try {
|
||||
const r = await fetch(`${GO_BASE}${path}`, { method: req.method, headers: { "Content-Type": "application/json" }, body });
|
||||
return new Response(await r.text(), { status: r.status, headers: { "Content-Type": "application/json" } });
|
||||
} catch (e) {
|
||||
return err(`Go gateway unreachable at ${GO_BASE}: ${e}`, 502);
|
||||
}
|
||||
}
|
||||
|
||||
// Proof — narrative HTML served from mcp-server/proof.html.
|
||||
// Live tests consumed client-side via /proof.json.
|
||||
if (url.pathname === "/proof") {
|
||||
|
||||
+8
-10
@@ -146,16 +146,15 @@ async function persistOp(op: ObservedOp) {
|
||||
// ─── LLM Team escalation (code_review mode) ───
|
||||
//
|
||||
// When recent failures on a single sig_hash cross a threshold the
|
||||
// local-model analysis is probably insufficient. J's 2026-04-24
|
||||
// local qwen2.5 analysis is probably insufficient. J's 2026-04-24
|
||||
// direction: "the observer would trigger to give more context" —
|
||||
// route failure clusters to LLM Team's specialized code_review mode
|
||||
// (via /api/run) so richer structured signal lands in the KB for
|
||||
// scrum + auditor + playbook memory to consume next pass.
|
||||
//
|
||||
// Non-destructive: runs in parallel to the existing local diagnose
|
||||
// call (qwen3.5:latest after the 2026-04-30 bump), never replaces
|
||||
// it. Writes to data/_kb/observer_escalations.jsonl as a dedicated
|
||||
// audit surface.
|
||||
// Non-destructive: runs in parallel to the existing qwen2.5 analysis,
|
||||
// never replaces it. Writes to data/_kb/observer_escalations.jsonl
|
||||
// as a dedicated audit surface.
|
||||
|
||||
const LLM_TEAM = process.env.LH_LLM_TEAM_URL ?? "http://localhost:5000";
|
||||
const LLM_TEAM_ESCALATIONS = "/home/profit/lakehouse/data/_kb/observer_escalations.jsonl";
|
||||
@@ -543,7 +542,7 @@ async function analyzeErrors() {
|
||||
if (failures.length === 0) return;
|
||||
|
||||
// NEW 2026-04-24: escalate recurring sig_hash clusters to LLM Team
|
||||
// code_review mode. Runs in parallel to the local diagnose call
|
||||
// code_review mode. Runs in parallel to the local qwen2.5 analysis
|
||||
// below — non-blocking, richer downstream signal for scrum/auditor.
|
||||
maybeEscalate(failures).catch(() => {});
|
||||
|
||||
@@ -553,14 +552,13 @@ async function analyzeErrors() {
|
||||
|
||||
// Ask local model to diagnose. Phase 44 migration (2026-04-27):
|
||||
// /v1/chat instead of legacy /ai/generate so /v1/usage tracks the
|
||||
// call + Langfuse traces it. 2026-04-30 model bump: qwen2.5 →
|
||||
// qwen3.5:latest to match the small-model-pipeline local-tier default.
|
||||
// call + Langfuse traces it. Same upstream model (qwen2.5 local).
|
||||
try {
|
||||
const resp = await fetch(`${LAKEHOUSE}/v1/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "qwen3.5:latest",
|
||||
model: "qwen2.5",
|
||||
provider: "ollama",
|
||||
messages: [{
|
||||
role: "user",
|
||||
@@ -771,7 +769,7 @@ async function tailOverseerCorrections(): Promise<number> {
|
||||
try { row = JSON.parse(line); } catch { continue; }
|
||||
const op: ObservedOp = {
|
||||
timestamp: row.created_at ?? new Date().toISOString(),
|
||||
endpoint: `overseer:${row.model ?? "claude-opus-4-7"}`,
|
||||
endpoint: `overseer:${row.model ?? "gpt-oss:120b"}`,
|
||||
input_summary: `${row.task_class ?? "?"}: ${row.reason ?? "escalation"}`,
|
||||
// Correction itself is neither success nor failure — it's a
|
||||
// mitigation attempt. We mark success=true so analyzeErrors
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 6 — Acceptance Gate Report
|
||||
|
||||
**Run:** 2026-04-27T15:43:37.943Z
|
||||
**Run:** 2026-04-27T04:54:32.225Z
|
||||
**Fixture:** `tests/fixtures/distillation/acceptance/`
|
||||
**Temp root:** `/tmp/distillation_phase6_acceptance`
|
||||
**Pipeline run_ids:** `acceptance-run-1-stable` (first) + `acceptance-run-2-stable` (second / hash reproducibility)
|
||||
@@ -40,13 +40,13 @@
|
||||
| 19 | scratchpad/tree-split case: fixture row materialized into evidence | found | found | ✓ |
|
||||
| 20 | PRD drift case: fixture row materialized | found | found | ✓ |
|
||||
| 21 | hash reproducibility: per-stage output_hash identical across runs | 0 mismatches | all match | ✓ |
|
||||
| 22 | hash reproducibility: run_hash identical | 8dfdacee62380ec2... | 8dfdacee62380ec2... | ✓ |
|
||||
| 22 | hash reproducibility: run_hash identical | 3ea12b160ee9099a... | 3ea12b160ee9099a... | ✓ |
|
||||
|
||||
## Hash reproducibility detail
|
||||
|
||||
run 1 run_hash: `8dfdacee62380ec20b7420d8f8bad3c395822da6eb0b41eeecd356e88fe20bf0`
|
||||
run 1 run_hash: `3ea12b160ee9099a3c52fe6e7fffd3076de7920d2704d24c789260d63cb1a5a2`
|
||||
|
||||
run 2 run_hash: `8dfdacee62380ec20b7420d8f8bad3c395822da6eb0b41eeecd356e88fe20bf0`
|
||||
run 2 run_hash: `3ea12b160ee9099a3c52fe6e7fffd3076de7920d2704d24c789260d63cb1a5a2`
|
||||
|
||||
**Bit-for-bit identical.** Two runs of the entire pipeline on the same fixture with the same `recorded_at` produce the same outputs. Distillation is deterministic.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Phase 8 — Full System Audit Report
|
||||
|
||||
**Run:** 2026-04-27T15:43:38.021Z
|
||||
**Git commit:** ca7375ea2b178159a0c61bbf62788a2ffa2390e9
|
||||
**Baseline:** 2026-04-27T10:31:44.043Z (d11632a6fae6)
|
||||
**Run:** 2026-04-27T04:54:32.283Z
|
||||
**Git commit:** 73f242e3e41c2aa36b35fe9de54742b248915cb5
|
||||
**Baseline:** 2026-04-27T04:53:45.796Z (5bdd159966e6)
|
||||
|
||||
## Result: **PASS** ✓
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| 1 | P0 | recon doc exists | Y | docs/recon/local-distillation-recon.md present | present | ✓ |
|
||||
| 2 | P0 | tier-1 source streams present | — | all 4 tier-1 jsonls on disk | all present | ✓ |
|
||||
| 3 | P1 | schema validators pass on fixtures | Y | ≥40 tests, 0 fail | 51 pass, 0 fail | ✓ |
|
||||
| 4 | P2 | materializer dry-run completes | Y | >=1 row from each tier-1 source | 1139 read · 82 written · 2 skipped | ✓ |
|
||||
| 4 | P2 | materializer dry-run completes | Y | >=1 row from each tier-1 source | 1073 read · 16 written · 2 skipped | ✓ |
|
||||
| 5 | P2 | tier-1 sources each materialize ≥1 row | — | 4/4: distilled_facts, scrum_reviews, audit_facts, mode_experiments | 1/4 hit (mode_experiments) | ✓ |
|
||||
| 6 | P3 | on-disk scored-runs distribution non-empty | Y | >=1 accepted | acc=386 part=132 rej=57 hum=480 | ✓ |
|
||||
| 7 | P3 | scored-runs distribution sums positive | — | >0 total | 1055 total | ✓ |
|
||||
@@ -38,19 +38,19 @@
|
||||
| 13 | P5 | latest run (3fa51d66-784c-4c7d-843d-6c48328a608c) has all 5 stage receipts | Y | collect,score,export-rag,export-sft,export-preference | all present | ✓ |
|
||||
| 14 | P5 | every stage receipt validates against schema | Y | 0 invalid | 0 invalid | ✓ |
|
||||
| 15 | P5 | RunSummary validates | Y | valid | valid | ✓ |
|
||||
| 16 | P5 | summary.git_commit is 40-char hex | — | match | 68b6697bcb38... (HEAD: ca7375ea2b17...) | ✓ |
|
||||
| 16 | P5 | summary.git_commit is 40-char hex | — | match | 68b6697bcb38... (HEAD: 73f242e3e41c...) | ✓ |
|
||||
| 17 | P5 | run_hash is sha256 | Y | /^[0-9a-f]{64}$/ | 2336b96c3638982d... | ✓ |
|
||||
| 18 | P6 | acceptance gate passes 22/22 invariants on fixture | Y | PASS — 22/22 | 22/22 (exit=0) | ✓ |
|
||||
| 19 | P7 | replay validation passes on 3/3 dry-run sample tasks | Y | 3/3 | 3/3 | ✓ |
|
||||
| 20 | P7 | replay retrieval surfaces ≥1 playbook on each task (when corpus present) | — | ≥1 task with retrieval | 3/3 | ✓ |
|
||||
| 21 | P7 | escalation loop guard: no path > 2 models | Y | 0 loops | 0 | ✓ |
|
||||
| 22 | P7 | replay_runs.jsonl populated by audit run | — | exists with ≥3 rows added | 27 rows total | ✓ |
|
||||
| 22 | P7 | replay_runs.jsonl populated by audit run | — | exists with ≥3 rows added | 21 rows total | ✓ |
|
||||
|
||||
## Drift vs prior baseline
|
||||
|
||||
| Metric | Baseline | Current | Δ% | Flag |
|
||||
|---|---|---|---|---|
|
||||
| p2_evidence_rows | 25 | 82 | 228% | warn |
|
||||
| p2_evidence_rows | 15 | 16 | 7% | ok |
|
||||
| p2_evidence_skips | 2 | 2 | 0% | ok |
|
||||
| p3_accepted | 386 | 386 | 0% | ok |
|
||||
| p3_partial | 132 | 132 | 0% | ok |
|
||||
@@ -61,7 +61,7 @@
|
||||
| p4_pref_pairs | 83 | 83 | 0% | ok |
|
||||
| p4_total_quarantined | 1325 | 1325 | 0% | ok |
|
||||
|
||||
**1 metric(s) drifted >20% from baseline.** Investigate before treating outputs as stable.
|
||||
All metrics within 20% of baseline — pipeline stable across runs.
|
||||
|
||||
## System health status
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# Lance backend re-benchmark — 10M vectors (scale_test_10m)
|
||||
|
||||
**Date:** 2026-05-02
|
||||
**Dataset:** `data/lance/scale_test_10m` (33 GB, ~10M vectors, 768d)
|
||||
**Driver:** live HTTP gateway `:3100/vectors/lance/*` (post sanitizer-fix binary)
|
||||
**Method tag on every search response:** `lance_ivf_pq` (confirms IVF_PQ, not brute-force)
|
||||
|
||||
ADR-019 deferred a 10M re-bench: *"at 10M we expect Lance to pull ahead because HNSW doesn't fit in RAM. Re-benchmark when we have a 10M-vector corpus to test against."* The corpus exists; this is that benchmark.
|
||||
|
||||
## Search latency, 10 diverse queries, top_k=10 (cold)
|
||||
|
||||
| Query | Latency |
|
||||
|---|---:|
|
||||
| warehouse forklift operator second shift | 50.5ms |
|
||||
| senior software engineer kubernetes | 52.9ms |
|
||||
| registered nurse pediatric | 37.6ms |
|
||||
| welder TIG aluminum | **127.7ms** |
|
||||
| data scientist python | 41.6ms |
|
||||
| electrician journeyman commercial | 31.4ms |
|
||||
| accountant CPA tax | 28.6ms |
|
||||
| machine learning research | 32.1ms |
|
||||
| construction site supervisor | 31.8ms |
|
||||
| biomedical engineer | 25.0ms |
|
||||
|
||||
Median ~32ms, mean ~46ms, one ~128ms outlier (TIG aluminum query — not investigated; could be query-specific IVF traversal pattern or transient I/O).
|
||||
|
||||
## Search latency, repeated query (warm cache)
|
||||
|
||||
Same query (`forklift operator`) hit 5 times in a row:
|
||||
|
||||
| Call | Latency |
|
||||
|---|---:|
|
||||
| 1 | 21.9ms |
|
||||
| 2 | 20.2ms |
|
||||
| 3 | 19.2ms |
|
||||
| 4 | 22.4ms |
|
||||
| 5 | 18.6ms |
|
||||
|
||||
**Warm-cache p50 ~20ms.** Stable across the 5 trials.
|
||||
|
||||
## Doc-fetch by id, 5 calls (post-warmup)
|
||||
|
||||
Fetched the same doc_id (`VEC-2196862`) repeatedly:
|
||||
|
||||
| Call | Latency |
|
||||
|---|---:|
|
||||
| 1 | 68.2ms |
|
||||
| 2 | 89.3ms |
|
||||
| 3 | 153.9ms |
|
||||
| 4 | 126.5ms |
|
||||
| 5 | 140.7ms |
|
||||
|
||||
**~100ms p50, climbing under repeat.** This is **substantially slower than the 100K-corpus number** from ADR-019 (311μs claimed; ~6ms measured today on 500k). The 100ms-class result on 10M suggests one of:
|
||||
1. The scalar btree index on `doc_id` isn't built on this dataset (possible — no `build_scalar_index` call recorded for it)
|
||||
2. 33GB doesn't fit warm; disk I/O dominates
|
||||
3. Handler-level HTTP/JSON serialization overhead is amortized at small dataset sizes but visible at 10M
|
||||
|
||||
This is the headline finding of the bench — search is fine at 10M, but **point lookups (the load-bearing Lance feature per ADR-019) need investigation**. The fix is likely "ensure scalar index is built on doc_id at activation time," but I haven't run that experiment.
|
||||
|
||||
## Compared to ADR-019 100K projections
|
||||
|
||||
| Op | 100K (ADR-019) | 10M (today) | Notes |
|
||||
|---|---:|---:|---|
|
||||
| Search (cold) | 2229μs | ~46ms | 21x slower at 100x scale → reasonable for IVF_PQ |
|
||||
| Search (warm) | (not measured) | ~20ms | Warm cache converges nicely |
|
||||
| Doc fetch | 311μs | ~100ms | **300x slower** — likely scalar-index gap |
|
||||
| Index method | lance_ivf_pq | lance_ivf_pq | confirmed via response tag |
|
||||
|
||||
## What this means
|
||||
|
||||
ADR-019's claim that "at 10M, Lance pulls ahead because HNSW doesn't fit in RAM" remains **unverified-but-not-refuted**. We can't directly compare to HNSW at 10M because HNSW's RAM footprint at 10M × 768d × 4 bytes = ~30 GB just for vectors, double that for the graph — way past any single-node deployment. So Lance "wins" at 10M by being the only contender that operationally exists.
|
||||
|
||||
What the bench DID surface:
|
||||
- **Search at 10M works at production-shape latency** (~20ms warm). Acceptable for batch / async / non-conversational workloads. Too slow for sub-10ms voice or recommendation paths.
|
||||
- **Doc-fetch at 10M is slow** (~100ms). The structural Lance win cited in ADR-019 (random-access in O(1)) is a scalar-index dependency. Worth a follow-up: either confirm the index is built on this dataset and live with 100ms, or rebuild the scalar index and re-bench.
|
||||
- **Sanitizer fix held under load** — no 500-with-leak surfaced even on rare query pattern (TIG aluminum). The fix is robust to long-tail queries.
|
||||
|
||||
## Repro
|
||||
|
||||
```bash
|
||||
# Search latency, single query
|
||||
curl -sS -X POST http://127.0.0.1:3100/vectors/lance/search/scale_test_10m \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"query":"forklift operator","top_k":10}' | jq '.latency_us'
|
||||
|
||||
# Doc fetch by id
|
||||
curl -sS http://127.0.0.1:3100/vectors/lance/doc/scale_test_10m/VEC-2196862 \
|
||||
| jq '.latency_us'
|
||||
```
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# lance smoke — gates the 5 /vectors/lance/* HTTP routes (search, doc,
|
||||
# index, append, migrate). Only the read paths are exercised here so a
|
||||
# CI run doesn't mutate state. Migrate + index + append have shape
|
||||
# probes (request bodies are well-formed) but ride the not-found path
|
||||
# that the 2026-05-02 audit added.
|
||||
#
|
||||
# Targets the live gateway at $LH_GATEWAY (default :3100). Uses an
|
||||
# existing on-disk Lance dataset — `workers_500k_v1` — so no
|
||||
# migration setup is needed. If the dataset is missing the smoke
|
||||
# fails loudly with a clear message.
|
||||
#
|
||||
# Surfaced 2026-05-02: the lance crates had zero tests + no smoke;
|
||||
# substrate change to lance_backend.rs would silently break the live
|
||||
# surface. This smoke is the regression gate.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/lance_smoke.sh
|
||||
# LH_GATEWAY=http://127.0.0.1:3100 ./scripts/lance_smoke.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GATEWAY="${LH_GATEWAY:-http://127.0.0.1:3100}"
|
||||
DATASET="${LH_LANCE_DATASET:-workers_500k_v1}"
|
||||
PREFIX="$GATEWAY/vectors/lance"
|
||||
PASS=0; FAIL=0
|
||||
PROBE() { local label="$1"; shift; "$@" && { echo " ✓ $label"; PASS=$((PASS+1)); } || { echo " ✗ $label"; FAIL=$((FAIL+1)); }; }
|
||||
|
||||
echo "[lance-smoke] gateway=$GATEWAY dataset=$DATASET"
|
||||
|
||||
# ── 0. Gateway alive ─────────────────────────────────────────────
|
||||
PROBE "gateway /v1/health responds" \
|
||||
bash -c "curl -sf -m 3 $GATEWAY/v1/health -o /dev/null"
|
||||
|
||||
# ── 1. Search returns IVF_PQ results on existing dataset ────────
|
||||
RESP=$(curl -sS -m 30 -X POST "$PREFIX/search/$DATASET" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"query":"forklift operator","top_k":3}' 2>/dev/null || echo '{}')
|
||||
PROBE "search/$DATASET returns top-3 lance_ivf_pq results" \
|
||||
bash -c "echo '$RESP' | jq -e '.method == \"lance_ivf_pq\" and (.results | length) == 3' >/dev/null"
|
||||
|
||||
# Capture one doc_id from those results so the next probe has something real to fetch.
|
||||
DOC_ID=$(echo "$RESP" | jq -r '.results[0].doc_id // ""')
|
||||
|
||||
# ── 2. get_doc by id returns the row ────────────────────────────
|
||||
PROBE "doc/$DATASET/<known-id> returns full row" \
|
||||
bash -c "[ -n '$DOC_ID' ] && curl -sf -m 5 '$PREFIX/doc/$DATASET/$DOC_ID' | jq -e '.row.doc_id == \"$DOC_ID\"' >/dev/null"
|
||||
|
||||
# ── 3. get_doc with bogus id returns 404 (not 500) ──────────────
|
||||
STATUS=$(curl -sS -m 5 -o /tmp/lance_smoke_404.json -w '%{http_code}' \
|
||||
"$PREFIX/doc/$DATASET/W500K-NOT-A-REAL-ID-00000")
|
||||
PROBE "doc/$DATASET/<missing-id> → 404" \
|
||||
test "$STATUS" = "404"
|
||||
|
||||
# ── 4. search on missing dataset returns 404 + sanitized message ─
|
||||
STATUS=$(curl -sS -m 5 -o /tmp/lance_smoke_500.json -w '%{http_code}' \
|
||||
-X POST "$PREFIX/search/no-such-dataset-${RANDOM}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"query":"x","top_k":1}')
|
||||
BODY=$(cat /tmp/lance_smoke_500.json)
|
||||
PROBE "search/<missing> → 404 (was 500 pre-2026-05-02)" \
|
||||
test "$STATUS" = "404"
|
||||
# The sanitizer fix specifically: no /home/ or /root/.cargo/ in body.
|
||||
PROBE "search/<missing> body sanitized — no filesystem leak" \
|
||||
bash -c "echo '$BODY' | grep -qvE '/home/|/root/\.cargo/'"
|
||||
|
||||
# ── 5. build_index on missing dataset also sanitized ────────────
|
||||
STATUS=$(curl -sS -m 5 -o /tmp/lance_smoke_idx.json -w '%{http_code}' \
|
||||
-X POST "$PREFIX/index/no-such-dataset-${RANDOM}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{}')
|
||||
BODY=$(cat /tmp/lance_smoke_idx.json)
|
||||
PROBE "index/<missing> body sanitized" \
|
||||
bash -c "echo '$BODY' | grep -qvE '/home/|/root/\.cargo/'"
|
||||
|
||||
# ── 6. append validates input shape (rejects empty rows array) ──
|
||||
STATUS=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' \
|
||||
-X POST "$PREFIX/append/$DATASET" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"rows":[]}')
|
||||
PROBE "append with empty rows[] → 400" \
|
||||
test "$STATUS" = "400"
|
||||
|
||||
# ── 7. migrate route is reachable (POST without body returns a real error, not 404) ──
|
||||
STATUS=$(curl -sS -m 5 -o /dev/null -w '%{http_code}' \
|
||||
-X POST "$PREFIX/migrate/probe-not-real-${RANDOM}?bucket=primary" 2>/dev/null)
|
||||
# Should be 4xx (bad request shape), NOT 404 (route registered) and NOT 200.
|
||||
PROBE "migrate route registered (non-404, non-200 on empty body)" \
|
||||
bash -c "[ '$STATUS' != '404' ] && [ '$STATUS' != '200' ]"
|
||||
|
||||
echo "[lance-smoke] $PASS PASS / $FAIL FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -1143,15 +1143,9 @@ Format each as a code-fenced block with the byte offset within the shard:
|
||||
EXACT LINE OF SOURCE — DO NOT PARAPHRASE, DO NOT TRUNCATE
|
||||
\`\`\`
|
||||
Pick the most reviewer-relevant lines: route definitions (e.g. \`@app.route(...)\`), function signatures, security-sensitive calls (auth/SQL/exec/template/secrets), hardcoded credentials/defaults, exception handlers, sensitive imports. The reviewer will REFUSE to act on any claim not backed by a verbatim anchor — so anchors are how you prove findings are real.`;
|
||||
// 2026-04-28: gpt-oss:120b → gemini-3-flash-preview via Ollama
|
||||
// Pro. Tree-split MAP fires once per shard (potentially 5-20×
|
||||
// per file), so latency dominates total scrum time. Gemini 3
|
||||
// flash returns shard digests substantially faster than the old
|
||||
// 120B free model while staying strong enough for byte-anchored
|
||||
// extraction.
|
||||
const r = await chat({
|
||||
provider: "ollama_cloud",
|
||||
model: "gemini-3-flash-preview",
|
||||
model: "gpt-oss:120b",
|
||||
prompt,
|
||||
max_tokens: 900,
|
||||
});
|
||||
@@ -1201,14 +1195,9 @@ COPY EVERY anchor block from the piece notes IN ORDER, character-perfect. DO NOT
|
||||
|
||||
Output the anchor blocks under their original \`\`\`@offset...\`\`\` fences, each on its own with a blank line between. The reviewer rejects findings that don't quote a string from this anchors block, so completeness here directly determines review quality.`;
|
||||
|
||||
// 2026-04-28: gpt-oss:120b → gemini-3-flash-preview via Ollama
|
||||
// Pro. The reducer runs once per file (vs once per shard for MAP)
|
||||
// but on a much larger context (all shard digests stacked), so
|
||||
// throughput per token still matters. Same model as MAP for
|
||||
// consistency in tree-split outputs.
|
||||
const reduced = await chat({
|
||||
provider: "ollama_cloud",
|
||||
model: "gemini-3-flash-preview",
|
||||
model: "gpt-oss:120b",
|
||||
prompt: reducePrompt,
|
||||
max_tokens: 2400,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user