Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7594725c25 | ||
|
|
98b6647f2a | ||
|
|
57bde63a06 | ||
|
|
ba928b1d64 | ||
|
|
654797a429 | ||
|
|
c5654d417c | ||
|
|
150cc3b681 | ||
|
|
9eed982f1a | ||
|
|
3d068681f5 | ||
|
|
8de94eba08 | ||
|
|
d475fc7fff | ||
|
|
f4dc1b29e3 | ||
|
|
f892230699 | ||
|
|
4b92d1da91 | ||
|
|
1745881426 | ||
|
|
a05174d2fa | ||
|
|
f9a408e4c4 | ||
|
|
a3b65f314e | ||
|
|
10ed3bc630 | ||
|
|
cdf5f5926a | ||
|
|
f92b55615f | ||
|
|
d571d62e9b | ||
|
|
631b0329b1 | ||
|
|
4c46cf6a21 | ||
|
|
6366487b45 | ||
|
|
db81fd8836 | ||
|
|
a789000982 | ||
|
|
aa56fbce61 | ||
|
|
ba41ad2846 | ||
|
|
f6a7621b2d | ||
|
|
31d8ef918c | ||
|
|
a1066db87b | ||
|
|
5f0beffe80 | ||
|
|
677065de76 | ||
|
|
fb99e92a60 | ||
|
|
ed57eda1d8 |
Generated
+1
@@ -48,6 +48,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"lru",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
+5
-3
@@ -16,12 +16,14 @@ import type { Gap, Proposal } from "./types.ts";
|
|||||||
// Phase 44 migration (2026-04-27): bot/propose.ts now flows through
|
// Phase 44 migration (2026-04-27): bot/propose.ts now flows through
|
||||||
// the gateway's /v1/chat instead of hitting the sidecar's /generate
|
// the gateway's /v1/chat instead of hitting the sidecar's /generate
|
||||||
// directly. /v1/usage tracks the call, Langfuse traces it, observer
|
// directly. /v1/usage tracks the call, Langfuse traces it, observer
|
||||||
// sees it. Same upstream model (CLOUD_MODEL gpt-oss:120b on
|
// sees it. Gateway owns the routing.
|
||||||
// Ollama Cloud) — gateway just 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.
|
||||||
const GATEWAY_URL = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
|
const GATEWAY_URL = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
|
||||||
const REPO_ROOT = "/home/profit/lakehouse";
|
const REPO_ROOT = "/home/profit/lakehouse";
|
||||||
const PRD_PATH = `${REPO_ROOT}/docs/PRD.md`;
|
const PRD_PATH = `${REPO_ROOT}/docs/PRD.md`;
|
||||||
const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "gpt-oss:120b";
|
const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "deepseek-v3.2";
|
||||||
const MAX_TOKENS = 6000;
|
const MAX_TOKENS = 6000;
|
||||||
|
|
||||||
export async function findGaps(): Promise<Gap[]> {
|
export async function findGaps(): Promise<Gap[]> {
|
||||||
|
|||||||
+7
-2
@@ -44,7 +44,10 @@ name = "staffing_inference"
|
|||||||
# pattern generalizes beyond code review.
|
# pattern generalizes beyond code review.
|
||||||
preferred_mode = "staffing_inference_lakehouse"
|
preferred_mode = "staffing_inference_lakehouse"
|
||||||
fallback_modes = ["ladder", "consensus", "pipeline"]
|
fallback_modes = ["ladder", "consensus", "pipeline"]
|
||||||
default_model = "openai/gpt-oss-120b:free"
|
# 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"
|
||||||
matrix_corpus = "workers_500k_v8"
|
matrix_corpus = "workers_500k_v8"
|
||||||
|
|
||||||
[[task_class]]
|
[[task_class]]
|
||||||
@@ -58,7 +61,9 @@ matrix_corpus = "kb_team_runs_v1"
|
|||||||
name = "doc_drift_check"
|
name = "doc_drift_check"
|
||||||
preferred_mode = "drift"
|
preferred_mode = "drift"
|
||||||
fallback_modes = ["validator"]
|
fallback_modes = ["validator"]
|
||||||
default_model = "gpt-oss:120b"
|
# 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"
|
||||||
matrix_corpus = "distilled_factual_v20260423095819"
|
matrix_corpus = "distilled_factual_v20260423095819"
|
||||||
|
|
||||||
[[task_class]]
|
[[task_class]]
|
||||||
|
|||||||
+14
-9
@@ -15,22 +15,27 @@
|
|||||||
|
|
||||||
[[provider]]
|
[[provider]]
|
||||||
name = "ollama"
|
name = "ollama"
|
||||||
base_url = "http://localhost:3200"
|
base_url = "http://localhost:11434"
|
||||||
auth = "none"
|
auth = "none"
|
||||||
default_model = "qwen3.5:latest"
|
default_model = "qwen3.5:latest"
|
||||||
# Hot-path local inference. No bearer needed — Python sidecar on
|
# Hot-path local inference. No bearer needed — direct to Ollama as of
|
||||||
# localhost handles the Ollama API. Model names are bare
|
# 2026-05-02 (Python sidecar's pass-through wrapper retired). Model
|
||||||
# (e.g. "qwen3.5:latest", not "ollama/qwen3.5:latest").
|
# names are bare (e.g. "qwen3.5:latest", not "ollama/qwen3.5:latest").
|
||||||
|
|
||||||
[[provider]]
|
[[provider]]
|
||||||
name = "ollama_cloud"
|
name = "ollama_cloud"
|
||||||
base_url = "https://ollama.com"
|
base_url = "https://ollama.com"
|
||||||
auth = "bearer"
|
auth = "bearer"
|
||||||
auth_env = "OLLAMA_CLOUD_KEY"
|
auth_env = "OLLAMA_CLOUD_KEY"
|
||||||
default_model = "gpt-oss:120b"
|
default_model = "deepseek-v3.2"
|
||||||
# Cloud-tier Ollama. Key resolved from OLLAMA_CLOUD_KEY env at gateway
|
# Cloud-tier Ollama (Pro plan as of 2026-04-28). Key resolved from
|
||||||
# boot. Model-prefix routing: "cloud/<model>" auto-routes here
|
# OLLAMA_CLOUD_KEY at gateway boot; Pro tier upgraded the account so
|
||||||
# (see gateway::v1::resolve_provider).
|
# 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).
|
||||||
|
|
||||||
[[provider]]
|
[[provider]]
|
||||||
name = "openrouter"
|
name = "openrouter"
|
||||||
@@ -38,7 +43,7 @@ base_url = "https://openrouter.ai/api/v1"
|
|||||||
auth = "bearer"
|
auth = "bearer"
|
||||||
auth_env = "OPENROUTER_API_KEY"
|
auth_env = "OPENROUTER_API_KEY"
|
||||||
auth_fallback_files = ["/home/profit/.env", "/root/llm_team_config.json"]
|
auth_fallback_files = ["/home/profit/.env", "/root/llm_team_config.json"]
|
||||||
default_model = "openai/gpt-oss-120b:free"
|
default_model = "x-ai/grok-4.1-fast"
|
||||||
# Multi-provider gateway. Covers Anthropic, Google, OpenAI, MiniMax,
|
# Multi-provider gateway. Covers Anthropic, Google, OpenAI, MiniMax,
|
||||||
# Qwen, Gemma, etc. Key resolved via crates/gateway/src/v1/openrouter.rs
|
# Qwen, Gemma, etc. Key resolved via crates/gateway/src/v1/openrouter.rs
|
||||||
# resolve_openrouter_key() — env first, then fallback files.
|
# resolve_openrouter_key() — env first, then fallback files.
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ serde_json = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
lru = "0.12"
|
||||||
|
|||||||
+410
-43
@@ -1,28 +1,74 @@
|
|||||||
|
use lru::LruCache;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
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;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// HTTP client for the Python AI sidecar.
|
/// 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.
|
||||||
///
|
///
|
||||||
/// `generate()` has two transport modes:
|
/// `generate()` has two transport modes:
|
||||||
/// - When `gateway_url` is None (default), it posts to
|
/// - When `gateway_url` is None (default), posts directly to Ollama's
|
||||||
/// `${base_url}/generate` (sidecar direct).
|
/// `${base_url}/api/generate`.
|
||||||
/// - When `gateway_url` is `Some(url)`, it posts to
|
/// - When `gateway_url` is `Some(url)`, posts to `${url}/v1/chat`
|
||||||
/// `${url}/v1/chat` with `provider="ollama"` so the call appears
|
/// with `provider="ollama"` so the call appears in `/v1/usage` and
|
||||||
/// in `/v1/usage` and Langfuse traces.
|
/// Langfuse traces.
|
||||||
///
|
///
|
||||||
/// `embed()`, `rerank()`, and admin methods always go direct to the
|
/// `embed()`, `rerank()`, and admin methods always go direct to
|
||||||
/// sidecar — no `/v1` equivalent yet, no point round-tripping.
|
/// Ollama — no `/v1` equivalent for those surfaces yet.
|
||||||
///
|
///
|
||||||
/// Phase 44 part 2 (2026-04-27): the gateway URL is wired in by
|
/// Phase 44 part 2 (2026-04-27): the gateway URL is wired in by
|
||||||
/// callers that want observability (vectord modules); it's left
|
/// callers that want observability (vectord modules); it's left
|
||||||
/// unset by callers that ARE the gateway internals (avoids self-loops
|
/// unset by callers that ARE the gateway internals (avoids self-loops
|
||||||
/// + redundant hops).
|
/// + 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)]
|
#[derive(Clone)]
|
||||||
pub struct AiClient {
|
pub struct AiClient {
|
||||||
client: Client,
|
client: Client,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
gateway_url: Option<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 --
|
// -- Request/Response types --
|
||||||
@@ -95,17 +141,49 @@ pub struct RerankResponse {
|
|||||||
|
|
||||||
impl AiClient {
|
impl AiClient {
|
||||||
pub fn new(base_url: &str) -> Self {
|
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()
|
let client = Client::builder()
|
||||||
.timeout(Duration::from_secs(120))
|
.timeout(Duration::from_secs(120))
|
||||||
.build()
|
.build()
|
||||||
.expect("failed to build HTTP client");
|
.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 {
|
Self {
|
||||||
client,
|
client,
|
||||||
base_url: base_url.trim_end_matches('/').to_string(),
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
gateway_url: None,
|
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
|
/// Same as `new`, but every `generate()` is routed through
|
||||||
/// `${gateway_url}/v1/chat` (provider=ollama) for observability.
|
/// `${gateway_url}/v1/chat` (provider=ollama) for observability.
|
||||||
/// Use this for callers OUTSIDE the gateway. Inside the gateway
|
/// Use this for callers OUTSIDE the gateway. Inside the gateway
|
||||||
@@ -118,50 +196,222 @@ impl AiClient {
|
|||||||
c
|
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> {
|
pub async fn health(&self) -> Result<serde_json::Value, String> {
|
||||||
let resp = self.client
|
let resp = self.client
|
||||||
.get(format!("{}/health", self.base_url))
|
.get(format!("{}/api/version", self.base_url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("sidecar unreachable: {e}"))?;
|
.map_err(|e| format!("ollama unreachable: {e}"))?;
|
||||||
resp.json().await.map_err(|e| format!("invalid response: {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"),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
pub async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String> {
|
||||||
|
let model_key = req.model.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
let resp = self.client
|
||||||
.post(format!("{}/embed", self.base_url))
|
.post(format!("{}/api/embed", self.base_url))
|
||||||
.json(&req)
|
.json(&serde_json::json!({
|
||||||
|
"model": &model,
|
||||||
|
"input": text,
|
||||||
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("embed request failed: {e}"))?;
|
.map_err(|e| format!("embed request failed: {e}"))?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return Err(format!("embed error ({}): {text}", text.len()));
|
return Err(format!("ollama embed error: {body}"));
|
||||||
}
|
}
|
||||||
resp.json().await.map_err(|e| format!("embed parse error: {e}"))
|
// 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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
|
pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
|
||||||
if let Some(gw) = self.gateway_url.as_deref() {
|
if let Some(gw) = self.gateway_url.as_deref() {
|
||||||
return self.generate_via_gateway(gw, req).await;
|
return self.generate_via_gateway(gw, req).await;
|
||||||
}
|
}
|
||||||
// Direct-sidecar legacy path. Used by gateway internals (so
|
// Direct Ollama path. Used by gateway internals (so the ollama
|
||||||
// ollama_arm can call sidecar without a self-loop) and by
|
// provider can call upstream without a self-loop through
|
||||||
// any consumer that wants raw transport without /v1/usage
|
// /v1/chat) and by any consumer that wants raw transport
|
||||||
// accounting.
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
let resp = self.client
|
let resp = self.client
|
||||||
.post(format!("{}/generate", self.base_url))
|
.post(format!("{}/api/generate", self.base_url))
|
||||||
.json(&req)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("generate request failed: {e}"))?;
|
.map_err(|e| format!("generate request failed: {e}"))?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
return Err(format!("generate error: {text}"));
|
return Err(format!("ollama generate error: {text}"));
|
||||||
}
|
}
|
||||||
resp.json().await.map_err(|e| format!("generate parse error: {e}"))
|
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()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 44 part 2: route generate() through the gateway's
|
/// Phase 44 part 2: route generate() through the gateway's
|
||||||
@@ -217,19 +467,60 @@ 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> {
|
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());
|
||||||
|
|
||||||
|
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
|
let resp = self.client
|
||||||
.post(format!("{}/rerank", self.base_url))
|
.post(format!("{}/api/generate", self.base_url))
|
||||||
.json(&req)
|
.json(&serde_json::json!({
|
||||||
|
"model": &model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": false,
|
||||||
|
"options": {"temperature": 0.0, "num_predict": 8},
|
||||||
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("rerank request failed: {e}"))?;
|
.map_err(|e| format!("rerank request failed: {e}"))?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return Err(format!("rerank error: {text}"));
|
return Err(format!("ollama rerank error: {body}"));
|
||||||
}
|
}
|
||||||
resp.json().await.map_err(|e| format!("rerank parse error: {e}"))
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force Ollama to unload the named model from VRAM (keep_alive=0).
|
/// Force Ollama to unload the named model from VRAM (keep_alive=0).
|
||||||
@@ -238,40 +529,116 @@ impl AiClient {
|
|||||||
/// profile's model can linger in VRAM next to the new one.
|
/// 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> {
|
pub async fn unload_model(&self, model: &str) -> Result<serde_json::Value, String> {
|
||||||
let resp = self.client
|
let resp = self.client
|
||||||
.post(format!("{}/admin/unload", self.base_url))
|
.post(format!("{}/api/generate", self.base_url))
|
||||||
.json(&serde_json::json!({ "model": model }))
|
.json(&serde_json::json!({
|
||||||
|
"model": model,
|
||||||
|
"prompt": "",
|
||||||
|
"keep_alive": 0,
|
||||||
|
"stream": false,
|
||||||
|
}))
|
||||||
.send().await
|
.send().await
|
||||||
.map_err(|e| format!("unload request failed: {e}"))?;
|
.map_err(|e| format!("unload request failed: {e}"))?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
return Err(format!("unload error: {text}"));
|
return Err(format!("ollama unload error: {text}"));
|
||||||
}
|
}
|
||||||
resp.json().await.map_err(|e| format!("unload parse error: {e}"))
|
// 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 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask Ollama to load the named model into VRAM proactively. Makes
|
/// Ask Ollama to load the named model into VRAM proactively. Makes
|
||||||
/// the first real request after profile activation fast (no cold-load
|
/// the first real request after profile activation fast (no cold-load
|
||||||
/// latency).
|
/// latency). Empty prompts confuse some models, so we send a single
|
||||||
|
/// space + cap num_predict=1 (matches the sidecar's prior behavior).
|
||||||
pub async fn preload_model(&self, model: &str) -> Result<serde_json::Value, String> {
|
pub async fn preload_model(&self, model: &str) -> Result<serde_json::Value, String> {
|
||||||
let resp = self.client
|
let resp = self.client
|
||||||
.post(format!("{}/admin/preload", self.base_url))
|
.post(format!("{}/api/generate", self.base_url))
|
||||||
.json(&serde_json::json!({ "model": model }))
|
.json(&serde_json::json!({
|
||||||
|
"model": model,
|
||||||
|
"prompt": " ",
|
||||||
|
"keep_alive": "5m",
|
||||||
|
"stream": false,
|
||||||
|
"options": {"num_predict": 1},
|
||||||
|
}))
|
||||||
.send().await
|
.send().await
|
||||||
.map_err(|e| format!("preload request failed: {e}"))?;
|
.map_err(|e| format!("preload request failed: {e}"))?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
return Err(format!("preload error: {text}"));
|
return Err(format!("ollama preload error: {text}"));
|
||||||
}
|
}
|
||||||
resp.json().await.map_err(|e| format!("preload parse error: {e}"))
|
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"),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GPU + loaded-model snapshot from the sidecar. Combines nvidia-smi
|
/// GPU + loaded-model snapshot. Combines nvidia-smi output (when
|
||||||
/// output (if available) with Ollama's /api/ps.
|
/// available) with Ollama's /api/ps. Same shape as the prior
|
||||||
|
/// sidecar /admin/vram endpoint so callers don't need updating.
|
||||||
pub async fn vram_snapshot(&self) -> Result<serde_json::Value, String> {
|
pub async fn vram_snapshot(&self) -> Result<serde_json::Value, String> {
|
||||||
let resp = self.client
|
let resp = self.client
|
||||||
.get(format!("{}/admin/vram", self.base_url))
|
.get(format!("{}/api/ps", self.base_url))
|
||||||
.send().await
|
.send().await
|
||||||
.map_err(|e| format!("vram request failed: {e}"))?;
|
.map_err(|e| format!("ollama ps request failed: {e}"))?;
|
||||||
resp.json().await.map_err(|e| format!("vram parse error: {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,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//! 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,6 +438,10 @@ impl ExecutionLoop {
|
|||||||
start_time: start_time.to_rfc3339(),
|
start_time: start_time.to_rfc3339(),
|
||||||
end_time: end_time.to_rfc3339(),
|
end_time: end_time.to_rfc3339(),
|
||||||
latency_ms: elapsed_ms,
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,10 +586,10 @@ impl ExecutionLoop {
|
|||||||
/// Phase 20 step (8) — T3 overseer escalation.
|
/// Phase 20 step (8) — T3 overseer escalation.
|
||||||
///
|
///
|
||||||
/// When the local executor/reviewer loop can't self-correct, call
|
/// When the local executor/reviewer loop can't self-correct, call
|
||||||
/// the cloud overseer (`gpt-oss:120b` via Ollama Cloud) with (a)
|
/// the cloud overseer (`claude-opus-4-7` via OpenCode Zen) with
|
||||||
/// the KB context — recent outcomes + prior corrections for this
|
/// (a) the KB context — recent outcomes + prior corrections for
|
||||||
/// sig_hash + task_class, across every profile that has run it —
|
/// this sig_hash + task_class, across every profile that has run
|
||||||
/// and (b) the recent log tail. Its output is appended as a
|
/// it — and (b) the recent log tail. Its output is appended as a
|
||||||
/// `system` role turn so the next executor generation sees it,
|
/// `system` role turn so the next executor generation sees it,
|
||||||
/// AND written to `data/_kb/overseer_corrections.jsonl` so every
|
/// AND written to `data/_kb/overseer_corrections.jsonl` so every
|
||||||
/// future profile activation reads from the same learning pool.
|
/// future profile activation reads from the same learning pool.
|
||||||
@@ -593,9 +597,16 @@ impl ExecutionLoop {
|
|||||||
/// This is the "pipe to the overviewer" piece from 2026-04-23 —
|
/// This is the "pipe to the overviewer" piece from 2026-04-23 —
|
||||||
/// the overseer is now a first-class KB consumer AND producer, not
|
/// the overseer is now a first-class KB consumer AND producer, not
|
||||||
/// a one-shot correction oracle.
|
/// 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> {
|
async fn escalate_to_overseer(&mut self, turn: u32, reason: &str) -> Result<(), String> {
|
||||||
let Some(cloud_key) = self.state.ollama_cloud_key.clone() else {
|
let Some(opencode_key) = self.state.opencode_key.clone() else {
|
||||||
return Err("OLLAMA_CLOUD_KEY not configured — skipping escalation".into());
|
return Err("OPENCODE_API_KEY not configured — skipping escalation".into());
|
||||||
};
|
};
|
||||||
|
|
||||||
let kb = KbContext::load_for(&sig_hash(&self.req), &self.req.task_class).await;
|
let kb = KbContext::load_for(&sig_hash(&self.req), &self.req.task_class).await;
|
||||||
@@ -604,16 +615,18 @@ impl ExecutionLoop {
|
|||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
let start_time = chrono::Utc::now();
|
let start_time = chrono::Utc::now();
|
||||||
let chat_req = crate::v1::ChatRequest {
|
let chat_req = crate::v1::ChatRequest {
|
||||||
model: "gpt-oss:120b".to_string(),
|
model: "claude-opus-4-7".to_string(),
|
||||||
messages: vec![crate::v1::Message::new_text("user", prompt.clone())],
|
messages: vec![crate::v1::Message::new_text("user", prompt.clone())],
|
||||||
temperature: Some(0.1),
|
temperature: Some(0.1),
|
||||||
max_tokens: None,
|
max_tokens: None,
|
||||||
stream: Some(false),
|
stream: Some(false),
|
||||||
think: Some(true), // overseer KEEPS thinking (Phase 20 rule)
|
// Anthropic models on opencode reject `think` (handled in
|
||||||
provider: Some("ollama_cloud".into()),
|
// the adapter), but we keep the intent flag for parity.
|
||||||
|
think: Some(true),
|
||||||
|
provider: Some("opencode".into()),
|
||||||
};
|
};
|
||||||
let resp = crate::v1::ollama_cloud::chat(&cloud_key, &chat_req).await
|
let resp = crate::v1::opencode::chat(&opencode_key, &chat_req).await
|
||||||
.map_err(|e| format!("ollama_cloud: {e}"))?;
|
.map_err(|e| format!("opencode: {e}"))?;
|
||||||
let latency_ms = started.elapsed().as_millis() as u64;
|
let latency_ms = started.elapsed().as_millis() as u64;
|
||||||
let end_time = chrono::Utc::now();
|
let end_time = chrono::Utc::now();
|
||||||
let correction_text: String = resp.choices.into_iter().next()
|
let correction_text: String = resp.choices.into_iter().next()
|
||||||
@@ -633,8 +646,8 @@ impl ExecutionLoop {
|
|||||||
if let Some(lf) = &self.state.langfuse {
|
if let Some(lf) = &self.state.langfuse {
|
||||||
use crate::v1::langfuse_trace::ChatTrace;
|
use crate::v1::langfuse_trace::ChatTrace;
|
||||||
lf.emit_chat(ChatTrace {
|
lf.emit_chat(ChatTrace {
|
||||||
provider: "ollama_cloud".into(),
|
provider: "opencode".into(),
|
||||||
model: "gpt-oss:120b".into(),
|
model: "claude-opus-4-7".into(),
|
||||||
input: vec![crate::v1::Message::new_text("user", prompt.clone())],
|
input: vec![crate::v1::Message::new_text("user", prompt.clone())],
|
||||||
output: correction_text.clone(),
|
output: correction_text.clone(),
|
||||||
prompt_tokens: resp.usage.prompt_tokens,
|
prompt_tokens: resp.usage.prompt_tokens,
|
||||||
@@ -645,12 +658,13 @@ impl ExecutionLoop {
|
|||||||
start_time: start_time.to_rfc3339(),
|
start_time: start_time.to_rfc3339(),
|
||||||
end_time: end_time.to_rfc3339(),
|
end_time: end_time.to_rfc3339(),
|
||||||
latency_ms,
|
latency_ms,
|
||||||
|
parent_trace_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append to the transcript so the next executor turn sees it.
|
// Append to the transcript so the next executor turn sees it.
|
||||||
self.append(LogEntry::new(
|
self.append(LogEntry::new(
|
||||||
turn, "system", "gpt-oss:120b", "overseer_correction",
|
turn, "system", "claude-opus-4-7", "overseer_correction",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"correction": correction_text,
|
"correction": correction_text,
|
||||||
@@ -672,7 +686,7 @@ impl ExecutionLoop {
|
|||||||
"task_class": self.req.task_class,
|
"task_class": self.req.task_class,
|
||||||
"operation": self.req.operation,
|
"operation": self.req.operation,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"model": "gpt-oss:120b",
|
"model": "claude-opus-4-7",
|
||||||
"correction": correction_text,
|
"correction": correction_text,
|
||||||
"applied_at_turn": turn,
|
"applied_at_turn": turn,
|
||||||
"kb_context_used": kb,
|
"kb_context_used": kb,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! 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,6 +362,22 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
c
|
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:
|
// Auth middleware (if enabled) — P5-001 fix 2026-04-23:
|
||||||
|
|||||||
@@ -21,12 +21,19 @@
|
|||||||
//! re-implementation. Staffing executors, agent loops, and future
|
//! re-implementation. Staffing executors, agent loops, and future
|
||||||
//! validators all reach the same code path.
|
//! validators all reach the same code path.
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
use axum::{extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, Json};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const DEFAULT_MAX_ITERATIONS: u32 = 3;
|
const DEFAULT_MAX_ITERATIONS: u32 = 3;
|
||||||
const LOOPBACK_TIMEOUT_SECS: u64 = 240;
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct IterateRequest {
|
pub struct IterateRequest {
|
||||||
/// "fill" | "email" | "playbook" — picks which validator runs.
|
/// "fill" | "email" | "playbook" — picks which validator runs.
|
||||||
@@ -80,6 +87,14 @@ pub struct IterateResponse {
|
|||||||
pub validation: serde_json::Value,
|
pub validation: serde_json::Value,
|
||||||
pub iterations: u32,
|
pub iterations: u32,
|
||||||
pub history: Vec<IterateAttempt>,
|
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)]
|
#[derive(Serialize)]
|
||||||
@@ -87,29 +102,52 @@ pub struct IterateFailure {
|
|||||||
pub error: String,
|
pub error: String,
|
||||||
pub iterations: u32,
|
pub iterations: u32,
|
||||||
pub history: Vec<IterateAttempt>,
|
pub history: Vec<IterateAttempt>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub trace_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn iterate(
|
pub async fn iterate(
|
||||||
State(_state): State<super::V1State>,
|
State(state): State<super::V1State>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(req): Json<IterateRequest>,
|
Json(req): Json<IterateRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let max_iter = req.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS).max(1);
|
let max_iter = req.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS).max(1);
|
||||||
let temperature = req.temperature.unwrap_or(0.2);
|
let temperature = req.temperature.unwrap_or(0.2);
|
||||||
let max_tokens = req.max_tokens.unwrap_or(4096);
|
let max_tokens = req.max_tokens.unwrap_or(4096);
|
||||||
let mut history: Vec<IterateAttempt> = Vec::with_capacity(max_iter as usize);
|
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();
|
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()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(LOOPBACK_TIMEOUT_SECS))
|
.timeout(std::time::Duration::from_secs(LOOPBACK_TIMEOUT_SECS))
|
||||||
.build() {
|
.build() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("client build: {e}")).into_response(),
|
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();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// Self-loopback to the gateway port. Carries gateway internal
|
// Self-loopback to the gateway port. Carries gateway internal
|
||||||
// calls through /v1/chat + /v1/validate so /v1/usage tracks them.
|
// calls through /v1/chat + /v1/validate so /v1/usage tracks them.
|
||||||
let gateway = "http://127.0.0.1:3100";
|
let gateway = "http://127.0.0.1:3100";
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
|
||||||
for iteration in 0..max_iter {
|
for iteration in 0..max_iter {
|
||||||
|
let attempt_started = chrono::Utc::now();
|
||||||
// ── Generate ──
|
// ── Generate ──
|
||||||
let mut messages = Vec::with_capacity(2);
|
let mut messages = Vec::with_capacity(2);
|
||||||
if let Some(sys) = &req.system {
|
if let Some(sys) = &req.system {
|
||||||
@@ -123,20 +161,33 @@ pub async fn iterate(
|
|||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
});
|
});
|
||||||
let raw = match call_chat(&client, gateway, &chat_body).await {
|
let raw = match call_chat(&client, gateway, &chat_body, &trace_id).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("/v1/chat hop failed at iter {iteration}: {e}")).into_response(),
|
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();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Extract JSON ──
|
// ── Extract JSON ──
|
||||||
let artifact = match extract_json(&raw) {
|
let artifact = match extract_json(&raw) {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
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 {
|
history.push(IterateAttempt {
|
||||||
iteration,
|
iteration,
|
||||||
raw: raw.chars().take(2000).collect(),
|
raw: raw.chars().take(2000).collect(),
|
||||||
status: AttemptStatus::NoJson,
|
status: AttemptStatus::NoJson,
|
||||||
});
|
});
|
||||||
|
attempt_records.push(super::session_log::SessionAttemptRecord {
|
||||||
|
iteration,
|
||||||
|
verdict_kind: "no_json".to_string(),
|
||||||
|
error: None,
|
||||||
|
span_id,
|
||||||
|
});
|
||||||
current_prompt = format!(
|
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.",
|
"{}\n\nYour previous attempt did not contain a JSON object. Reply with ONLY a valid JSON object matching the requested artifact shape.",
|
||||||
req.prompt,
|
req.prompt,
|
||||||
@@ -151,22 +202,41 @@ pub async fn iterate(
|
|||||||
"artifact": artifact,
|
"artifact": artifact,
|
||||||
"context": req.context.clone().unwrap_or(serde_json::Value::Null),
|
"context": req.context.clone().unwrap_or(serde_json::Value::Null),
|
||||||
});
|
});
|
||||||
match call_validate(&client, gateway, &validate_body).await {
|
match call_validate(&client, gateway, &validate_body, &trace_id).await {
|
||||||
Ok(report) => {
|
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 {
|
history.push(IterateAttempt {
|
||||||
iteration,
|
iteration,
|
||||||
raw: raw.chars().take(2000).collect(),
|
raw: raw.chars().take(2000).collect(),
|
||||||
status: AttemptStatus::Accepted,
|
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 {
|
return (StatusCode::OK, Json(IterateResponse {
|
||||||
artifact,
|
artifact,
|
||||||
validation: report,
|
validation: report,
|
||||||
iterations: iteration + 1,
|
iterations: iteration + 1,
|
||||||
history,
|
history,
|
||||||
|
trace_id: Some(trace_id.clone()),
|
||||||
})).into_response();
|
})).into_response();
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let err_summary = err.to_string();
|
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 {
|
history.push(IterateAttempt {
|
||||||
iteration,
|
iteration,
|
||||||
raw: raw.chars().take(2000).collect(),
|
raw: raw.chars().take(2000).collect(),
|
||||||
@@ -174,6 +244,12 @@ pub async fn iterate(
|
|||||||
error: serde_json::to_value(&err_summary).unwrap_or(serde_json::Value::Null),
|
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.
|
// Append validation feedback to prompt for next iter.
|
||||||
// The model sees concrete failure mode + retries with
|
// The model sees concrete failure mode + retries with
|
||||||
// corrective context. This is the "observer correction"
|
// corrective context. This is the "observer correction"
|
||||||
@@ -188,19 +264,167 @@ 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 {
|
(StatusCode::UNPROCESSABLE_ENTITY, Json(IterateFailure {
|
||||||
error: format!("max iterations reached ({max_iter}) without passing validation"),
|
error: format!("max iterations reached ({max_iter}) without passing validation"),
|
||||||
iterations: max_iter,
|
iterations: max_iter,
|
||||||
history,
|
history,
|
||||||
|
trace_id: Some(trace_id.clone()),
|
||||||
})).into_response()
|
})).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_chat(client: &reqwest::Client, gateway: &str, body: &serde_json::Value) -> Result<String, String> {
|
// ─── Helpers — Langfuse spans + session log + roster check ─────────
|
||||||
let resp = client.post(format!("{gateway}/v1/chat"))
|
|
||||||
.json(body)
|
fn emit_attempt_span(
|
||||||
.send()
|
state: &super::V1State,
|
||||||
.await
|
trace_id: &str,
|
||||||
.map_err(|e| format!("chat hop: {e}"))?;
|
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}"))?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
@@ -213,12 +437,12 @@ async fn call_chat(client: &reqwest::Client, gateway: &str, body: &serde_json::V
|
|||||||
.to_string())
|
.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_validate(client: &reqwest::Client, gateway: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {
|
async fn call_validate(client: &reqwest::Client, gateway: &str, body: &serde_json::Value, trace_id: &str) -> Result<serde_json::Value, String> {
|
||||||
let resp = client.post(format!("{gateway}/v1/validate"))
|
let mut req = client.post(format!("{gateway}/v1/validate")).json(body);
|
||||||
.json(body)
|
if !trace_id.is_empty() {
|
||||||
.send()
|
req = req.header(TRACE_ID_HEADER, trace_id);
|
||||||
.await
|
}
|
||||||
.map_err(|e| format!("validate hop: {e}"))?;
|
let resp = req.send().await.map_err(|e| format!("validate hop: {e}"))?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let parsed: serde_json::Value = resp.json().await.map_err(|e| format!("validate parse: {e}"))?;
|
let parsed: serde_json::Value = resp.json().await.map_err(|e| format!("validate parse: {e}"))?;
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
@@ -234,7 +458,13 @@ async fn call_validate(client: &reqwest::Client, gateway: &str, body: &serde_jso
|
|||||||
/// Extract the first JSON object from a model's output. Handles
|
/// Extract the first JSON object from a model's output. Handles
|
||||||
/// fenced code blocks (```json ... ```), bare braces, and stray
|
/// fenced code blocks (```json ... ```), bare braces, and stray
|
||||||
/// prose around the JSON. Returns None on no extractable object.
|
/// prose around the JSON. Returns None on no extractable object.
|
||||||
fn extract_json(raw: &str) -> Option<serde_json::Value> {
|
///
|
||||||
|
/// 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> {
|
||||||
// Try fenced first.
|
// Try fenced first.
|
||||||
let candidates: Vec<String> = {
|
let candidates: Vec<String> = {
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
|
|||||||
@@ -76,14 +76,85 @@ 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_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),
|
||||||
|
}),
|
||||||
|
"output": serde_json::json!({
|
||||||
|
"verdict": sp.verdict,
|
||||||
|
"error": sp.error,
|
||||||
|
"raw": truncate(&sp.raw, 4000),
|
||||||
|
}),
|
||||||
|
"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)
|
||||||
|
.basic_auth(&self.inner.public_key, Some(&self.inner.secret_key))
|
||||||
|
.json(&batch)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("POST failed: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("{}: {}", resp.status(), resp.text().await.unwrap_or_default()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn emit_chat_inner(&self, ev: ChatTrace) -> Result<(), String> {
|
async fn emit_chat_inner(&self, ev: ChatTrace) -> Result<(), String> {
|
||||||
let trace_id = uuid_v7_like();
|
// 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 gen_id = uuid_v7_like();
|
||||||
let trace_ts = ev.start_time.clone();
|
let trace_ts = ev.start_time.clone();
|
||||||
|
|
||||||
let batch = IngestionBatch {
|
let mut events = Vec::with_capacity(2);
|
||||||
batch: vec![
|
if !nested {
|
||||||
IngestionEvent {
|
// 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(),
|
id: uuid_v7_like(),
|
||||||
timestamp: trace_ts.clone(),
|
timestamp: trace_ts.clone(),
|
||||||
kind: "trace-create",
|
kind: "trace-create",
|
||||||
@@ -99,8 +170,9 @@ impl LangfuseClient {
|
|||||||
"think": ev.think,
|
"think": ev.think,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
});
|
||||||
IngestionEvent {
|
}
|
||||||
|
events.push(IngestionEvent {
|
||||||
id: uuid_v7_like(),
|
id: uuid_v7_like(),
|
||||||
timestamp: ev.end_time.clone(),
|
timestamp: ev.end_time.clone(),
|
||||||
kind: "generation-create",
|
kind: "generation-create",
|
||||||
@@ -129,23 +201,17 @@ impl LangfuseClient {
|
|||||||
"latency_ms": ev.latency_ms,
|
"latency_ms": ev.latency_ms,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
});
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("{}{}", self.inner.base_url.trim_end_matches('/'), INGESTION_PATH);
|
self.post_batch(IngestionBatch { batch: events }).await
|
||||||
let resp = self.inner.http
|
|
||||||
.post(url)
|
|
||||||
.basic_auth(&self.inner.public_key, Some(&self.inner.secret_key))
|
|
||||||
.json(&batch)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("POST failed: {e}"))?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("{}: {}", resp.status(), resp.text().await.unwrap_or_default()));
|
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Everything the v1.chat handler collects for one completed call.
|
||||||
@@ -162,6 +228,32 @@ pub struct ChatTrace {
|
|||||||
pub start_time: String,
|
pub start_time: String,
|
||||||
pub end_time: String,
|
pub end_time: String,
|
||||||
pub latency_ms: u64,
|
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)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod opencode;
|
|||||||
pub mod validate;
|
pub mod validate;
|
||||||
pub mod iterate;
|
pub mod iterate;
|
||||||
pub mod langfuse_trace;
|
pub mod langfuse_trace;
|
||||||
|
pub mod session_log;
|
||||||
pub mod mode;
|
pub mod mode;
|
||||||
pub mod respond;
|
pub mod respond;
|
||||||
pub mod truth;
|
pub mod truth;
|
||||||
@@ -83,6 +84,13 @@ pub struct V1State {
|
|||||||
/// disabled (keys missing or container unreachable). Traces are
|
/// disabled (keys missing or container unreachable). Traces are
|
||||||
/// fire-and-forget: never block the response path.
|
/// fire-and-forget: never block the response path.
|
||||||
pub langfuse: Option<langfuse_trace::LangfuseClient>,
|
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)]
|
#[derive(Default, Clone, Serialize)]
|
||||||
@@ -361,6 +369,7 @@ mod resolve_provider_tests {
|
|||||||
|
|
||||||
async fn chat(
|
async fn chat(
|
||||||
State(state): State<V1State>,
|
State(state): State<V1State>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Json(req): Json<ChatRequest>,
|
Json(req): Json<ChatRequest>,
|
||||||
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
||||||
if req.messages.is_empty() {
|
if req.messages.is_empty() {
|
||||||
@@ -490,6 +499,17 @@ async fn chat(
|
|||||||
let output = resp.choices.first()
|
let output = resp.choices.first()
|
||||||
.map(|c| c.message.text())
|
.map(|c| c.message.text())
|
||||||
.unwrap_or_default();
|
.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 {
|
lf.emit_chat(langfuse_trace::ChatTrace {
|
||||||
provider: used_provider.clone(),
|
provider: used_provider.clone(),
|
||||||
model: resp.model.clone(),
|
model: resp.model.clone(),
|
||||||
@@ -503,6 +523,7 @@ async fn chat(
|
|||||||
start_time: start_time.to_rfc3339(),
|
start_time: start_time.to_rfc3339(),
|
||||||
end_time: end_time.to_rfc3339(),
|
end_time: end_time.to_rfc3339(),
|
||||||
latency_ms,
|
latency_ms,
|
||||||
|
parent_trace_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
//! 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,6 +62,15 @@ pub struct GatewayConfig {
|
|||||||
pub host: String,
|
pub host: String,
|
||||||
#[serde(default = "default_gateway_port")]
|
#[serde(default = "default_gateway_port")]
|
||||||
pub port: u16,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -149,7 +158,13 @@ fn default_gateway_port() -> u16 { 3100 }
|
|||||||
fn default_storage_root() -> String { "./data".to_string() }
|
fn default_storage_root() -> String { "./data".to_string() }
|
||||||
fn default_profile_root() -> String { "./data/_profiles".to_string() }
|
fn default_profile_root() -> String { "./data/_profiles".to_string() }
|
||||||
fn default_manifest_prefix() -> String { "_catalog/manifests".to_string() }
|
fn default_manifest_prefix() -> String { "_catalog/manifests".to_string() }
|
||||||
fn default_sidecar_url() -> String { "http://localhost:3200".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_embed_model() -> String { "nomic-embed-text".to_string() }
|
fn default_embed_model() -> String { "nomic-embed-text".to_string() }
|
||||||
fn default_gen_model() -> String { "qwen2.5".to_string() }
|
fn default_gen_model() -> String { "qwen2.5".to_string() }
|
||||||
fn default_rerank_model() -> String { "qwen2.5".to_string() }
|
fn default_rerank_model() -> String { "qwen2.5".to_string() }
|
||||||
@@ -184,7 +199,11 @@ impl Config {
|
|||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
gateway: GatewayConfig { host: default_host(), port: default_gateway_port() },
|
gateway: GatewayConfig {
|
||||||
|
host: default_host(),
|
||||||
|
port: default_gateway_port(),
|
||||||
|
session_log_path: String::new(),
|
||||||
|
},
|
||||||
storage: StorageConfig {
|
storage: StorageConfig {
|
||||||
root: default_storage_root(),
|
root: default_storage_root(),
|
||||||
profile_root: default_profile_root(),
|
profile_root: default_profile_root(),
|
||||||
|
|||||||
@@ -603,3 +603,200 @@ fn row_from_batch(batch: &RecordBatch, row: usize) -> Result<Row, String> {
|
|||||||
|
|
||||||
Ok(Row { doc_id, chunk_text, vector: v, source, chunk_idx })
|
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,7 +163,11 @@ pub async fn query(
|
|||||||
// production caller of the Phase 21 primitives — see audit finding
|
// production caller of the Phase 21 primitives — see audit finding
|
||||||
// "Phase 21 Rust primitives are wired but not CALLED by any
|
// "Phase 21 Rust primitives are wired but not CALLED by any
|
||||||
// production surface" from 2026-04-21.
|
// production surface" from 2026-04-21.
|
||||||
let mut cont_opts = ContinuableOpts::new("qwen2.5:latest");
|
// 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");
|
||||||
cont_opts.max_tokens = Some(512);
|
cont_opts.max_tokens = Some(512);
|
||||||
cont_opts.temperature = Some(0.2);
|
cont_opts.temperature = Some(0.2);
|
||||||
cont_opts.shape = ResponseShape::Text;
|
cont_opts.shape = ResponseShape::Text;
|
||||||
@@ -176,7 +180,7 @@ pub async fn query(
|
|||||||
// echoes whatever Ollama loaded). Use the configured tier model
|
// echoes whatever Ollama loaded). Use the configured tier model
|
||||||
// for now; if RAG needs to report the actual resolved model,
|
// for now; if RAG needs to report the actual resolved model,
|
||||||
// the runner can add a post-call ps probe later.
|
// the runner can add a post-call ps probe later.
|
||||||
model: "qwen2.5:latest".to_string(),
|
model: "qwen3.5:latest".to_string(),
|
||||||
sources: results,
|
sources: results,
|
||||||
tokens_generated: None,
|
tokens_generated: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1855,10 +1855,10 @@ async fn lance_migrate(
|
|||||||
.map_err(|e| (StatusCode::NOT_FOUND, format!("read parquet: {e}")))?;
|
.map_err(|e| (StatusCode::NOT_FOUND, format!("read parquet: {e}")))?;
|
||||||
|
|
||||||
let lance_store = state.lance.store_for_new(&index_name, &bucket).await
|
let lance_store = state.lance.store_for_new(&index_name, &bucket).await
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||||
|
|
||||||
let stats = lance_store.migrate_from_parquet_bytes(&bytes).await
|
let stats = lance_store.migrate_from_parquet_bytes(&bytes).await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"lance migrate '{}': {} rows, {}d, {} bytes on disk, {:.2}s",
|
"lance migrate '{}': {} rows, {}d, {} bytes on disk, {:.2}s",
|
||||||
@@ -1888,6 +1888,38 @@ fn default_partitions() -> u32 { 316 } // ≈√100K — sane for the referenc
|
|||||||
fn default_bits() -> u32 { 8 }
|
fn default_bits() -> u32 { 8 }
|
||||||
fn default_subvectors() -> u32 { 48 } // 768/48 = 16 dims per subvector
|
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.
|
/// Build the IVF_PQ index on the Lance dataset.
|
||||||
async fn lance_build_index(
|
async fn lance_build_index(
|
||||||
State(state): State<VectorState>,
|
State(state): State<VectorState>,
|
||||||
@@ -1895,10 +1927,10 @@ async fn lance_build_index(
|
|||||||
Json(req): Json<LanceIndexRequest>,
|
Json(req): Json<LanceIndexRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let lance_store = state.lance.store_for(&index_name).await
|
let lance_store = state.lance.store_for(&index_name).await
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||||
match lance_store.build_index(req.num_partitions, req.num_bits, req.num_sub_vectors).await {
|
match lance_store.build_index(req.num_partitions, req.num_bits, req.num_sub_vectors).await {
|
||||||
Ok(stats) => Ok(Json(stats)),
|
Ok(stats) => Ok(Json(stats)),
|
||||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
Err(e) => Err(sanitize_lance_err(e, &index_name)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1947,13 +1979,13 @@ async fn lance_search(
|
|||||||
let qv: Vec<f32> = embed_resp.embeddings[0].iter().map(|&x| x as f32).collect();
|
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
|
let lance_store = state.lance.store_for(&index_name).await
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||||
|
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
let nprobes = req.nprobes.or(Some(LANCE_DEFAULT_NPROBES));
|
let nprobes = req.nprobes.or(Some(LANCE_DEFAULT_NPROBES));
|
||||||
let refine = req.refine_factor.or(Some(LANCE_DEFAULT_REFINE_FACTOR));
|
let refine = req.refine_factor.or(Some(LANCE_DEFAULT_REFINE_FACTOR));
|
||||||
let hits = lance_store.search(&qv, req.top_k, nprobes, refine).await
|
let hits = lance_store.search(&qv, req.top_k, nprobes, refine).await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"index_name": index_name,
|
"index_name": index_name,
|
||||||
@@ -1971,7 +2003,7 @@ async fn lance_get_doc(
|
|||||||
Path((index_name, doc_id)): Path<(String, String)>,
|
Path((index_name, doc_id)): Path<(String, String)>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let lance_store = state.lance.store_for(&index_name).await
|
let lance_store = state.lance.store_for(&index_name).await
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
match lance_store.get_by_doc_id(&doc_id).await {
|
match lance_store.get_by_doc_id(&doc_id).await {
|
||||||
Ok(Some(row)) => Ok(Json(serde_json::json!({
|
Ok(Some(row)) => Ok(Json(serde_json::json!({
|
||||||
@@ -1981,7 +2013,7 @@ async fn lance_get_doc(
|
|||||||
"row": row,
|
"row": row,
|
||||||
}))),
|
}))),
|
||||||
Ok(None) => Err((StatusCode::NOT_FOUND, format!("doc_id not found: {doc_id}"))),
|
Ok(None) => Err((StatusCode::NOT_FOUND, format!("doc_id not found: {doc_id}"))),
|
||||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
Err(e) => Err(sanitize_lance_err(e, &index_name)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2013,7 +2045,7 @@ async fn lance_append(
|
|||||||
return Err((StatusCode::BAD_REQUEST, "rows array is empty".into()));
|
return Err((StatusCode::BAD_REQUEST, "rows array is empty".into()));
|
||||||
}
|
}
|
||||||
let lance_store = state.lance.store_for(&index_name).await
|
let lance_store = state.lance.store_for(&index_name).await
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
.map_err(|e| sanitize_lance_err(e, &index_name))?;
|
||||||
|
|
||||||
let mut doc_ids = Vec::with_capacity(req.rows.len());
|
let mut doc_ids = Vec::with_capacity(req.rows.len());
|
||||||
let mut chunk_idxs = Vec::with_capacity(req.rows.len());
|
let mut chunk_idxs = Vec::with_capacity(req.rows.len());
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 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).
|
||||||
+26
-5
@@ -3,6 +3,15 @@
|
|||||||
[gateway]
|
[gateway]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 3100
|
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]
|
[storage]
|
||||||
root = "./data"
|
root = "./data"
|
||||||
@@ -44,12 +53,22 @@ manifest_prefix = "_catalog/manifests"
|
|||||||
# max_rows_per_query = 10000
|
# max_rows_per_query = 10000
|
||||||
|
|
||||||
[sidecar]
|
[sidecar]
|
||||||
url = "http://localhost:3200"
|
# 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"
|
||||||
|
|
||||||
[ai]
|
[ai]
|
||||||
embed_model = "nomic-embed-text"
|
embed_model = "nomic-embed-text"
|
||||||
gen_model = "qwen2.5"
|
# Local-tier defaults bumped 2026-04-30: qwen3.5:latest is the
|
||||||
rerank_model = "qwen2.5"
|
# 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"
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
enabled = false
|
enabled = false
|
||||||
@@ -72,7 +91,9 @@ min_recall = 0.9 # never promote below this
|
|||||||
max_trials_per_hour = 20 # hard budget cap
|
max_trials_per_hour = 20 # hard budget cap
|
||||||
|
|
||||||
# Model roster — available for profile hot-swap
|
# 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
|
# qwen3: 8.2B, 40K context, thinking+tools, best for reasoning tasks
|
||||||
# qwen2.5: 7B, 8K context, fast, good for SQL generation
|
# qwen2.5: 7B, 8K context, fast — kept loaded for the 2026-04 era
|
||||||
# mistral: 7B, 8K context, good for general generation
|
# comparison runs; new defaults use qwen3.5:latest
|
||||||
# nomic-embed-text: 137M, embedding-only, used by all profiles
|
# nomic-embed-text: 137M, embedding-only, used by all profiles
|
||||||
|
|||||||
+29
-2
@@ -23,6 +23,13 @@ import { roleBand, SCENES, SCENES_VERSION, FACE_RENDER_DIM, type RoleBand } from
|
|||||||
import { ICONS, ICONS_VERSION, DEFAULT_NEGATIVE, certToSlug, type IconRecipe } from "./icon_recipes.js";
|
import { ICONS, ICONS_VERSION, DEFAULT_NEGATIVE, certToSlug, type IconRecipe } from "./icon_recipes.js";
|
||||||
|
|
||||||
const BASE = process.env.LAKEHOUSE_URL || "http://localhost:3100";
|
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");
|
const PORT = parseInt(process.env.MCP_PORT || "3700");
|
||||||
|
|
||||||
// ─── Staffer roster — used by the per-staffer hot-swap index (G). ────
|
// ─── Staffer roster — used by the per-staffer hot-swap index (G). ────
|
||||||
@@ -313,9 +320,9 @@ ${(buckets as any[] || []).map((b: any) => `- ${b.name}: ${b.backend} (${b.reach
|
|||||||
- Ollama: :11434
|
- Ollama: :11434
|
||||||
|
|
||||||
## Available Models
|
## Available Models
|
||||||
|
- qwen3.5:latest: stronger local rung, JSON-clean (default for gen + rerank)
|
||||||
- qwen3: 8.2B, 40K context, thinking+tools (best for reasoning)
|
- qwen3: 8.2B, 40K context, thinking+tools (best for reasoning)
|
||||||
- qwen2.5: 7B, 8K context (best for fast SQL generation)
|
- qwen2.5: 7B, 8K context (legacy — 2026-04 era comparison runs only)
|
||||||
- mistral: 7B, 8K context (general generation)
|
|
||||||
- nomic-embed-text: 137M (embedding, automatic)
|
- nomic-embed-text: 137M (embedding, automatic)
|
||||||
`;
|
`;
|
||||||
return { contents: [{ uri: uri.href, mimeType: "text/plain", text }] };
|
return { contents: [{ uri: uri.href, mimeType: "text/plain", text }] };
|
||||||
@@ -711,6 +718,26 @@ async function main() {
|
|||||||
return new Response(await r.text(), { status: r.status, headers: { "Content-Type": "application/json" } });
|
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.
|
// Proof — narrative HTML served from mcp-server/proof.html.
|
||||||
// Live tests consumed client-side via /proof.json.
|
// Live tests consumed client-side via /proof.json.
|
||||||
if (url.pathname === "/proof") {
|
if (url.pathname === "/proof") {
|
||||||
|
|||||||
+10
-8
@@ -146,15 +146,16 @@ async function persistOp(op: ObservedOp) {
|
|||||||
// ─── LLM Team escalation (code_review mode) ───
|
// ─── LLM Team escalation (code_review mode) ───
|
||||||
//
|
//
|
||||||
// When recent failures on a single sig_hash cross a threshold the
|
// When recent failures on a single sig_hash cross a threshold the
|
||||||
// local qwen2.5 analysis is probably insufficient. J's 2026-04-24
|
// local-model analysis is probably insufficient. J's 2026-04-24
|
||||||
// direction: "the observer would trigger to give more context" —
|
// direction: "the observer would trigger to give more context" —
|
||||||
// route failure clusters to LLM Team's specialized code_review mode
|
// route failure clusters to LLM Team's specialized code_review mode
|
||||||
// (via /api/run) so richer structured signal lands in the KB for
|
// (via /api/run) so richer structured signal lands in the KB for
|
||||||
// scrum + auditor + playbook memory to consume next pass.
|
// scrum + auditor + playbook memory to consume next pass.
|
||||||
//
|
//
|
||||||
// Non-destructive: runs in parallel to the existing qwen2.5 analysis,
|
// Non-destructive: runs in parallel to the existing local diagnose
|
||||||
// never replaces it. Writes to data/_kb/observer_escalations.jsonl
|
// call (qwen3.5:latest after the 2026-04-30 bump), never replaces
|
||||||
// as a dedicated audit surface.
|
// 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 = process.env.LH_LLM_TEAM_URL ?? "http://localhost:5000";
|
||||||
const LLM_TEAM_ESCALATIONS = "/home/profit/lakehouse/data/_kb/observer_escalations.jsonl";
|
const LLM_TEAM_ESCALATIONS = "/home/profit/lakehouse/data/_kb/observer_escalations.jsonl";
|
||||||
@@ -542,7 +543,7 @@ async function analyzeErrors() {
|
|||||||
if (failures.length === 0) return;
|
if (failures.length === 0) return;
|
||||||
|
|
||||||
// NEW 2026-04-24: escalate recurring sig_hash clusters to LLM Team
|
// NEW 2026-04-24: escalate recurring sig_hash clusters to LLM Team
|
||||||
// code_review mode. Runs in parallel to the local qwen2.5 analysis
|
// code_review mode. Runs in parallel to the local diagnose call
|
||||||
// below — non-blocking, richer downstream signal for scrum/auditor.
|
// below — non-blocking, richer downstream signal for scrum/auditor.
|
||||||
maybeEscalate(failures).catch(() => {});
|
maybeEscalate(failures).catch(() => {});
|
||||||
|
|
||||||
@@ -552,13 +553,14 @@ async function analyzeErrors() {
|
|||||||
|
|
||||||
// Ask local model to diagnose. Phase 44 migration (2026-04-27):
|
// Ask local model to diagnose. Phase 44 migration (2026-04-27):
|
||||||
// /v1/chat instead of legacy /ai/generate so /v1/usage tracks the
|
// /v1/chat instead of legacy /ai/generate so /v1/usage tracks the
|
||||||
// call + Langfuse traces it. Same upstream model (qwen2.5 local).
|
// call + Langfuse traces it. 2026-04-30 model bump: qwen2.5 →
|
||||||
|
// qwen3.5:latest to match the small-model-pipeline local-tier default.
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${LAKEHOUSE}/v1/chat`, {
|
const resp = await fetch(`${LAKEHOUSE}/v1/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: "qwen2.5",
|
model: "qwen3.5:latest",
|
||||||
provider: "ollama",
|
provider: "ollama",
|
||||||
messages: [{
|
messages: [{
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -769,7 +771,7 @@ async function tailOverseerCorrections(): Promise<number> {
|
|||||||
try { row = JSON.parse(line); } catch { continue; }
|
try { row = JSON.parse(line); } catch { continue; }
|
||||||
const op: ObservedOp = {
|
const op: ObservedOp = {
|
||||||
timestamp: row.created_at ?? new Date().toISOString(),
|
timestamp: row.created_at ?? new Date().toISOString(),
|
||||||
endpoint: `overseer:${row.model ?? "gpt-oss:120b"}`,
|
endpoint: `overseer:${row.model ?? "claude-opus-4-7"}`,
|
||||||
input_summary: `${row.task_class ?? "?"}: ${row.reason ?? "escalation"}`,
|
input_summary: `${row.task_class ?? "?"}: ${row.reason ?? "escalation"}`,
|
||||||
// Correction itself is neither success nor failure — it's a
|
// Correction itself is neither success nor failure — it's a
|
||||||
// mitigation attempt. We mark success=true so analyzeErrors
|
// mitigation attempt. We mark success=true so analyzeErrors
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 6 — Acceptance Gate Report
|
# Phase 6 — Acceptance Gate Report
|
||||||
|
|
||||||
**Run:** 2026-04-27T04:54:32.225Z
|
**Run:** 2026-04-27T15:43:37.943Z
|
||||||
**Fixture:** `tests/fixtures/distillation/acceptance/`
|
**Fixture:** `tests/fixtures/distillation/acceptance/`
|
||||||
**Temp root:** `/tmp/distillation_phase6_acceptance`
|
**Temp root:** `/tmp/distillation_phase6_acceptance`
|
||||||
**Pipeline run_ids:** `acceptance-run-1-stable` (first) + `acceptance-run-2-stable` (second / hash reproducibility)
|
**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 | ✓ |
|
| 19 | scratchpad/tree-split case: fixture row materialized into evidence | found | found | ✓ |
|
||||||
| 20 | PRD drift case: fixture row materialized | 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 | ✓ |
|
| 21 | hash reproducibility: per-stage output_hash identical across runs | 0 mismatches | all match | ✓ |
|
||||||
| 22 | hash reproducibility: run_hash identical | 3ea12b160ee9099a... | 3ea12b160ee9099a... | ✓ |
|
| 22 | hash reproducibility: run_hash identical | 8dfdacee62380ec2... | 8dfdacee62380ec2... | ✓ |
|
||||||
|
|
||||||
## Hash reproducibility detail
|
## Hash reproducibility detail
|
||||||
|
|
||||||
run 1 run_hash: `3ea12b160ee9099a3c52fe6e7fffd3076de7920d2704d24c789260d63cb1a5a2`
|
run 1 run_hash: `8dfdacee62380ec20b7420d8f8bad3c395822da6eb0b41eeecd356e88fe20bf0`
|
||||||
|
|
||||||
run 2 run_hash: `3ea12b160ee9099a3c52fe6e7fffd3076de7920d2704d24c789260d63cb1a5a2`
|
run 2 run_hash: `8dfdacee62380ec20b7420d8f8bad3c395822da6eb0b41eeecd356e88fe20bf0`
|
||||||
|
|
||||||
**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.
|
**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
|
# Phase 8 — Full System Audit Report
|
||||||
|
|
||||||
**Run:** 2026-04-27T04:54:32.283Z
|
**Run:** 2026-04-27T15:43:38.021Z
|
||||||
**Git commit:** 73f242e3e41c2aa36b35fe9de54742b248915cb5
|
**Git commit:** ca7375ea2b178159a0c61bbf62788a2ffa2390e9
|
||||||
**Baseline:** 2026-04-27T04:53:45.796Z (5bdd159966e6)
|
**Baseline:** 2026-04-27T10:31:44.043Z (d11632a6fae6)
|
||||||
|
|
||||||
## Result: **PASS** ✓
|
## Result: **PASS** ✓
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
| 1 | P0 | recon doc exists | Y | docs/recon/local-distillation-recon.md present | present | ✓ |
|
| 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 | ✓ |
|
| 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 | ✓ |
|
| 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 | 1073 read · 16 written · 2 skipped | ✓ |
|
| 4 | P2 | materializer dry-run completes | Y | >=1 row from each tier-1 source | 1139 read · 82 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) | ✓ |
|
| 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 | ✓ |
|
| 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 | ✓ |
|
| 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 | ✓ |
|
| 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 | ✓ |
|
| 14 | P5 | every stage receipt validates against schema | Y | 0 invalid | 0 invalid | ✓ |
|
||||||
| 15 | P5 | RunSummary validates | Y | valid | valid | ✓ |
|
| 15 | P5 | RunSummary validates | Y | valid | valid | ✓ |
|
||||||
| 16 | P5 | summary.git_commit is 40-char hex | — | match | 68b6697bcb38... (HEAD: 73f242e3e41c...) | ✓ |
|
| 16 | P5 | summary.git_commit is 40-char hex | — | match | 68b6697bcb38... (HEAD: ca7375ea2b17...) | ✓ |
|
||||||
| 17 | P5 | run_hash is sha256 | Y | /^[0-9a-f]{64}$/ | 2336b96c3638982d... | ✓ |
|
| 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) | ✓ |
|
| 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 | ✓ |
|
| 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 | ✓ |
|
| 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 | ✓ |
|
| 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 | 21 rows total | ✓ |
|
| 22 | P7 | replay_runs.jsonl populated by audit run | — | exists with ≥3 rows added | 27 rows total | ✓ |
|
||||||
|
|
||||||
## Drift vs prior baseline
|
## Drift vs prior baseline
|
||||||
|
|
||||||
| Metric | Baseline | Current | Δ% | Flag |
|
| Metric | Baseline | Current | Δ% | Flag |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| p2_evidence_rows | 15 | 16 | 7% | ok |
|
| p2_evidence_rows | 25 | 82 | 228% | warn |
|
||||||
| p2_evidence_skips | 2 | 2 | 0% | ok |
|
| p2_evidence_skips | 2 | 2 | 0% | ok |
|
||||||
| p3_accepted | 386 | 386 | 0% | ok |
|
| p3_accepted | 386 | 386 | 0% | ok |
|
||||||
| p3_partial | 132 | 132 | 0% | ok |
|
| p3_partial | 132 | 132 | 0% | ok |
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
| p4_pref_pairs | 83 | 83 | 0% | ok |
|
| p4_pref_pairs | 83 | 83 | 0% | ok |
|
||||||
| p4_total_quarantined | 1325 | 1325 | 0% | ok |
|
| p4_total_quarantined | 1325 | 1325 | 0% | ok |
|
||||||
|
|
||||||
All metrics within 20% of baseline — pipeline stable across runs.
|
**1 metric(s) drifted >20% from baseline.** Investigate before treating outputs as stable.
|
||||||
|
|
||||||
## System health status
|
## System health status
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 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'
|
||||||
|
```
|
||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/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,9 +1143,15 @@ Format each as a code-fenced block with the byte offset within the shard:
|
|||||||
EXACT LINE OF SOURCE — DO NOT PARAPHRASE, DO NOT TRUNCATE
|
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.`;
|
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({
|
const r = await chat({
|
||||||
provider: "ollama_cloud",
|
provider: "ollama_cloud",
|
||||||
model: "gpt-oss:120b",
|
model: "gemini-3-flash-preview",
|
||||||
prompt,
|
prompt,
|
||||||
max_tokens: 900,
|
max_tokens: 900,
|
||||||
});
|
});
|
||||||
@@ -1195,9 +1201,14 @@ 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.`;
|
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({
|
const reduced = await chat({
|
||||||
provider: "ollama_cloud",
|
provider: "ollama_cloud",
|
||||||
model: "gpt-oss:120b",
|
model: "gemini-3-flash-preview",
|
||||||
prompt: reducePrompt,
|
prompt: reducePrompt,
|
||||||
max_tokens: 2400,
|
max_tokens: 2400,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user