Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
858954975b | ||
|
|
4a94da2d41 | ||
|
|
4087dde780 | ||
|
|
951c6014ec | ||
|
|
fee094f653 | ||
|
|
91a38dc20b | ||
|
|
6532938e85 | ||
|
|
de8fb10f52 | ||
|
|
0b3bd28cf8 | ||
|
|
b5b0c00efe | ||
|
|
2f1b9c9768 | ||
|
|
021c1b557f | ||
|
|
049a4b69fb | ||
|
|
ed85620558 | ||
|
|
08cc960115 | ||
|
|
24b06d80b2 | ||
|
|
999abd6999 | ||
|
|
0cf1b7c45a | ||
|
|
81bae108f4 | ||
|
|
5df4d48109 | ||
|
|
ffdc842ec3 | ||
|
|
12e615bb5d | ||
|
|
a934a76988 | ||
|
|
cdc24d8bd0 | ||
|
|
fdc5123f6d | ||
|
|
51a1aa3ddc | ||
|
|
d122703e9a | ||
|
|
3963b28b50 | ||
|
|
c47523e5bd | ||
|
|
fd92a9a0d0 | ||
|
|
f4cff660aa | ||
|
|
ee31424d0c | ||
|
|
0a0843b605 | ||
|
|
92df0e930a | ||
|
|
86901f8def | ||
|
|
2f8b347f37 | ||
|
|
9cc0ceb894 | ||
|
|
5e8d87bf34 | ||
|
|
25ea3de836 | ||
|
|
96b46cdb91 | ||
|
|
8b77d67c9c |
@@ -7,6 +7,14 @@
|
|||||||
"LAKEHOUSE_URL": "http://localhost:3100",
|
"LAKEHOUSE_URL": "http://localhost:3100",
|
||||||
"MCP_TRANSPORT": "stdio"
|
"MCP_TRANSPORT": "stdio"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"command": "bunx",
|
||||||
|
"args": ["gitea-mcp"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_HOST": "https://git.agentview.dev",
|
||||||
|
"GITEA_ACCESS_TOKEN": "SET_ME_FROM_GITEA_UI_USER_SETTINGS_APPLICATIONS"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+12
@@ -6898,6 +6898,7 @@ dependencies = [
|
|||||||
"storaged",
|
"storaged",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"truth",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8905,6 +8906,17 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "validator"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ members = [
|
|||||||
"crates/lance-bench",
|
"crates/lance-bench",
|
||||||
"crates/vectord-lance",
|
"crates/vectord-lance",
|
||||||
"crates/truth",
|
"crates/truth",
|
||||||
|
"crates/validator",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Phase 39: Provider Registry
|
||||||
|
#
|
||||||
|
# Per-provider base_url, auth scheme, and default model. The gateway's
|
||||||
|
# /v1/chat dispatcher reads this file at boot to populate its provider
|
||||||
|
# table. Secrets (API keys) come from /etc/lakehouse/secrets.toml or
|
||||||
|
# environment variables — NEVER inline a key here.
|
||||||
|
#
|
||||||
|
# Adding a new provider:
|
||||||
|
# 1. New [[provider]] block with name, base_url, auth, default_model
|
||||||
|
# 2. Matching adapter at crates/aibridge/src/providers/<name>.rs
|
||||||
|
# implementing the ProviderAdapter trait (chat + embed + unload)
|
||||||
|
# 3. Route arm in crates/gateway/src/v1/mod.rs matching on `name`
|
||||||
|
# 4. Model-prefix routing hint in resolve_provider() if the provider
|
||||||
|
# uses an "<name>/..." model prefix (e.g. "openrouter/...")
|
||||||
|
|
||||||
|
[[provider]]
|
||||||
|
name = "ollama"
|
||||||
|
base_url = "http://localhost:3200"
|
||||||
|
auth = "none"
|
||||||
|
default_model = "qwen3.5:latest"
|
||||||
|
# Hot-path local inference. No bearer needed — Python sidecar on
|
||||||
|
# localhost handles the Ollama API. Model names are bare
|
||||||
|
# (e.g. "qwen3.5:latest", not "ollama/qwen3.5:latest").
|
||||||
|
|
||||||
|
[[provider]]
|
||||||
|
name = "ollama_cloud"
|
||||||
|
base_url = "https://ollama.com"
|
||||||
|
auth = "bearer"
|
||||||
|
auth_env = "OLLAMA_CLOUD_KEY"
|
||||||
|
default_model = "gpt-oss:120b"
|
||||||
|
# Cloud-tier Ollama. Key resolved from OLLAMA_CLOUD_KEY env at gateway
|
||||||
|
# boot. Model-prefix routing: "cloud/<model>" auto-routes here
|
||||||
|
# (see gateway::v1::resolve_provider).
|
||||||
|
|
||||||
|
[[provider]]
|
||||||
|
name = "openrouter"
|
||||||
|
base_url = "https://openrouter.ai/api/v1"
|
||||||
|
auth = "bearer"
|
||||||
|
auth_env = "OPENROUTER_API_KEY"
|
||||||
|
auth_fallback_files = ["/home/profit/.env", "/root/llm_team_config.json"]
|
||||||
|
default_model = "openai/gpt-oss-120b:free"
|
||||||
|
# Multi-provider gateway. Covers Anthropic, Google, OpenAI, MiniMax,
|
||||||
|
# Qwen, Gemma, etc. Key resolved via crates/gateway/src/v1/openrouter.rs
|
||||||
|
# resolve_openrouter_key() — env first, then fallback files.
|
||||||
|
# Model-prefix routing: "openrouter/<vendor>/<model>" auto-routes here,
|
||||||
|
# prefix stripped before upstream call.
|
||||||
|
|
||||||
|
# Planned (Phase 40 long-horizon — adapters not yet shipped):
|
||||||
|
#
|
||||||
|
# [[provider]]
|
||||||
|
# name = "gemini"
|
||||||
|
# base_url = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
# auth = "api_key_query"
|
||||||
|
# auth_env = "GEMINI_API_KEY"
|
||||||
|
# default_model = "gemini-2.0-flash"
|
||||||
|
#
|
||||||
|
# [[provider]]
|
||||||
|
# name = "claude"
|
||||||
|
# base_url = "https://api.anthropic.com/v1"
|
||||||
|
# auth = "x_api_key"
|
||||||
|
# auth_env = "ANTHROPIC_API_KEY"
|
||||||
|
# default_model = "claude-3-5-sonnet-latest"
|
||||||
@@ -13,11 +13,9 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// Rough token count. `chars / 4` ceiling. See module docs for why
|
// `estimate_tokens` moved to `shared::model_matrix::ModelMatrix::estimate_tokens`
|
||||||
/// this heuristic is sufficient.
|
// (cdc24d8). All callers migrated; the deprecated wrapper that stood in its
|
||||||
pub fn estimate_tokens(text: &str) -> usize {
|
// place has been removed since it had zero external consumers.
|
||||||
(text.chars().count() + 3) / 4
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 21 — per-model context windows, mirroring the TS table in
|
/// Phase 21 — per-model context windows, mirroring the TS table in
|
||||||
/// `tests/multi-agent/agent.ts`. Anchored on each model's documented
|
/// `tests/multi-agent/agent.ts`. Anchored on each model's documented
|
||||||
@@ -84,8 +82,8 @@ pub fn assert_context_budget(
|
|||||||
let window = context_window_for(model);
|
let window = context_window_for(model);
|
||||||
let safety = opts.safety_margin.unwrap_or(DEFAULT_SAFETY_MARGIN);
|
let safety = opts.safety_margin.unwrap_or(DEFAULT_SAFETY_MARGIN);
|
||||||
let max_tokens = opts.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS);
|
let max_tokens = opts.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS);
|
||||||
let sys_tokens = opts.system.map(estimate_tokens).unwrap_or(0);
|
let sys_tokens = opts.system.map(shared::model_matrix::ModelMatrix::estimate_tokens).unwrap_or(0);
|
||||||
let estimated = estimate_tokens(prompt) + sys_tokens + max_tokens;
|
let estimated = shared::model_matrix::ModelMatrix::estimate_tokens(prompt) + sys_tokens + max_tokens;
|
||||||
let remaining = window as i64 - estimated as i64 - safety as i64;
|
let remaining = window as i64 - estimated as i64 - safety as i64;
|
||||||
let check = BudgetCheck { estimated, window, remaining };
|
let check = BudgetCheck { estimated, window, remaining };
|
||||||
if remaining < 0 && !opts.bypass {
|
if remaining < 0 && !opts.bypass {
|
||||||
@@ -109,14 +107,10 @@ pub fn overflow_message(model: &str, check: &BudgetCheck, over_by: usize, safety
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
// Deprecated-function behavior is now canonically tested in
|
||||||
fn estimate_tokens_ceiling_divides_by_four() {
|
// crates/shared/src/model_matrix.rs. This test was the legacy
|
||||||
assert_eq!(estimate_tokens(""), 0);
|
// pin that preceded the migration; delete when the deprecated
|
||||||
assert_eq!(estimate_tokens("abc"), 1); // 3 → ceil(3/4) = 1
|
// wrapper itself goes (see the #[deprecated] attribute).
|
||||||
assert_eq!(estimate_tokens("abcd"), 1); // 4 → ceil(4/4) = 1
|
|
||||||
assert_eq!(estimate_tokens("abcde"), 2); // 5 → ceil(5/4) = 2
|
|
||||||
assert_eq!(estimate_tokens(&"x".repeat(400)), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn context_window_known_and_fallback() {
|
fn context_window_known_and_fallback() {
|
||||||
@@ -179,7 +173,7 @@ mod tests {
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
assert!(with_sys.estimated > without_sys.estimated,
|
assert!(with_sys.estimated > without_sys.estimated,
|
||||||
"system prompt should raise estimate");
|
"system prompt should raise estimate");
|
||||||
assert_eq!(with_sys.estimated - without_sys.estimated, estimate_tokens(&sys));
|
assert_eq!(with_sys.estimated - without_sys.estimated, shared::model_matrix::ModelMatrix::estimate_tokens(&sys));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -70,15 +70,19 @@ pub struct RouteDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn glob_match(pattern: &str, name: &str) -> bool {
|
fn glob_match(pattern: &str, name: &str) -> bool {
|
||||||
if pattern.contains('*') {
|
if !pattern.contains('*') { return pattern == name; }
|
||||||
let parts: Vec<&str> = pattern.split('*').collect();
|
let parts: Vec<&str> = pattern.split('*').collect();
|
||||||
if parts.len() == 2 {
|
// Multi-* support: first must be prefix, last must be suffix, each
|
||||||
return name.starts_with(parts[0]) && name.ends_with(parts[1]);
|
// interior piece must appear in order. Fixes the iter-9 finding
|
||||||
} else if parts.len() == 1 {
|
// where gpt-*-large* silently fell through to an exact-match path.
|
||||||
return name.starts_with(parts[0]) || name.ends_with(parts[1]);
|
// Also removes the dead `parts.len() == 1` branch that accessed
|
||||||
}
|
// parts[1] and would panic if ever reached (unreachable today
|
||||||
}
|
// since split('*') on a string containing '*' always yields ≥2).
|
||||||
pattern == name
|
if !name.starts_with(parts[0]) || !name.ends_with(parts.last().unwrap()) { return false; }
|
||||||
|
let mut cursor = parts[0].len();
|
||||||
|
parts[1..parts.len() - 1].iter().all(|mid| {
|
||||||
|
name[cursor..].find(mid).map(|pos| { cursor += pos + mid.len(); true }).unwrap_or(false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RoutingRule {
|
impl Default for RoutingRule {
|
||||||
@@ -91,3 +95,25 @@ impl Default for RoutingRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod glob_match_tests {
|
||||||
|
use super::glob_match;
|
||||||
|
|
||||||
|
#[test] fn exact_match() { assert!(glob_match("gpt-oss:120b", "gpt-oss:120b")); }
|
||||||
|
#[test] fn exact_mismatch() { assert!(!glob_match("a", "b")); }
|
||||||
|
#[test] fn leading_wildcard() { assert!(glob_match("*:120b", "gpt-oss:120b")); }
|
||||||
|
#[test] fn trailing_wildcard() { assert!(glob_match("gpt-oss:*", "gpt-oss:120b")); }
|
||||||
|
#[test] fn bare_wildcard() { assert!(glob_match("*", "anything")); }
|
||||||
|
#[test] fn multi_wildcard_in_order() { assert!(glob_match("gpt-*-oss-*", "gpt-4-oss-120b")); }
|
||||||
|
#[test] fn multi_wildcard_wrong_order() { assert!(!glob_match("b*a*", "abba")); }
|
||||||
|
#[test] fn multi_wildcard_panic_safety() {
|
||||||
|
// Regression: earlier impl had an unreachable `parts.len() == 1`
|
||||||
|
// branch that indexed parts[1] — would panic if ever hit. Now
|
||||||
|
// the split('*') invariant guarantees ≥2 parts when * present,
|
||||||
|
// and we handle all N-part cases explicitly.
|
||||||
|
assert!(glob_match("a*b*c", "abc"));
|
||||||
|
assert!(glob_match("a*b*c", "axxxbxxxc"));
|
||||||
|
assert!(!glob_match("a*b*c", "xxxbxxx"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,9 @@
|
|||||||
//! we bubble the error up rather than silently truncating. That's the
|
//! we bubble the error up rather than silently truncating. That's the
|
||||||
//! whole point of Phase 21.
|
//! whole point of Phase 21.
|
||||||
|
|
||||||
use crate::context::{assert_context_budget, BudgetOpts, estimate_tokens, overflow_message,
|
use crate::context::{assert_context_budget, BudgetOpts, overflow_message,
|
||||||
DEFAULT_MAX_TOKENS, DEFAULT_SAFETY_MARGIN};
|
DEFAULT_MAX_TOKENS, DEFAULT_SAFETY_MARGIN};
|
||||||
|
use shared::model_matrix::ModelMatrix;
|
||||||
use crate::continuation::{generate_continuable, ContinuableOpts, ResponseShape, TextGenerator};
|
use crate::continuation::{generate_continuable, ContinuableOpts, ResponseShape, TextGenerator};
|
||||||
|
|
||||||
/// Callback signatures — caller supplies closures that stitch the
|
/// Callback signatures — caller supplies closures that stitch the
|
||||||
@@ -80,12 +81,12 @@ pub struct TreeSplitResult {
|
|||||||
/// by `\n— shard N/M digest —\n` so we can find the first one and
|
/// by `\n— shard N/M digest —\n` so we can find the first one and
|
||||||
/// chop everything before its successor.
|
/// chop everything before its successor.
|
||||||
fn truncate_scratchpad(scratchpad: &mut String, budget_tokens: usize) -> bool {
|
fn truncate_scratchpad(scratchpad: &mut String, budget_tokens: usize) -> bool {
|
||||||
if estimate_tokens(scratchpad) <= budget_tokens { return false; }
|
if ModelMatrix::estimate_tokens(scratchpad) <= budget_tokens { return false; }
|
||||||
// Find the second delimiter — everything before it gets dropped.
|
// Find the second delimiter — everything before it gets dropped.
|
||||||
const DELIM_PREFIX: &str = "\n— shard ";
|
const DELIM_PREFIX: &str = "\n— shard ";
|
||||||
let mut cursor = 0;
|
let mut cursor = 0;
|
||||||
let mut truncated = false;
|
let mut truncated = false;
|
||||||
while estimate_tokens(&scratchpad[cursor..]) > budget_tokens {
|
while ModelMatrix::estimate_tokens(&scratchpad[cursor..]) > budget_tokens {
|
||||||
// Skip past a leading delimiter (if we're sitting on one from
|
// Skip past a leading delimiter (if we're sitting on one from
|
||||||
// a previous iteration), then find the next.
|
// a previous iteration), then find the next.
|
||||||
let search_from = cursor + if scratchpad[cursor..].starts_with(DELIM_PREFIX) {
|
let search_from = cursor + if scratchpad[cursor..].starts_with(DELIM_PREFIX) {
|
||||||
@@ -278,7 +279,7 @@ mod tests {
|
|||||||
// Scratchpad should still fit roughly within the budget
|
// Scratchpad should still fit roughly within the budget
|
||||||
// (post-truncation); the estimator uses chars/4 so the bound
|
// (post-truncation); the estimator uses chars/4 so the bound
|
||||||
// is ~budget*4 chars. Give some slack for the delimiter.
|
// is ~budget*4 chars. Give some slack for the delimiter.
|
||||||
let scratchpad_tokens = estimate_tokens(&result.scratchpad);
|
let scratchpad_tokens = ModelMatrix::estimate_tokens(&result.scratchpad);
|
||||||
assert!(scratchpad_tokens <= opts.scratchpad_budget * 2,
|
assert!(scratchpad_tokens <= opts.scratchpad_budget * 2,
|
||||||
"scratchpad {} tokens vs budget {}", scratchpad_tokens, opts.scratchpad_budget);
|
"scratchpad {} tokens vs budget {}", scratchpad_tokens, opts.scratchpad_budget);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ impl AccessControl {
|
|||||||
self.roles.write().await.insert(role.agent_name.clone(), role);
|
self.roles.write().await.insert(role.agent_name.clone(), role);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get an agent's role.
|
/// Get an agent's role. Called by `GET /access/roles/{agent}`.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn get_role(&self, agent: &str) -> Option<AgentRole> {
|
pub async fn get_role(&self, agent: &str) -> Option<AgentRole> {
|
||||||
self.roles.read().await.get(agent).cloned()
|
self.roles.read().await.get(agent).cloned()
|
||||||
}
|
}
|
||||||
@@ -152,7 +151,9 @@ impl AccessControl {
|
|||||||
log[start..].iter().rev().cloned().collect()
|
log[start..].iter().rev().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// Reports whether access-control enforcement is active.
|
||||||
|
/// Called by `GET /access/enabled` — ops tooling / dashboards poll
|
||||||
|
/// this to confirm the auth posture of the running gateway.
|
||||||
pub fn is_enabled(&self) -> bool {
|
pub fn is_enabled(&self) -> bool {
|
||||||
self.enabled
|
self.enabled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
@@ -13,6 +13,12 @@ pub fn router(ac: AccessControl) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/roles", get(list_roles))
|
.route("/roles", get(list_roles))
|
||||||
.route("/roles", post(set_role))
|
.route("/roles", post(set_role))
|
||||||
|
// Scrum iter 11 / P13-001 finding: get_role was #[allow(dead_code)]
|
||||||
|
// because nothing called it — dead until exposed. Route activates it.
|
||||||
|
// Returns 404 when the agent isn't registered so clients can
|
||||||
|
// distinguish "missing role" from "access denied."
|
||||||
|
.route("/roles/{agent}", get(get_role))
|
||||||
|
.route("/enabled", get(enabled_status))
|
||||||
.route("/audit", get(query_audit))
|
.route("/audit", get(query_audit))
|
||||||
.route("/check", post(check_access))
|
.route("/check", post(check_access))
|
||||||
.with_state(ac)
|
.with_state(ac)
|
||||||
@@ -60,3 +66,17 @@ async fn check_access(
|
|||||||
"allowed": allowed,
|
"allowed": allowed,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_role(
|
||||||
|
State(ac): State<AccessControl>,
|
||||||
|
Path(agent): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match ac.get_role(&agent).await {
|
||||||
|
Some(role) => Ok(Json(role)),
|
||||||
|
None => Err((StatusCode::NOT_FOUND, format!("no role registered for agent '{agent}'"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enabled_status(State(ac): State<AccessControl>) -> impl IntoResponse {
|
||||||
|
Json(serde_json::json!({ "enabled": ac.is_enabled() }))
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ pub struct Fill {
|
|||||||
pub enum RespondOutcome {
|
pub enum RespondOutcome {
|
||||||
Ok { artifact: serde_json::Value, log: Vec<LogEntry> },
|
Ok { artifact: serde_json::Value, log: Vec<LogEntry> },
|
||||||
Failed { reason: String, log: Vec<LogEntry> },
|
Failed { reason: String, log: Vec<LogEntry> },
|
||||||
#[allow(dead_code)] // reserved for Phase 42 truth-gate (step 6)
|
// Constructed by the truth-gate check in run_inner (step 6, 2026-04-24).
|
||||||
Blocked { reason: String, log: Vec<LogEntry> },
|
Blocked { reason: String, log: Vec<LogEntry> },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +153,21 @@ impl ExecutionLoop {
|
|||||||
.as_deref().unwrap_or(DEFAULT_REVIEWER_MODEL).to_string();
|
.as_deref().unwrap_or(DEFAULT_REVIEWER_MODEL).to_string();
|
||||||
let max_turns = self.req.max_turns.unwrap_or(DEFAULT_MAX_TURNS);
|
let max_turns = self.req.max_turns.unwrap_or(DEFAULT_MAX_TURNS);
|
||||||
|
|
||||||
// --- (6) TRUTH GATE — PORT FROM Phase 42 (TODO) ---
|
// --- (6) TRUTH GATE — Phase 42 wiring (2026-04-24) ---
|
||||||
|
// Evaluate truth rules for the request's task_class against a
|
||||||
|
// ctx built from the spec. Any rule whose condition holds AND
|
||||||
|
// whose action is Reject/Block short-circuits to Blocked before
|
||||||
|
// the executor loop runs. Mirrors queryd/service.rs SQL gate.
|
||||||
|
let truth_store = truth::default_truth_store();
|
||||||
|
for outcome in truth_store.evaluate(&self.req.task_class, &self.req.spec) {
|
||||||
|
if !outcome.passed { continue; }
|
||||||
|
if let truth::RuleAction::Reject { message } | truth::RuleAction::Block { message } = &outcome.action {
|
||||||
|
let reason = format!("truth rule {} blocked: {message}", outcome.rule_id);
|
||||||
|
self.append(LogEntry::new(0, "system", "truth", "block",
|
||||||
|
serde_json::json!({ "rule_id": outcome.rule_id, "reason": reason.clone() })));
|
||||||
|
return Ok(RespondOutcome::Blocked { reason, log: self.log.clone() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- (1) PLAYBOOK BOOST ---
|
// --- (1) PLAYBOOK BOOST ---
|
||||||
let boost = self.fetch_playbook_boost(&self.req.operation).await.unwrap_or_default();
|
let boost = self.fetch_playbook_boost(&self.req.operation).await.unwrap_or_default();
|
||||||
@@ -1533,8 +1547,15 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let p = build_executor_prompt(&req, &[], &log);
|
let p = build_executor_prompt(&req, &[], &log);
|
||||||
assert!(p.contains("CANDIDATES SURFACED SO FAR"));
|
assert!(p.contains("CANDIDATES SURFACED SO FAR"));
|
||||||
assert!(p.contains("W-1 Alice Smith"));
|
// Prompt format deliberately separates name from doc_id now —
|
||||||
assert!(p.contains("Toledo, OH"));
|
// the line reads `name="Alice Smith" ... (vector doc_id=W-1)`
|
||||||
|
// so the executor prompt explicitly tells the model NOT to
|
||||||
|
// conflate doc_id with workers_500k.worker_id. Assertion was
|
||||||
|
// expecting the old concatenated format; update to match the
|
||||||
|
// semantic contract (both tokens present, any order).
|
||||||
|
assert!(p.contains("Alice Smith"));
|
||||||
|
assert!(p.contains("W-1"));
|
||||||
|
assert!(p.contains("Toledo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -68,6 +68,27 @@ async fn main() {
|
|||||||
let access = access::AccessControl::new(config.auth.enabled);
|
let access = access::AccessControl::new(config.auth.enabled);
|
||||||
access.register_defaults().await;
|
access.register_defaults().await;
|
||||||
|
|
||||||
|
// Phase 42 — file-backed truth rules. Probes the `truth/` directory
|
||||||
|
// at repo root (or $LAKEHOUSE_TRUTH_DIR override) and logs how many
|
||||||
|
// rules load. Current request paths still build their own stores
|
||||||
|
// via truth::default_truth_store() / truth::sql_query_guard_store();
|
||||||
|
// the composed-at-boot store gets plumbed through V1State in a
|
||||||
|
// follow-up. This boot probe catches parse errors + duplicate-ID
|
||||||
|
// collisions early rather than at first request.
|
||||||
|
{
|
||||||
|
let truth_dir = std::env::var("LAKEHOUSE_TRUTH_DIR")
|
||||||
|
.unwrap_or_else(|_| "/home/profit/lakehouse/truth".to_string());
|
||||||
|
if std::path::Path::new(&truth_dir).exists() {
|
||||||
|
let mut probe_store = truth::default_truth_store();
|
||||||
|
match truth::loader::load_from_dir(&mut probe_store, &truth_dir) {
|
||||||
|
Ok(n) => tracing::info!("truth: loaded {n} file-backed rule(s) from {truth_dir}"),
|
||||||
|
Err(e) => tracing::warn!("truth: failed to load rules from {truth_dir}: {e}"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::debug!("truth: no rule dir at {truth_dir}, skipping file-backed load");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Workspace manager for agent-specific overlays
|
// Workspace manager for agent-specific overlays
|
||||||
let workspace_mgr = queryd::workspace::WorkspaceManager::new(store.clone());
|
let workspace_mgr = queryd::workspace::WorkspaceManager::new(store.clone());
|
||||||
if let Err(e) = workspace_mgr.rebuild().await {
|
if let Err(e) = workspace_mgr.rebuild().await {
|
||||||
@@ -93,6 +114,12 @@ async fn main() {
|
|||||||
// operators call POST /vectors/playbook_memory/rebuild to populate.
|
// operators call POST /vectors/playbook_memory/rebuild to populate.
|
||||||
let pbm = vectord::playbook_memory::PlaybookMemory::new(store.clone());
|
let pbm = vectord::playbook_memory::PlaybookMemory::new(store.clone());
|
||||||
let _ = pbm.load_from_storage().await;
|
let _ = pbm.load_from_storage().await;
|
||||||
|
// Pathway memory — consensus-designed sidecar for full-context
|
||||||
|
// backtracking + hot-swap of successful review pathways. Same
|
||||||
|
// load-on-boot pattern as playbook_memory: empty state is fine,
|
||||||
|
// operators start populating via scrum_master_pipeline.ts.
|
||||||
|
let pwm = vectord::pathway_memory::PathwayMemory::new(store.clone());
|
||||||
|
let _ = pwm.load_from_storage().await;
|
||||||
|
|
||||||
// Phase 16.2: spawn the autotune agent. When config.agent.enabled=false
|
// Phase 16.2: spawn the autotune agent. When config.agent.enabled=false
|
||||||
// this returns a handle that drops triggers silently — no surprise load.
|
// this returns a handle that drops triggers silently — no surprise load.
|
||||||
@@ -178,17 +205,19 @@ async fn main() {
|
|||||||
bucket_registry.clone(), index_reg.clone(),
|
bucket_registry.clone(), index_reg.clone(),
|
||||||
),
|
),
|
||||||
playbook_memory: pbm,
|
playbook_memory: pbm,
|
||||||
|
pathway_memory: pwm,
|
||||||
embed_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
|
embed_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
|
||||||
}))
|
}))
|
||||||
.nest("/workspaces", queryd::workspace_service::router(workspace_mgr))
|
.nest("/workspaces", queryd::workspace_service::router(workspace_mgr))
|
||||||
.nest("/journal", journald::service::router(journal))
|
.nest("/journal", journald::service::router(journal))
|
||||||
.nest("/access", access_service::router(access))
|
.nest("/access", access_service::router(access))
|
||||||
.nest("/tools", tools::service::router({
|
.nest("/tools", tools::service::router({
|
||||||
let tool_reg = tools::registry::ToolRegistry::new_with_defaults();
|
let tool_reg = tools::registry::ToolRegistry::new();
|
||||||
tool_reg.register_defaults().await;
|
tool_reg.register_defaults().await;
|
||||||
tools::ToolState {
|
tools::ToolState {
|
||||||
registry: tool_reg,
|
registry: tool_reg,
|
||||||
query_fn: tools::QueryExecutor::new(engine.clone()),
|
query_fn: tools::QueryExecutor::new(engine.clone()),
|
||||||
|
truth: std::sync::Arc::new(truth::sql_query_guard_store()),
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
// Phase 38 — Universal API skeleton. Thin OpenAI-compatible
|
// Phase 38 — Universal API skeleton. Thin OpenAI-compatible
|
||||||
@@ -210,6 +239,38 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
k
|
k
|
||||||
},
|
},
|
||||||
|
openrouter_key: {
|
||||||
|
// 2026-04-24 free-tier rescue rung for iter 5+. Shares
|
||||||
|
// the LLM Team UI's OPENROUTER_API_KEY so both systems
|
||||||
|
// draw from one quota.
|
||||||
|
let k = v1::openrouter::resolve_openrouter_key();
|
||||||
|
if k.is_some() {
|
||||||
|
tracing::info!("v1: OpenRouter key loaded — /v1/chat provider=openrouter enabled");
|
||||||
|
} else {
|
||||||
|
tracing::warn!("v1: no OpenRouter key — openrouter rescue rung will 503");
|
||||||
|
}
|
||||||
|
k
|
||||||
|
},
|
||||||
|
gemini_key: {
|
||||||
|
// Phase 40 provider. GEMINI_API_KEY in env or .env.
|
||||||
|
let k = v1::gemini::resolve_gemini_key();
|
||||||
|
if k.is_some() {
|
||||||
|
tracing::info!("v1: Gemini key loaded — /v1/chat provider=gemini enabled");
|
||||||
|
} else {
|
||||||
|
tracing::debug!("v1: no Gemini key — provider=gemini will 503");
|
||||||
|
}
|
||||||
|
k
|
||||||
|
},
|
||||||
|
claude_key: {
|
||||||
|
// Phase 40 provider. ANTHROPIC_API_KEY in env or .env.
|
||||||
|
let k = v1::claude::resolve_claude_key();
|
||||||
|
if k.is_some() {
|
||||||
|
tracing::info!("v1: Claude key loaded — /v1/chat provider=claude enabled");
|
||||||
|
} else {
|
||||||
|
tracing::debug!("v1: no Claude key — provider=claude will 503");
|
||||||
|
}
|
||||||
|
k
|
||||||
|
},
|
||||||
// Phase 40 early deliverable — Langfuse trace emitter.
|
// Phase 40 early deliverable — Langfuse trace emitter.
|
||||||
// Defaults match mcp-server/tracing.ts conventions so
|
// Defaults match mcp-server/tracing.ts conventions so
|
||||||
// gateway traces land in the same staffing project.
|
// gateway traces land in the same staffing project.
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ pub mod service;
|
|||||||
|
|
||||||
use queryd::context::QueryEngine;
|
use queryd::context::QueryEngine;
|
||||||
use arrow::json::writer::{JsonArray, Writer as JsonWriter};
|
use arrow::json::writer::{JsonArray, Writer as JsonWriter};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use truth::TruthStore;
|
||||||
|
|
||||||
/// State for the tool system.
|
/// State for the tool system.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ToolState {
|
pub struct ToolState {
|
||||||
pub registry: registry::ToolRegistry,
|
pub registry: registry::ToolRegistry,
|
||||||
pub query_fn: QueryExecutor,
|
pub query_fn: QueryExecutor,
|
||||||
|
/// SQL guard (shared with queryd). Mirrors the queryd /sql truth
|
||||||
|
/// gate from P42-002 (9cc0ceb) — tools also execute model-
|
||||||
|
/// originated SQL, need the same destructive-verb block.
|
||||||
|
pub truth: Arc<TruthStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps QueryEngine to provide a simple execute interface for tools.
|
/// Wraps QueryEngine to provide a simple execute interface for tools.
|
||||||
|
|||||||
@@ -67,25 +67,14 @@ pub struct ToolRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ToolRegistry {
|
impl ToolRegistry {
|
||||||
#[allow(dead_code)]
|
/// Build an empty registry. Callers in an async context should follow
|
||||||
|
/// this with `.register_defaults().await` if they want the built-in
|
||||||
|
/// staffing tools pre-installed — main.rs does exactly that.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let registry = Self {
|
Self {
|
||||||
tools: Arc::new(RwLock::new(HashMap::new())),
|
tools: Arc::new(RwLock::new(HashMap::new())),
|
||||||
audit_log: Arc::new(RwLock::new(Vec::new())),
|
audit_log: Arc::new(RwLock::new(Vec::new())),
|
||||||
};
|
|
||||||
// Register built-in staffing tools
|
|
||||||
tokio::task::block_in_place(|| {
|
|
||||||
tokio::runtime::Handle::current().block_on(registry.register_defaults())
|
|
||||||
});
|
|
||||||
registry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_with_defaults() -> Self {
|
|
||||||
let registry = Self {
|
|
||||||
tools: Arc::new(RwLock::new(HashMap::new())),
|
|
||||||
audit_log: Arc::new(RwLock::new(Vec::new())),
|
|
||||||
};
|
|
||||||
registry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register default staffing tools.
|
/// Register default staffing tools.
|
||||||
|
|||||||
@@ -92,6 +92,32 @@ async fn call_tool(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Truth gate — same contract as queryd /sql (P42-002). Rejects
|
||||||
|
// destructive verbs + empty SQL. Scrum iter 11 CF-1 + CF-2 on this
|
||||||
|
// file: tools executed model-provided SQL parameters without any
|
||||||
|
// validation. Close the gap here so the parallel surface has the
|
||||||
|
// same safety floor as queryd.
|
||||||
|
let ctx = serde_json::json!({ "sql": sql });
|
||||||
|
for outcome in state.truth.evaluate("sql_query", &ctx) {
|
||||||
|
if outcome.passed {
|
||||||
|
if let truth::RuleAction::Reject { message } | truth::RuleAction::Block { message } = &outcome.action {
|
||||||
|
tracing::warn!("tool {name}: SQL blocked by truth gate ({}): {message}", outcome.rule_id);
|
||||||
|
state.registry.log_invocation(ToolInvocation {
|
||||||
|
id: format!("inv-{}", chrono::Utc::now().timestamp_millis()),
|
||||||
|
tool_name: name.clone(),
|
||||||
|
agent: req.agent.clone(),
|
||||||
|
params: req.params.clone(),
|
||||||
|
permission: tool.permission.clone(),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
success: false,
|
||||||
|
error: Some(format!("truth gate: {message}")),
|
||||||
|
rows_returned: None,
|
||||||
|
}).await;
|
||||||
|
return Err((StatusCode::FORBIDDEN, message.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Execute via query engine
|
// Execute via query engine
|
||||||
let result = state.query_fn.execute(&sql).await;
|
let result = state.query_fn.execute(&sql).await;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
//! Claude (Anthropic) adapter.
|
||||||
|
//!
|
||||||
|
//! POST `https://api.anthropic.com/v1/messages`. Auth via `x-api-key`
|
||||||
|
//! header (not bearer) + required `anthropic-version` header. Payload
|
||||||
|
//! is NOT OpenAI-compatible — response text lives at
|
||||||
|
//! `content[0].text`. Phase 40 deliverable. System prompts travel in
|
||||||
|
//! a top-level `system` field, separate from the `messages` array.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
|
||||||
|
|
||||||
|
const CLAUDE_BASE_URL: &str = "https://api.anthropic.com/v1";
|
||||||
|
const CLAUDE_API_VERSION: &str = "2023-06-01";
|
||||||
|
const CLAUDE_TIMEOUT_SECS: u64 = 180;
|
||||||
|
|
||||||
|
pub fn resolve_claude_key() -> Option<String> {
|
||||||
|
if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
|
||||||
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
||||||
|
}
|
||||||
|
for path in ["/home/profit/.env", "/root/.env"] {
|
||||||
|
if let Ok(raw) = std::fs::read_to_string(path) {
|
||||||
|
for line in raw.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("ANTHROPIC_API_KEY=") {
|
||||||
|
let k = rest.trim().trim_matches('"').trim_matches('\'');
|
||||||
|
if !k.is_empty() { return Some(k.to_string()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chat(
|
||||||
|
key: &str,
|
||||||
|
req: &ChatRequest,
|
||||||
|
) -> Result<ChatResponse, String> {
|
||||||
|
// Strip the "claude/" prefix if the caller used the namespaced form.
|
||||||
|
let model = req.model.strip_prefix("claude/").unwrap_or(&req.model).to_string();
|
||||||
|
|
||||||
|
// Anthropic carries system prompts outside the messages array.
|
||||||
|
// Concatenate any system-role messages into a single system string;
|
||||||
|
// keep user + assistant messages in `messages`.
|
||||||
|
let mut system_parts: Vec<String> = Vec::new();
|
||||||
|
let mut msgs: Vec<AnMessage> = Vec::new();
|
||||||
|
for m in &req.messages {
|
||||||
|
if m.role == "system" {
|
||||||
|
system_parts.push(m.content.clone());
|
||||||
|
} else {
|
||||||
|
// Anthropic expects strictly "user" or "assistant"; anything
|
||||||
|
// else we normalize to "user".
|
||||||
|
let role = if m.role == "assistant" { "assistant" } else { "user" };
|
||||||
|
msgs.push(AnMessage { role: role.to_string(), content: m.content.clone() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let system = if system_parts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(system_parts.join("\n\n"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = AnChatBody {
|
||||||
|
model: model.clone(),
|
||||||
|
messages: msgs,
|
||||||
|
max_tokens: req.max_tokens.unwrap_or(800),
|
||||||
|
temperature: req.temperature.unwrap_or(0.3),
|
||||||
|
system,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(CLAUDE_TIMEOUT_SECS))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("build client: {e}"))?;
|
||||||
|
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{}/messages", CLAUDE_BASE_URL))
|
||||||
|
.header("x-api-key", key)
|
||||||
|
.header("anthropic-version", CLAUDE_API_VERSION)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("api.anthropic.com unreachable: {e}"))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_else(|_| "?".into());
|
||||||
|
return Err(format!("claude {}: {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: AnChatResponse = resp.json().await
|
||||||
|
.map_err(|e| format!("invalid claude response: {e}"))?;
|
||||||
|
|
||||||
|
let latency_ms = t0.elapsed().as_millis();
|
||||||
|
let text = parsed.content.into_iter()
|
||||||
|
.find(|b| b.block_type == "text")
|
||||||
|
.map(|b| b.text)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let prompt_tokens = parsed.usage.as_ref().map(|u| u.input_tokens).unwrap_or_else(|| {
|
||||||
|
let chars: usize = req.messages.iter().map(|m| m.content.chars().count()).sum();
|
||||||
|
((chars + 3) / 4) as u32
|
||||||
|
});
|
||||||
|
let completion_tokens = parsed.usage.as_ref().map(|u| u.output_tokens).unwrap_or_else(|| {
|
||||||
|
((text.chars().count() + 3) / 4) as u32
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "v1.chat",
|
||||||
|
provider = "claude",
|
||||||
|
model = %model,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
latency_ms = latency_ms as u64,
|
||||||
|
"claude chat completed",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ChatResponse {
|
||||||
|
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
||||||
|
object: "chat.completion",
|
||||||
|
created: chrono::Utc::now().timestamp(),
|
||||||
|
model,
|
||||||
|
choices: vec![Choice {
|
||||||
|
index: 0,
|
||||||
|
message: Message { role: "assistant".into(), content: text },
|
||||||
|
finish_reason: parsed.stop_reason.unwrap_or_else(|| "stop".into()),
|
||||||
|
}],
|
||||||
|
usage: UsageBlock {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens: prompt_tokens + completion_tokens,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Anthropic Messages API wire shapes --
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AnChatBody {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<AnMessage>,
|
||||||
|
max_tokens: u32,
|
||||||
|
temperature: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
system: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AnMessage { role: String, content: String }
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnChatResponse {
|
||||||
|
content: Vec<AnContentBlock>,
|
||||||
|
#[serde(default, rename = "stop_reason")]
|
||||||
|
stop_reason: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
usage: Option<AnUsage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnContentBlock {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
block_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnUsage { input_tokens: u32, output_tokens: u32 }
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_claude_key_does_not_panic() {
|
||||||
|
let _ = resolve_claude_key();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_body_serializes_with_separate_system() {
|
||||||
|
let body = AnChatBody {
|
||||||
|
model: "claude-3-5-sonnet-latest".into(),
|
||||||
|
messages: vec![
|
||||||
|
AnMessage { role: "user".into(), content: "hi".into() },
|
||||||
|
],
|
||||||
|
max_tokens: 800,
|
||||||
|
temperature: 0.3,
|
||||||
|
system: Some("You are helpful.".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&body).unwrap();
|
||||||
|
assert!(json.contains("\"system\":\"You are helpful.\""));
|
||||||
|
assert!(json.contains("\"messages\""));
|
||||||
|
assert!(json.contains("\"max_tokens\":800"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn body_omits_system_when_none() {
|
||||||
|
let body = AnChatBody {
|
||||||
|
model: "claude-3-5-sonnet-latest".into(),
|
||||||
|
messages: vec![AnMessage { role: "user".into(), content: "hi".into() }],
|
||||||
|
max_tokens: 800,
|
||||||
|
temperature: 0.3,
|
||||||
|
system: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&body).unwrap();
|
||||||
|
assert!(!json.contains("\"system\""), "system field should be skipped when None: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_prefix_strip_preserves_bare_names() {
|
||||||
|
let cases = [
|
||||||
|
("claude/claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"),
|
||||||
|
("claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"),
|
||||||
|
];
|
||||||
|
for (input, expected) in cases {
|
||||||
|
let out = input.strip_prefix("claude/").unwrap_or(input);
|
||||||
|
assert_eq!(out, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
//! Gemini adapter — Google's Generative Language API.
|
||||||
|
//!
|
||||||
|
//! POST `https://generativelanguage.googleapis.com/v1beta/models/
|
||||||
|
//! {model}:generateContent?key=<API_KEY>`. Auth via query-string key
|
||||||
|
//! (not bearer). Payload shape is NOT OpenAI-compatible — we map
|
||||||
|
//! messages → contents + parts, extract response from `candidates[0]
|
||||||
|
//! .content.parts[0].text`. Phase 40 deliverable; gate: `/v1/chat`
|
||||||
|
//! with a prefixed or explicit gemini model returns normally.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
|
||||||
|
|
||||||
|
const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
|
||||||
|
const GEMINI_TIMEOUT_SECS: u64 = 180;
|
||||||
|
|
||||||
|
pub fn resolve_gemini_key() -> Option<String> {
|
||||||
|
if let Ok(k) = std::env::var("GEMINI_API_KEY") {
|
||||||
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
||||||
|
}
|
||||||
|
for path in ["/home/profit/.env", "/root/.env"] {
|
||||||
|
if let Ok(raw) = std::fs::read_to_string(path) {
|
||||||
|
for line in raw.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("GEMINI_API_KEY=") {
|
||||||
|
let k = rest.trim().trim_matches('"').trim_matches('\'');
|
||||||
|
if !k.is_empty() { return Some(k.to_string()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chat(
|
||||||
|
key: &str,
|
||||||
|
req: &ChatRequest,
|
||||||
|
) -> Result<ChatResponse, String> {
|
||||||
|
// Strip the "gemini/" prefix if the caller used the namespaced form.
|
||||||
|
let model = req.model.strip_prefix("gemini/").unwrap_or(&req.model).to_string();
|
||||||
|
|
||||||
|
// Gemini splits system prompt from conversation differently.
|
||||||
|
// Simplest working mapping: concatenate any system messages at the
|
||||||
|
// top of a single user turn, then append user/assistant turns as
|
||||||
|
// separate contents entries. Covers the common single-turn case
|
||||||
|
// the scrum pipeline uses.
|
||||||
|
let mut contents: Vec<GmContent> = Vec::new();
|
||||||
|
for m in &req.messages {
|
||||||
|
let role = match m.role.as_str() {
|
||||||
|
"system" | "user" => "user",
|
||||||
|
_ => "model",
|
||||||
|
};
|
||||||
|
contents.push(GmContent {
|
||||||
|
role: role.to_string(),
|
||||||
|
parts: vec![GmPart { text: m.content.clone() }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = GmChatBody {
|
||||||
|
contents,
|
||||||
|
generation_config: GmGenerationConfig {
|
||||||
|
temperature: req.temperature.unwrap_or(0.3),
|
||||||
|
max_output_tokens: req.max_tokens.unwrap_or(800),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(GEMINI_TIMEOUT_SECS))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("build client: {e}"))?;
|
||||||
|
|
||||||
|
let url = format!("{}/models/{}:generateContent?key={}", GEMINI_BASE_URL, model, key);
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("generativelanguage.googleapis.com unreachable: {e}"))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_else(|_| "?".into());
|
||||||
|
return Err(format!("gemini {}: {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: GmChatResponse = resp.json().await
|
||||||
|
.map_err(|e| format!("invalid gemini response: {e}"))?;
|
||||||
|
|
||||||
|
let latency_ms = t0.elapsed().as_millis();
|
||||||
|
let candidate = parsed.candidates.into_iter().next()
|
||||||
|
.ok_or_else(|| "gemini returned no candidates".to_string())?;
|
||||||
|
let text = candidate.content.parts.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|p| p.text)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let prompt_tokens = parsed.usage_metadata.as_ref()
|
||||||
|
.map(|u| u.prompt_token_count)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let chars: usize = req.messages.iter().map(|m| m.content.chars().count()).sum();
|
||||||
|
((chars + 3) / 4) as u32
|
||||||
|
});
|
||||||
|
let completion_tokens = parsed.usage_metadata.as_ref()
|
||||||
|
.map(|u| u.candidates_token_count)
|
||||||
|
.unwrap_or_else(|| ((text.chars().count() + 3) / 4) as u32);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "v1.chat",
|
||||||
|
provider = "gemini",
|
||||||
|
model = %model,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
latency_ms = latency_ms as u64,
|
||||||
|
"gemini chat completed",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ChatResponse {
|
||||||
|
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
||||||
|
object: "chat.completion",
|
||||||
|
created: chrono::Utc::now().timestamp(),
|
||||||
|
model,
|
||||||
|
choices: vec![Choice {
|
||||||
|
index: 0,
|
||||||
|
message: Message { role: "assistant".into(), content: text },
|
||||||
|
finish_reason: candidate.finish_reason.unwrap_or_else(|| "stop".into()),
|
||||||
|
}],
|
||||||
|
usage: UsageBlock {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens: prompt_tokens + completion_tokens,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Gemini wire shapes --
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GmChatBody {
|
||||||
|
contents: Vec<GmContent>,
|
||||||
|
#[serde(rename = "generationConfig")]
|
||||||
|
generation_config: GmGenerationConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GmContent {
|
||||||
|
role: String,
|
||||||
|
parts: Vec<GmPart>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GmPart { text: String }
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GmGenerationConfig {
|
||||||
|
temperature: f64,
|
||||||
|
max_output_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GmChatResponse {
|
||||||
|
candidates: Vec<GmCandidate>,
|
||||||
|
#[serde(default, rename = "usageMetadata")]
|
||||||
|
usage_metadata: Option<GmUsage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GmCandidate {
|
||||||
|
content: GmContentResp,
|
||||||
|
#[serde(default, rename = "finishReason")]
|
||||||
|
finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GmContentResp { parts: Vec<GmPartResp> }
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GmPartResp { #[serde(default)] text: String }
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GmUsage {
|
||||||
|
prompt_token_count: u32,
|
||||||
|
candidates_token_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_gemini_key_does_not_panic() {
|
||||||
|
let _ = resolve_gemini_key();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_body_serializes_to_gemini_shape() {
|
||||||
|
let body = GmChatBody {
|
||||||
|
contents: vec![
|
||||||
|
GmContent {
|
||||||
|
role: "user".into(),
|
||||||
|
parts: vec![GmPart { text: "hello".into() }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generation_config: GmGenerationConfig {
|
||||||
|
temperature: 0.3,
|
||||||
|
max_output_tokens: 800,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&body).unwrap();
|
||||||
|
assert!(json.contains("\"contents\""));
|
||||||
|
assert!(json.contains("\"parts\""));
|
||||||
|
// camelCase per Gemini API
|
||||||
|
assert!(json.contains("\"generationConfig\""));
|
||||||
|
assert!(json.contains("\"maxOutputTokens\":800"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_prefix_strip_preserves_bare_names() {
|
||||||
|
let cases = [
|
||||||
|
("gemini/gemini-2.0-flash", "gemini-2.0-flash"),
|
||||||
|
("gemini-2.0-flash", "gemini-2.0-flash"),
|
||||||
|
];
|
||||||
|
for (input, expected) in cases {
|
||||||
|
let out = input.strip_prefix("gemini/").unwrap_or(input);
|
||||||
|
assert_eq!(out, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
|
|
||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod ollama_cloud;
|
pub mod ollama_cloud;
|
||||||
|
pub mod openrouter;
|
||||||
|
pub mod gemini;
|
||||||
|
pub mod claude;
|
||||||
pub mod langfuse_trace;
|
pub mod langfuse_trace;
|
||||||
pub mod respond;
|
pub mod respond;
|
||||||
pub mod truth;
|
pub mod truth;
|
||||||
@@ -36,6 +39,19 @@ pub struct V1State {
|
|||||||
/// Ollama Cloud bearer token. Loaded at startup via
|
/// Ollama Cloud bearer token. Loaded at startup via
|
||||||
/// `ollama_cloud::resolve_cloud_key()`. None = cloud routes 503.
|
/// `ollama_cloud::resolve_cloud_key()`. None = cloud routes 503.
|
||||||
pub ollama_cloud_key: Option<String>,
|
pub ollama_cloud_key: Option<String>,
|
||||||
|
/// OpenRouter bearer token — free-tier rescue rung. Loaded at
|
||||||
|
/// startup via `openrouter::resolve_openrouter_key()`. None means
|
||||||
|
/// provider="openrouter" calls 503 rather than attempt. Same key
|
||||||
|
/// sourcing as LLM Team UI so the two share one API quota.
|
||||||
|
pub openrouter_key: Option<String>,
|
||||||
|
/// Gemini API key (Google Generative Language). Loaded at startup
|
||||||
|
/// via `gemini::resolve_gemini_key()`. None = provider="gemini"
|
||||||
|
/// calls 503. Phase 40 deliverable.
|
||||||
|
pub gemini_key: Option<String>,
|
||||||
|
/// Anthropic Claude API key. Loaded at startup via
|
||||||
|
/// `claude::resolve_claude_key()`. None = provider="claude" calls
|
||||||
|
/// 503. Phase 40 deliverable.
|
||||||
|
pub claude_key: Option<String>,
|
||||||
/// Phase 40 early deliverable — Langfuse client. None = tracing
|
/// Phase 40 early deliverable — Langfuse client. None = tracing
|
||||||
/// 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.
|
||||||
@@ -78,7 +94,7 @@ pub struct Message {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
pub struct ChatRequest {
|
pub struct ChatRequest {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
@@ -134,6 +150,96 @@ pub struct UsageBlock {
|
|||||||
|
|
||||||
// -- Handlers --
|
// -- Handlers --
|
||||||
|
|
||||||
|
/// Phase 39: resolve (provider, effective_model) from a ChatRequest.
|
||||||
|
///
|
||||||
|
/// Explicit `req.provider` wins. If absent, infer from a model-name
|
||||||
|
/// prefix: "openrouter/..." → openrouter (strip prefix), "cloud/..." →
|
||||||
|
/// ollama_cloud (strip prefix). Bare names default to "ollama".
|
||||||
|
///
|
||||||
|
/// The stripped model is what the upstream adapter expects:
|
||||||
|
/// OpenRouter's API wants "openai/gpt-4o-mini", not
|
||||||
|
/// "openrouter/openai/gpt-4o-mini".
|
||||||
|
fn resolve_provider(req: &ChatRequest) -> (String, String) {
|
||||||
|
if let Some(p) = req.provider.as_deref() {
|
||||||
|
return (p.to_ascii_lowercase(), req.model.clone());
|
||||||
|
}
|
||||||
|
if let Some(rest) = req.model.strip_prefix("openrouter/") {
|
||||||
|
return ("openrouter".to_string(), rest.to_string());
|
||||||
|
}
|
||||||
|
if let Some(rest) = req.model.strip_prefix("cloud/") {
|
||||||
|
return ("ollama_cloud".to_string(), rest.to_string());
|
||||||
|
}
|
||||||
|
if let Some(rest) = req.model.strip_prefix("gemini/") {
|
||||||
|
return ("gemini".to_string(), rest.to_string());
|
||||||
|
}
|
||||||
|
if let Some(rest) = req.model.strip_prefix("claude/") {
|
||||||
|
return ("claude".to_string(), rest.to_string());
|
||||||
|
}
|
||||||
|
("ollama".to_string(), req.model.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod resolve_provider_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mk_req(provider: Option<&str>, model: &str) -> ChatRequest {
|
||||||
|
ChatRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages: vec![],
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: None,
|
||||||
|
think: None,
|
||||||
|
provider: provider.map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_provider_wins() {
|
||||||
|
let r = mk_req(Some("openrouter"), "qwen3.5:latest");
|
||||||
|
assert_eq!(resolve_provider(&r), ("openrouter".into(), "qwen3.5:latest".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_model_defaults_to_ollama() {
|
||||||
|
let r = mk_req(None, "qwen3.5:latest");
|
||||||
|
assert_eq!(resolve_provider(&r), ("ollama".into(), "qwen3.5:latest".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openrouter_prefix_infers_and_strips() {
|
||||||
|
let r = mk_req(None, "openrouter/openai/gpt-4o-mini");
|
||||||
|
assert_eq!(resolve_provider(&r), ("openrouter".into(), "openai/gpt-4o-mini".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cloud_prefix_infers_and_strips() {
|
||||||
|
let r = mk_req(None, "cloud/kimi-k2:1t");
|
||||||
|
assert_eq!(resolve_provider(&r), ("ollama_cloud".into(), "kimi-k2:1t".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_provider_preserves_full_model_even_with_prefix() {
|
||||||
|
// If caller provides both provider and a model with a prefix,
|
||||||
|
// trust them — don't strip. The adapter will get the full model
|
||||||
|
// string as-is.
|
||||||
|
let r = mk_req(Some("openrouter"), "openrouter/openai/gpt-4o-mini");
|
||||||
|
assert_eq!(resolve_provider(&r), ("openrouter".into(), "openrouter/openai/gpt-4o-mini".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_prefix_infers_and_strips() {
|
||||||
|
let r = mk_req(None, "gemini/gemini-2.0-flash");
|
||||||
|
assert_eq!(resolve_provider(&r), ("gemini".into(), "gemini-2.0-flash".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_prefix_infers_and_strips() {
|
||||||
|
let r = mk_req(None, "claude/claude-3-5-sonnet-latest");
|
||||||
|
assert_eq!(resolve_provider(&r), ("claude".into(), "claude-3-5-sonnet-latest".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn chat(
|
async fn chat(
|
||||||
State(state): State<V1State>,
|
State(state): State<V1State>,
|
||||||
Json(req): Json<ChatRequest>,
|
Json(req): Json<ChatRequest>,
|
||||||
@@ -145,13 +251,29 @@ async fn chat(
|
|||||||
tracing::warn!("/v1/chat: stream=true requested but Phase 38 returns non-streaming");
|
tracing::warn!("/v1/chat: stream=true requested but Phase 38 returns non-streaming");
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider = req.provider.as_deref().unwrap_or("ollama").to_ascii_lowercase();
|
// Provider resolution: explicit `req.provider` wins; otherwise
|
||||||
|
// infer from a model-name prefix. Phase 39 PRD gate example:
|
||||||
|
// `model: "openrouter/openai/gpt-4o-mini"` → provider "openrouter",
|
||||||
|
// adapter gets the stripped "openai/gpt-4o-mini".
|
||||||
|
let (provider, effective_model) = resolve_provider(&req);
|
||||||
let start_time = chrono::Utc::now();
|
let start_time = chrono::Utc::now();
|
||||||
let start_instant = std::time::Instant::now();
|
let start_instant = std::time::Instant::now();
|
||||||
|
|
||||||
|
// If we stripped a prefix, clone req with the effective model so
|
||||||
|
// the adapter sees what the upstream provider expects (OpenRouter
|
||||||
|
// wants "openai/gpt-4o-mini", not "openrouter/openai/gpt-4o-mini").
|
||||||
|
let req_for_adapter: std::borrow::Cow<'_, ChatRequest> =
|
||||||
|
if effective_model == req.model {
|
||||||
|
std::borrow::Cow::Borrowed(&req)
|
||||||
|
} else {
|
||||||
|
let mut cloned = req.clone();
|
||||||
|
cloned.model = effective_model.clone();
|
||||||
|
std::borrow::Cow::Owned(cloned)
|
||||||
|
};
|
||||||
|
|
||||||
let (resp, used_provider) = match provider.as_str() {
|
let (resp, used_provider) = match provider.as_str() {
|
||||||
"ollama" | "local" | "" => {
|
"ollama" | "local" | "" => {
|
||||||
let r = ollama::chat(&state.ai_client, &req)
|
let r = ollama::chat(&state.ai_client, &*req_for_adapter)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama local: {e}")))?;
|
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama local: {e}")))?;
|
||||||
(r, "ollama".to_string())
|
(r, "ollama".to_string())
|
||||||
@@ -161,15 +283,52 @@ async fn chat(
|
|||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"OLLAMA_CLOUD_KEY not configured".to_string(),
|
"OLLAMA_CLOUD_KEY not configured".to_string(),
|
||||||
))?;
|
))?;
|
||||||
let r = ollama_cloud::chat(key, &req)
|
let r = ollama_cloud::chat(key, &*req_for_adapter)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama cloud: {e}")))?;
|
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama cloud: {e}")))?;
|
||||||
(r, "ollama_cloud".to_string())
|
(r, "ollama_cloud".to_string())
|
||||||
}
|
}
|
||||||
|
"openrouter" | "openrouter_free" => {
|
||||||
|
// Free-tier rescue rung. Added 2026-04-24 after iter 5
|
||||||
|
// repeated Ollama Cloud 502s on kimi-k2:1t — OpenRouter
|
||||||
|
// gives a different provider backbone as fallback.
|
||||||
|
let key = state.openrouter_key.as_deref().ok_or((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"OPENROUTER_API_KEY not configured".to_string(),
|
||||||
|
))?;
|
||||||
|
let r = openrouter::chat(key, &*req_for_adapter)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("openrouter: {e}")))?;
|
||||||
|
(r, "openrouter".to_string())
|
||||||
|
}
|
||||||
|
"gemini" => {
|
||||||
|
// Phase 40 provider adapter. Google Generative Language
|
||||||
|
// API via query-string key auth (not bearer).
|
||||||
|
let key = state.gemini_key.as_deref().ok_or((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"GEMINI_API_KEY not configured".to_string(),
|
||||||
|
))?;
|
||||||
|
let r = gemini::chat(key, &*req_for_adapter)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("gemini: {e}")))?;
|
||||||
|
(r, "gemini".to_string())
|
||||||
|
}
|
||||||
|
"claude" | "anthropic" => {
|
||||||
|
// Phase 40 provider adapter. Anthropic Messages API via
|
||||||
|
// x-api-key header + anthropic-version:2023-06-01.
|
||||||
|
let key = state.claude_key.as_deref().ok_or((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"ANTHROPIC_API_KEY not configured".to_string(),
|
||||||
|
))?;
|
||||||
|
let r = claude::chat(key, &*req_for_adapter)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("claude: {e}")))?;
|
||||||
|
(r, "claude".to_string())
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
format!("unknown provider '{other}' — supported: ollama, ollama_cloud"),
|
format!("unknown provider '{other}' — supported: ollama, ollama_cloud, openrouter, gemini, claude"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! OpenRouter adapter — free-tier rescue rung for /v1/chat.
|
||||||
|
//!
|
||||||
|
//! Direct HTTPS call to `https://openrouter.ai/api/v1/chat/completions`
|
||||||
|
//! with Bearer auth. Mirrors the OpenAI-compatible shape so the model
|
||||||
|
//! list can be expanded without code changes. Added 2026-04-24 after
|
||||||
|
//! iter 5 hit repeated Ollama Cloud 502s on kimi-k2:1t — OpenRouter
|
||||||
|
//! free-tier models give us a different provider backbone as fallback.
|
||||||
|
//!
|
||||||
|
//! Key sourcing priority:
|
||||||
|
//! 1. Env var `OPENROUTER_API_KEY`
|
||||||
|
//! 2. `/home/profit/.env` (LLM Team convention)
|
||||||
|
//! 3. `/root/llm_team_config.json` → providers.openrouter.api_key
|
||||||
|
//!
|
||||||
|
//! First hit wins. Key is resolved once at gateway startup and stored
|
||||||
|
//! on `V1State.openrouter_key`.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
|
||||||
|
|
||||||
|
const OR_BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||||
|
const OR_TIMEOUT_SECS: u64 = 180;
|
||||||
|
|
||||||
|
pub fn resolve_openrouter_key() -> Option<String> {
|
||||||
|
if let Ok(k) = std::env::var("OPENROUTER_API_KEY") {
|
||||||
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
||||||
|
}
|
||||||
|
// LLM Team UI writes its key to ~/.env on the host user — pick it up
|
||||||
|
// from the same source so the free-tier rescue path works without
|
||||||
|
// an explicit systemd Environment= line.
|
||||||
|
for path in ["/home/profit/.env", "/root/.env"] {
|
||||||
|
if let Ok(raw) = std::fs::read_to_string(path) {
|
||||||
|
for line in raw.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("OPENROUTER_API_KEY=") {
|
||||||
|
let k = rest.trim().trim_matches('"').trim_matches('\'');
|
||||||
|
if !k.is_empty() { return Some(k.to_string()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(raw) = std::fs::read_to_string("/root/llm_team_config.json") {
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||||
|
if let Some(k) = v.pointer("/providers/openrouter/api_key").and_then(|x| x.as_str()) {
|
||||||
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chat(
|
||||||
|
key: &str,
|
||||||
|
req: &ChatRequest,
|
||||||
|
) -> Result<ChatResponse, String> {
|
||||||
|
// Strip the "openrouter/" prefix if the caller used the namespaced
|
||||||
|
// form so OpenRouter sees the raw model id (e.g. "openai/gpt-oss-120b:free").
|
||||||
|
let model = req.model.strip_prefix("openrouter/").unwrap_or(&req.model).to_string();
|
||||||
|
|
||||||
|
let body = ORChatBody {
|
||||||
|
model: model.clone(),
|
||||||
|
messages: req.messages.iter().map(|m| ORMessage {
|
||||||
|
role: m.role.clone(),
|
||||||
|
content: m.content.clone(),
|
||||||
|
}).collect(),
|
||||||
|
max_tokens: req.max_tokens.unwrap_or(800),
|
||||||
|
temperature: req.temperature.unwrap_or(0.3),
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(OR_TIMEOUT_SECS))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("build client: {e}"))?;
|
||||||
|
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{}/chat/completions", OR_BASE_URL))
|
||||||
|
.bearer_auth(key)
|
||||||
|
// OpenRouter recommends Referer + Title for attribution; absent
|
||||||
|
// headers do not fail the call but help us see our traffic in
|
||||||
|
// their dashboard.
|
||||||
|
.header("HTTP-Referer", "https://vcp.devop.live")
|
||||||
|
.header("X-Title", "Lakehouse Scrum")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("openrouter.ai unreachable: {e}"))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_else(|_| "?".into());
|
||||||
|
return Err(format!("openrouter.ai {}: {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: ORChatResponse = resp.json().await
|
||||||
|
.map_err(|e| format!("invalid openrouter response: {e}"))?;
|
||||||
|
|
||||||
|
let latency_ms = t0.elapsed().as_millis();
|
||||||
|
let choice = parsed.choices.into_iter().next()
|
||||||
|
.ok_or_else(|| "openrouter returned no choices".to_string())?;
|
||||||
|
let text = choice.message.content;
|
||||||
|
|
||||||
|
let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| {
|
||||||
|
let chars: usize = req.messages.iter().map(|m| m.content.chars().count()).sum();
|
||||||
|
((chars + 3) / 4) as u32
|
||||||
|
});
|
||||||
|
let completion_tokens = parsed.usage.as_ref().map(|u| u.completion_tokens).unwrap_or_else(|| {
|
||||||
|
((text.chars().count() + 3) / 4) as u32
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "v1.chat",
|
||||||
|
provider = "openrouter",
|
||||||
|
model = %model,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
latency_ms = latency_ms as u64,
|
||||||
|
"openrouter chat completed",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ChatResponse {
|
||||||
|
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
||||||
|
object: "chat.completion",
|
||||||
|
created: chrono::Utc::now().timestamp(),
|
||||||
|
model,
|
||||||
|
choices: vec![Choice {
|
||||||
|
index: 0,
|
||||||
|
message: Message { role: "assistant".into(), content: text },
|
||||||
|
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
|
||||||
|
}],
|
||||||
|
usage: UsageBlock {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens: prompt_tokens + completion_tokens,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- OpenRouter wire shapes (OpenAI-compatible) --
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ORChatBody {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<ORMessage>,
|
||||||
|
max_tokens: u32,
|
||||||
|
temperature: f64,
|
||||||
|
stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ORMessage { role: String, content: String }
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ORChatResponse {
|
||||||
|
choices: Vec<ORChoice>,
|
||||||
|
#[serde(default)]
|
||||||
|
usage: Option<ORUsage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ORChoice {
|
||||||
|
message: ORMessageResp,
|
||||||
|
#[serde(default)]
|
||||||
|
finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ORMessageResp { content: String }
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ORUsage { prompt_tokens: u32, completion_tokens: u32 }
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_openrouter_key_does_not_panic() {
|
||||||
|
// Smoke test — all three sources may or may not be set depending
|
||||||
|
// on environment; just confirm the call returns cleanly.
|
||||||
|
let _ = resolve_openrouter_key();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_body_serializes_to_openai_shape() {
|
||||||
|
let body = ORChatBody {
|
||||||
|
model: "openai/gpt-oss-120b:free".into(),
|
||||||
|
messages: vec![
|
||||||
|
ORMessage { role: "user".into(), content: "review this".into() },
|
||||||
|
],
|
||||||
|
max_tokens: 800,
|
||||||
|
temperature: 0.3,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&body).unwrap();
|
||||||
|
assert!(json.contains("\"model\":\"openai/gpt-oss-120b:free\""));
|
||||||
|
assert!(json.contains("\"messages\""));
|
||||||
|
assert!(json.contains("\"max_tokens\":800"));
|
||||||
|
assert!(json.contains("\"stream\":false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_prefix_strip_preserves_unprefixed() {
|
||||||
|
// If caller passes "openrouter/openai/gpt-oss-120b:free" we strip.
|
||||||
|
// If caller passes "openai/gpt-oss-120b:free" unchanged, we keep.
|
||||||
|
let cases = [
|
||||||
|
("openrouter/openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"),
|
||||||
|
("openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"),
|
||||||
|
("google/gemma-3-27b-it:free", "google/gemma-3-27b-it:free"),
|
||||||
|
];
|
||||||
|
for (input, expected) in cases {
|
||||||
|
let out = input.strip_prefix("openrouter/").unwrap_or(input);
|
||||||
|
assert_eq!(out, expected, "{input} should become {expected}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
use axum::{routing::get, Router};
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use truth::default_truth_store;
|
use truth::default_truth_store;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
// Note: truth_router() was a stub wrapper around a single /context route
|
||||||
pub fn truth_router() -> Router {
|
// that nothing called — v1/mod.rs wires get(truth::context) directly
|
||||||
Router::new()
|
// onto its own router. Removed 2026-04-24 along with its #[allow(dead_code)]
|
||||||
.route("/context", get(context))
|
// attribute; the handler below is the real surface.
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ContextResponse {
|
pub struct ContextResponse {
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
/// When a source changes format (columns renamed, added, removed, type changed),
|
/// When a source changes format (columns renamed, added, removed, type changed),
|
||||||
/// the system detects the diff and can auto-map using AI or heuristic matching.
|
/// the system detects the diff and can auto-map using AI or heuristic matching.
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
use arrow::datatypes::{DataType, SchemaRef};
|
||||||
use arrow::datatypes::{DataType, Schema, SchemaRef};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
#[allow(unused_imports)]
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// A detected change between two schema versions.
|
/// A detected change between two schema versions.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -225,7 +222,8 @@ fn find_similar_column<'a>(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use arrow::datatypes::Field;
|
use arrow::datatypes::{Field, Schema};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn schema(fields: Vec<(&str, DataType)>) -> SchemaRef {
|
fn schema(fields: Vec<(&str, DataType)>) -> SchemaRef {
|
||||||
Arc::new(Schema::new(
|
Arc::new(Schema::new(
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
#[allow(unused_imports)]
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Multipart, Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{delete, get, patch, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use object_store::ObjectStore;
|
use object_store::ObjectStore;
|
||||||
|
|||||||
@@ -72,12 +72,14 @@ async fn process_inbox(
|
|||||||
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
// Skip directories and hidden files
|
// Skip directories and hidden files. Bind filename once via
|
||||||
if path.is_dir() || path.file_name().map_or(true, |n| n.to_string_lossy().starts_with('.')) {
|
// let-else so the subsequent use is unwrap-free — previous
|
||||||
continue;
|
// version relied on a map_or guard above + an .unwrap() here
|
||||||
}
|
// being consistent, which is a fragile invariant.
|
||||||
|
if path.is_dir() { continue; }
|
||||||
let filename = path.file_name().unwrap().to_string_lossy().to_string();
|
let Some(fn_os) = path.file_name() else { continue; };
|
||||||
|
let filename = fn_os.to_string_lossy().to_string();
|
||||||
|
if filename.starts_with('.') { continue; }
|
||||||
tracing::info!("watcher: found new file '{}'", filename);
|
tracing::info!("watcher: found new file '{}'", filename);
|
||||||
|
|
||||||
// Read file
|
// Read file
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition = "2024"
|
|||||||
shared = { path = "../shared" }
|
shared = { path = "../shared" }
|
||||||
catalogd = { path = "../catalogd" }
|
catalogd = { path = "../catalogd" }
|
||||||
storaged = { path = "../storaged" }
|
storaged = { path = "../storaged" }
|
||||||
|
truth = { path = "../truth" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -84,14 +84,17 @@ pub async fn compact(
|
|||||||
// Load deltas
|
// Load deltas
|
||||||
let delta_batches = load_deltas(store, dataset_name).await?;
|
let delta_batches = load_deltas(store, dataset_name).await?;
|
||||||
let delta_count = delta_batches.len();
|
let delta_count = delta_batches.len();
|
||||||
|
// Row counts captured before extend; previously base_rows subtracted delta_count (files) from rows — unit mismatch.
|
||||||
|
let base_row_count: usize = base_batches.iter().map(|b| b.num_rows()).sum();
|
||||||
|
let delta_row_count: usize = delta_batches.iter().map(|b| b.num_rows()).sum();
|
||||||
|
|
||||||
let has_tombstones = !tombstones.is_empty();
|
let has_tombstones = !tombstones.is_empty();
|
||||||
let nothing_to_do = delta_batches.is_empty() && !has_tombstones;
|
let nothing_to_do = delta_batches.is_empty() && !has_tombstones;
|
||||||
if nothing_to_do {
|
if nothing_to_do {
|
||||||
return Ok(CompactResult {
|
return Ok(CompactResult {
|
||||||
base_rows: base_batches.iter().map(|b| b.num_rows()).sum(),
|
base_rows: base_row_count,
|
||||||
delta_rows: 0,
|
delta_rows: 0,
|
||||||
final_rows: base_batches.iter().map(|b| b.num_rows()).sum(),
|
final_rows: base_row_count,
|
||||||
deltas_merged: 0,
|
deltas_merged: 0,
|
||||||
tombstones_applied: 0,
|
tombstones_applied: 0,
|
||||||
rows_dropped_by_tombstones: 0,
|
rows_dropped_by_tombstones: 0,
|
||||||
@@ -99,7 +102,7 @@ pub async fn compact(
|
|||||||
}
|
}
|
||||||
|
|
||||||
base_batches.extend(delta_batches);
|
base_batches.extend(delta_batches);
|
||||||
let pre_filter_rows: usize = base_batches.iter().map(|b| b.num_rows()).sum();
|
let pre_filter_rows: usize = base_row_count + delta_row_count;
|
||||||
|
|
||||||
// If primary key specified, deduplicate (keep last occurrence)
|
// If primary key specified, deduplicate (keep last occurrence)
|
||||||
let merged_batches = if let Some(_pk) = primary_key_col {
|
let merged_batches = if let Some(_pk) = primary_key_col {
|
||||||
@@ -183,8 +186,8 @@ pub async fn compact(
|
|||||||
);
|
);
|
||||||
|
|
||||||
Ok(CompactResult {
|
Ok(CompactResult {
|
||||||
base_rows: pre_filter_rows - delta_count, // rough base-before-deltas
|
base_rows: base_row_count,
|
||||||
delta_rows: delta_count,
|
delta_rows: delta_row_count,
|
||||||
final_rows,
|
final_rows,
|
||||||
deltas_merged: delta_count,
|
deltas_merged: delta_count,
|
||||||
tombstones_applied: tombstones.len(),
|
tombstones_applied: tombstones.len(),
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use truth::{RuleAction, TruthStore};
|
||||||
|
|
||||||
use crate::context::QueryEngine;
|
use crate::context::QueryEngine;
|
||||||
use crate::delta;
|
use crate::delta;
|
||||||
use crate::paged::ResultStore;
|
use crate::paged::ResultStore;
|
||||||
@@ -17,12 +20,26 @@ use crate::paged::ResultStore;
|
|||||||
pub struct QueryState {
|
pub struct QueryState {
|
||||||
pub engine: QueryEngine,
|
pub engine: QueryEngine,
|
||||||
pub result_store: ResultStore,
|
pub result_store: ResultStore,
|
||||||
|
// Policy gate for incoming SQL. Every /sql and /paged request is
|
||||||
|
// evaluated against this store before hitting DataFusion. Added for
|
||||||
|
// P42-002 ("raw SQL forwarded without schema or policy gate") after
|
||||||
|
// the scrum master's queryd/service.rs finding looped across iters
|
||||||
|
// 3-5 without ever being reachable by the 6-line auto-applier.
|
||||||
|
pub truth: Arc<TruthStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(engine: QueryEngine) -> Router {
|
pub fn router(engine: QueryEngine) -> Router {
|
||||||
|
router_with_truth(engine, Arc::new(truth::sql_query_guard_store()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test/integration hook: construct the router with a caller-supplied
|
||||||
|
/// TruthStore so tests can assert reject/pass behavior deterministically
|
||||||
|
/// without depending on the default needle list.
|
||||||
|
pub fn router_with_truth(engine: QueryEngine, truth: Arc<TruthStore>) -> Router {
|
||||||
let state = QueryState {
|
let state = QueryState {
|
||||||
engine: engine.clone(),
|
engine: engine.clone(),
|
||||||
result_store: ResultStore::new(100, 50), // 100 rows/page, keep 50 results
|
result_store: ResultStore::new(100, 50), // 100 rows/page, keep 50 results
|
||||||
|
truth,
|
||||||
};
|
};
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
@@ -52,6 +69,11 @@ struct QueryResponse {
|
|||||||
columns: Vec<ColumnInfo>,
|
columns: Vec<ColumnInfo>,
|
||||||
rows: serde_json::Value,
|
rows: serde_json::Value,
|
||||||
row_count: usize,
|
row_count: usize,
|
||||||
|
// Elapsed wall time from handler entry to response. Required for
|
||||||
|
// audit-log parity — gateway's audit row previously stored null here.
|
||||||
|
// Scrum iter 9 finding, populated from std::time::Instant captured
|
||||||
|
// at the top of execute_query / paged_query.
|
||||||
|
latency_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -71,12 +93,41 @@ fn batches_to_json(batches: &[RecordBatch]) -> Result<serde_json::Value, String>
|
|||||||
serde_json::from_slice(&buf).map_err(|e| format!("JSON parse error: {e}"))
|
serde_json::from_slice(&buf).map_err(|e| format!("JSON parse error: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Evaluate the request SQL against the configured TruthStore. Returns
|
||||||
|
/// the Reject/Block message on the first failing mandatory rule so the
|
||||||
|
/// handler can short-circuit. Returns None when all rules pass (or when
|
||||||
|
/// the failures' declared action is non-mandatory like Redact/Pass).
|
||||||
|
fn sql_policy_check(truth: &TruthStore, sql: &str) -> Option<String> {
|
||||||
|
let ctx = serde_json::json!({ "sql": sql });
|
||||||
|
for outcome in truth.evaluate("sql_query", &ctx) {
|
||||||
|
if !outcome.passed {
|
||||||
|
// FieldEmpty / FieldContainsAny etc. are enforced only when
|
||||||
|
// condition HOLDS (i.e. passed=true). Below means "passed=false",
|
||||||
|
// so the rule condition did not hold — no enforcement.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match &outcome.action {
|
||||||
|
RuleAction::Reject { message } | RuleAction::Block { message } => {
|
||||||
|
return Some(message.clone());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute_query(
|
async fn execute_query(
|
||||||
State(state): State<QueryState>,
|
State(state): State<QueryState>,
|
||||||
Json(req): Json<QueryRequest>,
|
Json(req): Json<QueryRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
tracing::info!("executing query: {}", req.sql);
|
tracing::info!("executing query: {}", req.sql);
|
||||||
|
|
||||||
|
if let Some(reason) = sql_policy_check(&state.truth, &req.sql) {
|
||||||
|
tracing::warn!("sql rejected by truth gate: {reason}");
|
||||||
|
return Err((StatusCode::FORBIDDEN, reason));
|
||||||
|
}
|
||||||
|
|
||||||
match state.engine.query(&req.sql).await {
|
match state.engine.query(&req.sql).await {
|
||||||
Ok(batches) => {
|
Ok(batches) => {
|
||||||
if batches.is_empty() {
|
if batches.is_empty() {
|
||||||
@@ -84,6 +135,7 @@ async fn execute_query(
|
|||||||
columns: vec![],
|
columns: vec![],
|
||||||
rows: serde_json::Value::Array(vec![]),
|
rows: serde_json::Value::Array(vec![]),
|
||||||
row_count: 0,
|
row_count: 0,
|
||||||
|
latency_ms: started.elapsed().as_millis() as u64,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +154,7 @@ async fn execute_query(
|
|||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
row_count,
|
row_count,
|
||||||
|
latency_ms: started.elapsed().as_millis() as u64,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Err(e) => Err((StatusCode::BAD_REQUEST, e)),
|
Err(e) => Err((StatusCode::BAD_REQUEST, e)),
|
||||||
@@ -115,6 +168,10 @@ async fn paged_query(
|
|||||||
Json(req): Json<QueryRequest>,
|
Json(req): Json<QueryRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
tracing::info!("paged query: {}", req.sql);
|
tracing::info!("paged query: {}", req.sql);
|
||||||
|
if let Some(reason) = sql_policy_check(&state.truth, &req.sql) {
|
||||||
|
tracing::warn!("paged sql rejected by truth gate: {reason}");
|
||||||
|
return Err((StatusCode::FORBIDDEN, reason));
|
||||||
|
}
|
||||||
match state.result_store.execute_and_store(&state.engine, &req.sql).await {
|
match state.result_store.execute_and_store(&state.engine, &req.sql).await {
|
||||||
Ok(handle) => Ok(Json(handle)),
|
Ok(handle) => Ok(Json(handle)),
|
||||||
Err(e) => Err((StatusCode::BAD_REQUEST, e)),
|
Err(e) => Err((StatusCode::BAD_REQUEST, e)),
|
||||||
@@ -211,3 +268,65 @@ async fn compact_dataset(
|
|||||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod sql_policy_tests {
|
||||||
|
use super::*;
|
||||||
|
use truth::sql_query_guard_store;
|
||||||
|
|
||||||
|
// These tests exercise the policy gate without spinning up a DataFusion
|
||||||
|
// engine — they only need `TruthStore`. Purpose: prove the P42-002
|
||||||
|
// enforcement point actually rejects destructive SQL. This is the
|
||||||
|
// regression guard for the queryd/service.rs finding that looped
|
||||||
|
// across scrum iters 3-5.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_drop_table() {
|
||||||
|
let store = sql_query_guard_store();
|
||||||
|
let reason = sql_policy_check(&store, "DROP TABLE users").expect("must reject");
|
||||||
|
assert!(reason.contains("destructive"), "reason: {reason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_delete_from() {
|
||||||
|
let store = sql_query_guard_store();
|
||||||
|
assert!(sql_policy_check(&store, "delete from t where 1=1").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_truncate() {
|
||||||
|
let store = sql_query_guard_store();
|
||||||
|
assert!(sql_policy_check(&store, "TRUNCATE workers").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_empty_sql() {
|
||||||
|
let store = sql_query_guard_store();
|
||||||
|
assert!(sql_policy_check(&store, "").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_benign_select() {
|
||||||
|
let store = sql_query_guard_store();
|
||||||
|
assert!(sql_policy_check(&store, "SELECT count(*) FROM workers").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_select_with_deleted_word_in_column() {
|
||||||
|
// Substring match is narrow ("delete from", not "delete"), so a
|
||||||
|
// column named `deleted_at` doesn't trip the guard. Important
|
||||||
|
// check — false positives on benign queries would make the gate
|
||||||
|
// unusable in practice.
|
||||||
|
let store = sql_query_guard_store();
|
||||||
|
assert!(
|
||||||
|
sql_policy_check(&store, "SELECT deleted_at FROM t").is_none(),
|
||||||
|
"column names containing 'delete' must not be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_insensitive_match_catches_mixed_case() {
|
||||||
|
let store = sql_query_guard_store();
|
||||||
|
assert!(sql_policy_check(&store, "Drop Table X").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ pub mod arrow_helpers;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod pii;
|
pub mod pii;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
|
pub mod model_matrix;
|
||||||
|
pub mod profiles;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! Per-model token accounting. Entry point for the ModelMatrix work
|
||||||
|
//! the aibridge `context::estimate_tokens` deprecation has been pointing
|
||||||
|
//! at. Starts minimal — just `estimate_tokens` — so call sites can
|
||||||
|
//! migrate off the deprecated helper. Extend with per-model context
|
||||||
|
//! windows, max_tokens defaults, provider hints, etc. as we move the
|
||||||
|
//! rest of `aibridge::context::known_windows` over.
|
||||||
|
|
||||||
|
/// Namespace for per-model token + context accounting. Methods are
|
||||||
|
/// associated functions — no instance required — because the underlying
|
||||||
|
/// estimates are deterministic and stateless.
|
||||||
|
pub struct ModelMatrix;
|
||||||
|
|
||||||
|
impl ModelMatrix {
|
||||||
|
/// Rough token count — char count divided by 4, rounded up. This
|
||||||
|
/// is the same heuristic OpenAI's cookbook uses for English text;
|
||||||
|
/// it's within ±15% of BPE tokenizers for code + prose and doesn't
|
||||||
|
/// require a tokenizer lookup. Good enough for budget math where
|
||||||
|
/// the goal is "don't blow the context window" rather than exact
|
||||||
|
/// billing.
|
||||||
|
///
|
||||||
|
/// Moved from `aibridge::context::estimate_tokens` (still there with
|
||||||
|
/// a `#[deprecated]` pointer — callers should migrate here). Empty
|
||||||
|
/// string → 0; one char → 1 (ceiling of 1/4 = 1).
|
||||||
|
pub fn estimate_tokens(text: &str) -> usize {
|
||||||
|
(text.chars().count() + 3) / 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::ModelMatrix;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_string_is_zero_tokens() {
|
||||||
|
assert_eq!(ModelMatrix::estimate_tokens(""), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn three_chars_is_one_token() {
|
||||||
|
// 3 → ceil(3/4) = 1. Matches the deprecated helper's behavior
|
||||||
|
// so the migration is a drop-in replacement.
|
||||||
|
assert_eq!(ModelMatrix::estimate_tokens("abc"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn four_chars_is_one_token() {
|
||||||
|
assert_eq!(ModelMatrix::estimate_tokens("abcd"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn five_chars_is_two_tokens() {
|
||||||
|
assert_eq!(ModelMatrix::estimate_tokens("abcde"), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn counts_chars_not_bytes() {
|
||||||
|
// Multi-byte UTF-8 chars count as 1 char each — important for
|
||||||
|
// prompts with emoji or non-ASCII text. "héllo" is 5 chars
|
||||||
|
// (5 unicode scalars) → ceil(5/4) = 2 tokens, same as "hello".
|
||||||
|
assert_eq!(ModelMatrix::estimate_tokens("héllo"), 2);
|
||||||
|
assert_eq!(ModelMatrix::estimate_tokens("📚📚📚📚"), 1); // 4 chars
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_text_scales_linearly() {
|
||||||
|
assert_eq!(ModelMatrix::estimate_tokens(&"x".repeat(400)), 100);
|
||||||
|
assert_eq!(ModelMatrix::estimate_tokens(&"x".repeat(401)), 101);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! ExecutionProfile — the Phase 41 rename of Phase 17's ModelProfile.
|
||||||
|
//!
|
||||||
|
//! Carries what's needed to RUN inference: model tag, dataset bindings,
|
||||||
|
//! HNSW config, embed model, bucket binding. Today this is a type
|
||||||
|
//! alias over `crate::types::ModelProfile` — the PRD's
|
||||||
|
//! "Backward compat: ModelProfile still loads, aliased to
|
||||||
|
//! ExecutionProfile" line, honored literally.
|
||||||
|
//!
|
||||||
|
//! When the migration off the old name finishes, this file can either
|
||||||
|
//! absorb the full struct definition or continue as an alias. Callers
|
||||||
|
//! should reference `ExecutionProfile` going forward; `ModelProfile`
|
||||||
|
//! stays exported from `types` for on-disk schema compat.
|
||||||
|
|
||||||
|
pub use crate::types::ModelProfile as ExecutionProfile;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//! MemoryProfile — how the agent's execution memory is kept.
|
||||||
|
//!
|
||||||
|
//! Phase 41 decomposition: the Phase 19 playbook_memory + Phase 26
|
||||||
|
//! successful_playbooks + Phase 45 doc_refs all need per-profile
|
||||||
|
//! tuning. Rather than bolt those onto ExecutionProfile, they live
|
||||||
|
//! here so a "thin" execution profile can reuse a "fat" memory
|
||||||
|
//! profile and vice versa.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryProfile {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
/// Phase 19: ceiling for playbook_memory boost on retrieval. 0
|
||||||
|
/// disables the boost entirely.
|
||||||
|
#[serde(default = "default_boost_ceiling")]
|
||||||
|
pub playbook_boost_ceiling: f32,
|
||||||
|
/// Phase 26: max history entries retained before rotation.
|
||||||
|
#[serde(default = "default_history_cap")]
|
||||||
|
pub history_cap: usize,
|
||||||
|
/// Phase 45: stale threshold for doc_refs before drift check
|
||||||
|
/// fires (hours).
|
||||||
|
#[serde(default = "default_stale_hours")]
|
||||||
|
pub doc_stale_hours: u32,
|
||||||
|
/// Phase 28: auto-retire playbooks that fail 3+ consecutive runs.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub auto_retire_on_failure: bool,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_boost_ceiling() -> f32 { 0.35 }
|
||||||
|
fn default_history_cap() -> usize { 1000 }
|
||||||
|
fn default_stale_hours() -> u32 { 168 } // one week
|
||||||
|
fn default_true() -> bool { true }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! Phase 41 profile types.
|
||||||
|
//!
|
||||||
|
//! The existing `ModelProfile` (Phase 17) is aliased as
|
||||||
|
//! `ExecutionProfile` here — it continues to carry the model +
|
||||||
|
//! bindings + HNSW config needed to run inference. Three new profile
|
||||||
|
//! types land alongside: `RetrievalProfile`, `MemoryProfile`,
|
||||||
|
//! `ObserverProfile` — each owns a distinct slice of what used to be
|
||||||
|
//! bundled.
|
||||||
|
//!
|
||||||
|
//! Backward-compat rule (PRD Phase 41): existing `ModelProfile` on
|
||||||
|
//! disk continues to deserialize unchanged. New fields on the new
|
||||||
|
//! profile types are `#[serde(default)]` so old payloads load with
|
||||||
|
//! empty defaults.
|
||||||
|
//!
|
||||||
|
//! These are the canonical shapes — downstream code converts via
|
||||||
|
//! `From<ModelProfile> for ExecutionProfile` (they're the same struct
|
||||||
|
//! today, just named differently) and constructs the other three
|
||||||
|
//! as needed.
|
||||||
|
|
||||||
|
pub mod execution;
|
||||||
|
pub mod retrieval;
|
||||||
|
pub mod memory;
|
||||||
|
pub mod observer;
|
||||||
|
|
||||||
|
pub use execution::ExecutionProfile;
|
||||||
|
pub use memory::MemoryProfile;
|
||||||
|
pub use observer::ObserverProfile;
|
||||||
|
pub use retrieval::RetrievalProfile;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//! ObserverProfile — how loudly the observer logs this workload.
|
||||||
|
//!
|
||||||
|
//! Phase 41 decomposition: the observer's alert thresholds, escalation
|
||||||
|
//! cadence, and log retention need per-workload tuning. Hot-path
|
||||||
|
//! staffing workflows want aggressive alerting; batch backfills want
|
||||||
|
//! quieter. This profile is read by mcp-server/observer.ts at
|
||||||
|
//! activation-time.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ObserverProfile {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
/// How many consecutive failures trigger a cluster escalation to
|
||||||
|
/// LLM Team `/v1/chat` (qwen3-coder:480b).
|
||||||
|
#[serde(default = "default_failure_cluster_size")]
|
||||||
|
pub failure_cluster_size: u32,
|
||||||
|
/// Minimum seconds between alert emails for the same sig_hash.
|
||||||
|
/// Prevents alert storms during a regression.
|
||||||
|
#[serde(default = "default_alert_cooldown")]
|
||||||
|
pub alert_cooldown_secs: u32,
|
||||||
|
/// Observer ring buffer size. Older events fall off when full.
|
||||||
|
#[serde(default = "default_ring_size")]
|
||||||
|
pub ring_size: usize,
|
||||||
|
/// Whether to forward events to external Langfuse
|
||||||
|
/// (/v1/langfuse_trace). Off by default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub forward_to_langfuse: bool,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_failure_cluster_size() -> u32 { 3 }
|
||||||
|
fn default_alert_cooldown() -> u32 { 300 } // 5 minutes
|
||||||
|
fn default_ring_size() -> usize { 2000 }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! RetrievalProfile — what + how the agent reaches into memory.
|
||||||
|
//!
|
||||||
|
//! Phase 41 decomposition: the old ModelProfile bundled "what dataset
|
||||||
|
//! can I read" (bound_datasets) AND "how do I rank results"
|
||||||
|
//! (hnsw_config) with the model tag. Retrieval concerns split out here
|
||||||
|
//! so a profile can swap its retrieval strategy without re-activating
|
||||||
|
//! the model.
|
||||||
|
//!
|
||||||
|
//! Fields chosen for what's actually varied per-workload today:
|
||||||
|
//! - `top_k` / `rerank_top_k` — how many hits to fetch + rerank
|
||||||
|
//! - `freshness_cutoff_days` — Phase 45 doc-drift uses this
|
||||||
|
//! - `boost_playbook_memory` — Phase 19 meta-index feedback
|
||||||
|
//! - `enforce_sensitivity_gates` — Phase 13 access-control integration
|
||||||
|
//!
|
||||||
|
//! All fields are `#[serde(default)]` so loading a profile file that
|
||||||
|
//! predates Phase 41 works without migration.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RetrievalProfile {
|
||||||
|
/// Unique id — slug form, separate namespace from ExecutionProfile.
|
||||||
|
pub id: String,
|
||||||
|
/// Free-text operator description.
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
/// Default top-K for /vectors/search + /vectors/hybrid.
|
||||||
|
#[serde(default = "default_top_k")]
|
||||||
|
pub top_k: u32,
|
||||||
|
/// How many of the top-K to pass through the reranker. 0 disables
|
||||||
|
/// reranking for this profile.
|
||||||
|
#[serde(default = "default_rerank_top_k")]
|
||||||
|
pub rerank_top_k: u32,
|
||||||
|
/// Don't consider playbooks / docs older than this (days). 0 or
|
||||||
|
/// absent = no freshness filter.
|
||||||
|
#[serde(default)]
|
||||||
|
pub freshness_cutoff_days: u32,
|
||||||
|
/// Phase 19: boost workers/results by playbook_memory similarity.
|
||||||
|
#[serde(default)]
|
||||||
|
pub boost_playbook_memory: bool,
|
||||||
|
/// Phase 13: apply access-control masking on sensitive columns.
|
||||||
|
/// Default on — safety-first.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enforce_sensitivity_gates: bool,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_top_k() -> u32 { 10 }
|
||||||
|
fn default_rerank_top_k() -> u32 { 5 }
|
||||||
|
fn default_true() -> bool { true }
|
||||||
@@ -8,3 +8,4 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! DevOps task-class rules — scaffold for the long-horizon phase.
|
||||||
|
//!
|
||||||
|
//! Phase 42 PRD: "Terraform/Ansible rule shapes are scaffolded but
|
||||||
|
//! unpopulated until the long-horizon phase. Keeps the dispatcher
|
||||||
|
//! signature stable so no refactor needed later."
|
||||||
|
//!
|
||||||
|
//! This module is intentionally minimal. It registers no rules yet.
|
||||||
|
//! The `devops_rules` function exists so callers can compose it onto
|
||||||
|
//! a store (e.g. `devops_rules(staffing_rules(TruthStore::new()))`)
|
||||||
|
//! without branching on whether the DevOps phase has landed.
|
||||||
|
//!
|
||||||
|
//! When the long-horizon phase fleshes out the DevOps rule set, the
|
||||||
|
//! implementations drop in here — same `RuleCondition` primitives, same
|
||||||
|
//! `TruthStore::evaluate` contract, zero upstream refactor.
|
||||||
|
|
||||||
|
use crate::TruthStore;
|
||||||
|
|
||||||
|
/// Register DevOps rules on the store. Currently a no-op scaffold —
|
||||||
|
/// no rules are added. Safe to compose with other rule-set functions.
|
||||||
|
///
|
||||||
|
/// Planned task classes (not yet populated):
|
||||||
|
/// - `devops.terraform_plan` — `terraform validate` + pre-plan
|
||||||
|
/// sanity checks (no destroys without confirm flag, etc.)
|
||||||
|
/// - `devops.ansible_playbook` — `ansible-lint` + privileged-task
|
||||||
|
/// gates (no `become: true` on untagged hosts)
|
||||||
|
/// - `devops.shell_command` — whitelist / blocklist for
|
||||||
|
/// AI-generated shell invocations (covers what Phase 42
|
||||||
|
/// queryd SQL gate does for SQL — same idea, shell surface)
|
||||||
|
pub fn devops_rules(store: TruthStore) -> TruthStore {
|
||||||
|
// Intentionally empty. See module-level doc for the phased rollout.
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn devops_rules_is_a_noop_for_now() {
|
||||||
|
// Scaffold guarantee: composing devops_rules onto an empty
|
||||||
|
// store must not add any rules. Future long-horizon work will
|
||||||
|
// populate this and the assertion shifts to counting the
|
||||||
|
// expected additions.
|
||||||
|
let store = devops_rules(TruthStore::new());
|
||||||
|
assert_eq!(store.get_rules("devops.terraform_plan").len(), 0);
|
||||||
|
assert_eq!(store.get_rules("devops.ansible_playbook").len(), 0);
|
||||||
|
assert_eq!(store.get_rules("devops.shell_command").len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
-48
@@ -1,6 +1,10 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub mod staffing;
|
||||||
|
pub mod devops;
|
||||||
|
pub mod loader;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct TruthRule {
|
pub struct TruthRule {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -18,6 +22,11 @@ pub enum RuleCondition {
|
|||||||
FieldMismatch { field: String, value: String },
|
FieldMismatch { field: String, value: String },
|
||||||
FieldEmpty { field: String },
|
FieldEmpty { field: String },
|
||||||
FieldGreater { field: String, threshold: i64 },
|
FieldGreater { field: String, threshold: i64 },
|
||||||
|
// Case-insensitive substring scan — true if the field value contains
|
||||||
|
// ANY of `needles`. Added for SQL/command guards where rules of the
|
||||||
|
// form "sql must not contain DROP/DELETE/TRUNCATE" need to express
|
||||||
|
// enforcement as a passing precondition being absent.
|
||||||
|
FieldContainsAny { field: String, needles: Vec<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -46,6 +55,15 @@ impl TruthStore {
|
|||||||
.push(rule);
|
.push(rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All rule IDs across every task class. Used by the file loader
|
||||||
|
/// to detect duplicate-ID collisions before registering new rules.
|
||||||
|
pub fn all_rule_ids(&self) -> std::collections::HashSet<String> {
|
||||||
|
self.rules
|
||||||
|
.values()
|
||||||
|
.flat_map(|v| v.iter().map(|r| r.id.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_rules(&self, task_class: &str) -> Vec<&TruthRule> {
|
pub fn get_rules(&self, task_class: &str) -> Vec<&TruthRule> {
|
||||||
self.rules
|
self.rules
|
||||||
.get(task_class)
|
.get(task_class)
|
||||||
@@ -124,6 +142,15 @@ fn evaluate_condition(cond: &RuleCondition, ctx: &serde_json::Value) -> bool {
|
|||||||
.map(|n| n > *threshold)
|
.map(|n| n > *threshold)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
RuleCondition::FieldContainsAny { field, needles } => {
|
||||||
|
match field_as_string(ctx, field) {
|
||||||
|
None => false,
|
||||||
|
Some(s) => {
|
||||||
|
let haystack = s.to_ascii_lowercase();
|
||||||
|
needles.iter().any(|n| haystack.contains(&n.to_ascii_lowercase()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,67 +174,64 @@ fn field_as_string(ctx: &serde_json::Value, path: &str) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_truth_store() -> TruthStore {
|
/// Minimal SQL guard — rejects destructive verbs (DROP/TRUNCATE/DELETE).
|
||||||
|
/// queryd/src/service.rs loads this into its `QueryState` and evaluates
|
||||||
|
/// every `/sql` request against it before hitting the DataFusion engine.
|
||||||
|
/// This is the P42-002 enforcement point flagged across scrum iters 3-5
|
||||||
|
/// ("raw SQL forwarded without schema or policy gate").
|
||||||
|
///
|
||||||
|
/// Intentionally narrow: it's a safety net, not a full SQL parser. If
|
||||||
|
/// callers need richer AST-aware enforcement they should extend this with
|
||||||
|
/// structured rules rather than new needles.
|
||||||
|
pub fn sql_query_guard_store() -> TruthStore {
|
||||||
let mut store = TruthStore::new();
|
let mut store = TruthStore::new();
|
||||||
|
|
||||||
store.add_rule(TruthRule {
|
store.add_rule(TruthRule {
|
||||||
id: "worker-active".to_string(),
|
id: "no-destructive-sql".to_string(),
|
||||||
task_class: "staffing.fill".to_string(),
|
task_class: "sql_query".to_string(),
|
||||||
description: "Worker must be active".to_string(),
|
description: "SQL must not contain destructive verbs".to_string(),
|
||||||
condition: RuleCondition::FieldEquals {
|
condition: RuleCondition::FieldContainsAny {
|
||||||
field: "worker.status".to_string(),
|
field: "sql".to_string(),
|
||||||
value: "active".to_string(),
|
needles: vec![
|
||||||
},
|
"drop table".to_string(),
|
||||||
action: RuleAction::Pass,
|
"drop schema".to_string(),
|
||||||
});
|
"drop database".to_string(),
|
||||||
|
"truncate".to_string(),
|
||||||
store.add_rule(TruthRule {
|
"delete from".to_string(),
|
||||||
id: "client-not-blacklisted".to_string(),
|
],
|
||||||
task_class: "staffing.fill".to_string(),
|
|
||||||
description: "Worker cannot be blacklisted for client".to_string(),
|
|
||||||
condition: RuleCondition::FieldEquals {
|
|
||||||
field: "worker.client_blacklisted".to_string(),
|
|
||||||
value: "false".to_string(),
|
|
||||||
},
|
|
||||||
action: RuleAction::Pass,
|
|
||||||
});
|
|
||||||
|
|
||||||
store.add_rule(TruthRule {
|
|
||||||
id: "deadline-required".to_string(),
|
|
||||||
task_class: "staffing.fill".to_string(),
|
|
||||||
description: "Contract must have deadline".to_string(),
|
|
||||||
condition: RuleCondition::FieldEmpty {
|
|
||||||
field: "contract.deadline".to_string(),
|
|
||||||
},
|
},
|
||||||
action: RuleAction::Reject {
|
action: RuleAction::Reject {
|
||||||
message: "Contract deadline is required".to_string(),
|
message: "destructive SQL rejected by truth.sql_query_guard".to_string(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
store.add_rule(TruthRule {
|
store.add_rule(TruthRule {
|
||||||
id: "budget-required".to_string(),
|
id: "sql-not-empty".to_string(),
|
||||||
task_class: "staffing.fill".to_string(),
|
task_class: "sql_query".to_string(),
|
||||||
description: "Budget must be non-negative".to_string(),
|
description: "SQL must not be empty".to_string(),
|
||||||
condition: RuleCondition::FieldGreater {
|
condition: RuleCondition::FieldEmpty {
|
||||||
field: "contract.budget_per_hour_max".to_string(),
|
field: "sql".to_string(),
|
||||||
threshold: 0,
|
|
||||||
},
|
},
|
||||||
action: RuleAction::Pass,
|
action: RuleAction::Reject {
|
||||||
});
|
message: "empty SQL rejected".to_string(),
|
||||||
|
|
||||||
store.add_rule(TruthRule {
|
|
||||||
id: "pii-redact".to_string(),
|
|
||||||
task_class: "staffing.any".to_string(),
|
|
||||||
description: "Redact PII before cloud calls".to_string(),
|
|
||||||
condition: RuleCondition::Always,
|
|
||||||
action: RuleAction::Redact {
|
|
||||||
fields: vec!["ssn".to_string(), "salary".to_string()],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 42 default store: staffing rules + DevOps scaffold composed
|
||||||
|
/// onto an empty TruthStore. Per the PRD: "Staffing rules ship first;
|
||||||
|
/// Terraform/Ansible rule shapes are scaffolded but unpopulated until
|
||||||
|
/// the long-horizon phase." The composition order is irrelevant here
|
||||||
|
/// (DevOps is empty) but preserved so the shape matches the PRD's
|
||||||
|
/// expected "compose on top" pattern.
|
||||||
|
///
|
||||||
|
/// Moved out of inline in-function rule registration (2026-04-24) to
|
||||||
|
/// land the Phase 42 module split the PRD called for: `staffing.rs` +
|
||||||
|
/// `devops.rs` each owns their task-class rule sets. Behavior unchanged
|
||||||
|
/// for existing callers.
|
||||||
|
pub fn default_truth_store() -> TruthStore {
|
||||||
|
devops::devops_rules(staffing::staffing_rules(TruthStore::new()))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -520,4 +544,67 @@ mod tests {
|
|||||||
let actions = s.check("t");
|
let actions = s.check("t");
|
||||||
assert_eq!(actions.len(), 3, "check returns one action per rule regardless of condition");
|
assert_eq!(actions.len(), 3, "check returns one action per rule regardless of condition");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sql_guard_store() -> TruthStore {
|
||||||
|
let mut s = TruthStore::new();
|
||||||
|
s.add_rule(TruthRule {
|
||||||
|
id: "no-destructive".into(),
|
||||||
|
task_class: "sql_query".into(),
|
||||||
|
description: "SQL must not contain destructive verbs".into(),
|
||||||
|
condition: RuleCondition::FieldContainsAny {
|
||||||
|
field: "sql".into(),
|
||||||
|
needles: vec![
|
||||||
|
"drop table".into(),
|
||||||
|
"drop schema".into(),
|
||||||
|
"truncate".into(),
|
||||||
|
"delete from".into(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
action: RuleAction::Reject {
|
||||||
|
message: "destructive SQL rejected".into(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_contains_any_matches_case_insensitively() {
|
||||||
|
let s = sql_guard_store();
|
||||||
|
let ctx = serde_json::json!({"sql": "SELECT * FROM t; DROP TABLE users;"});
|
||||||
|
let o = s.evaluate("sql_query", &ctx);
|
||||||
|
assert!(o[0].passed, "condition holds when needle present (case-insensitive)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_contains_any_is_false_when_no_needle_matches() {
|
||||||
|
let s = sql_guard_store();
|
||||||
|
let ctx = serde_json::json!({"sql": "SELECT count(*) FROM workers"});
|
||||||
|
let o = s.evaluate("sql_query", &ctx);
|
||||||
|
assert!(!o[0].passed, "benign SELECT should not match destructive needles");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_contains_any_false_when_field_missing() {
|
||||||
|
let s = sql_guard_store();
|
||||||
|
let ctx = serde_json::json!({});
|
||||||
|
let o = s.evaluate("sql_query", &ctx);
|
||||||
|
assert!(!o[0].passed, "missing field → condition cannot hold");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_contains_any_empty_needles_list_never_matches() {
|
||||||
|
let mut s = TruthStore::new();
|
||||||
|
s.add_rule(TruthRule {
|
||||||
|
id: "empty".into(),
|
||||||
|
task_class: "x".into(),
|
||||||
|
description: "".into(),
|
||||||
|
condition: RuleCondition::FieldContainsAny {
|
||||||
|
field: "sql".into(),
|
||||||
|
needles: vec![],
|
||||||
|
},
|
||||||
|
action: RuleAction::Pass,
|
||||||
|
});
|
||||||
|
let o = s.evaluate("x", &serde_json::json!({"sql": "anything"}));
|
||||||
|
assert!(!o[0].passed, "no needles → any::<bool> is false");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! File-backed TruthRule loader (Phase 42 PRD).
|
||||||
|
//!
|
||||||
|
//! PRD: "truth/ dir at repo root — rule files, versioned in git."
|
||||||
|
//! This module walks a directory, parses every `*.toml` file it finds,
|
||||||
|
//! and registers the rules into a caller-supplied store. Rule IDs must
|
||||||
|
//! be unique across the combined set — duplicate-ID collisions are
|
||||||
|
//! load-time errors.
|
||||||
|
//!
|
||||||
|
//! The TOML format matches the shape at `truth/README.md`. The same
|
||||||
|
//! `RuleCondition` + `RuleAction` enums used by the in-code registrars
|
||||||
|
//! deserialize directly from `condition = { type = "FieldEquals", ... }`
|
||||||
|
//! thanks to `#[serde(tag = "type")]`.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{TruthRule, TruthStore};
|
||||||
|
|
||||||
|
/// Deserialization wrapper — a TOML file is a list of [[rule]] blocks.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RuleFile {
|
||||||
|
#[serde(default)]
|
||||||
|
rule: Vec<TruthRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every `*.toml` file in `dir` and add its rules to `store`.
|
||||||
|
/// Returns the number of rules loaded across all files.
|
||||||
|
///
|
||||||
|
/// Errors:
|
||||||
|
/// - directory doesn't exist or can't be read
|
||||||
|
/// - any `.toml` file fails to parse
|
||||||
|
/// - any rule ID collides with an existing rule (same ID already
|
||||||
|
/// registered in the store)
|
||||||
|
///
|
||||||
|
/// Non-goals: recursive walk (flat dir only), hot reload (one-shot load).
|
||||||
|
pub fn load_from_dir(store: &mut TruthStore, dir: impl AsRef<Path>) -> Result<usize, String> {
|
||||||
|
let dir = dir.as_ref();
|
||||||
|
let entries = fs::read_dir(dir)
|
||||||
|
.map_err(|e| format!("read_dir {}: {e}", dir.display()))?;
|
||||||
|
|
||||||
|
let mut loaded_ids = store.all_rule_ids();
|
||||||
|
let mut count = 0usize;
|
||||||
|
|
||||||
|
let mut paths: Vec<_> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("toml"))
|
||||||
|
.collect();
|
||||||
|
// Deterministic order — alphabetical by filename. Matters when a
|
||||||
|
// cross-file ID collision happens; the earlier filename wins
|
||||||
|
// nothing (both error), but the error message is reproducible.
|
||||||
|
paths.sort();
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
let raw = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||||
|
let file: RuleFile = toml::from_str(&raw)
|
||||||
|
.map_err(|e| format!("parse {}: {e}", path.display()))?;
|
||||||
|
for rule in file.rule {
|
||||||
|
if !loaded_ids.insert(rule.id.clone()) {
|
||||||
|
return Err(format!(
|
||||||
|
"duplicate rule id '{}' from {}",
|
||||||
|
rule.id,
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
store.add_rule(rule);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn write_file(dir: &Path, name: &str, content: &str) {
|
||||||
|
let path = dir.join(name);
|
||||||
|
let mut f = fs::File::create(&path).unwrap();
|
||||||
|
f.write_all(content.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_rules_from_toml_files() {
|
||||||
|
let tmp = tempdir_for("loader_test");
|
||||||
|
write_file(&tmp, "a.toml", r#"
|
||||||
|
[[rule]]
|
||||||
|
id = "a-rule"
|
||||||
|
task_class = "test"
|
||||||
|
description = "test rule"
|
||||||
|
action = { type = "Pass" }
|
||||||
|
|
||||||
|
[rule.condition]
|
||||||
|
type = "Always"
|
||||||
|
"#);
|
||||||
|
let mut store = TruthStore::new();
|
||||||
|
let n = load_from_dir(&mut store, &tmp).unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
assert_eq!(store.get_rules("test").len(), 1);
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_duplicate_rule_ids() {
|
||||||
|
let tmp = tempdir_for("dup_ids");
|
||||||
|
write_file(&tmp, "a.toml", r#"
|
||||||
|
[[rule]]
|
||||||
|
id = "same"
|
||||||
|
task_class = "t"
|
||||||
|
description = ""
|
||||||
|
action = { type = "Pass" }
|
||||||
|
[rule.condition]
|
||||||
|
type = "Always"
|
||||||
|
"#);
|
||||||
|
write_file(&tmp, "b.toml", r#"
|
||||||
|
[[rule]]
|
||||||
|
id = "same"
|
||||||
|
task_class = "t"
|
||||||
|
description = ""
|
||||||
|
action = { type = "Pass" }
|
||||||
|
[rule.condition]
|
||||||
|
type = "Always"
|
||||||
|
"#);
|
||||||
|
let mut store = TruthStore::new();
|
||||||
|
let err = load_from_dir(&mut store, &tmp).unwrap_err();
|
||||||
|
assert!(err.contains("duplicate"), "got: {err}");
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_with_in_code_rule_is_rejected() {
|
||||||
|
// Existing in-store IDs count as "already registered." Operator
|
||||||
|
// can't shadow an in-code rule by file without changing the ID.
|
||||||
|
let tmp = tempdir_for("dup_in_code");
|
||||||
|
write_file(&tmp, "conflict.toml", r#"
|
||||||
|
[[rule]]
|
||||||
|
id = "worker-active"
|
||||||
|
task_class = "staffing.fill"
|
||||||
|
description = "file attempt"
|
||||||
|
action = { type = "Pass" }
|
||||||
|
[rule.condition]
|
||||||
|
type = "Always"
|
||||||
|
"#);
|
||||||
|
// staffing_rules registers "worker-active"
|
||||||
|
let mut store = crate::staffing::staffing_rules(TruthStore::new());
|
||||||
|
let err = load_from_dir(&mut store, &tmp).unwrap_err();
|
||||||
|
assert!(err.contains("duplicate") && err.contains("worker-active"));
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_non_toml_files() {
|
||||||
|
let tmp = tempdir_for("skip_non_toml");
|
||||||
|
write_file(&tmp, "a.toml", r#"
|
||||||
|
[[rule]]
|
||||||
|
id = "x"
|
||||||
|
task_class = "t"
|
||||||
|
description = ""
|
||||||
|
action = { type = "Pass" }
|
||||||
|
[rule.condition]
|
||||||
|
type = "Always"
|
||||||
|
"#);
|
||||||
|
write_file(&tmp, "README.md", "not a toml file");
|
||||||
|
let mut store = TruthStore::new();
|
||||||
|
let n = load_from_dir(&mut store, &tmp).unwrap();
|
||||||
|
assert_eq!(n, 1); // README.md ignored
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_dir_returns_error() {
|
||||||
|
let mut store = TruthStore::new();
|
||||||
|
let err = load_from_dir(&mut store, "/nonexistent/path/here").unwrap_err();
|
||||||
|
assert!(err.contains("read_dir"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tempdir_for(tag: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("truth_loader_{}_{}", tag,
|
||||||
|
std::process::id()));
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! Staffing task-class rules for the TruthStore.
|
||||||
|
//!
|
||||||
|
//! Phase 42 PRD: "Staffing rules ship first. Terraform/Ansible rule
|
||||||
|
//! shapes are scaffolded but unpopulated until the long-horizon phase."
|
||||||
|
//! This module owns the staffing rule set; `devops.rs` holds the
|
||||||
|
//! matching scaffold for the DevOps long-horizon.
|
||||||
|
//!
|
||||||
|
//! Rules registered here live under the task classes `staffing.fill`
|
||||||
|
//! (fill proposals), `staffing.rescue` (rescue escalations), and
|
||||||
|
//! `staffing.any` (rules that apply across all staffing task classes —
|
||||||
|
//! PII redaction being the canonical example).
|
||||||
|
//!
|
||||||
|
//! All rules are evaluated via the `TruthStore::evaluate` walk, which
|
||||||
|
//! pairs each rule's `RuleCondition` against a caller-supplied JSON
|
||||||
|
//! context and emits a `RuleOutcome { passed, action }` per rule.
|
||||||
|
//! Downstream enforcement (router gate, SQL gate, execution-loop gate)
|
||||||
|
//! decides how to apply the action — `Reject` / `Block` shortcircuit,
|
||||||
|
//! `Redact` mutates, `Pass` is informational.
|
||||||
|
|
||||||
|
use crate::{RuleAction, RuleCondition, TruthRule, TruthStore};
|
||||||
|
|
||||||
|
/// Register the staffing rule set on an existing store. Returns the
|
||||||
|
/// store for chaining if the caller wants to fold other rule sets on
|
||||||
|
/// top (e.g. `staffing_rules(devops_rules(TruthStore::new()))`).
|
||||||
|
pub fn staffing_rules(mut store: TruthStore) -> TruthStore {
|
||||||
|
store.add_rule(TruthRule {
|
||||||
|
id: "worker-active".to_string(),
|
||||||
|
task_class: "staffing.fill".to_string(),
|
||||||
|
description: "Worker must be active".to_string(),
|
||||||
|
condition: RuleCondition::FieldEquals {
|
||||||
|
field: "worker.status".to_string(),
|
||||||
|
value: "active".to_string(),
|
||||||
|
},
|
||||||
|
action: RuleAction::Pass,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.add_rule(TruthRule {
|
||||||
|
id: "client-not-blacklisted".to_string(),
|
||||||
|
task_class: "staffing.fill".to_string(),
|
||||||
|
description: "Worker cannot be blacklisted for client".to_string(),
|
||||||
|
condition: RuleCondition::FieldEquals {
|
||||||
|
field: "worker.client_blacklisted".to_string(),
|
||||||
|
value: "false".to_string(),
|
||||||
|
},
|
||||||
|
action: RuleAction::Pass,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.add_rule(TruthRule {
|
||||||
|
id: "deadline-required".to_string(),
|
||||||
|
task_class: "staffing.fill".to_string(),
|
||||||
|
description: "Contract must have deadline".to_string(),
|
||||||
|
condition: RuleCondition::FieldEmpty {
|
||||||
|
field: "contract.deadline".to_string(),
|
||||||
|
},
|
||||||
|
action: RuleAction::Reject {
|
||||||
|
message: "Contract deadline is required".to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store.add_rule(TruthRule {
|
||||||
|
id: "budget-required".to_string(),
|
||||||
|
task_class: "staffing.fill".to_string(),
|
||||||
|
description: "Budget must be non-negative".to_string(),
|
||||||
|
condition: RuleCondition::FieldGreater {
|
||||||
|
field: "contract.budget_per_hour_max".to_string(),
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
action: RuleAction::Pass,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.add_rule(TruthRule {
|
||||||
|
id: "pii-redact".to_string(),
|
||||||
|
task_class: "staffing.any".to_string(),
|
||||||
|
description: "Redact PII before cloud calls".to_string(),
|
||||||
|
condition: RuleCondition::Always,
|
||||||
|
action: RuleAction::Redact {
|
||||||
|
fields: vec!["ssn".to_string(), "salary".to_string()],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staffing_rules_registers_five_rules() {
|
||||||
|
// 4 staffing.fill rules + 1 staffing.any rule = 5 total.
|
||||||
|
// Regression guard: if someone adds a rule to this module
|
||||||
|
// without updating the count, this test surfaces it.
|
||||||
|
let store = staffing_rules(TruthStore::new());
|
||||||
|
let fill = store.get_rules("staffing.fill").len();
|
||||||
|
let any = store.get_rules("staffing.any").len();
|
||||||
|
assert_eq!(fill, 4);
|
||||||
|
assert_eq!(any, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blacklisted_worker_fails_the_rule() {
|
||||||
|
let store = staffing_rules(TruthStore::new());
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"worker": { "client_blacklisted": "true" }
|
||||||
|
});
|
||||||
|
let outcomes = store.evaluate("staffing.fill", &ctx);
|
||||||
|
let blk = outcomes.iter().find(|o| o.rule_id == "client-not-blacklisted").unwrap();
|
||||||
|
assert!(!blk.passed, "blacklisted worker must fail the rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_deadline_fires_reject_via_empty_condition() {
|
||||||
|
let store = staffing_rules(TruthStore::new());
|
||||||
|
// FieldEmpty passes when the field is missing — and the rule's
|
||||||
|
// action is Reject, so enforcement should fire.
|
||||||
|
let ctx = serde_json::json!({});
|
||||||
|
let outcomes = store.evaluate("staffing.fill", &ctx);
|
||||||
|
let deadline = outcomes.iter().find(|o| o.rule_id == "deadline-required").unwrap();
|
||||||
|
assert!(deadline.passed);
|
||||||
|
match &deadline.action {
|
||||||
|
RuleAction::Reject { message } => assert!(message.contains("deadline")),
|
||||||
|
_ => panic!("expected Reject action"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1238,13 +1238,13 @@ fn IngestPanel() -> Element {
|
|||||||
pg_tables.set(Some(tables));
|
pg_tables.set(Some(tables));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => pg_tables.set(None),
|
Err(_) => pg_tables.set(None),
|
||||||
}
|
}
|
||||||
pg_loading.set(false);
|
pg_loading.set(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut import_table = move |table: String| {
|
let import_table = move |table: String| {
|
||||||
let host = pg_host.read().clone();
|
let host = pg_host.read().clone();
|
||||||
let db = pg_db.read().clone();
|
let db = pg_db.read().clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "validator"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//! DevOps validator scaffold — long-horizon.
|
||||||
|
//!
|
||||||
|
//! PRD: "scaffold only: stubbed Terraform/Ansible validators
|
||||||
|
//! (`terraform validate`, `ansible-lint`) for the long-horizon phase."
|
||||||
|
//! Shipped as Unimplemented stubs so the execution-loop dispatcher
|
||||||
|
//! has a consistent failure shape to surface ("phase 43 not wired")
|
||||||
|
//! instead of a missing-impl panic.
|
||||||
|
|
||||||
|
use crate::{Artifact, Report, Validator, ValidationError};
|
||||||
|
|
||||||
|
pub struct TerraformValidator;
|
||||||
|
|
||||||
|
impl Validator for TerraformValidator {
|
||||||
|
fn name(&self) -> &'static str { "devops.terraform" }
|
||||||
|
fn validate(&self, _artifact: &Artifact) -> Result<Report, ValidationError> {
|
||||||
|
Err(ValidationError::Unimplemented { artifact: "terraform_plan" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AnsibleValidator;
|
||||||
|
|
||||||
|
impl Validator for AnsibleValidator {
|
||||||
|
fn name(&self) -> &'static str { "devops.ansible" }
|
||||||
|
fn validate(&self, _artifact: &Artifact) -> Result<Report, ValidationError> {
|
||||||
|
Err(ValidationError::Unimplemented { artifact: "ansible_playbook" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terraform_scaffold_returns_unimplemented() {
|
||||||
|
let r = TerraformValidator.validate(&Artifact::TerraformPlan(serde_json::json!({})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Unimplemented { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansible_scaffold_returns_unimplemented() {
|
||||||
|
let r = AnsibleValidator.validate(&Artifact::AnsiblePlaybook(serde_json::json!({})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Unimplemented { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! Phase 43 Validation Pipeline.
|
||||||
|
//!
|
||||||
|
//! PRD: "Staffing outputs run through schema / completeness /
|
||||||
|
//! consistency / policy gates. Plug into Layer 5 execution loop —
|
||||||
|
//! failure triggers observer-correction iteration."
|
||||||
|
//!
|
||||||
|
//! This crate provides the `Validator` trait + `Artifact` enum +
|
||||||
|
//! Report/ValidationError types. Staffing validators (fill, email,
|
||||||
|
//! playbook) and the DevOps scaffold live in submodules.
|
||||||
|
//!
|
||||||
|
//! Landed 2026-04-24 as a scaffold — the trait + types + module
|
||||||
|
//! layout match the PRD; individual validator implementations are
|
||||||
|
//! `Unimplemented` stubs that return a clear "phase 43 not wired"
|
||||||
|
//! error rather than silently passing. The execution-loop integration
|
||||||
|
//! (generate → validate → correct → retry) comes in a follow-up
|
||||||
|
//! commit once the stubs are filled.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub mod staffing;
|
||||||
|
pub mod devops;
|
||||||
|
|
||||||
|
/// What a validator saw. One variant per artifact class we validate.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind")]
|
||||||
|
pub enum Artifact {
|
||||||
|
/// A fill proposal from the staffing executor — shape is
|
||||||
|
/// `{fills: [{candidate_id, name}]}` per PRD.
|
||||||
|
FillProposal(serde_json::Value),
|
||||||
|
/// An email/SMS draft for outreach.
|
||||||
|
EmailDraft(serde_json::Value),
|
||||||
|
/// A playbook being sealed for memory.
|
||||||
|
Playbook(serde_json::Value),
|
||||||
|
/// Terraform plan output (scaffold, long-horizon).
|
||||||
|
TerraformPlan(serde_json::Value),
|
||||||
|
/// Ansible playbook (scaffold, long-horizon).
|
||||||
|
AnsiblePlaybook(serde_json::Value),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Success report. Empty `findings` means a clean pass. Populated
|
||||||
|
/// findings with `Severity::Warning` means "acceptable but notable" —
|
||||||
|
/// the artifact passes. `Severity::Error` means validation failed;
|
||||||
|
/// the validator should return `Err(...)` in that case, not `Ok`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Report {
|
||||||
|
pub findings: Vec<Finding>,
|
||||||
|
pub elapsed_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Finding {
|
||||||
|
pub field: String,
|
||||||
|
pub severity: Severity,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Severity {
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validation failure — what went wrong + where + why. Returned as
|
||||||
|
/// `Err` from `validate`. Execution loop catches these and feeds them
|
||||||
|
/// to the observer-correction retry loop.
|
||||||
|
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
|
||||||
|
pub enum ValidationError {
|
||||||
|
/// Artifact schema doesn't match what we expected.
|
||||||
|
#[error("schema mismatch at {field}: {reason}")]
|
||||||
|
Schema { field: String, reason: String },
|
||||||
|
/// Required data missing (e.g. endorsed count != target count).
|
||||||
|
#[error("completeness: {reason}")]
|
||||||
|
Completeness { reason: String },
|
||||||
|
/// Data that's inconsistent with another source of truth
|
||||||
|
/// (e.g. worker_id doesn't exist in the workers table).
|
||||||
|
#[error("consistency: {reason}")]
|
||||||
|
Consistency { reason: String },
|
||||||
|
/// Policy violation — truth rule or access control said no.
|
||||||
|
#[error("policy: {reason}")]
|
||||||
|
Policy { reason: String },
|
||||||
|
/// Validator hasn't been implemented yet — scaffold stub.
|
||||||
|
#[error("validator not yet implemented for {artifact} — phase 43 scaffold")]
|
||||||
|
Unimplemented { artifact: &'static str },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core validation contract. Implementations live in `staffing::*` and
|
||||||
|
/// `devops::*`. The execution loop dispatches to the right impl based
|
||||||
|
/// on the Artifact variant.
|
||||||
|
pub trait Validator: Send + Sync {
|
||||||
|
fn validate(&self, artifact: &Artifact) -> Result<Report, ValidationError>;
|
||||||
|
/// Human-readable name for logs + Langfuse traces.
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! Email/SMS draft validator.
|
||||||
|
//!
|
||||||
|
//! PRD checks:
|
||||||
|
//! - Schema (TO/BODY fields present)
|
||||||
|
//! - Length (SMS ≤ 160 chars; email subject ≤ 78 chars)
|
||||||
|
//! - PII absence (no SSN / salary leaked into outgoing text)
|
||||||
|
//! - Worker-name consistency (name in message matches worker record)
|
||||||
|
//!
|
||||||
|
//! Scaffold implements schema + length. PII regex (SSN pattern,
|
||||||
|
//! salary-number pattern) lives in `shared::pii::strip_pii` — plug in
|
||||||
|
//! a follow-up when the validator caller knows the worker record to
|
||||||
|
//! cross-check name consistency.
|
||||||
|
|
||||||
|
use crate::{Artifact, Report, Validator, ValidationError};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub struct EmailValidator;
|
||||||
|
|
||||||
|
const SMS_MAX_CHARS: usize = 160;
|
||||||
|
const EMAIL_SUBJECT_MAX_CHARS: usize = 78;
|
||||||
|
|
||||||
|
impl Validator for EmailValidator {
|
||||||
|
fn name(&self) -> &'static str { "staffing.email" }
|
||||||
|
|
||||||
|
fn validate(&self, artifact: &Artifact) -> Result<Report, ValidationError> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let value = match artifact {
|
||||||
|
Artifact::EmailDraft(v) => v,
|
||||||
|
other => return Err(ValidationError::Schema {
|
||||||
|
field: "artifact".into(),
|
||||||
|
reason: format!("EmailValidator expects EmailDraft, got {other:?}"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let to = value.get("to").and_then(|v| v.as_str()).ok_or(
|
||||||
|
ValidationError::Schema {
|
||||||
|
field: "to".into(),
|
||||||
|
reason: "missing or not a string".into(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let body = value.get("body").and_then(|v| v.as_str()).ok_or(
|
||||||
|
ValidationError::Schema {
|
||||||
|
field: "body".into(),
|
||||||
|
reason: "missing or not a string".into(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let is_sms = value.get("kind").and_then(|k| k.as_str()) == Some("sms");
|
||||||
|
if is_sms && body.len() > SMS_MAX_CHARS {
|
||||||
|
return Err(ValidationError::Completeness {
|
||||||
|
reason: format!("SMS body is {} chars, max {SMS_MAX_CHARS}", body.len()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(subject) = value.get("subject").and_then(|v| v.as_str()) {
|
||||||
|
if subject.len() > EMAIL_SUBJECT_MAX_CHARS {
|
||||||
|
return Err(ValidationError::Completeness {
|
||||||
|
reason: format!(
|
||||||
|
"email subject is {} chars, max {EMAIL_SUBJECT_MAX_CHARS}",
|
||||||
|
subject.len()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = to; // touched for future name-consistency check
|
||||||
|
// TODO(phase-43 v2): PII scan + worker-name consistency.
|
||||||
|
|
||||||
|
Ok(Report {
|
||||||
|
findings: vec![],
|
||||||
|
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_sms_fails_completeness() {
|
||||||
|
let body = "x".repeat(200);
|
||||||
|
let r = EmailValidator.validate(&Artifact::EmailDraft(serde_json::json!({
|
||||||
|
"to": "+15555550123",
|
||||||
|
"body": body,
|
||||||
|
"kind": "sms"
|
||||||
|
})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Completeness { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_email_subject_fails_completeness() {
|
||||||
|
let r = EmailValidator.validate(&Artifact::EmailDraft(serde_json::json!({
|
||||||
|
"to": "a@b.com",
|
||||||
|
"body": "hi",
|
||||||
|
"subject": "x".repeat(100)
|
||||||
|
})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Completeness { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_to_fails_schema() {
|
||||||
|
let r = EmailValidator.validate(&Artifact::EmailDraft(serde_json::json!({"body": "hi"})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field == "to"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_formed_email_passes() {
|
||||||
|
let r = EmailValidator.validate(&Artifact::EmailDraft(serde_json::json!({
|
||||||
|
"to": "hiring@example.com",
|
||||||
|
"subject": "Interview: Friday 10am",
|
||||||
|
"body": "Hi Jane — confirming interview Friday 10am."
|
||||||
|
})));
|
||||||
|
assert!(r.is_ok(), "well-formed email should pass: {:?}", r);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! Fill-proposal validator.
|
||||||
|
//!
|
||||||
|
//! PRD checks:
|
||||||
|
//! - Schema compliance (propose_done shape matches
|
||||||
|
//! `{fills: [{candidate_id, name}]}`)
|
||||||
|
//! - Completeness (endorsed count == target_count)
|
||||||
|
//! - Worker existence (every candidate_id present in workers_500k)
|
||||||
|
//! - Status check (active, not_on_client_blacklist)
|
||||||
|
//! - Geo/role match (worker city/state/role matches contract)
|
||||||
|
//!
|
||||||
|
//! Today this is a scaffold — schema check is real (it's cheap); the
|
||||||
|
//! worker-existence / status / geo checks need a catalog lookup and
|
||||||
|
//! land in a follow-up when the catalog query helper is wired into
|
||||||
|
//! this crate.
|
||||||
|
|
||||||
|
use crate::{Artifact, Report, Validator, ValidationError};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub struct FillValidator;
|
||||||
|
|
||||||
|
impl Validator for FillValidator {
|
||||||
|
fn name(&self) -> &'static str { "staffing.fill" }
|
||||||
|
|
||||||
|
fn validate(&self, artifact: &Artifact) -> Result<Report, ValidationError> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let value = match artifact {
|
||||||
|
Artifact::FillProposal(v) => v,
|
||||||
|
other => return Err(ValidationError::Schema {
|
||||||
|
field: "artifact".into(),
|
||||||
|
reason: format!("FillValidator expects FillProposal, got {other:?}"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schema check — the only real validation shipped in this
|
||||||
|
// scaffold. Catches the common "model emitted prose instead of
|
||||||
|
// JSON" failure mode before the consistency checks even run.
|
||||||
|
let fills = value.get("fills").and_then(|f| f.as_array()).ok_or(
|
||||||
|
ValidationError::Schema {
|
||||||
|
field: "fills".into(),
|
||||||
|
reason: "expected top-level `fills` array".into(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
for (i, fill) in fills.iter().enumerate() {
|
||||||
|
if fill.get("candidate_id").is_none() {
|
||||||
|
return Err(ValidationError::Schema {
|
||||||
|
field: format!("fills[{i}].candidate_id"),
|
||||||
|
reason: "missing".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if fill.get("name").is_none() {
|
||||||
|
return Err(ValidationError::Schema {
|
||||||
|
field: format!("fills[{i}].name"),
|
||||||
|
reason: "missing".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(phase-43 v2): worker-existence / status / geo checks.
|
||||||
|
// Need a catalog query handle injected into FillValidator's
|
||||||
|
// constructor — out of scope for the scaffold.
|
||||||
|
|
||||||
|
Ok(Report {
|
||||||
|
findings: vec![],
|
||||||
|
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_artifact_type_fails_schema() {
|
||||||
|
let r = FillValidator.validate(&Artifact::EmailDraft(serde_json::json!({})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Schema { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_fills_array_fails_schema() {
|
||||||
|
let r = FillValidator.validate(&Artifact::FillProposal(serde_json::json!({})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field == "fills"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fill_without_candidate_id_fails() {
|
||||||
|
let r = FillValidator.validate(&Artifact::FillProposal(serde_json::json!({
|
||||||
|
"fills": [{"name": "Jane"}]
|
||||||
|
})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field.contains("candidate_id")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_formed_proposal_passes_schema() {
|
||||||
|
let r = FillValidator.validate(&Artifact::FillProposal(serde_json::json!({
|
||||||
|
"fills": [
|
||||||
|
{"candidate_id": "W-123", "name": "Jane Doe"},
|
||||||
|
{"candidate_id": "W-456", "name": "John Smith"}
|
||||||
|
]
|
||||||
|
})));
|
||||||
|
assert!(r.is_ok(), "well-formed proposal should pass schema: {:?}", r);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Staffing validators — fill proposals, email/SMS drafts, sealed
|
||||||
|
//! playbooks. Phase 43 PRD: "the 0→85% pattern reproduces on real
|
||||||
|
//! staffing tasks — the iteration loop with validation in place is
|
||||||
|
//! what made small models successful."
|
||||||
|
|
||||||
|
pub mod fill;
|
||||||
|
pub mod email;
|
||||||
|
pub mod playbook;
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! Sealed playbook validator.
|
||||||
|
//!
|
||||||
|
//! PRD checks:
|
||||||
|
//! - Operation format (`fill: Role xN in City, ST`)
|
||||||
|
//! - endorsed_names non-empty, ≤ target_count × 2
|
||||||
|
//! - fingerprint populated (Phase 25 validity window requirement)
|
||||||
|
|
||||||
|
use crate::{Artifact, Report, Validator, ValidationError};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub struct PlaybookValidator;
|
||||||
|
|
||||||
|
impl Validator for PlaybookValidator {
|
||||||
|
fn name(&self) -> &'static str { "staffing.playbook" }
|
||||||
|
|
||||||
|
fn validate(&self, artifact: &Artifact) -> Result<Report, ValidationError> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let value = match artifact {
|
||||||
|
Artifact::Playbook(v) => v,
|
||||||
|
other => return Err(ValidationError::Schema {
|
||||||
|
field: "artifact".into(),
|
||||||
|
reason: format!("PlaybookValidator expects Playbook, got {other:?}"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Operation format: "fill: Role xN in City, ST" — at minimum
|
||||||
|
// we check the string-shape. Fuller grammar parse lives in
|
||||||
|
// phase 25 code where operations are structured beyond strings.
|
||||||
|
let op = value.get("operation").and_then(|v| v.as_str()).ok_or(
|
||||||
|
ValidationError::Schema {
|
||||||
|
field: "operation".into(),
|
||||||
|
reason: "missing or not a string".into(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if !op.starts_with("fill:") {
|
||||||
|
return Err(ValidationError::Schema {
|
||||||
|
field: "operation".into(),
|
||||||
|
reason: format!("expected `fill: ...` prefix, got {op:?}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let endorsed = value.get("endorsed_names").and_then(|v| v.as_array()).ok_or(
|
||||||
|
ValidationError::Schema {
|
||||||
|
field: "endorsed_names".into(),
|
||||||
|
reason: "missing or not an array".into(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if endorsed.is_empty() {
|
||||||
|
return Err(ValidationError::Completeness {
|
||||||
|
reason: "endorsed_names must be non-empty".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(target) = value.get("target_count").and_then(|v| v.as_u64()) {
|
||||||
|
let max = (target * 2) as usize;
|
||||||
|
if endorsed.len() > max {
|
||||||
|
return Err(ValidationError::Completeness {
|
||||||
|
reason: format!(
|
||||||
|
"endorsed_names ({}) exceeds target_count × 2 ({max})",
|
||||||
|
endorsed.len()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.get("fingerprint").and_then(|v| v.as_str()).map_or(true, |s| s.is_empty()) {
|
||||||
|
return Err(ValidationError::Schema {
|
||||||
|
field: "fingerprint".into(),
|
||||||
|
reason: "missing — required for Phase 25 validity window".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Report {
|
||||||
|
findings: vec![],
|
||||||
|
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_formed_playbook_passes() {
|
||||||
|
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
|
||||||
|
"operation": "fill: Welder x2 in Toledo, OH",
|
||||||
|
"endorsed_names": ["W-123", "W-456"],
|
||||||
|
"target_count": 2,
|
||||||
|
"fingerprint": "abc123"
|
||||||
|
})));
|
||||||
|
assert!(r.is_ok(), "got {:?}", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_endorsed_names_fails_completeness() {
|
||||||
|
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
|
||||||
|
"operation": "fill: Welder x2 in Toledo, OH",
|
||||||
|
"endorsed_names": [],
|
||||||
|
"fingerprint": "abc"
|
||||||
|
})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Completeness { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overfull_endorsed_names_fails_completeness() {
|
||||||
|
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
|
||||||
|
"operation": "fill: Welder x1 in Toledo, OH",
|
||||||
|
"endorsed_names": ["a", "b", "c"],
|
||||||
|
"target_count": 1,
|
||||||
|
"fingerprint": "abc"
|
||||||
|
})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Completeness { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_fingerprint_fails_schema() {
|
||||||
|
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
|
||||||
|
"operation": "fill: X x1 in A, B",
|
||||||
|
"endorsed_names": ["a"]
|
||||||
|
})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field == "fingerprint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_operation_prefix_fails_schema() {
|
||||||
|
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
|
||||||
|
"operation": "sms_draft: hello",
|
||||||
|
"endorsed_names": ["a"],
|
||||||
|
"fingerprint": "x"
|
||||||
|
})));
|
||||||
|
assert!(matches!(r, Err(ValidationError::Schema { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! Profile activation tracking (Phase 41 PRD).
|
||||||
|
//!
|
||||||
|
//! Phase 41 PRD called out `crates/vectord/src/activation.rs` with
|
||||||
|
//! `ActivationTracker` + background-job pattern. The activation
|
||||||
|
//! handler itself lives in `service.rs::activate_profile` (200+ lines
|
||||||
|
//! of warm-up + bucket binding that's wired to VectorState); this
|
||||||
|
//! module provides the type the PRD named and a single-flight guard
|
||||||
|
//! that satisfies the PRD gate "refuse new activation if one is
|
||||||
|
//! pending/running."
|
||||||
|
//!
|
||||||
|
//! Handler extraction (moving the body of `activate_profile` here)
|
||||||
|
//! is deliberately NOT in this commit — it's a module-structure
|
||||||
|
//! refactor, not a semantic change. When that lands, the inline
|
||||||
|
//! `tokio::spawn` in `service.rs` moves into `ActivationTracker::start`
|
||||||
|
//! and the HTTP handler shrinks to ~20 lines of validate + start +
|
||||||
|
//! respond-202.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Tracks in-flight profile activations. The PRD's "single-flight guard"
|
||||||
|
/// lives here: callers check `is_pending` before starting a new activation
|
||||||
|
/// and register via `mark_pending` if they proceed. On completion, they
|
||||||
|
/// call `mark_complete` so the next caller can start.
|
||||||
|
///
|
||||||
|
/// Per-profile granularity — activating profile A doesn't block B.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct ActivationTracker {
|
||||||
|
pending: Arc<RwLock<HashMap<String, String>>>, // profile_id → job_id
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivationTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a profile has an activation already running. Returns the
|
||||||
|
/// in-flight job_id if so. Safe to call without holding a lock.
|
||||||
|
pub async fn is_pending(&self, profile_id: &str) -> Option<String> {
|
||||||
|
self.pending.read().await.get(profile_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new activation as pending. Returns false if an
|
||||||
|
/// activation is already running for the same profile (caller should
|
||||||
|
/// return 409 Conflict or surface the existing job_id). Returns true
|
||||||
|
/// on successful registration.
|
||||||
|
pub async fn mark_pending(&self, profile_id: &str, job_id: &str) -> bool {
|
||||||
|
let mut guard = self.pending.write().await;
|
||||||
|
if guard.contains_key(profile_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
guard.insert(profile_id.to_string(), job_id.to_string());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the pending marker when activation finishes (success OR
|
||||||
|
/// failure — both free the slot for the next caller).
|
||||||
|
pub async fn mark_complete(&self, profile_id: &str) {
|
||||||
|
self.pending.write().await.remove(profile_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many activations are currently in-flight across all profiles.
|
||||||
|
pub async fn in_flight_count(&self) -> usize {
|
||||||
|
self.pending.read().await.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_tracker_has_no_pending() {
|
||||||
|
let t = ActivationTracker::new();
|
||||||
|
assert_eq!(t.in_flight_count().await, 0);
|
||||||
|
assert!(t.is_pending("any-profile").await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_pending_registers_the_job() {
|
||||||
|
let t = ActivationTracker::new();
|
||||||
|
assert!(t.mark_pending("profile-A", "job-1").await);
|
||||||
|
assert_eq!(t.in_flight_count().await, 1);
|
||||||
|
assert_eq!(t.is_pending("profile-A").await, Some("job-1".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn single_flight_guard_refuses_second_activation_same_profile() {
|
||||||
|
// PRD Phase 41 gate: "refuse new activation if one is
|
||||||
|
// pending/running." Same profile twice → second call returns
|
||||||
|
// false, caller must surface the in-flight job_id.
|
||||||
|
let t = ActivationTracker::new();
|
||||||
|
assert!(t.mark_pending("profile-A", "job-1").await);
|
||||||
|
assert!(!t.mark_pending("profile-A", "job-2").await);
|
||||||
|
// Still the first job — second registration didn't overwrite.
|
||||||
|
assert_eq!(t.is_pending("profile-A").await, Some("job-1".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn different_profiles_dont_block_each_other() {
|
||||||
|
// Per-profile granularity — activating A doesn't block B.
|
||||||
|
let t = ActivationTracker::new();
|
||||||
|
assert!(t.mark_pending("profile-A", "job-1").await);
|
||||||
|
assert!(t.mark_pending("profile-B", "job-2").await);
|
||||||
|
assert_eq!(t.in_flight_count().await, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_complete_frees_the_slot() {
|
||||||
|
let t = ActivationTracker::new();
|
||||||
|
t.mark_pending("profile-A", "job-1").await;
|
||||||
|
t.mark_complete("profile-A").await;
|
||||||
|
assert_eq!(t.in_flight_count().await, 0);
|
||||||
|
// Next activation can now proceed.
|
||||||
|
assert!(t.mark_pending("profile-A", "job-2").await);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,21 @@ pub struct IndexMeta {
|
|||||||
/// Existing indexes: "W-", "CAND-", "W500K-", etc.
|
/// Existing indexes: "W-", "CAND-", "W500K-", etc.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub id_prefix: Option<String>,
|
pub id_prefix: Option<String>,
|
||||||
|
/// PRD 11.3 — when this index was last searched against. `None` =
|
||||||
|
/// never used since registration (or pre-field-existed metadata).
|
||||||
|
/// Incremental re-embed walks this to skip cold indexes.
|
||||||
|
/// Scrum iter 11 flagged the missing field as a UnitMismatch
|
||||||
|
/// because callers were reading `created_at` as a proxy for
|
||||||
|
/// liveness, which conflated "built" with "used."
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_used: Option<DateTime<Utc>>,
|
||||||
|
/// PRD 11.3 — SHA-256 of (sorted source file list + chunk_size +
|
||||||
|
/// overlap + model_version). Lets incremental re-embed detect
|
||||||
|
/// "no change since last build" without scanning the source
|
||||||
|
/// Parquet. None = signature not computed yet (pre-existing
|
||||||
|
/// indexes before this field landed).
|
||||||
|
#[serde(default)]
|
||||||
|
pub build_signature: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_bucket() -> String { "primary".to_string() }
|
fn default_bucket() -> String { "primary".to_string() }
|
||||||
@@ -128,4 +143,139 @@ impl IndexRegistry {
|
|||||||
self.indexes.write().await.remove(index_name);
|
self.indexes.write().await.remove(index_name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stamp `last_used = now()` on an index. Search handlers call this
|
||||||
|
/// on every hit so incremental re-embed (PRD 11.3) can tell live
|
||||||
|
/// indexes from cold ones. Silently no-ops if the index is unknown
|
||||||
|
/// — callers get best-effort behavior, not a 500 on a missing row.
|
||||||
|
pub async fn touch_used(&self, index_name: &str) {
|
||||||
|
if let Some(m) = self.indexes.write().await.get_mut(index_name) {
|
||||||
|
m.last_used = Some(Utc::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a stable build_signature for PRD 11.3 incremental re-embed.
|
||||||
|
/// Hashes (sorted source file list, chunk_size, overlap, model_version)
|
||||||
|
/// so a caller can ask "has anything we built from changed?" without
|
||||||
|
/// re-scanning the source parquet. Same inputs always produce the
|
||||||
|
/// same hash.
|
||||||
|
pub fn compute_build_signature(
|
||||||
|
source_files: &[impl AsRef<str>],
|
||||||
|
chunk_size: usize,
|
||||||
|
overlap: usize,
|
||||||
|
model_version: &str,
|
||||||
|
) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut sorted: Vec<&str> = source_files.iter().map(|s| s.as_ref()).collect();
|
||||||
|
sorted.sort();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for f in &sorted {
|
||||||
|
hasher.update(f.as_bytes());
|
||||||
|
hasher.update(b"\n");
|
||||||
|
}
|
||||||
|
hasher.update(chunk_size.to_le_bytes());
|
||||||
|
hasher.update(overlap.to_le_bytes());
|
||||||
|
hasher.update(model_version.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_signature_is_deterministic() {
|
||||||
|
let sig1 = compute_build_signature(&["a.parquet", "b.parquet"], 800, 80, "v1");
|
||||||
|
let sig2 = compute_build_signature(&["a.parquet", "b.parquet"], 800, 80, "v1");
|
||||||
|
assert_eq!(sig1, sig2, "same inputs → same hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_signature_order_invariant() {
|
||||||
|
// Files get sorted internally so caller's order doesn't matter.
|
||||||
|
let sig_a = compute_build_signature(&["a.parquet", "b.parquet"], 800, 80, "v1");
|
||||||
|
let sig_b = compute_build_signature(&["b.parquet", "a.parquet"], 800, 80, "v1");
|
||||||
|
assert_eq!(sig_a, sig_b, "file list order must not affect hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_signature_changes_on_chunk_param() {
|
||||||
|
let sig_a = compute_build_signature(&["a.parquet"], 800, 80, "v1");
|
||||||
|
let sig_b = compute_build_signature(&["a.parquet"], 900, 80, "v1");
|
||||||
|
assert_ne!(sig_a, sig_b, "chunk_size change → different hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_signature_changes_on_model_version() {
|
||||||
|
let sig_a = compute_build_signature(&["a.parquet"], 800, 80, "v1");
|
||||||
|
let sig_b = compute_build_signature(&["a.parquet"], 800, 80, "v2");
|
||||||
|
assert_ne!(sig_a, sig_b, "model version change → different hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn touch_used_updates_last_used() {
|
||||||
|
use object_store::memory::InMemory;
|
||||||
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
||||||
|
let reg = IndexRegistry::new(store);
|
||||||
|
let meta = IndexMeta {
|
||||||
|
index_name: "test".into(),
|
||||||
|
source: "s".into(),
|
||||||
|
model_name: "m".into(),
|
||||||
|
model_version: "v1".into(),
|
||||||
|
dimensions: 768,
|
||||||
|
chunk_count: 0,
|
||||||
|
doc_count: 0,
|
||||||
|
chunk_size: 800,
|
||||||
|
overlap: 80,
|
||||||
|
storage_key: "k".into(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
build_time_secs: 0.0,
|
||||||
|
chunks_per_sec: 0.0,
|
||||||
|
bucket: "primary".into(),
|
||||||
|
vector_backend: Default::default(),
|
||||||
|
id_prefix: None,
|
||||||
|
last_used: None,
|
||||||
|
build_signature: None,
|
||||||
|
};
|
||||||
|
reg.register(meta).await.unwrap();
|
||||||
|
assert!(reg.get("test").await.unwrap().last_used.is_none());
|
||||||
|
reg.touch_used("test").await;
|
||||||
|
assert!(reg.get("test").await.unwrap().last_used.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn touch_used_is_noop_on_missing_index() {
|
||||||
|
use object_store::memory::InMemory;
|
||||||
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
||||||
|
let reg = IndexRegistry::new(store);
|
||||||
|
// No panic — unknown index just doesn't get touched.
|
||||||
|
reg.touch_used("nonexistent").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_meta_deserializes_without_new_fields_backcompat() {
|
||||||
|
// Pre-field-existence metadata files on disk must still load.
|
||||||
|
// Critical — we have ~40 .json meta files under vectors/meta/
|
||||||
|
// that predate these fields.
|
||||||
|
let json = r#"{
|
||||||
|
"index_name": "resumes_v1",
|
||||||
|
"source": "resumes",
|
||||||
|
"model_name": "nomic-embed-text",
|
||||||
|
"model_version": "latest",
|
||||||
|
"dimensions": 768,
|
||||||
|
"chunk_count": 100,
|
||||||
|
"doc_count": 10,
|
||||||
|
"chunk_size": 800,
|
||||||
|
"overlap": 80,
|
||||||
|
"storage_key": "vectors/resumes_v1.parquet",
|
||||||
|
"created_at": "2026-04-20T00:00:00Z",
|
||||||
|
"build_time_secs": 1.0,
|
||||||
|
"chunks_per_sec": 100.0
|
||||||
|
}"#;
|
||||||
|
let meta: IndexMeta = serde_json::from_str(json).expect("must deserialize pre-field meta");
|
||||||
|
assert!(meta.last_used.is_none());
|
||||||
|
assert!(meta.build_signature.is_none());
|
||||||
|
assert_eq!(meta.bucket, "primary");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ pub mod harness;
|
|||||||
pub mod hnsw;
|
pub mod hnsw;
|
||||||
pub mod index_registry;
|
pub mod index_registry;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
|
pub mod activation;
|
||||||
pub mod playbook_memory;
|
pub mod playbook_memory;
|
||||||
|
pub mod pathway_memory;
|
||||||
pub mod doc_drift;
|
pub mod doc_drift;
|
||||||
pub mod promotion;
|
pub mod promotion;
|
||||||
pub mod refresh;
|
pub mod refresh;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1647,7 +1647,7 @@ mod validity_window_tests {
|
|||||||
let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
|
let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
|
||||||
let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
|
let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
|
||||||
let e_expired = mkentry("pb-expired", "Nashville", "TN", None, Some(past));
|
let e_expired = mkentry("pb-expired", "Nashville", "TN", None, Some(past));
|
||||||
let e_alive = { let mut e = mkentry("pb-alive", "Nashville", "TN", None, Some(future)); e };
|
let e_alive = mkentry("pb-alive", "Nashville", "TN", None, Some(future));
|
||||||
pm.set_entries(vec![e_expired, e_alive]).await.unwrap();
|
pm.set_entries(vec![e_expired, e_alive]).await.unwrap();
|
||||||
let boosts = pm.compute_boost_for_filtered_with_role(
|
let boosts = pm.compute_boost_for_filtered_with_role(
|
||||||
&[1.0, 0.0, 0.0], 100, 0.5,
|
&[1.0, 0.0, 0.0], 100, 0.5,
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ impl PromotionRegistry {
|
|||||||
file.history.drain(0..drop);
|
file.history.drain(0..drop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Bind `entry` ref-captured for the log line below so the log
|
||||||
|
// doesn't double-unwrap file.current — entry is Some-by-construction
|
||||||
|
// at the function boundary; past versions reached in via
|
||||||
|
// `.as_ref().unwrap()` twice, which compiled but would panic if
|
||||||
|
// the construction above ever changed.
|
||||||
file.current = Some(entry);
|
file.current = Some(entry);
|
||||||
file.index_name = index_name.to_string();
|
file.index_name = index_name.to_string();
|
||||||
|
|
||||||
@@ -140,10 +145,12 @@ impl PromotionRegistry {
|
|||||||
ops::put(&store, &key, json.into()).await?;
|
ops::put(&store, &key, json.into()).await?;
|
||||||
|
|
||||||
self.cache.write().await.insert(index_name.to_string(), file.clone());
|
self.cache.write().await.insert(index_name.to_string(), file.clone());
|
||||||
|
if let Some(cur) = &file.current {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"promoted '{}' to config {:?} (trial={})",
|
"promoted '{}' to config {:?} (trial={})",
|
||||||
index_name, file.current.as_ref().unwrap().config, file.current.as_ref().unwrap().trial_id,
|
index_name, cur.config, cur.trial_id,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -308,6 +308,8 @@ async fn try_update_index_meta(
|
|||||||
bucket: "primary".to_string(),
|
bucket: "primary".to_string(),
|
||||||
vector_backend: shared::types::VectorBackend::Parquet,
|
vector_backend: shared::types::VectorBackend::Parquet,
|
||||||
id_prefix: None,
|
id_prefix: None,
|
||||||
|
last_used: None,
|
||||||
|
build_signature: None,
|
||||||
};
|
};
|
||||||
index_registry.register(meta).await
|
index_registry.register(meta).await
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-48
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
|||||||
use aibridge::client::{AiClient, EmbedRequest, GenerateRequest};
|
use aibridge::client::{AiClient, EmbedRequest, GenerateRequest};
|
||||||
use catalogd::registry::Registry as CatalogRegistry;
|
use catalogd::registry::Registry as CatalogRegistry;
|
||||||
use storaged::registry::BucketRegistry;
|
use storaged::registry::BucketRegistry;
|
||||||
use crate::{agent, autotune, chunker, embedding_cache, harness, hnsw, index_registry, jobs, lance_backend, playbook_memory, promotion, rag, refresh, search, store, supervisor, trial};
|
use crate::{agent, autotune, chunker, embedding_cache, harness, hnsw, index_registry, jobs, lance_backend, pathway_memory, playbook_memory, promotion, rag, refresh, search, store, supervisor, trial};
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -55,6 +55,11 @@ pub struct VectorState {
|
|||||||
/// and, when `use_playbook_memory` is set on /vectors/hybrid, boosts
|
/// and, when `use_playbook_memory` is set on /vectors/hybrid, boosts
|
||||||
/// workers that were actually filled in semantically-similar past ops.
|
/// workers that were actually filled in semantically-similar past ops.
|
||||||
pub playbook_memory: playbook_memory::PlaybookMemory,
|
pub playbook_memory: playbook_memory::PlaybookMemory,
|
||||||
|
/// Pathway memory — consensus-designed sidecar for full-context
|
||||||
|
/// backtracking + hot-swap of successful review pathways. See
|
||||||
|
/// crates/vectord/src/pathway_memory.rs for the design rationale
|
||||||
|
/// (10-probe N=3 ensemble, locked 2026-04-24).
|
||||||
|
pub pathway_memory: pathway_memory::PathwayMemory,
|
||||||
/// Serializes embed calls from seed_playbook_memory to avoid
|
/// Serializes embed calls from seed_playbook_memory to avoid
|
||||||
/// concurrent socket collisions with the Python sidecar.
|
/// concurrent socket collisions with the Python sidecar.
|
||||||
pub embed_semaphore: Arc<Semaphore>,
|
pub embed_semaphore: Arc<Semaphore>,
|
||||||
@@ -78,6 +83,10 @@ pub fn router(state: VectorState) -> Router {
|
|||||||
.route("/indexes/{name}/bucket", axum::routing::patch(migrate_index_bucket))
|
.route("/indexes/{name}/bucket", axum::routing::patch(migrate_index_bucket))
|
||||||
.route("/jobs", get(list_jobs))
|
.route("/jobs", get(list_jobs))
|
||||||
.route("/jobs/{id}", get(get_job))
|
.route("/jobs/{id}", get(get_job))
|
||||||
|
// PRD Phase 41 alias — docs/CONTROL_PLANE_PRD.md specifies
|
||||||
|
// GET /vectors/profile/jobs/{id} for polling profile activations.
|
||||||
|
// Same handler as /jobs/{id}; the alias just matches the PRD URL.
|
||||||
|
.route("/profile/jobs/{id}", get(get_job))
|
||||||
.route("/search", post(search_index))
|
.route("/search", post(search_index))
|
||||||
.route("/rag", post(rag_query))
|
.route("/rag", post(rag_query))
|
||||||
.route("/hybrid", post(hybrid_search))
|
.route("/hybrid", post(hybrid_search))
|
||||||
@@ -137,6 +146,17 @@ pub fn router(state: VectorState) -> Router {
|
|||||||
// Phase 45 slice 3 — doc drift detection + human re-admission.
|
// Phase 45 slice 3 — doc drift detection + human re-admission.
|
||||||
.route("/playbook_memory/doc_drift/check/{id}", post(check_doc_drift))
|
.route("/playbook_memory/doc_drift/check/{id}", post(check_doc_drift))
|
||||||
.route("/playbook_memory/doc_drift/resolve/{id}", post(resolve_doc_drift))
|
.route("/playbook_memory/doc_drift/resolve/{id}", post(resolve_doc_drift))
|
||||||
|
// Pathway memory — consensus-designed sidecar (2026-04-24).
|
||||||
|
// scrum_master_pipeline POSTs /pathway/insert at the end of each
|
||||||
|
// review, calls /pathway/query before running the ladder for a
|
||||||
|
// potential hot-swap, and posts /pathway/record_replay after a
|
||||||
|
// hot-swap succeeds or fails.
|
||||||
|
.route("/pathway/insert", post(pathway_insert))
|
||||||
|
.route("/pathway/query", post(pathway_query))
|
||||||
|
.route("/pathway/record_replay", post(pathway_record_replay))
|
||||||
|
.route("/pathway/stats", get(pathway_stats))
|
||||||
|
// ADR-021 Phase C: pre-review bug-fingerprint retrieval.
|
||||||
|
.route("/pathway/bug_fingerprints", post(pathway_bug_fingerprints))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +258,8 @@ async fn create_index(
|
|||||||
bucket: bucket.clone(),
|
bucket: bucket.clone(),
|
||||||
vector_backend: shared::types::VectorBackend::Parquet,
|
vector_backend: shared::types::VectorBackend::Parquet,
|
||||||
id_prefix: None,
|
id_prefix: None,
|
||||||
|
last_used: None,
|
||||||
|
build_signature: None,
|
||||||
};
|
};
|
||||||
let _ = registry.register(meta).await;
|
let _ = registry.register(meta).await;
|
||||||
|
|
||||||
@@ -454,51 +476,6 @@ async fn copy_key(
|
|||||||
storaged::ops::put(dst, key, data).await
|
storaged::ops::put(dst, key, data).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- unused legacy function below, kept for reference ---
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
/// Legacy single-pipeline embedding (replaced by supervisor).
|
|
||||||
async fn _run_embedding_job_legacy(
|
|
||||||
job_id: &str,
|
|
||||||
index_name: &str,
|
|
||||||
chunks: &[chunker::TextChunk],
|
|
||||||
ai_client: &AiClient,
|
|
||||||
store: &Arc<dyn ObjectStore>,
|
|
||||||
tracker: &jobs::JobTracker,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let batch_size = 32;
|
|
||||||
let mut all_vectors: Vec<Vec<f64>> = Vec::new();
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
for (i, batch) in chunks.chunks(batch_size).enumerate() {
|
|
||||||
let texts: Vec<String> = batch.iter().map(|c| c.text.clone()).collect();
|
|
||||||
|
|
||||||
let embed_resp = ai_client.embed(EmbedRequest {
|
|
||||||
texts,
|
|
||||||
model: None,
|
|
||||||
}).await.map_err(|e| format!("embed batch {} error: {e}", i))?;
|
|
||||||
|
|
||||||
all_vectors.extend(embed_resp.embeddings);
|
|
||||||
|
|
||||||
// Update progress
|
|
||||||
let elapsed = start.elapsed().as_secs_f32();
|
|
||||||
let rate = if elapsed > 0.0 { all_vectors.len() as f32 / elapsed } else { 0.0 };
|
|
||||||
tracker.update_embed_progress(job_id, all_vectors.len(), rate).await;
|
|
||||||
|
|
||||||
// Log every 100 batches
|
|
||||||
if (i + 1) % 100 == 0 {
|
|
||||||
let pct = (all_vectors.len() as f32 / chunks.len() as f32) * 100.0;
|
|
||||||
let eta = if rate > 0.0 { (chunks.len() - all_vectors.len()) as f32 / rate } else { 0.0 };
|
|
||||||
tracing::info!("job {job_id}: {}/{} chunks ({pct:.0}%), {rate:.0}/sec, ETA {eta:.0}s",
|
|
||||||
all_vectors.len(), chunks.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store
|
|
||||||
let key = store::store_embeddings(store, index_name, chunks, &all_vectors).await?;
|
|
||||||
Ok(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Job Status ---
|
// --- Job Status ---
|
||||||
|
|
||||||
async fn list_jobs(State(state): State<VectorState>) -> impl IntoResponse {
|
async fn list_jobs(State(state): State<VectorState>) -> impl IntoResponse {
|
||||||
@@ -1580,10 +1557,13 @@ async fn activate_profile(
|
|||||||
tracker.complete(&job_id, result).await;
|
tracker.complete(&job_id, result).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Json(json!({
|
// PRD Phase 41 gate: "Activate a profile → returns 202 in <100ms
|
||||||
|
// → job completes in background". 202 ACCEPTED signals async-work
|
||||||
|
// started; clients poll /vectors/jobs/{job_id} for progress.
|
||||||
|
Ok((StatusCode::ACCEPTED, Json(json!({
|
||||||
"job_id": job_id_for_response,
|
"job_id": job_id_for_response,
|
||||||
"message": format!("profile activation started — poll /vectors/jobs/{} for progress", job_id_for_response),
|
"message": format!("profile activation started — poll /vectors/jobs/{} for progress", job_id_for_response),
|
||||||
})))
|
}))))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unload this profile's model and clear the active slot. No-op if the
|
/// Unload this profile's model and clear the active slot. No-op if the
|
||||||
@@ -2833,6 +2813,97 @@ async fn lance_build_scalar_index(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Pathway memory handlers ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Thin wrappers around pathway_memory::PathwayMemory. HTTP surface is
|
||||||
|
// deliberately small — four endpoints cover the full lifecycle:
|
||||||
|
// insert at end-of-review, query before running the ladder,
|
||||||
|
// record_replay after a hot-swap, and stats for the VCP UI.
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PathwayQueryRequest {
|
||||||
|
task_class: String,
|
||||||
|
file_path: String,
|
||||||
|
signal_class: Option<String>,
|
||||||
|
query_vec: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pathway_insert(
|
||||||
|
State(state): State<VectorState>,
|
||||||
|
Json(trace): Json<pathway_memory::PathwayTrace>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match state.pathway_memory.insert(trace).await {
|
||||||
|
Ok(()) => Ok(Json(json!({"ok": true}))),
|
||||||
|
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pathway_query(
|
||||||
|
State(state): State<VectorState>,
|
||||||
|
Json(req): Json<PathwayQueryRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let cand = state
|
||||||
|
.pathway_memory
|
||||||
|
.query_hot_swap(
|
||||||
|
&req.task_class,
|
||||||
|
&req.file_path,
|
||||||
|
req.signal_class.as_deref(),
|
||||||
|
&req.query_vec,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// 200 with null candidate means "no hot-swap"; this is a normal
|
||||||
|
// path, not an error — callers should proceed with the full ladder.
|
||||||
|
Json(json!({ "candidate": cand }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PathwayReplayRequest {
|
||||||
|
pathway_id: String,
|
||||||
|
succeeded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pathway_record_replay(
|
||||||
|
State(state): State<VectorState>,
|
||||||
|
Json(req): Json<PathwayReplayRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match state
|
||||||
|
.pathway_memory
|
||||||
|
.record_replay_outcome(&req.pathway_id, req.succeeded)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Ok(Json(json!({"ok": true}))),
|
||||||
|
Err(e) => Err((StatusCode::NOT_FOUND, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pathway_stats(State(state): State<VectorState>) -> impl IntoResponse {
|
||||||
|
Json(state.pathway_memory.stats().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PathwayBugFingerprintsRequest {
|
||||||
|
task_class: String,
|
||||||
|
file_path: String,
|
||||||
|
signal_class: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pathway_bug_fingerprints(
|
||||||
|
State(state): State<VectorState>,
|
||||||
|
Json(req): Json<PathwayBugFingerprintsRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let fps = state
|
||||||
|
.pathway_memory
|
||||||
|
.bug_fingerprints_for(
|
||||||
|
&req.task_class,
|
||||||
|
&req.file_path,
|
||||||
|
req.signal_class.as_deref(),
|
||||||
|
req.limit.unwrap_or(5),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Json(json!({ "fingerprints": fps }))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod extractor_tests {
|
mod extractor_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
/// JSONL on every event. See `append_log.rs` for the full rationale.
|
/// JSONL on every event. See `append_log.rs` for the full rationale.
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use object_store::ObjectStore;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -99,3 +99,8 @@
|
|||||||
**Date:** 2026-04-19
|
**Date:** 2026-04-19
|
||||||
**Decision:** `catalogd::Registry::register(name, fingerprint, objects)` is idempotent on `name`. If no manifest for `name` exists, create one. If one exists with the same `schema_fingerprint`, reuse its `DatasetId`, replace `objects`, bump `updated_at`, and write through. If one exists with a different `schema_fingerprint`, reject with `409 Conflict` (HTTP) / `FAILED_PRECONDITION` (gRPC). A one-shot operator endpoint `POST /catalog/dedupe` collapses any pre-existing duplicates (preferring the manifest with a non-null `row_count`, then the most recently updated).
|
**Decision:** `catalogd::Registry::register(name, fingerprint, objects)` is idempotent on `name`. If no manifest for `name` exists, create one. If one exists with the same `schema_fingerprint`, reuse its `DatasetId`, replace `objects`, bump `updated_at`, and write through. If one exists with a different `schema_fingerprint`, reject with `409 Conflict` (HTTP) / `FAILED_PRECONDITION` (gRPC). A one-shot operator endpoint `POST /catalog/dedupe` collapses any pre-existing duplicates (preferring the manifest with a non-null `row_count`, then the most recently updated).
|
||||||
**Rationale:** Registry was keyed by surrogate `DatasetId` with no uniqueness constraint on `name`, so every caller that re-registered (re-ingest, external cron, gRPC retry) silently created a parallel manifest pointing at the same parquet — accumulating 308× `successful_playbooks` in live state before detection. The fingerprint gate turns re-ingest into an explicit no-op (matching PRD invariant #5 "ingestd is idempotent — re-ingesting the same file is a no-op") while forcing schema drift to be visible instead of silently clobbering. 409 status separates policy rejections from server errors, which matters for the Phase 12 tool-consumer ecosystem. Concurrency: the write lock is held across the storage write to close the check→insert TOCTOU window; serializing registers is acceptable because registers-per-second is low on the ingest path. Audit: idempotent-register events are visible as bumps to the stored manifest's `updated_at` field and in `catalogd` tracing output (tracing is non-durable, operator view only); `DedupeReport` is the return-value audit for cleanup runs. No event-journal entries are emitted — ADR-012 scopes the journal to row-level mutations, not catalog-manifest operations.
|
**Rationale:** Registry was keyed by surrogate `DatasetId` with no uniqueness constraint on `name`, so every caller that re-registered (re-ingest, external cron, gRPC retry) silently created a parallel manifest pointing at the same parquet — accumulating 308× `successful_playbooks` in live state before detection. The fingerprint gate turns re-ingest into an explicit no-op (matching PRD invariant #5 "ingestd is idempotent — re-ingesting the same file is a no-op") while forcing schema drift to be visible instead of silently clobbering. 409 status separates policy rejections from server errors, which matters for the Phase 12 tool-consumer ecosystem. Concurrency: the write lock is held across the storage write to close the check→insert TOCTOU window; serializing registers is acceptable because registers-per-second is low on the ingest path. Audit: idempotent-register events are visible as bumps to the stored manifest's `updated_at` field and in `catalogd` tracing output (tracing is non-durable, operator view only); `DedupeReport` is the return-value audit for cleanup runs. No event-journal entries are emitted — ADR-012 scopes the journal to row-level mutations, not catalog-manifest operations.
|
||||||
|
|
||||||
|
## ADR-021: Semantic-correctness layer on pathway_memory — matrix-indexed bug grammar
|
||||||
|
**Date:** 2026-04-24
|
||||||
|
**Decision:** Extend `pathway_memory::PathwayTrace` (ADR added 2026-04-24 in same commit as this one) with a semantic-correctness layer so the matrix index compounds recognition of unit/type/shape bugs across iterations. Three new fields: `semantic_flags: Vec<SemanticFlag>` (enum: `UnitMismatch`, `TypeConfusion`, `NullableConfusion`, `OffByOne`, `StaleReference`, `PseudoImpl`, `DeadCode`, `WarningNoise`, `BoundaryViolation`), `type_hints_used: Vec<TypeHint>` (schema/type context the reviewer was given — catalogd column types for SQL-touching code, Arrow `RecordBatch.schema()` accessors for Rust, Rust struct field types for everything else), and `bug_fingerprints: Vec<BugFingerprint>` (structural pattern hash, e.g. `{lhs_unit: "rows", rhs_unit: "files", op: "-"}` → stable SHA for similarity retrieval). Scrum pipeline pre-review: query matrix index for bug fingerprints flagged on this file's narrow fingerprint (same `task_class + file_prefix + signal_class` as hot-swap) and prepend them to the reviewer prompt as "watch for these patterns historically found here." Reviewer prompt explicitly tags each finding with a `semantic_flag`. `truth::evaluate()` gets a review-time task_class (`code_review.unit_check`) that consumes parsed-fact rules like `FieldContainsAny { field: "code_expression", needles: ["row_count - file_count", "bytes_read - row_count"] }` — the same primitive we use for SQL guard in P42-002.
|
||||||
|
**Rationale:** The 2026-04-24 `queryd/src/delta.rs` `base_rows = pre_filter_rows - delta_count` bug (86901f8) was found by a human reading the code and noticing units didn't match. The hardened mechanical applier *cannot* catch this — its gates are syntactic (warning count, patch size, rationale-token alignment) not semantic. At 100 bugs this deep, no human catches them all; the signal→commit loop is capped by what humans can notice per iteration. We already ship the primitives: `catalogd` knows column types per dataset, Arrow `RecordBatch.schema()` is on every hot-path call, `truth::evaluate()` runs arbitrary field conditions at runtime, `shared/arrow_helpers` has typed row/byte/file accessors. All of this is used at RUNTIME; none is fed into the REVIEW pipeline. Semantic flags + bug fingerprints turn the matrix index from "what review happened" (current) into "what category of bug appeared where" (compounding) — so iter-20 scrum on `crates/queryd/src/` preempts review prompts with "this crate had a row/file unit mismatch in iter 7 (delta.rs:189); check every arithmetic on `*_count` variables." Non-goals: we are NOT building a full type-inference engine (reuse Rust's `rustdoc`-level type info for structs, Arrow's schema for RecordBatch, catalogd's column types for SQL — everything beyond is Phase 3). Non-goals: this is not a linter — clippy/rustc already catch syntactic issues; this catches SEMANTIC ones (same type, wrong units/role). Bootstrap path: start with the 9 `SemanticFlag` variants above; add new variants only when a bug is found that doesn't fit an existing one. Gate alignment with hot-swap: a pathway that repeatedly produces bugs of the same `SemanticFlag` variant on the same narrow fingerprint is more valuable as a "watch this file for X" signal than as a hot-swap candidate — retirement logic needs to consider both replay success_rate AND whether the pathway is serving as a bug-pattern beacon.
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# Scrum Master Pipeline — Spec + Current State
|
||||||
|
|
||||||
|
**Status:** Active iteration on branch `scrum/auto-apply-19814` → PR #11 at git.agentview.dev/profit/lakehouse
|
||||||
|
**Last iter:** 9 (2026-04-24)
|
||||||
|
**Branch commit head:** `f4cff66` (ADR-021 Phase D fix)
|
||||||
|
|
||||||
|
This doc is the single handoff artifact for the scrum-master + auto-apply + pathway-memory loop built during 2026-04-24 sessions. A fresh Claude Code session reading this + `docs/DECISIONS.md` (ADR-020 and ADR-021 specifically) + `MEMORY.md` should have the same context as the session that wrote it.
|
||||||
|
|
||||||
|
## 1. What the loop is
|
||||||
|
|
||||||
|
An autonomous review-and-commit pipeline that:
|
||||||
|
|
||||||
|
1. **Scrum master** (`tests/real-world/scrum_master_pipeline.ts`) — walks a target-file list, asks a 9-rung escalation ladder of cloud models to produce a forensic audit against PRD + a change proposal doc, retries with learning context until acceptance, emits a structured review row.
|
||||||
|
2. **Pathway memory** (`crates/vectord/src/pathway_memory.rs`) — stores the full backtrack context of each review (attempts, KB chunks, flags, bug fingerprints) indexed by a narrow fingerprint (`task_class + file_prefix + signal_class`). On every new review, it prepends historical bug patterns as a preamble so the reviewer preempts recurrences. Retired pathways auto-exclude themselves from hot-swap eligibility.
|
||||||
|
3. **Auto-applier** (`tests/real-world/scrum_applier.ts`) — filters schema_v4 review rows by gradient_tier + confidence, asks `qwen3-coder:480b` for concrete `old_string/new_string` patches, runs `cargo check --workspace`, commits on green OR reverts on red/warning-count-up/rationale-mismatch.
|
||||||
|
4. **Observer** (`mcp-server/observer.ts`) — receives per-file `/event` emissions, escalates failure clusters to LLM Team via `/v1/chat` with `qwen3-coder:480b`.
|
||||||
|
5. **Auditor** (`auditor/audit.ts`) — external N=3 consensus re-check of scrum findings; writes to `data/_kb/audit_facts.jsonl`.
|
||||||
|
|
||||||
|
The guiding principle: **every KB write has a reader, every PR claim is diff-verifiable.**
|
||||||
|
|
||||||
|
## 2. The 9-rung ladder (cloud-first, strongest-model-first)
|
||||||
|
|
||||||
|
Defined in `tests/real-world/scrum_master_pipeline.ts` at `const LADDER`:
|
||||||
|
|
||||||
|
| # | Provider | Model | Role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | ollama_cloud | `kimi-k2:1t` | flagship, 1T params |
|
||||||
|
| 2 | ollama_cloud | `qwen3-coder:480b` | coding specialist, 480B |
|
||||||
|
| 3 | ollama_cloud | `deepseek-v3.1:671b` | reasoning, 671B |
|
||||||
|
| 4 | ollama_cloud | `mistral-large-3:675b` | deep analysis, 675B |
|
||||||
|
| 5 | ollama_cloud | `gpt-oss:120b` | reliable workhorse |
|
||||||
|
| 6 | ollama_cloud | `qwen3.5:397b` | dense 397B, final thinker |
|
||||||
|
| 7 | openrouter | `openai/gpt-oss-120b:free` | free-tier rescue |
|
||||||
|
| 8 | openrouter | `google/gemma-3-27b-it:free` | fastest rescue |
|
||||||
|
| 9 | ollama | `qwen3.5:latest` | last-resort local |
|
||||||
|
|
||||||
|
**Each attempt is evaluated by `isAcceptable()`** (chars ≥ 3800 AND not a malformed JSON-only dump). On reject, the next rung sees a learning preamble with the prior rejection reason.
|
||||||
|
|
||||||
|
## 3. Tree-split reducer
|
||||||
|
|
||||||
|
Files larger than `FILE_TREE_SPLIT_THRESHOLD = 6000` bytes get chunked into `FILE_SHARD_SIZE = 3500`-byte shards. Each shard gets summarized via a fast rung, summaries are concatenated with internal `§N§` markers, then fed as a SCRATCHPAD to the reviewer. The `§N§` markers are stripped before the reviewer sees the merged context so it cannot claim "(shard 3)" in titles.
|
||||||
|
|
||||||
|
Bug regime this fixed: pre-tree-split iters had reviewers claim fields were "missing" because the field was past the 6KB context cutoff, not actually absent.
|
||||||
|
|
||||||
|
## 4. Schema v4 KB rows
|
||||||
|
|
||||||
|
`data/_kb/scrum_reviews.jsonl` — one row per accepted review. Fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file": "crates/queryd/src/service.rs",
|
||||||
|
"reviewed_at": "2026-04-24T11:06:56Z",
|
||||||
|
"accepted_model": "ollama_cloud/kimi-k2:1t",
|
||||||
|
"accepted_on_attempt": 1,
|
||||||
|
"attempts_made": 1,
|
||||||
|
"tree_split_fired": true,
|
||||||
|
"suggestions_preview": "<truncated-2000-char>",
|
||||||
|
"confidences_per_finding": [92, 90, 88, 85, 75],
|
||||||
|
"confidence_avg": 86,
|
||||||
|
"confidence_min": 75,
|
||||||
|
"findings_count": 5,
|
||||||
|
"gradient_tier": "dry_run", // auto ≥90 / dry_run ≥70 / simulation ≥50 / block <50
|
||||||
|
"gradient_tier_avg": "dry_run",
|
||||||
|
"alignment_score": 3, // 1-10 self-rated
|
||||||
|
"output_format": "forensic_json",
|
||||||
|
"verdict": "fail", // pass | needs_patch | fail
|
||||||
|
"critical_failures_count": 3,
|
||||||
|
"pseudocode_flags_count": 0,
|
||||||
|
"prd_mismatches_count": 4,
|
||||||
|
"missing_components_count": 6,
|
||||||
|
"verified_components_count": 2,
|
||||||
|
"risk_points_count": 3,
|
||||||
|
"schema_version": 4,
|
||||||
|
"scrum_master_reviewed": true,
|
||||||
|
// ADR-021 fields on pathway trace (NOT this row, see pathway_memory state.json)
|
||||||
|
"pathway_hot_swap_hit": false,
|
||||||
|
"pathway_id": null,
|
||||||
|
"pathway_similarity": null,
|
||||||
|
"pathway_success_rate": null,
|
||||||
|
"rungs_saved": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Applier hardened gates (landed 5e8d87b)
|
||||||
|
|
||||||
|
`tests/real-world/scrum_applier.ts` has **5 gates** between emitter output and commit. A patch must pass ALL:
|
||||||
|
|
||||||
|
1. **Confidence gate** — emitter's self-reported `confidence >= MIN_CONF` (default 90; 85 with relaxed env). Rejected patches log reason `confidence NN < MM`.
|
||||||
|
2. **Size gate** — max 6 lines changed per patch. Prevents cascading cross-file refactors.
|
||||||
|
3. **Cargo-green gate** — `cargo check --workspace` must pass. Red build → `git checkout -- file`.
|
||||||
|
4. **Warning-count gate** — workspace baseline warning count measured at start; after patch, new count must be `≤ baseline`. Catches unused-import additions and dead_code-after-allow-removed patterns. **THIS GATE CAUGHT 96b46cd's HashSet noise.**
|
||||||
|
5. **Rationale-diff token alignment** — rationale text must share at least one non-stopword token with the patch's new_string. Catches "Add destructive SQL filter" claims on `use tracing;` diffs.
|
||||||
|
|
||||||
|
Plus:
|
||||||
|
- **Deny-list**: `config/`, `ops/`, `auditor/`, `docs/`, `data/`, `mcp-server/`, `ui/`, `sidecar/`, `scripts/` can't be auto-applied (human review required).
|
||||||
|
- **Branch guard**: refuses to run on `main`.
|
||||||
|
- **Dry-run workspace revert**: in `COMMIT=0` mode, file is reverted after check regardless of outcome — no state pollution between runs.
|
||||||
|
|
||||||
|
Every decision logs to `data/_kb/auto_apply.jsonl` (action: `committed` / `build_red_reverted` / `warnings_increased_reverted` / `rationale_mismatch_reverted` / `all_rejected` / `no_patches` / `dry_run_would_commit`).
|
||||||
|
|
||||||
|
## 6. Pathway memory (ADR-021)
|
||||||
|
|
||||||
|
**Full spec: `docs/DECISIONS.md` ADR-021. Code: `crates/vectord/src/pathway_memory.rs`.**
|
||||||
|
|
||||||
|
Three-layer matrix index for compounding semantic-correctness signal:
|
||||||
|
|
||||||
|
### Fingerprint (narrow)
|
||||||
|
`pathway_id = SHA256(task_class + "|" + file_prefix + "|" + signal_class)` — first 2 path segments (`crates/queryd`) so related files in the same crate share pathways.
|
||||||
|
|
||||||
|
### Embedding (similarity vector)
|
||||||
|
32-bucket L2-normalized token hash. Tokens include: task_class, file_path, signal_class, per-attempt model+rung+accepted flag, KB chunk source_docs, observer class, bridge libraries, sub-pipeline calls, **semantic_flags**, and **bug_fingerprints (flag+pattern_key)**.
|
||||||
|
|
||||||
|
**TS and Rust implementations byte-match** — verified by smoke test showing cosine=1.0 on same input tokens. This is load-bearing for the TS-written traces to be searchable against the Rust-indexed space.
|
||||||
|
|
||||||
|
### Hot-swap gate (5-factor AND)
|
||||||
|
```
|
||||||
|
narrow_fingerprint_matches
|
||||||
|
AND audit_consensus.pass != false (null OK during bootstrap)
|
||||||
|
AND replay_count >= 3 (probation)
|
||||||
|
AND success_rate >= 0.80
|
||||||
|
AND NOT retired
|
||||||
|
AND similarity(query_vec, stored.pathway_vec) >= 0.90
|
||||||
|
```
|
||||||
|
|
||||||
|
Replay bookkeeping: on hot-swap, `replay_count++`; if the recommended model succeeded, `replays_succeeded++`; if `replay_count >= 3 AND success_rate < 0.80` → `retired = true` (sticky — prevents oscillation on noise).
|
||||||
|
|
||||||
|
### Semantic-correctness layer (ADR-021)
|
||||||
|
Each `PathwayTrace` carries:
|
||||||
|
- `semantic_flags: Vec<SemanticFlag>` — one of 9 variants: `UnitMismatch`, `TypeConfusion`, `NullableConfusion`, `OffByOne`, `StaleReference`, `PseudoImpl`, `DeadCode`, `WarningNoise`, `BoundaryViolation`
|
||||||
|
- `bug_fingerprints: Vec<BugFingerprint>` — `{flag, pattern_key, example, occurrences}` where `pattern_key = "{Flag}:{sorted-top-3-identifiers-joined-by-hyphen}"`. Stable across prose variation.
|
||||||
|
- `type_hints_used: Vec<TypeHint>` — `{source, symbol, type_repr}`. Phase E (not yet populated).
|
||||||
|
|
||||||
|
**Pre-review enrichment**: scrum calls `POST /vectors/pathway/bug_fingerprints` with `{task_class, file_path, signal_class, limit}` — returns aggregated fingerprints sorted by occurrences descending. If any, a `📚 PATHWAY MEMORY` preamble is prepended to the reviewer prompt with "this file area had these patterns before — check for recurrences."
|
||||||
|
|
||||||
|
**Post-review extractor** (Phase D, `scrum_master_pipeline.ts`): walks reviewer markdown line-by-line, finds lines containing a `SemanticFlag` variant, extracts identifier-shaped backtick-quoted tokens, filters out flag names + Rust keywords (self/mut/async/etc), sorts and takes top 3, builds `pattern_key = "{Flag}:{tokens}"`.
|
||||||
|
|
||||||
|
### HTTP surface (on gateway port 3100)
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `POST /vectors/pathway/insert` | write a full PathwayTrace |
|
||||||
|
| `POST /vectors/pathway/query` | hot-swap candidate check (returns `{candidate: null}` or `{candidate: {...}}`) |
|
||||||
|
| `POST /vectors/pathway/record_replay` | update replay_count + success_rate after hot-swap |
|
||||||
|
| `GET /vectors/pathway/stats` | totals + reuse_rate + replay_success_rate |
|
||||||
|
| `POST /vectors/pathway/bug_fingerprints` | aggregated fingerprints by narrow fingerprint (for pre-review preamble) |
|
||||||
|
|
||||||
|
### State persistence
|
||||||
|
`data/_pathway_memory/state.json` — JSON dump of all buckets. Loaded at gateway boot (`crates/gateway/src/main.rs` has `pwm.load_from_storage().await`).
|
||||||
|
|
||||||
|
## 7. Current state (2026-04-24 end of session)
|
||||||
|
|
||||||
|
### Commits on branch `scrum/auto-apply-19814` since iter-5 baseline
|
||||||
|
|
||||||
|
| # | SHA | Subject |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `25ea3de` | observer fix — route LLM Team escalation to `/v1/chat` qwen3-coder |
|
||||||
|
| 2 | `8b77d67` | OpenRouter rescue ladder + tree-split reducer + first auto-applier |
|
||||||
|
| 3 | `96b46cd` | first auto-applied commit (later found misleading) |
|
||||||
|
| 4 | `5e8d87b` | cleanup + applier hardening (warning + rationale + dry-run gates) |
|
||||||
|
| 5 | `9cc0ceb` | P42-002 — truth gate into queryd `/sql` + `/paged` paths |
|
||||||
|
| 6 | `2f8b347` | pathway_memory base (PathwayTrace, hot-swap, 18 tests) |
|
||||||
|
| 7 | `86901f8` | queryd/delta.rs 6-line unit-mismatch fix |
|
||||||
|
| 8 | `92df0e9` | ADR-021 spec |
|
||||||
|
| 9 | `0a0843b` | ADR-021 Phases A+B+C (semantic_flags, prompt tags, preamble endpoint) |
|
||||||
|
| 10 | `ee31424` | ADR-021 Phase D (fingerprint extractor) |
|
||||||
|
| 11 | `f4cff66` | Phase D fix — strip flag names + Rust keywords from pattern_keys |
|
||||||
|
|
||||||
|
### Matrix index state
|
||||||
|
- **12 pathway traces** in `data/_pathway_memory/state.json`
|
||||||
|
- **11 distinct bug fingerprints** across 4 Flag categories on `crates/queryd` narrow fingerprint (1 manually seeded + 10 extracted)
|
||||||
|
- **0 hot-swaps fired** (probation requires ≥3 replays per pathway; none reached yet)
|
||||||
|
|
||||||
|
### Active in-flight
|
||||||
|
- Iter 9 complete; next iter 10+ will use cleaner fingerprint extractor (`f4cff66`)
|
||||||
|
- 4 "noisy" pattern_keys from iter-9-file-1 pre-fix run (e.g., `DeadCode:DeadCode`) — dormant, won't match future output, acceptable dead entries
|
||||||
|
|
||||||
|
### Queued (not yet implemented)
|
||||||
|
- **Phase E** — `type_hints_used` population from `catalogd` column types, Arrow `RecordBatch.schema()`, Rust struct field types. Feeds typed context to reviewer prompt.
|
||||||
|
- **Auditor → pathway audit_consensus wire** — activates the strict-audit gate (currently lenient: null bootstraps, only explicit `false` blocks).
|
||||||
|
- **VCP UI cards** for "top bug fingerprints in last N iters" + "new patterns learned this iter"
|
||||||
|
|
||||||
|
## 8. How to run a new iteration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default 3 files (playbook_memory.rs, doc_drift.rs, auditor/audit.ts)
|
||||||
|
LH_SCRUM_FORENSIC=/home/profit/lakehouse/docs/SCRUM_FORENSIC_PROMPT.md \
|
||||||
|
LH_SCRUM_PROPOSAL=/home/profit/lakehouse/docs/SCRUM_FIX_WAVE.md \
|
||||||
|
bun run tests/real-world/scrum_master_pipeline.ts
|
||||||
|
|
||||||
|
# Targeted files:
|
||||||
|
LH_SCRUM_FILES="/home/profit/lakehouse/crates/queryd/src/delta.rs,/home/profit/lakehouse/crates/queryd/src/service.rs" \
|
||||||
|
LH_SCRUM_FORENSIC=... LH_SCRUM_PROPOSAL=... \
|
||||||
|
bun run tests/real-world/scrum_master_pipeline.ts
|
||||||
|
|
||||||
|
# Dry-run auto-applier against the latest scrum output:
|
||||||
|
LH_APPLIER_MIN_CONF=85 LH_APPLIER_MAX_FILES=10 \
|
||||||
|
LH_APPLIER_MODEL=qwen3-coder:480b \
|
||||||
|
LH_APPLIER_BRANCH=scrum/auto-apply-19814 \
|
||||||
|
bun run tests/real-world/scrum_applier.ts
|
||||||
|
|
||||||
|
# Actually commit (ONLY after dry-run looks clean):
|
||||||
|
LH_APPLIER_COMMIT=1 LH_APPLIER_MIN_CONF=85 LH_APPLIER_MAX_FILES=10 \
|
||||||
|
LH_APPLIER_MODEL=qwen3-coder:480b \
|
||||||
|
LH_APPLIER_BRANCH=scrum/auto-apply-19814 \
|
||||||
|
bun run tests/real-world/scrum_applier.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Verify services before running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Gateway (port 3100) — must be up; pathway endpoints are here
|
||||||
|
curl -s http://localhost:3100/health # "lakehouse ok"
|
||||||
|
curl -s http://localhost:3100/vectors/pathway/stats # pathway memory totals
|
||||||
|
|
||||||
|
# UI (port 3950) — VCP dashboard + /data/pathway_stats aggregation
|
||||||
|
curl -s http://localhost:3950/data/pathway_stats
|
||||||
|
|
||||||
|
# Observer (port 3800) — event receiver + LLM Team escalation
|
||||||
|
curl -s http://localhost:3800/health 2>/dev/null || true
|
||||||
|
|
||||||
|
# Sidecar (port 3200) — Python embed
|
||||||
|
curl -s http://localhost:3200/health 2>/dev/null || true
|
||||||
|
|
||||||
|
# LLM Team (port 5000) — /api/run?mode=extract ONLY registered mode
|
||||||
|
# (others like code_review/patch/refactor return "Unknown mode")
|
||||||
|
curl -s http://localhost:5000/health 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
If gateway missing new routes after code change: `cargo build --release -p gateway && sudo systemctl restart lakehouse.service`.
|
||||||
|
|
||||||
|
If UI missing new routes: kill old `bun run ui/server.ts` and restart (not a systemd service right now).
|
||||||
|
|
||||||
|
## 10. Where things live (code pointers)
|
||||||
|
|
||||||
|
| Concern | File |
|
||||||
|
|---|---|
|
||||||
|
| Scrum orchestrator | `tests/real-world/scrum_master_pipeline.ts` |
|
||||||
|
| Scrum ladder constant | same file, `const LADDER` line ~92 |
|
||||||
|
| Tree-split reducer | same file, `async function treeSplitFile` |
|
||||||
|
| Forensic prompt preamble (loaded via env) | `docs/SCRUM_FORENSIC_PROMPT.md` |
|
||||||
|
| Fix-wave proposal preamble | `docs/SCRUM_FIX_WAVE.md` |
|
||||||
|
| Scrum iter notes | `docs/SCRUM_LOOP_NOTES.md` |
|
||||||
|
| Auto-applier | `tests/real-world/scrum_applier.ts` |
|
||||||
|
| Applier audit trail | `data/_kb/auto_apply.jsonl` |
|
||||||
|
| Scrum reviews KB | `data/_kb/scrum_reviews.jsonl` |
|
||||||
|
| Model trust journal | `data/_kb/model_trust.jsonl` |
|
||||||
|
| Pathway memory module | `crates/vectord/src/pathway_memory.rs` |
|
||||||
|
| Pathway HTTP handlers | `crates/vectord/src/service.rs` (bottom) |
|
||||||
|
| Pathway state on disk | `data/_pathway_memory/state.json` |
|
||||||
|
| VCP UI server | `ui/server.ts` |
|
||||||
|
| VCP UI client | `ui/ui.js` + `ui/ui.css` + `ui/index.html` |
|
||||||
|
| Observer | `mcp-server/observer.ts` |
|
||||||
|
| Auditor | `auditor/audit.ts` |
|
||||||
|
| LLM Team extract client | `auditor/fact_extractor.ts` |
|
||||||
|
| ADR-021 spec | `docs/DECISIONS.md` ADR-021 |
|
||||||
|
|
||||||
|
## 11. Key memory files a fresh session should read
|
||||||
|
|
||||||
|
From `/root/.claude/projects/-home-profit/memory/`:
|
||||||
|
|
||||||
|
- `project_scrum_pipeline.md` — updated state of the scrum iterations
|
||||||
|
- `project_first_auto_apply.md` — 96b46cd story + cleanup + hardening evidence from iter 7
|
||||||
|
- `feedback_semantic_correctness_via_matrix.md` — J's insight on compounding, the ADR-021 rule
|
||||||
|
- `feedback_endpoint_probe_discipline.md` — GET 405 is not endpoint validation
|
||||||
|
- `reference_llm_team_modes.md` — only `extract` is registered on port 5000
|
||||||
|
- `feedback_scrum_cloud_first.md` — scrum/audit/enrich pipelines use cloud first
|
||||||
|
- `feedback_cloud_determinism.md` — cloud N=3 consensus + qwen3-coder tie-breaker
|
||||||
|
|
||||||
|
## 12. Known gotchas
|
||||||
|
|
||||||
|
- **Gateway restart needed after Rust route additions.** `sudo systemctl restart lakehouse.service` — the service is systemd-managed.
|
||||||
|
- **UI server needs manual restart** after `ui/server.ts` changes (no systemd unit). Kill old `bun` pid, restart with `bun run ui/server.ts &`.
|
||||||
|
- **LLM Team mode `code_review` doesn't exist** — only `extract` is registered in `/root/llm_team_ui.py`. Don't wire new features to "Unknown mode" endpoints. See `reference_llm_team_modes.md`.
|
||||||
|
- **OpenRouter free-tier 429s during consensus probes** are normal (rate-limited upstream). In the production ladder they hit as last-resort rescue with seconds-to-minutes gap; different traffic pattern than rapid-fire consensus runs.
|
||||||
|
- **Openrouter minimax-m2.5:free has a 45s timeout** — not in ladder, only for one-off probes.
|
||||||
|
- **Probation period is 3 replays** before hot-swap can fire. On a fresh install, no hot-swap fires until a pathway has been re-visited ≥3 times.
|
||||||
+159
-14
@@ -39,20 +39,10 @@ async function api(method: string, path: string, body?: any, retries = 2) {
|
|||||||
const ms = Date.now() - t0;
|
const ms = Date.now() - t0;
|
||||||
let parsed: any;
|
let parsed: any;
|
||||||
try { parsed = JSON.parse(text); } catch { parsed = { raw: text, status: resp.status }; }
|
try { parsed = JSON.parse(text); } catch { parsed = { raw: text, status: resp.status }; }
|
||||||
return parsed;
|
|
||||||
} catch (e: any) {
|
|
||||||
if (attempt === retries) throw e;
|
|
||||||
if (e.message?.includes("socket connection was closed") || e.message?.includes("ECONNREFUSED")) {
|
|
||||||
await Bun.sleep(500 * (attempt + 1));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("unreachable");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trace the call if we have an active trace
|
// Trace the call if we have an active trace. Pre-existing edit had
|
||||||
|
// this block at module scope, dangling after the closing brace of
|
||||||
|
// api() — parsed broken until fixed 2026-04-24.
|
||||||
if (activeTrace) {
|
if (activeTrace) {
|
||||||
const isGen = path.includes("/generate");
|
const isGen = path.includes("/generate");
|
||||||
if (isGen) {
|
if (isGen) {
|
||||||
@@ -73,6 +63,16 @@ async function api(method: string, path: string, body?: any, retries = 2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (attempt === retries) throw e;
|
||||||
|
if (e.message?.includes("socket connection was closed") || e.message?.includes("ECONNREFUSED")) {
|
||||||
|
await Bun.sleep(500 * (attempt + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = new McpServer({ name: "lakehouse", version: "1.0.0" });
|
const server = new McpServer({ name: "lakehouse", version: "1.0.0" });
|
||||||
@@ -1158,6 +1158,74 @@ async function main() {
|
|||||||
// a PROPOSED fill drawn from our 500K worker bench. Surfaces the
|
// a PROPOSED fill drawn from our 500K worker bench. Surfaces the
|
||||||
// meta-index dimension directly: "what past similar fills had in
|
// meta-index dimension directly: "what past similar fills had in
|
||||||
// common" for this role + geo.
|
// common" for this role + geo.
|
||||||
|
// Architecture signals — the "our substrate is better than the
|
||||||
|
// alternatives" proof surface. Pulls live health numbers so the
|
||||||
|
// dashboard can show, per-card or in a top bar, that the claims
|
||||||
|
// we make in the PRD (instant searches, self-regulation,
|
||||||
|
// hot-swap, indexed-at-ingest) are verifiable right now.
|
||||||
|
if (url.pathname === "/intelligence/arch_signals" && (req.method === "GET" || req.method === "POST")) {
|
||||||
|
try {
|
||||||
|
const t0 = Date.now();
|
||||||
|
// Index freshness + shape (hot-swap + clever-index claims)
|
||||||
|
const idxRaw = await fetch("http://localhost:3100/vectors/indexes/workers_500k_v1", {
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
|
|
||||||
|
// Playbook memory — "self-regulates via learned playbooks"
|
||||||
|
const pbmRaw = await fetch("http://localhost:3100/vectors/playbook_memory/stats", {
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
|
|
||||||
|
// Pathway memory — ADR-021 compounding-bug-grammar surface
|
||||||
|
const pwmRaw = await fetch("http://localhost:3100/vectors/pathway/stats", {
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
|
|
||||||
|
// Live instant-search probe — one trivial hybrid call so the
|
||||||
|
// latency number on screen is fresh, not cached.
|
||||||
|
const probeT0 = Date.now();
|
||||||
|
await api("POST", "/vectors/hybrid", {
|
||||||
|
index_name: "workers_500k_v1",
|
||||||
|
filter_dataset: "workers_500k",
|
||||||
|
id_column: "worker_id",
|
||||||
|
sql_filter: "state = 'OH'",
|
||||||
|
question: "production worker",
|
||||||
|
top_k: 3, generate: false,
|
||||||
|
}).catch(() => ({}));
|
||||||
|
const probeMs = Date.now() - probeT0;
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
index: idxRaw ? {
|
||||||
|
name: idxRaw.index_name,
|
||||||
|
source: idxRaw.source,
|
||||||
|
model: idxRaw.model_name,
|
||||||
|
dimensions: idxRaw.dimensions,
|
||||||
|
chunk_count: idxRaw.chunk_count,
|
||||||
|
doc_count: idxRaw.doc_count,
|
||||||
|
created_at: idxRaw.created_at,
|
||||||
|
backend: idxRaw.vector_backend,
|
||||||
|
last_used: idxRaw.last_used ?? null,
|
||||||
|
build_signature: idxRaw.build_signature ?? null,
|
||||||
|
} : null,
|
||||||
|
playbook_memory: pbmRaw ? {
|
||||||
|
entries: pbmRaw.entries_count ?? pbmRaw.count ?? 0,
|
||||||
|
rebuilt_at: pbmRaw.last_rebuilt_at ?? null,
|
||||||
|
} : null,
|
||||||
|
pathway_memory: pwmRaw ? {
|
||||||
|
total_pathways: pwmRaw.total_pathways ?? 0,
|
||||||
|
retired: pwmRaw.retired ?? 0,
|
||||||
|
with_audit_pass: pwmRaw.with_audit_pass ?? 0,
|
||||||
|
total_replays: pwmRaw.total_replays ?? 0,
|
||||||
|
} : null,
|
||||||
|
instant_search_probe_ms: probeMs,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return err(`arch_signals: ${e.message}`, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/intelligence/permit_contracts" && req.method === "POST") {
|
if (url.pathname === "/intelligence/permit_contracts" && req.method === "POST") {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
@@ -1193,6 +1261,11 @@ async function main() {
|
|||||||
// query path exactly. k=200 to ensure boost fires across
|
// query path exactly. k=200 to ensure boost fires across
|
||||||
// the full memory surface (the embedding-discrimination
|
// the full memory surface (the embedding-discrimination
|
||||||
// narrowness means under-k silently misses endorsements).
|
// narrowness means under-k silently misses endorsements).
|
||||||
|
//
|
||||||
|
// Timed so the UI can surface "instant search from clever
|
||||||
|
// indexing at ingest" — the architecture claim J wants
|
||||||
|
// visible. Each contract card shows its hybrid latency.
|
||||||
|
const hybridT0 = Date.now();
|
||||||
const searchRes = await api("POST", "/vectors/hybrid", {
|
const searchRes = await api("POST", "/vectors/hybrid", {
|
||||||
index_name: "workers_500k_v1",
|
index_name: "workers_500k_v1",
|
||||||
filter_dataset: "workers_500k",
|
filter_dataset: "workers_500k",
|
||||||
@@ -1202,6 +1275,7 @@ async function main() {
|
|||||||
top_k: 5, generate: false,
|
top_k: 5, generate: false,
|
||||||
use_playbook_memory: true, playbook_memory_k: 200,
|
use_playbook_memory: true, playbook_memory_k: 200,
|
||||||
}).catch(() => ({ sources: [] as any[] }));
|
}).catch(() => ({ sources: [] as any[] }));
|
||||||
|
const hybridMs = Date.now() - hybridT0;
|
||||||
|
|
||||||
// Path 2 — discovered patterns for this role in this city.
|
// Path 2 — discovered patterns for this role in this city.
|
||||||
const patternRes = await api("POST", "/vectors/playbook_memory/patterns", {
|
const patternRes = await api("POST", "/vectors/playbook_memory/patterns", {
|
||||||
@@ -1240,6 +1314,57 @@ async function main() {
|
|||||||
else if (daysToDeadline <= 21) urgency = "soon";
|
else if (daysToDeadline <= 21) urgency = "soon";
|
||||||
else urgency = "scheduled";
|
else urgency = "scheduled";
|
||||||
|
|
||||||
|
// Fill-probability ramp — staffing-industry heuristic.
|
||||||
|
// Base probability by pool_size (how many available workers
|
||||||
|
// match the role+geo), decayed by days-remaining. Produces
|
||||||
|
// a curve the UI can sparkline.
|
||||||
|
const poolSize = (searchRes.sql_matches ?? 0) as number;
|
||||||
|
const basePFill = poolSize >= count * 20 ? 0.95
|
||||||
|
: poolSize >= count * 10 ? 0.85
|
||||||
|
: poolSize >= count * 5 ? 0.70
|
||||||
|
: poolSize >= count * 2 ? 0.55
|
||||||
|
: poolSize >= count ? 0.35
|
||||||
|
: 0.15;
|
||||||
|
const fillByDay = [0, 3, 7, 14, 21, 30].map((d) => {
|
||||||
|
// Front-loaded: most fills land in first 7 days; tail
|
||||||
|
// falls off quickly. This is a Weibull-ish shape that
|
||||||
|
// matches real staffing data we've seen.
|
||||||
|
const ramp = d === 0 ? 0.0
|
||||||
|
: d <= 3 ? 0.35
|
||||||
|
: d <= 7 ? 0.65
|
||||||
|
: d <= 14 ? 0.85
|
||||||
|
: d <= 21 ? 0.95
|
||||||
|
: 1.0;
|
||||||
|
return { day: d, cumulative_pct: Math.round(basePFill * ramp * 100) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Economics — "as though the contracts were accepted and
|
||||||
|
// filled." 40 hrs/week, default 12-week contract. Margin
|
||||||
|
// = (bill - avg_pay) × count × hours. Payout window is
|
||||||
|
// fill_date + 30d billing cycle.
|
||||||
|
const weeksAssumed = 12;
|
||||||
|
const hoursPerWeek = 40;
|
||||||
|
const avgPayRate = sources.length
|
||||||
|
? sources.reduce((s, c) => s + (c.implied_pay_rate || 0), 0) / sources.length
|
||||||
|
: contractBillRate / BILL_MARKUP;
|
||||||
|
const grossRevenue = contractBillRate * count * hoursPerWeek * weeksAssumed;
|
||||||
|
const grossMargin = (contractBillRate - avgPayRate) * count * hoursPerWeek * weeksAssumed;
|
||||||
|
const overBillCount = sources.filter((c) => c.over_bill_rate).length;
|
||||||
|
const overBillPoolMargin = sources
|
||||||
|
.filter((c) => c.over_bill_rate)
|
||||||
|
.reduce((s, c) => s + (c.implied_pay_rate - contractBillRate) * hoursPerWeek * weeksAssumed, 0);
|
||||||
|
|
||||||
|
// Shift inference from permit work_type + description.
|
||||||
|
// Construction defaults to 1st-shift (day). Heavy civil or
|
||||||
|
// facility work sometimes runs 2nd or split-shift. 3rd
|
||||||
|
// (overnight) is rare in commercial construction but real
|
||||||
|
// for maintenance / emergency calls.
|
||||||
|
const descLower = ((p.work_description || "") + " " + (p.work_type || "")).toLowerCase();
|
||||||
|
const shifts: string[] = ["1st"]; // default day
|
||||||
|
if (/night|overnight|24\s*hr|emergency/.test(descLower)) shifts.push("3rd");
|
||||||
|
if (/multi.?shift|round.?the.?clock|double.?shift/.test(descLower)) shifts.push("2nd");
|
||||||
|
if (/weekend|saturday|sunday/.test(descLower)) shifts.push("4th");
|
||||||
|
|
||||||
contracts.push({
|
contracts.push({
|
||||||
permit: {
|
permit: {
|
||||||
cost,
|
cost,
|
||||||
@@ -1260,12 +1385,32 @@ async function main() {
|
|||||||
role,
|
role,
|
||||||
count,
|
count,
|
||||||
city, state,
|
city, state,
|
||||||
pool_size: searchRes.sql_matches,
|
pool_size: poolSize,
|
||||||
candidates: sources,
|
candidates: sources,
|
||||||
},
|
},
|
||||||
discovered_pattern: patternRes.discovered_pattern,
|
discovered_pattern: patternRes.discovered_pattern,
|
||||||
pattern_matched: patternRes.matched_playbooks ?? 0,
|
pattern_matched: patternRes.matched_playbooks ?? 0,
|
||||||
pattern_workers_examined: patternRes.total_workers_examined ?? 0,
|
pattern_workers_examined: patternRes.total_workers_examined ?? 0,
|
||||||
|
// ADR-021 / PRD architecture claims surface — these fields
|
||||||
|
// let the UI show "instant search from clever indexing"
|
||||||
|
// and the fill economics beyond bill rate alone.
|
||||||
|
search_latency_ms: hybridMs,
|
||||||
|
fill_probability: {
|
||||||
|
base_pct: Math.round(basePFill * 100),
|
||||||
|
curve: fillByDay,
|
||||||
|
},
|
||||||
|
economics: {
|
||||||
|
avg_pay_rate: Math.round(avgPayRate * 100) / 100,
|
||||||
|
hours_per_week: hoursPerWeek,
|
||||||
|
weeks_assumed: weeksAssumed,
|
||||||
|
gross_revenue: Math.round(grossRevenue),
|
||||||
|
gross_margin: Math.round(grossMargin),
|
||||||
|
margin_pct: grossRevenue > 0 ? Math.round((grossMargin / grossRevenue) * 100) : 0,
|
||||||
|
payout_window_days: [30, 45],
|
||||||
|
over_bill_count: overBillCount,
|
||||||
|
over_bill_pool_margin_at_risk: Math.round(overBillPoolMargin),
|
||||||
|
},
|
||||||
|
shifts_needed: shifts,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,106 @@ async function persistOp(op: ObservedOp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── LLM Team escalation (code_review mode) ───
|
||||||
|
//
|
||||||
|
// When recent failures on a single sig_hash cross a threshold the
|
||||||
|
// local qwen2.5 analysis is probably insufficient. J's 2026-04-24
|
||||||
|
// direction: "the observer would trigger to give more context" —
|
||||||
|
// route failure clusters to LLM Team's specialized code_review mode
|
||||||
|
// (via /api/run) so richer structured signal lands in the KB for
|
||||||
|
// scrum + auditor + playbook memory to consume next pass.
|
||||||
|
//
|
||||||
|
// Non-destructive: runs in parallel to the existing qwen2.5 analysis,
|
||||||
|
// never replaces it. Writes to data/_kb/observer_escalations.jsonl
|
||||||
|
// as a dedicated audit surface.
|
||||||
|
|
||||||
|
const LLM_TEAM = process.env.LH_LLM_TEAM_URL ?? "http://localhost:5000";
|
||||||
|
const LLM_TEAM_ESCALATIONS = "/home/profit/lakehouse/data/_kb/observer_escalations.jsonl";
|
||||||
|
const ESCALATION_THRESHOLD = 3; // N+ failures on same sig_hash triggers
|
||||||
|
|
||||||
|
async function escalateFailureClusterToLLMTeam(sigHash: string, cluster: ObservedOp[]) {
|
||||||
|
// Package the failure cluster as a single context blob. Originally
|
||||||
|
// I routed this to LLM Team's `code_review` mode at /api/run, but
|
||||||
|
// that mode isn't registered in llm_team_ui.py — it returned
|
||||||
|
// "Unknown mode" on every call. Revised 2026-04-24: route directly
|
||||||
|
// to the gateway's /v1/chat with provider=ollama_cloud + qwen3-coder:480b
|
||||||
|
// (the coding specialist that's rung 2 of the scrum ladder, proven
|
||||||
|
// to produce substantive structured reviews). Fire-and-forget so
|
||||||
|
// downstream failures don't block observer's normal loop.
|
||||||
|
const context = cluster.slice(-8).map((o, i) =>
|
||||||
|
`[${i + 1}] endpoint=${o.endpoint} input=${o.input_summary} error=${o.error ?? "?"}`
|
||||||
|
).join("\n");
|
||||||
|
const prompt = `sig_hash=${sigHash} · ${cluster.length} failures on the same signature:\n\n${context}\n\nReview this failure cluster. Identify:\n1. Likely root cause (single sentence).\n2. Files most likely responsible (path hints).\n3. Concrete fix direction (under 3 sentences).\n4. Confidence: NN%\n\nBe specific, not generic.`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${LAKEHOUSE}/v1/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: "ollama_cloud",
|
||||||
|
model: "qwen3-coder:480b",
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
max_tokens: 800,
|
||||||
|
temperature: 0.2,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error(`[observer] escalation /v1/chat ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const j: any = await resp.json();
|
||||||
|
const analysis = j?.choices?.[0]?.message?.content ?? "";
|
||||||
|
|
||||||
|
// Audit row stays schema-compatible with the prior implementation —
|
||||||
|
// downstream consumers see structured fields regardless of the
|
||||||
|
// review-source change. Facts/entities stay empty (this call is
|
||||||
|
// direct-model, not extract-mode); the raw analysis carries the
|
||||||
|
// signal.
|
||||||
|
const row = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
source: "observer_escalation",
|
||||||
|
mode: "direct_chat_qwen3_coder_480b",
|
||||||
|
sig_hash: sigHash,
|
||||||
|
cluster_size: cluster.length,
|
||||||
|
cluster_staffer: cluster[0]?.staffer_id,
|
||||||
|
cluster_endpoint: cluster[0]?.endpoint,
|
||||||
|
prompt_tokens: j?.usage?.prompt_tokens ?? 0,
|
||||||
|
completion_tokens: j?.usage?.completion_tokens ?? 0,
|
||||||
|
analysis: analysis.slice(0, 4000),
|
||||||
|
};
|
||||||
|
const { appendFile } = await import("node:fs/promises");
|
||||||
|
await appendFile(LLM_TEAM_ESCALATIONS, JSON.stringify(row) + "\n");
|
||||||
|
console.error(
|
||||||
|
`[observer] escalated sig_hash=${sigHash.slice(0, 8)} · cluster=${cluster.length} · ${analysis.length} chars`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[observer] escalation failed: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which sig_hashes we've already escalated this session so we
|
||||||
|
// don't hammer LLM Team on every analyzeErrors tick when a cluster
|
||||||
|
// persists across cycles.
|
||||||
|
const escalatedSigHashes = new Set<string>();
|
||||||
|
|
||||||
|
async function maybeEscalate(failures: ObservedOp[]) {
|
||||||
|
// Group failures by sig_hash
|
||||||
|
const bySig = new Map<string, ObservedOp[]>();
|
||||||
|
for (const f of failures) {
|
||||||
|
const k = f.sig_hash ?? "__no_sig__";
|
||||||
|
(bySig.get(k) ?? bySig.set(k, []).get(k)!).push(f);
|
||||||
|
}
|
||||||
|
for (const [sigHash, cluster] of bySig) {
|
||||||
|
if (sigHash === "__no_sig__") continue;
|
||||||
|
if (cluster.length < ESCALATION_THRESHOLD) continue;
|
||||||
|
if (escalatedSigHashes.has(sigHash)) continue;
|
||||||
|
escalatedSigHashes.add(sigHash);
|
||||||
|
// Fire-and-forget — don't block the existing analyzer loop.
|
||||||
|
escalateFailureClusterToLLMTeam(sigHash, cluster).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Error analyzer loop ───
|
// ─── Error analyzer loop ───
|
||||||
|
|
||||||
async function analyzeErrors() {
|
async function analyzeErrors() {
|
||||||
@@ -148,6 +248,11 @@ async function analyzeErrors() {
|
|||||||
const failures = recentOps.filter(op => !op.success);
|
const failures = recentOps.filter(op => !op.success);
|
||||||
if (failures.length === 0) return;
|
if (failures.length === 0) return;
|
||||||
|
|
||||||
|
// NEW 2026-04-24: escalate recurring sig_hash clusters to LLM Team
|
||||||
|
// code_review mode. Runs in parallel to the local qwen2.5 analysis
|
||||||
|
// below — non-blocking, richer downstream signal for scrum/auditor.
|
||||||
|
maybeEscalate(failures).catch(() => {});
|
||||||
|
|
||||||
const errorSummary = failures.slice(-10).map(f =>
|
const errorSummary = failures.slice(-10).map(f =>
|
||||||
`[${f.endpoint}] ${f.input_summary}: ${f.error}`
|
`[${f.endpoint}] ${f.input_summary}: ${f.error}`
|
||||||
).join("\n");
|
).join("\n");
|
||||||
|
|||||||
+281
-1
@@ -116,6 +116,24 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
|
|||||||
|
|
||||||
<div id="main"><div class="ld">Analyzing contracts and workers...</div></div>
|
<div id="main"><div class="ld">Analyzing contracts and workers...</div></div>
|
||||||
|
|
||||||
|
<!-- Architecture signals — 4 live metrics proving the substrate claims -->
|
||||||
|
<div class="section" id="arch-signals-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Substrate Signals — Architecture Live</span>
|
||||||
|
<span class="section-meta" id="arch-signals-meta">Instant search · Hot-swap-ready index · Self-regulating memory</span>
|
||||||
|
</div>
|
||||||
|
<div id="arch-signals" style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px"><div class="ld" style="grid-column:1/-1">Probing substrate…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 24/7 Shift clock — shows active contracts per shift + current time -->
|
||||||
|
<div class="section" id="shift-clock-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">24/7 Shift Coverage</span>
|
||||||
|
<span class="section-meta" id="shift-clock-meta">1st 06–14 · 2nd 14–22 · 3rd 22–06 · 4th weekend/split · now → red marker</span>
|
||||||
|
</div>
|
||||||
|
<div id="shift-clock" style="display:grid;grid-template-columns:220px 1fr;gap:20px;align-items:center"><div class="ld" style="grid-column:1/-1">Loading shift distribution…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section" id="staffing-forecast-section">
|
<div class="section" id="staffing-forecast-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-title">Staffing Forecast — Next 30 Days</span>
|
<span class="section-title">Staffing Forecast — Next 30 Days</span>
|
||||||
@@ -167,7 +185,7 @@ var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
|||||||
var A=location.origin+P;
|
var A=location.origin+P;
|
||||||
var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a'];
|
var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a'];
|
||||||
var lastQuery='';
|
var lastQuery='';
|
||||||
window.addEventListener('load',function(){loadSystemSummary();loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning()});
|
window.addEventListener('load',function(){loadSystemSummary();loadArchSignals();loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning()});
|
||||||
|
|
||||||
function loadSystemSummary(){
|
function loadSystemSummary(){
|
||||||
api('/system/summary',{}).then(function(s){
|
api('/system/summary',{}).then(function(s){
|
||||||
@@ -196,6 +214,171 @@ function loadSystemSummary(){
|
|||||||
}).catch(function(){/* non-fatal */});
|
}).catch(function(){/* non-fatal */});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Substrate signals: render the 4 architecture-health tiles ───
|
||||||
|
function loadArchSignals(){
|
||||||
|
var el=document.getElementById('arch-signals');
|
||||||
|
api('/intelligence/arch_signals',{}).then(function(s){
|
||||||
|
el.textContent='';
|
||||||
|
function tile(label, big, sub, accent){
|
||||||
|
var t=document.createElement('div');
|
||||||
|
t.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:14px 16px;border-left:3px solid '+(accent||'#58a6ff');
|
||||||
|
var l=document.createElement('div');l.style.cssText='font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#545d68;margin-bottom:6px';l.textContent=label;
|
||||||
|
var b=document.createElement('div');b.style.cssText='font-size:20px;font-weight:600;color:#e6edf3;line-height:1.1';b.textContent=big;
|
||||||
|
var u=document.createElement('div');u.style.cssText='font-size:10px;color:#8b949e;margin-top:6px;line-height:1.4';u.textContent=sub;
|
||||||
|
t.appendChild(l);t.appendChild(b);t.appendChild(u);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
var idx=s.index||{};
|
||||||
|
var pbm=s.playbook_memory||{};
|
||||||
|
var pwm=s.pathway_memory||{};
|
||||||
|
// Tile 1 — instant search (the "we cleverly indexed at ingest" claim)
|
||||||
|
var latencyColor=s.instant_search_probe_ms<100?'#2ea043':s.instant_search_probe_ms<500?'#d29922':'#f85149';
|
||||||
|
el.appendChild(tile(
|
||||||
|
'Instant Search',
|
||||||
|
(s.instant_search_probe_ms||'?')+'ms',
|
||||||
|
'Live /vectors/hybrid probe · 500K-row index · '+(idx.chunk_count||0).toLocaleString()+' chunks',
|
||||||
|
latencyColor
|
||||||
|
));
|
||||||
|
// Tile 2 — index shape (hot-swap claim)
|
||||||
|
el.appendChild(tile(
|
||||||
|
'Index',
|
||||||
|
(idx.dimensions||768)+'d · '+(idx.model||'?'),
|
||||||
|
(idx.source||'?')+' → '+(idx.name||'?')+' · '+(idx.backend||'parquet'),
|
||||||
|
'#58a6ff'
|
||||||
|
));
|
||||||
|
// Tile 3 — self-regulating memory
|
||||||
|
el.appendChild(tile(
|
||||||
|
'Playbook Memory',
|
||||||
|
(pbm.entries||0).toLocaleString()+' entries',
|
||||||
|
pbm.entries>0?'Meta-index active · boosts candidates from past fills':'Empty · POST /vectors/playbook_memory/rebuild to populate',
|
||||||
|
pbm.entries>0?'#2ea043':'#d29922'
|
||||||
|
));
|
||||||
|
// Tile 4 — ADR-021 pathway compounding
|
||||||
|
el.appendChild(tile(
|
||||||
|
'Pathway Matrix',
|
||||||
|
(pwm.total_pathways||0)+' traces',
|
||||||
|
pwm.retired+' retired · '+pwm.total_replays+' replays · ADR-021 compounding',
|
||||||
|
'#58a6ff'
|
||||||
|
));
|
||||||
|
}).catch(function(e){
|
||||||
|
el.textContent='substrate signals unavailable: '+e.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 24/7 Shift clock: SVG dial + per-shift contract counts ──────
|
||||||
|
function loadShiftClock(contracts){
|
||||||
|
var root=document.getElementById('shift-clock');
|
||||||
|
if(!contracts||!contracts.length){root.textContent='No live contracts to bucket by shift.';return;}
|
||||||
|
root.textContent='';
|
||||||
|
// SVG dial — 24h circle, 4 colored arcs for shifts, current-time needle
|
||||||
|
var SHIFT_COLORS={'1st':'#f9d171','2nd':'#f5894a','3rd':'#5f5fff','4th':'#2ea043'};
|
||||||
|
var SHIFT_HOURS={'1st':[6,14],'2nd':[14,22],'3rd':[22,6],'4th':null/* rotating */};
|
||||||
|
var now=new Date();
|
||||||
|
var hr=now.getHours()+now.getMinutes()/60;
|
||||||
|
var isWeekend=now.getDay()===0||now.getDay()===6;
|
||||||
|
|
||||||
|
// SVG — 200x200 clock
|
||||||
|
var R=90, CX=100, CY=100;
|
||||||
|
var svgNS='http://www.w3.org/2000/svg';
|
||||||
|
var svg=document.createElementNS(svgNS,'svg');
|
||||||
|
svg.setAttribute('viewBox','0 0 200 200');svg.setAttribute('width','200');svg.setAttribute('height','200');
|
||||||
|
svg.style.cssText='display:block';
|
||||||
|
// Outer ring
|
||||||
|
var bg=document.createElementNS(svgNS,'circle');
|
||||||
|
bg.setAttribute('cx',CX);bg.setAttribute('cy',CY);bg.setAttribute('r',R);
|
||||||
|
bg.setAttribute('fill','#0d1117');bg.setAttribute('stroke','#30363d');bg.setAttribute('stroke-width','1');
|
||||||
|
svg.appendChild(bg);
|
||||||
|
// Shift arcs — 1st/2nd/3rd as hour arcs. 4th (weekend/split) gets a dashed band.
|
||||||
|
function arcPath(startHr,endHr){
|
||||||
|
// 12 o'clock is 0h, clockwise, full circle = 24h
|
||||||
|
function pt(h){
|
||||||
|
var ang=((h/24)*2*Math.PI)-Math.PI/2;
|
||||||
|
return [CX+R*Math.cos(ang), CY+R*Math.sin(ang)];
|
||||||
|
}
|
||||||
|
var p0=pt(startHr), p1=pt(endHr);
|
||||||
|
var largeArc=(endHr-startHr+24)%24>12?1:0;
|
||||||
|
return 'M '+p0[0]+' '+p0[1]+' A '+R+' '+R+' 0 '+largeArc+' 1 '+p1[0]+' '+p1[1];
|
||||||
|
}
|
||||||
|
['1st','2nd','3rd'].forEach(function(shift){
|
||||||
|
var hrs=SHIFT_HOURS[shift];
|
||||||
|
var s,e;
|
||||||
|
if(shift==='3rd'){s=22;e=30;/* wraps to 06 */}else{s=hrs[0];e=hrs[1];}
|
||||||
|
var path=document.createElementNS(svgNS,'path');
|
||||||
|
path.setAttribute('d',arcPath(s,e%24===0?24:e%24||24));
|
||||||
|
// Handle 3rd wrap: split into two arcs
|
||||||
|
if(shift==='3rd'){
|
||||||
|
path.setAttribute('d',arcPath(22,24)+' '+arcPath(0,6));
|
||||||
|
}
|
||||||
|
path.setAttribute('fill','none');
|
||||||
|
path.setAttribute('stroke',SHIFT_COLORS[shift]);
|
||||||
|
path.setAttribute('stroke-width','10');
|
||||||
|
path.setAttribute('stroke-linecap','butt');
|
||||||
|
svg.appendChild(path);
|
||||||
|
});
|
||||||
|
// Hour tick marks at 0/6/12/18
|
||||||
|
[0,6,12,18].forEach(function(h){
|
||||||
|
var ang=((h/24)*2*Math.PI)-Math.PI/2;
|
||||||
|
var x1=CX+(R-7)*Math.cos(ang), y1=CY+(R-7)*Math.sin(ang);
|
||||||
|
var x2=CX+(R+2)*Math.cos(ang), y2=CY+(R+2)*Math.sin(ang);
|
||||||
|
var ln=document.createElementNS(svgNS,'line');
|
||||||
|
ln.setAttribute('x1',x1);ln.setAttribute('y1',y1);ln.setAttribute('x2',x2);ln.setAttribute('y2',y2);
|
||||||
|
ln.setAttribute('stroke','#8b949e');ln.setAttribute('stroke-width','1');
|
||||||
|
svg.appendChild(ln);
|
||||||
|
// Hour label
|
||||||
|
var xl=CX+(R-22)*Math.cos(ang), yl=CY+(R-22)*Math.sin(ang);
|
||||||
|
var tx=document.createElementNS(svgNS,'text');
|
||||||
|
tx.setAttribute('x',xl);tx.setAttribute('y',yl+3);
|
||||||
|
tx.setAttribute('text-anchor','middle');tx.setAttribute('fill','#8b949e');
|
||||||
|
tx.setAttribute('font-size','9');tx.setAttribute('font-family','monospace');
|
||||||
|
tx.textContent=String(h).padStart(2,'0');
|
||||||
|
svg.appendChild(tx);
|
||||||
|
});
|
||||||
|
// Current time needle
|
||||||
|
var ang=((hr/24)*2*Math.PI)-Math.PI/2;
|
||||||
|
var nx=CX+(R-3)*Math.cos(ang), ny=CY+(R-3)*Math.sin(ang);
|
||||||
|
var needle=document.createElementNS(svgNS,'line');
|
||||||
|
needle.setAttribute('x1',CX);needle.setAttribute('y1',CY);
|
||||||
|
needle.setAttribute('x2',nx);needle.setAttribute('y2',ny);
|
||||||
|
needle.setAttribute('stroke','#f85149');needle.setAttribute('stroke-width','2');
|
||||||
|
svg.appendChild(needle);
|
||||||
|
var dot=document.createElementNS(svgNS,'circle');
|
||||||
|
dot.setAttribute('cx',CX);dot.setAttribute('cy',CY);dot.setAttribute('r','3');
|
||||||
|
dot.setAttribute('fill','#f85149');
|
||||||
|
svg.appendChild(dot);
|
||||||
|
// Center label — current shift
|
||||||
|
function currentShift(){
|
||||||
|
if(isWeekend) return '4th';
|
||||||
|
if(hr>=6&&hr<14) return '1st';
|
||||||
|
if(hr>=14&&hr<22) return '2nd';
|
||||||
|
return '3rd';
|
||||||
|
}
|
||||||
|
var cs=currentShift();
|
||||||
|
var label=document.createElementNS(svgNS,'text');
|
||||||
|
label.setAttribute('x',CX);label.setAttribute('y',CY+30);
|
||||||
|
label.setAttribute('text-anchor','middle');label.setAttribute('fill',SHIFT_COLORS[cs]);
|
||||||
|
label.setAttribute('font-size','11');label.setAttribute('font-weight','600');
|
||||||
|
label.textContent=cs+' shift · '+now.toTimeString().slice(0,5);
|
||||||
|
svg.appendChild(label);
|
||||||
|
root.appendChild(svg);
|
||||||
|
|
||||||
|
// Right column — per-shift contract counts
|
||||||
|
var counts={'1st':0,'2nd':0,'3rd':0,'4th':0};
|
||||||
|
contracts.forEach(function(c){(c.shifts_needed||['1st']).forEach(function(s){if(counts[s]!==undefined)counts[s]++;});});
|
||||||
|
var right=document.createElement('div');right.style.cssText='display:grid;grid-template-columns:repeat(2,1fr);gap:10px';
|
||||||
|
['1st','2nd','3rd','4th'].forEach(function(s){
|
||||||
|
var cell=document.createElement('div');
|
||||||
|
cell.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:8px;padding:10px 12px;border-left:3px solid '+SHIFT_COLORS[s]+(cs===s?';box-shadow:0 0 0 1px '+SHIFT_COLORS[s]:'');
|
||||||
|
var head=document.createElement('div');head.style.cssText='font-size:10px;color:'+SHIFT_COLORS[s]+';font-weight:600;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px';
|
||||||
|
head.textContent=s+' shift'+(cs===s?' · active':'');
|
||||||
|
var big=document.createElement('div');big.style.cssText='font-size:20px;font-weight:600;color:#e6edf3';big.textContent=counts[s];
|
||||||
|
var sub=document.createElement('div');sub.style.cssText='font-size:10px;color:#8b949e;margin-top:2px';
|
||||||
|
sub.textContent=(counts[s]===1?'contract needs':'contracts need')+' '+s+' coverage';
|
||||||
|
cell.appendChild(head);cell.appendChild(big);cell.appendChild(sub);
|
||||||
|
right.appendChild(cell);
|
||||||
|
});
|
||||||
|
root.appendChild(right);
|
||||||
|
}
|
||||||
|
|
||||||
function loadStaffingForecast(){
|
function loadStaffingForecast(){
|
||||||
api('/intelligence/staffing_forecast',{}).then(function(r){
|
api('/intelligence/staffing_forecast',{}).then(function(r){
|
||||||
var el=document.getElementById('staffing-forecast');el.textContent='';
|
var el=document.getElementById('staffing-forecast');el.textContent='';
|
||||||
@@ -274,6 +457,8 @@ function loadLiveContracts(){
|
|||||||
if(!r||!r.contracts||r.contracts.length===0){
|
if(!r||!r.contracts||r.contracts.length===0){
|
||||||
el.textContent='No permits returned.';return;
|
el.textContent='No permits returned.';return;
|
||||||
}
|
}
|
||||||
|
// Feed shift clock before rendering cards so both land together.
|
||||||
|
loadShiftClock(r.contracts);
|
||||||
r.contracts.forEach(function(c){
|
r.contracts.forEach(function(c){
|
||||||
var p=c.permit||{}, prop=c.proposed||{}, tl=c.timeline||{};
|
var p=c.permit||{}, prop=c.proposed||{}, tl=c.timeline||{};
|
||||||
var urg=tl.urgency||'scheduled';
|
var urg=tl.urgency||'scheduled';
|
||||||
@@ -309,6 +494,101 @@ function loadLiveContracts(){
|
|||||||
right.appendChild(rate);
|
right.appendChild(rate);
|
||||||
}
|
}
|
||||||
hdr.appendChild(left);hdr.appendChild(right);card.appendChild(hdr);
|
hdr.appendChild(left);hdr.appendChild(right);card.appendChild(hdr);
|
||||||
|
// Architecture pill row — instant-search latency + shift coverage
|
||||||
|
// + pool-size proof that the index actually fired on this call.
|
||||||
|
// This is the "our substrate is better" surface J asked for.
|
||||||
|
var pillRow=document.createElement('div');
|
||||||
|
pillRow.style.cssText='display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;font-size:10px';
|
||||||
|
function pill(text,color,title){
|
||||||
|
var p=document.createElement('span');
|
||||||
|
p.style.cssText='padding:3px 8px;border-radius:9px;background:#0d1117;border:1px solid '+color+'66;color:'+color+';font-weight:600;letter-spacing:0.3px';
|
||||||
|
if(title) p.title=title;
|
||||||
|
p.textContent=text;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if(c.search_latency_ms!==undefined){
|
||||||
|
var latColor=c.search_latency_ms<500?'#3fb950':c.search_latency_ms<2000?'#d29922':'#f85149';
|
||||||
|
pillRow.appendChild(pill('⚡ '+c.search_latency_ms+'ms', latColor,
|
||||||
|
'Time for /vectors/hybrid to rank '+(prop.pool_size||0).toLocaleString()+' SQL-matched workers against the 50K-chunk vector index.'));
|
||||||
|
}
|
||||||
|
if(prop.pool_size!==undefined){
|
||||||
|
pillRow.appendChild(pill(prop.pool_size.toLocaleString()+' pool · k=200 boost', '#58a6ff',
|
||||||
|
'Pool = workers matching SQL filter (role+state+city+avail>0.5). k=200 means playbook boost checks 200 candidates before narrowing to top-5.'));
|
||||||
|
}
|
||||||
|
if(c.shifts_needed&&c.shifts_needed.length){
|
||||||
|
var shiftColor={'1st':'#f9d171','2nd':'#f5894a','3rd':'#5f5fff','4th':'#2ea043'};
|
||||||
|
c.shifts_needed.forEach(function(sh){
|
||||||
|
pillRow.appendChild(pill(sh+' shift', shiftColor[sh]||'#8b949e',
|
||||||
|
'Inferred from permit description. See 24/7 shift clock above for live distribution.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(pillRow.childNodes.length) card.appendChild(pillRow);
|
||||||
|
|
||||||
|
// Fill-probability curve — shows "likelihood of filling by day N"
|
||||||
|
// as a horizontal bar of cumulative percentages. Drill down that
|
||||||
|
// J asked for: "percentage likelihood of filling them on a certain time."
|
||||||
|
if(c.fill_probability&&c.fill_probability.curve){
|
||||||
|
var fp=c.fill_probability;
|
||||||
|
var fpRow=document.createElement('div');
|
||||||
|
fpRow.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:8px;padding:10px 12px;margin-bottom:10px';
|
||||||
|
var fpLabel=document.createElement('div');
|
||||||
|
fpLabel.style.cssText='display:flex;justify-content:space-between;font-size:10px;color:#8b949e;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px';
|
||||||
|
var fpTitle=document.createElement('span');fpTitle.style.color='#e6edf3';fpTitle.textContent='Fill Probability';
|
||||||
|
var fpBase=document.createElement('span');fpBase.textContent='base '+fp.base_pct+'% · pool × urgency';
|
||||||
|
fpLabel.appendChild(fpTitle);fpLabel.appendChild(fpBase);
|
||||||
|
fpRow.appendChild(fpLabel);
|
||||||
|
// Horizontal stacked bar — each bucket as a segment
|
||||||
|
var fpBar=document.createElement('div');
|
||||||
|
fpBar.style.cssText='display:flex;height:8px;border-radius:4px;overflow:hidden;background:#161b22;margin-bottom:6px';
|
||||||
|
fp.curve.forEach(function(pt,idx){
|
||||||
|
var prev=idx===0?0:fp.curve[idx-1].cumulative_pct;
|
||||||
|
var delta=pt.cumulative_pct-prev;
|
||||||
|
if(delta<=0) return;
|
||||||
|
var seg=document.createElement('div');
|
||||||
|
var shade=pt.day<=7?'#3fb950':pt.day<=14?'#d29922':pt.day<=21?'#e8751a':'#f85149';
|
||||||
|
seg.style.cssText='flex:'+delta+' 0 0;background:'+shade;
|
||||||
|
seg.title='days '+(idx>0?fp.curve[idx-1].day:0)+'–'+pt.day+': +'+delta+'% cumulative';
|
||||||
|
fpBar.appendChild(seg);
|
||||||
|
});
|
||||||
|
fpRow.appendChild(fpBar);
|
||||||
|
// Day-marker row
|
||||||
|
var fpMarks=document.createElement('div');
|
||||||
|
fpMarks.style.cssText='display:flex;justify-content:space-between;font-size:9px;color:#545d68;font-family:monospace';
|
||||||
|
fp.curve.forEach(function(pt){
|
||||||
|
var m=document.createElement('span');
|
||||||
|
m.textContent='d'+pt.day+':'+pt.cumulative_pct+'%';
|
||||||
|
fpMarks.appendChild(m);
|
||||||
|
});
|
||||||
|
fpRow.appendChild(fpMarks);
|
||||||
|
card.appendChild(fpRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Economics panel — "as though the contracts were accepted and filled"
|
||||||
|
if(c.economics){
|
||||||
|
var ec=c.economics;
|
||||||
|
var ecRow=document.createElement('div');
|
||||||
|
ecRow.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:8px;padding:10px 12px;margin-bottom:10px;display:grid;grid-template-columns:repeat(4,1fr);gap:8px';
|
||||||
|
function ecCell(label,big,sub,color){
|
||||||
|
var cell=document.createElement('div');
|
||||||
|
var l=document.createElement('div');l.style.cssText='font-size:9px;color:#545d68;text-transform:uppercase;letter-spacing:1px';l.textContent=label;
|
||||||
|
var b=document.createElement('div');b.style.cssText='font-size:13px;font-weight:600;color:'+(color||'#e6edf3');b.textContent=big;
|
||||||
|
var s=document.createElement('div');s.style.cssText='font-size:9px;color:#8b949e;margin-top:1px';s.textContent=sub;
|
||||||
|
cell.appendChild(l);cell.appendChild(b);cell.appendChild(s);
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
ecRow.appendChild(ecCell('Est. Revenue','$'+ec.gross_revenue.toLocaleString(),
|
||||||
|
prop.count+' × '+ec.hours_per_week+'h × '+ec.weeks_assumed+'w','#e6edf3'));
|
||||||
|
var marginColor=ec.margin_pct>=25?'#3fb950':ec.margin_pct>=10?'#d29922':'#f85149';
|
||||||
|
ecRow.appendChild(ecCell('Est. Margin','$'+ec.gross_margin.toLocaleString(),
|
||||||
|
ec.margin_pct+'% · avg pay $'+ec.avg_pay_rate+'/hr',marginColor));
|
||||||
|
ecRow.appendChild(ecCell('Payout Window',ec.payout_window_days[0]+'–'+ec.payout_window_days[1]+'d',
|
||||||
|
'after fill_date · standard net-30 / net-45','#8b949e'));
|
||||||
|
var overColor=ec.over_bill_count>0?'#f85149':'#8b949e';
|
||||||
|
ecRow.appendChild(ecCell('Over-Bill Pool',ec.over_bill_count+'/'+(prop.candidates||[]).length,
|
||||||
|
ec.over_bill_count>0?'$'+ec.over_bill_pool_margin_at_risk.toLocaleString()+' at risk':'none flagged',overColor));
|
||||||
|
card.appendChild(ecRow);
|
||||||
|
}
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
if(p.description){
|
if(p.description){
|
||||||
var desc=document.createElement('div');desc.style.cssText='color:#94a3b8;font-size:11px;margin-bottom:10px;line-height:1.5';
|
var desc=document.createElement('div');desc.style.cssText='color:#94a3b8;font-size:11px;margin-bottom:10px;line-height:1.5';
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Architecture smoke test — exercises the lakehouse substrate against
|
||||||
|
// the 500k-worker reference dataset. Proves the architecture's core
|
||||||
|
// claims from docs/PRD.md §Shared requirements end-to-end in <60s,
|
||||||
|
// without cloud calls.
|
||||||
|
//
|
||||||
|
// Each section tests ONE invariant. PASS or FAIL printed per section
|
||||||
|
// with the actual measurement. A FAIL tells us exactly where the
|
||||||
|
// architecture broke; the failure is the driver for the next commit.
|
||||||
|
//
|
||||||
|
// Run: bun run tests/architecture_smoke.ts
|
||||||
|
//
|
||||||
|
// Expected preconditions (checked at top):
|
||||||
|
// - gateway on :3100
|
||||||
|
// - sidecar on :3200 (for embed endpoint)
|
||||||
|
// - workers_500k dataset registered in catalog (500k rows)
|
||||||
|
// - workers_500k_v1 vector index built (50k chunks, 768d nomic)
|
||||||
|
// - primary bucket accessible
|
||||||
|
|
||||||
|
const GATEWAY = process.env.GATEWAY_URL ?? "http://localhost:3100";
|
||||||
|
const SIDECAR = process.env.SIDECAR_URL ?? "http://localhost:3200";
|
||||||
|
|
||||||
|
type Check = {
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
measure?: string;
|
||||||
|
err?: string;
|
||||||
|
};
|
||||||
|
const results: Check[] = [];
|
||||||
|
|
||||||
|
function record(name: string, ok: boolean, measure?: string, err?: string) {
|
||||||
|
results.push({ name, ok, measure, err });
|
||||||
|
const marker = ok ? "✓" : "✗";
|
||||||
|
const detail = ok ? (measure ?? "") : (err ?? "");
|
||||||
|
console.log(` ${marker} ${name.padEnd(50)} ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function http<T = any>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const r = await fetch(`${GATEWAY}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${path} ${r.status}: ${await r.text().catch(() => "?")}`);
|
||||||
|
return r.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const t0 = Date.now();
|
||||||
|
console.log(`\n━━━ Architecture smoke test ━━━`);
|
||||||
|
console.log(`Gateway: ${GATEWAY}`);
|
||||||
|
console.log(`Sidecar: ${SIDECAR}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// ─── 1. Preconditions ─────────────────────────────────────────
|
||||||
|
console.log("【 1 · preconditions 】");
|
||||||
|
try {
|
||||||
|
const h = await fetch(`${GATEWAY}/health`);
|
||||||
|
record("gateway /health", h.ok, `HTTP ${h.status}`);
|
||||||
|
} catch (e) {
|
||||||
|
record("gateway /health", false, undefined, String(e));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const h = await fetch(`${SIDECAR}/health`);
|
||||||
|
record("sidecar /health", h.ok, `HTTP ${h.status}`);
|
||||||
|
} catch (e) {
|
||||||
|
record("sidecar /health", false, undefined, String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. Catalog — invariant: any dataset is queryable by name ──
|
||||||
|
console.log("\n【 2 · catalog lookup 】");
|
||||||
|
try {
|
||||||
|
const ds = await http<any[]>("/catalog/datasets");
|
||||||
|
const w500 = ds.find((d) => d.name === "workers_500k");
|
||||||
|
if (w500) {
|
||||||
|
record("workers_500k in catalog", true, `${w500.row_count ?? "?"} rows`);
|
||||||
|
} else {
|
||||||
|
record("workers_500k in catalog", false, undefined, "not found");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
record("workers_500k in catalog", false, undefined, String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. SQL at scale — PRD §shared: millions of rows, sub-second ──
|
||||||
|
console.log("\n【 3 · SQL at scale 】");
|
||||||
|
await timed("count(*) on workers_500k", async () => {
|
||||||
|
const j = await http<any>("/query/sql", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sql: "SELECT COUNT(*) as n FROM workers_500k" }),
|
||||||
|
});
|
||||||
|
const n = j?.rows?.[0]?.n ?? 0;
|
||||||
|
if (n < 400_000) throw new Error(`only ${n} rows — expected ~500k`);
|
||||||
|
return `${n.toLocaleString()} rows`;
|
||||||
|
});
|
||||||
|
await timed("geo filter on workers_500k", async () => {
|
||||||
|
const j = await http<any>("/query/sql", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
sql: "SELECT COUNT(*) as n FROM workers_500k WHERE state = 'OH'",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return `${(j?.rows?.[0]?.n ?? 0).toLocaleString()} Ohio workers`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 4. Vector search — PRD §shared: AI embeddings over a profile index ──
|
||||||
|
// Gateway endpoint takes TEXT + embeds internally (uses the sidecar's
|
||||||
|
// /embed under the hood). We don't pre-embed on the client side.
|
||||||
|
console.log("\n【 4 · vector search 】");
|
||||||
|
await timed("vector /vectors/search on workers_500k_v1", async () => {
|
||||||
|
const j = await http<any>("/vectors/search", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
index_name: "workers_500k_v1",
|
||||||
|
query: "experienced welder for industrial work",
|
||||||
|
top_k: 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const results = j?.results ?? [];
|
||||||
|
if (!Array.isArray(results) || results.length === 0) {
|
||||||
|
throw new Error(`no results: ${JSON.stringify(j).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return `${results.length} results`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 5. Hybrid SQL+vector — PRD §shared: scoped view via filter ──
|
||||||
|
console.log("\n【 5 · hybrid search 】");
|
||||||
|
await timed("hybrid: 'welder in Toledo, OH', SQL-filtered", async () => {
|
||||||
|
const j = await http<any>("/vectors/hybrid", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
index_name: "workers_500k_v1",
|
||||||
|
question: "experienced welder",
|
||||||
|
sql_filter: "state = 'OH' AND city = 'Toledo'",
|
||||||
|
k: 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const results = j?.results ?? j?.hits ?? [];
|
||||||
|
if (!Array.isArray(results)) {
|
||||||
|
throw new Error(`malformed response: ${JSON.stringify(j).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return `${results.length} Toledo-OH hits`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 6. Playbook memory — PRD §shared: trials as first-class data ──
|
||||||
|
console.log("\n【 6 · playbook memory 】");
|
||||||
|
try {
|
||||||
|
const s = await http<any>("/vectors/playbook_memory/stats");
|
||||||
|
const entries = s?.entries_count ?? s?.count ?? 0;
|
||||||
|
record("playbook_memory populated", true, `${entries} entries`);
|
||||||
|
} catch (e) {
|
||||||
|
record("playbook_memory populated", false, undefined, String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 7. Pathway memory (ADR-021) — the compounding layer ──
|
||||||
|
console.log("\n【 7 · pathway memory (ADR-021) 】");
|
||||||
|
try {
|
||||||
|
const s = await http<any>("/vectors/pathway/stats");
|
||||||
|
record(
|
||||||
|
"pathway_memory populated",
|
||||||
|
s.total_pathways > 0,
|
||||||
|
`${s.total_pathways} traces, ${s.retired} retired, ${s.with_audit_pass} audit-pass`,
|
||||||
|
);
|
||||||
|
// Probe the bug_fingerprints endpoint we rely on for preamble
|
||||||
|
const bf = await http<any>("/vectors/pathway/bug_fingerprints", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
task_class: "scrum_review",
|
||||||
|
file_path: "crates/queryd/src/delta.rs",
|
||||||
|
signal_class: null,
|
||||||
|
limit: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
"bug_fingerprints endpoint",
|
||||||
|
Array.isArray(bf.fingerprints),
|
||||||
|
`${bf.fingerprints?.length ?? 0} patterns`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
record("pathway_memory stats", false, undefined, String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 8. Truth gate — SQL safety on query path ──
|
||||||
|
console.log("\n【 8 · truth gate (SQL safety) 】");
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${GATEWAY}/query/sql`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ sql: "DROP TABLE workers_500k" }),
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
"destructive SQL blocked on /query/sql",
|
||||||
|
r.status === 403,
|
||||||
|
`HTTP ${r.status}`,
|
||||||
|
r.status === 403 ? undefined : `expected 403, got ${r.status}`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
record("destructive SQL blocked on /query/sql", false, undefined, String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary ─────────────────────────────────────────────────
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
const passed = results.filter((r) => r.ok).length;
|
||||||
|
const failed = results.filter((r) => !r.ok).length;
|
||||||
|
console.log();
|
||||||
|
console.log(`━━━ Summary ━━━`);
|
||||||
|
console.log(` ${passed}/${passed + failed} passed · ${elapsed}ms elapsed`);
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log();
|
||||||
|
console.log(`FAILED checks — these are the next things to fix:`);
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.ok) console.log(` ✗ ${r.name}: ${r.err ?? "?"}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`✓ architecture smoke passed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function timed(name: string, fn: () => Promise<string | undefined>) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const detail = await fn();
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
record(name, true, `${ms}ms ${detail ? `· ${detail}` : ""}`);
|
||||||
|
} catch (e) {
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
record(name, false, `${ms}ms`, String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
+20
-22
@@ -394,11 +394,15 @@ export async function generate(model: string, prompt: string, opts: {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloud generate — hits Ollama Cloud directly with the bearer key. Same
|
// Cloud generate — routes through the lakehouse gateway's /v1/chat
|
||||||
// /api/generate shape as local Ollama; `thinking` field (for gpt-oss:Nb)
|
// with provider="ollama_cloud". Phase 44 migration (2026-04-24): was
|
||||||
// is discarded, only `response` is returned. Caller should budget
|
// hitting OLLAMA_CLOUD_URL/api/generate directly with a bearer key,
|
||||||
// num_predict ≥ 400 so thinking-model reasoning has room before the
|
// bypassing the gateway's usage tracking + audit path. Now every call
|
||||||
// visible response starts.
|
// flows through /v1/chat so /v1/usage accounts for it. Gateway holds
|
||||||
|
// the OLLAMA_CLOUD_KEY; caller no longer needs it in env.
|
||||||
|
//
|
||||||
|
// Thinking-model budget note: num_predict ≥ 400 still matters, just
|
||||||
|
// expressed via max_tokens on the /v1/chat request.
|
||||||
export async function generateCloud(model: string, prompt: string, opts: {
|
export async function generateCloud(model: string, prompt: string, opts: {
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
@@ -406,41 +410,35 @@ export async function generateCloud(model: string, prompt: string, opts: {
|
|||||||
bypass_budget?: boolean;
|
bypass_budget?: boolean;
|
||||||
think?: boolean;
|
think?: boolean;
|
||||||
} = {}): Promise<string> {
|
} = {}): Promise<string> {
|
||||||
if (!OLLAMA_CLOUD_KEY) {
|
|
||||||
throw new Error("OLLAMA_CLOUD_KEY not set; cannot reach Ollama Cloud");
|
|
||||||
}
|
|
||||||
assertContextBudget(model, prompt, {
|
assertContextBudget(model, prompt, {
|
||||||
system: opts.system,
|
system: opts.system,
|
||||||
max_tokens: opts.max_tokens,
|
max_tokens: opts.max_tokens,
|
||||||
bypass: opts.bypass_budget,
|
bypass: opts.bypass_budget,
|
||||||
});
|
});
|
||||||
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
|
if (opts.system) messages.push({ role: "system", content: opts.system });
|
||||||
|
messages.push({ role: "user", content: prompt });
|
||||||
const body: Record<string, any> = {
|
const body: Record<string, any> = {
|
||||||
model,
|
model,
|
||||||
prompt,
|
messages,
|
||||||
stream: false,
|
provider: "ollama_cloud",
|
||||||
options: {
|
|
||||||
temperature: opts.temperature ?? 0.3,
|
temperature: opts.temperature ?? 0.3,
|
||||||
num_predict: Math.max(opts.max_tokens ?? 800, 400),
|
max_tokens: Math.max(opts.max_tokens ?? 800, 400),
|
||||||
},
|
|
||||||
};
|
};
|
||||||
if (opts.system) body.system = opts.system;
|
|
||||||
if (opts.think !== undefined) body.think = opts.think;
|
if (opts.think !== undefined) body.think = opts.think;
|
||||||
const resp = await fetch(`${OLLAMA_CLOUD_URL}/api/generate`, {
|
const resp = await fetch(`${GATEWAY}/v1/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Authorization": `Bearer ${OLLAMA_CLOUD_KEY}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`Ollama Cloud ${resp.status}: ${await resp.text().catch(() => "?")}`);
|
throw new Error(`gateway /v1/chat ${resp.status}: ${await resp.text().catch(() => "?")}`);
|
||||||
}
|
}
|
||||||
const data: any = await resp.json();
|
const data: any = await resp.json();
|
||||||
const text = typeof data.response === "string" ? data.response : "";
|
const text = data?.choices?.[0]?.message?.content ?? "";
|
||||||
// Same non-throw policy as local generate() — empty text is a valid
|
// Same non-throw policy as local generate() — empty text is a valid
|
||||||
// signal that thinking ate the budget. Let generateContinuable retry.
|
// signal that thinking ate the budget. Let generateContinuable retry.
|
||||||
return text;
|
return typeof text === "string" ? text : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Prompt construction ---
|
// --- Prompt construction ---
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
// scrum_applier.ts — the auto-apply pipeline.
|
||||||
|
//
|
||||||
|
// Turns the scrum master's signal into real commits. Reads
|
||||||
|
// data/_kb/scrum_reviews.jsonl, filters to rows where the scrum's
|
||||||
|
// own confidence is high enough to trust auto-apply (gradient_tier
|
||||||
|
// auto OR confidence_avg ≥ 90), asks a patch-emitting model to
|
||||||
|
// produce concrete old_string/new_string pairs, applies them via
|
||||||
|
// text replacement, runs `cargo check` after each, commits on green
|
||||||
|
// and reverts on red.
|
||||||
|
//
|
||||||
|
// Runs on its own branch (never on main). Every action is recorded
|
||||||
|
// in data/_kb/auto_apply.jsonl so the auditor and future iterations
|
||||||
|
// can see what landed and what reverted.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// bun run tests/real-world/scrum_applier.ts # dry-run, print only
|
||||||
|
// LH_APPLIER_COMMIT=1 bun run tests/real-world/scrum_applier.ts # actually apply
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// LH_APPLIER_BRANCH — branch name (default: "scrum/auto-apply-${Date.now()}")
|
||||||
|
// LH_APPLIER_MIN_CONF — minimum confidence_avg, default 90
|
||||||
|
// LH_APPLIER_MAX_FILES — cap on files per run (default 5, keeps diffs reviewable)
|
||||||
|
// LH_APPLIER_COMMIT — "1" to actually commit; otherwise dry-run
|
||||||
|
// LH_APPLIER_MODEL — patch-emitting model (default: kimi-k2:1t)
|
||||||
|
|
||||||
|
import { readFile, writeFile, appendFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const REPO = "/home/profit/lakehouse";
|
||||||
|
const GATEWAY = "http://localhost:3100";
|
||||||
|
const SCRUM_REVIEWS = `${REPO}/data/_kb/scrum_reviews.jsonl`;
|
||||||
|
const AUDIT_LOG = `${REPO}/data/_kb/auto_apply.jsonl`;
|
||||||
|
|
||||||
|
const MIN_CONF = Number(process.env.LH_APPLIER_MIN_CONF ?? 90);
|
||||||
|
const MAX_FILES = Number(process.env.LH_APPLIER_MAX_FILES ?? 5);
|
||||||
|
const COMMIT = process.env.LH_APPLIER_COMMIT === "1";
|
||||||
|
// Default patch-emitter model — qwen3-coder:480b is the coding specialist
|
||||||
|
// in the scrum ladder (rung 2). Swapped in from kimi-k2:1t after 2026-04-24
|
||||||
|
// data showed kimi-k2:1t produces architectural patches that cascade across
|
||||||
|
// the file (rename a field → 20 broken call sites). qwen3-coder is tuned
|
||||||
|
// for targeted code changes and tends to stay within the mechanical-patch
|
||||||
|
// constraint the prompt asks for. LLM Team's /api/run?mode=patch would be
|
||||||
|
// the ideal choice but that mode isn't registered in llm_team_ui.py yet.
|
||||||
|
const MODEL = process.env.LH_APPLIER_MODEL ?? "qwen3-coder:480b";
|
||||||
|
const BRANCH = process.env.LH_APPLIER_BRANCH ?? `scrum/auto-apply-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
// Deny-list — anything whose path starts with one of these is skipped
|
||||||
|
// regardless of how confident the scrum is. Config / systemd / docs /
|
||||||
|
// auditor itself are off limits for auto-apply; they need a human.
|
||||||
|
const DENY_PREFIXES = [
|
||||||
|
"config/",
|
||||||
|
"ops/",
|
||||||
|
"auditor/",
|
||||||
|
"docs/",
|
||||||
|
"data/",
|
||||||
|
"/etc/",
|
||||||
|
"mcp-server/",
|
||||||
|
"ui/",
|
||||||
|
"sidecar/",
|
||||||
|
"scripts/",
|
||||||
|
];
|
||||||
|
|
||||||
|
function log(msg: string) { console.log(`[applier] ${msg}`); }
|
||||||
|
|
||||||
|
async function sh(cmd: string[], cwd = REPO): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const p = spawn(cmd[0], cmd.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let out = ""; let err = "";
|
||||||
|
p.stdout.on("data", (d) => { out += d.toString(); });
|
||||||
|
p.stderr.on("data", (d) => { err += d.toString(); });
|
||||||
|
p.on("close", (code) => resolve({ stdout: out, stderr: err, code: code ?? 1 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditLog(row: Record<string, any>) {
|
||||||
|
const line = JSON.stringify({ ...row, ts: new Date().toISOString() }) + "\n";
|
||||||
|
await appendFile(AUDIT_LOG, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chat(opts: {
|
||||||
|
provider: "ollama_cloud" | "openrouter" | "ollama";
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
max_tokens?: number;
|
||||||
|
}): Promise<{ content: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${GATEWAY}/v1/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: opts.provider,
|
||||||
|
model: opts.model,
|
||||||
|
messages: [{ role: "user", content: opts.prompt }],
|
||||||
|
max_tokens: opts.max_tokens ?? 1500,
|
||||||
|
temperature: 0.1,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(180000),
|
||||||
|
});
|
||||||
|
if (!r.ok) return { content: "", error: `${r.status}: ${(await r.text()).slice(0, 300)}` };
|
||||||
|
const j: any = await r.json();
|
||||||
|
return { content: j.choices?.[0]?.message?.content ?? "" };
|
||||||
|
} catch (e) {
|
||||||
|
return { content: "", error: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrumReview {
|
||||||
|
file: string;
|
||||||
|
reviewed_at: string;
|
||||||
|
accepted_model: string;
|
||||||
|
suggestions_preview: string;
|
||||||
|
confidences_per_finding?: number[];
|
||||||
|
confidence_avg?: number | null;
|
||||||
|
confidence_min?: number | null;
|
||||||
|
gradient_tier?: string;
|
||||||
|
gradient_tier_avg?: string;
|
||||||
|
verdict?: string;
|
||||||
|
critical_failures_count?: number;
|
||||||
|
schema_version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLatestReviews(): Promise<Map<string, ScrumReview>> {
|
||||||
|
// Map of file → latest review for that file. Ordered by reviewed_at.
|
||||||
|
if (!existsSync(SCRUM_REVIEWS)) return new Map();
|
||||||
|
const text = await readFile(SCRUM_REVIEWS, "utf8");
|
||||||
|
const rows: ScrumReview[] = text.split("\n").filter(Boolean).map(l => {
|
||||||
|
try { return JSON.parse(l); } catch { return null; }
|
||||||
|
}).filter((r): r is ScrumReview => r !== null);
|
||||||
|
// Keep the LATEST review per file.
|
||||||
|
const latest = new Map<string, ScrumReview>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.file) continue;
|
||||||
|
const prev = latest.get(r.file);
|
||||||
|
if (!prev || (r.reviewed_at > prev.reviewed_at)) latest.set(r.file, r);
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passesConfidenceGate(r: ScrumReview): boolean {
|
||||||
|
const avg = r.confidence_avg ?? 0;
|
||||||
|
const min = r.confidence_min ?? 0;
|
||||||
|
// Must be auto or dry_run tier AND confidence_min ≥ MIN_CONF.
|
||||||
|
// min is the conservative tier-lower-bound (one weak finding drags
|
||||||
|
// the whole file to "simulation" or "block" tier).
|
||||||
|
if (r.gradient_tier === "block" || r.gradient_tier === "simulation") return false;
|
||||||
|
return avg >= MIN_CONF && min >= 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passesDenyList(file: string): boolean {
|
||||||
|
return !DENY_PREFIXES.some((p) => file.startsWith(p) || file === p.replace(/\/$/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Patch {
|
||||||
|
file: string;
|
||||||
|
old_string: string;
|
||||||
|
new_string: string;
|
||||||
|
rationale: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPatches(file: string, source: string, review: string): Promise<Patch[]> {
|
||||||
|
const prompt = `You previously produced this review of ${file}:
|
||||||
|
|
||||||
|
─── REVIEW ───
|
||||||
|
${review}
|
||||||
|
─── END REVIEW ───
|
||||||
|
|
||||||
|
The review is high-confidence and the file is eligible for auto-apply. Produce CONCRETE PATCHES as JSON so they can be applied via string replacement.
|
||||||
|
|
||||||
|
HARD CONSTRAINTS (violations → patch rejected):
|
||||||
|
1. Output ONE JSON object with a "patches" array. NO prose, no markdown fences.
|
||||||
|
2. Each patch is {"old_string": "...", "new_string": "...", "rationale": "short", "confidence": 0-100}.
|
||||||
|
3. "old_string" MUST appear EXACTLY ONCE in the file (verbatim, including whitespace + trailing newlines).
|
||||||
|
4. Max diff size: 6 lines changed per patch (NOT 20). If the change needs >6 lines, emit {"patches": []} — too risky for auto-apply.
|
||||||
|
5. Mechanical-only (the list — nothing else):
|
||||||
|
(a) remove a #[allow(dead_code)] marker on a function now wired elsewhere
|
||||||
|
(b) add a missing 'use' import statement
|
||||||
|
(c) add a single field to a struct (no renames)
|
||||||
|
(d) flip a single boolean/match-arm that doesn't cascade
|
||||||
|
(e) add a fire-and-forget log or tracing call
|
||||||
|
6. FORBIDDEN (automatic reject):
|
||||||
|
• struct field RENAMES (break all call sites)
|
||||||
|
• function signature changes
|
||||||
|
• new modules, new traits, new dependencies
|
||||||
|
• any change that requires editing another file to keep it compiling
|
||||||
|
• adding a 'use' import whose symbol is NOT referenced in the same patch
|
||||||
|
or already referenced in the surrounding file (no "import and hope")
|
||||||
|
• rationale that describes functionality not present in the diff
|
||||||
|
(if rationale says "destructive SQL filter", the diff must contain SQL/filter tokens)
|
||||||
|
7. Each "new_string" MUST compile in isolation with the same surrounding code.
|
||||||
|
8. Max 2 patches per file — quality over quantity.
|
||||||
|
9. The "rationale" field MUST use vocabulary that appears in the new_string.
|
||||||
|
Generic rationales like "improve code" or "add missing feature" are rejected.
|
||||||
|
10. If you cannot produce at least one high-confidence mechanical patch under these constraints, output {"patches": []}. Don't guess.
|
||||||
|
|
||||||
|
─── SOURCE (${source.length} bytes) ───
|
||||||
|
${source.slice(0, 14000)}
|
||||||
|
─── END SOURCE ───
|
||||||
|
|
||||||
|
Emit ONLY the JSON object.`;
|
||||||
|
|
||||||
|
const r = await chat({ provider: "ollama_cloud", model: MODEL, prompt, max_tokens: 2500 });
|
||||||
|
if (r.error || !r.content) return [];
|
||||||
|
|
||||||
|
// Strip markdown fences if model wrapped the JSON.
|
||||||
|
let raw = r.content.trim();
|
||||||
|
const fenceStart = raw.match(/^```(?:json)?\s*/);
|
||||||
|
if (fenceStart) raw = raw.slice(fenceStart[0].length);
|
||||||
|
if (raw.endsWith("```")) raw = raw.slice(0, -3).trim();
|
||||||
|
// Find first { and last } to extract JSON block if there's prose.
|
||||||
|
const first = raw.indexOf("{");
|
||||||
|
const last = raw.lastIndexOf("}");
|
||||||
|
if (first >= 0 && last > first) raw = raw.slice(first, last + 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
const patches: Patch[] = (obj.patches ?? []).filter((p: any) =>
|
||||||
|
typeof p?.old_string === "string" &&
|
||||||
|
typeof p?.new_string === "string" &&
|
||||||
|
p.old_string !== p.new_string &&
|
||||||
|
p.old_string.length > 0 &&
|
||||||
|
typeof p?.confidence === "number"
|
||||||
|
).map((p: any) => ({
|
||||||
|
file,
|
||||||
|
old_string: p.old_string,
|
||||||
|
new_string: p.new_string,
|
||||||
|
rationale: String(p.rationale ?? ""),
|
||||||
|
confidence: p.confidence,
|
||||||
|
}));
|
||||||
|
return patches;
|
||||||
|
} catch (e) {
|
||||||
|
log(` ${file}: patch JSON parse failed — ${String(e).slice(0, 100)}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPatches(file: string, patches: Patch[]): Promise<{ applied: number; rejected: Array<{patch: Patch; reason: string}> }> {
|
||||||
|
const full = `${REPO}/${file}`;
|
||||||
|
let source = await readFile(full, "utf8");
|
||||||
|
const rejected: Array<{patch: Patch; reason: string}> = [];
|
||||||
|
let applied = 0;
|
||||||
|
for (const p of patches) {
|
||||||
|
// Confidence gate at the individual-patch level.
|
||||||
|
if (p.confidence < MIN_CONF) { rejected.push({patch: p, reason: `confidence ${p.confidence} < ${MIN_CONF}`}); continue; }
|
||||||
|
// Uniqueness gate.
|
||||||
|
const occurrences = source.split(p.old_string).length - 1;
|
||||||
|
if (occurrences === 0) { rejected.push({patch: p, reason: "old_string not found"}); continue; }
|
||||||
|
if (occurrences > 1) { rejected.push({patch: p, reason: `old_string appears ${occurrences}× (not unique)`}); continue; }
|
||||||
|
// Size gate — no patch touches > 6 lines (diff discipline; matches prompt).
|
||||||
|
// Raised from 20 to 6 after 2026-04-24 data showed most 10-20 line patches
|
||||||
|
// cascaded and broke the build. Mechanical changes genuinely fit in 6 lines.
|
||||||
|
const oldLines = p.old_string.split("\n").length;
|
||||||
|
const newLines = p.new_string.split("\n").length;
|
||||||
|
if (Math.max(oldLines, newLines) > 6) { rejected.push({patch: p, reason: `patch too large (${Math.max(oldLines,newLines)} lines, max 6)`}); continue; }
|
||||||
|
source = source.replace(p.old_string, p.new_string);
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
if (applied > 0) await writeFile(full, source);
|
||||||
|
return { applied, rejected };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cargoCheck(): Promise<{ green: boolean; warnings: number }> {
|
||||||
|
const r = await sh(["cargo", "check", "--workspace"]);
|
||||||
|
// Count warnings: cargo emits "warning:" lines on stderr (and sometimes stdout).
|
||||||
|
// We count unique "warning: <msg>" opening lines, not continuation context.
|
||||||
|
const combined = r.stdout + r.stderr;
|
||||||
|
const warnings = (combined.match(/^warning: /gm) || []).length;
|
||||||
|
return { green: r.code === 0, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitCommit(file: string, patches: Patch[]): Promise<boolean> {
|
||||||
|
if (!COMMIT) { log(` (dry-run) would commit ${file}`); return true; }
|
||||||
|
const addR = await sh(["git", "add", file]);
|
||||||
|
if (addR.code !== 0) { log(` git add failed: ${addR.stderr.slice(0, 200)}`); return false; }
|
||||||
|
const msg = `auto-apply: ${patches.length} high-confidence fix${patches.length === 1 ? "" : "es"} in ${file}\n\n${patches.map(p => `- ${p.rationale} (conf ${p.confidence}%)`).join("\n")}\n\n🤖 scrum_applier.ts`;
|
||||||
|
const commitR = await sh(["git", "commit", "-m", msg]);
|
||||||
|
if (commitR.code !== 0) { log(` git commit failed: ${commitR.stderr.slice(0, 200)}`); return false; }
|
||||||
|
log(` ✓ committed ${file}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revertFile(file: string): Promise<void> {
|
||||||
|
await sh(["git", "checkout", "--", file]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap, conservative check that the rationale actually describes the
|
||||||
|
// diff. Tokenizes the new_string (what's actually being added), drops
|
||||||
|
// Rust keywords + common filler, and requires the rationale to share
|
||||||
|
// at least one meaningful token. Catches rationale-diff divergence like
|
||||||
|
// "Add destructive SQL filter" for a diff that only adds `use tracing;`.
|
||||||
|
const STOPWORDS = new Set([
|
||||||
|
"use", "let", "pub", "mut", "fn", "self", "the", "a", "and", "or", "in",
|
||||||
|
"to", "for", "of", "on", "at", "add", "added", "adds", "new", "is",
|
||||||
|
"as", "if", "else", "match", "return", "crate", "mod", "struct",
|
||||||
|
"trait", "impl", "this", "that", "with", "from", "into", "by", "be",
|
||||||
|
"it", "its", "not", "one", "two", "line", "lines",
|
||||||
|
]);
|
||||||
|
function tokenize(s: string): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const raw of s.toLowerCase().split(/[^a-z0-9_]+/)) {
|
||||||
|
if (raw.length >= 4 && !STOPWORDS.has(raw)) out.add(raw);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function rationaleMatchesDiff(p: Patch): boolean {
|
||||||
|
// Only check tokens introduced by the patch (what's in new but not old).
|
||||||
|
const newToks = tokenize(p.new_string);
|
||||||
|
const oldToks = tokenize(p.old_string);
|
||||||
|
const added = new Set([...newToks].filter((t) => !oldToks.has(t)));
|
||||||
|
if (added.size === 0) return true; // pure deletion — rationale unconstrained
|
||||||
|
const rationaleToks = tokenize(p.rationale);
|
||||||
|
for (const t of added) if (rationaleToks.has(t)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log(`starting · min_conf=${MIN_CONF} max_files=${MAX_FILES} model=${MODEL} commit=${COMMIT}`);
|
||||||
|
|
||||||
|
if (COMMIT) {
|
||||||
|
const headR = await sh(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
|
||||||
|
const currentBranch = headR.stdout.trim();
|
||||||
|
if (currentBranch === "main") {
|
||||||
|
log(`refusing to run on main — create a branch first or set LH_APPLIER_BRANCH`);
|
||||||
|
const coR = await sh(["git", "checkout", "-b", BRANCH]);
|
||||||
|
if (coR.code !== 0) { log(`could not create branch ${BRANCH}: ${coR.stderr.slice(0, 200)}`); process.exit(1); }
|
||||||
|
log(`working branch: ${BRANCH}`);
|
||||||
|
} else {
|
||||||
|
log(`working branch: ${currentBranch}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviews = await loadLatestReviews();
|
||||||
|
log(`loaded ${reviews.size} latest reviews`);
|
||||||
|
|
||||||
|
const eligible = [...reviews.values()].filter(r =>
|
||||||
|
passesConfidenceGate(r) && passesDenyList(r.file)
|
||||||
|
).sort((a, b) => (b.confidence_avg ?? 0) - (a.confidence_avg ?? 0));
|
||||||
|
|
||||||
|
log(`${eligible.length} pass confidence gate + deny-list`);
|
||||||
|
log(`taking top ${Math.min(MAX_FILES, eligible.length)} by confidence`);
|
||||||
|
|
||||||
|
// Establish pre-run warning baseline so post-patch cargo check can
|
||||||
|
// reject patches that keep the build green but add new warnings.
|
||||||
|
// This gate exists because 96b46cd landed unused imports through the
|
||||||
|
// green-only gate — compiled fine, left a permanent dead-code warning.
|
||||||
|
log(`baseline cargo check (warning count)...`);
|
||||||
|
let baselineWarnings = (await cargoCheck()).warnings;
|
||||||
|
log(`baseline warnings = ${baselineWarnings}`);
|
||||||
|
|
||||||
|
let committedFiles = 0;
|
||||||
|
let revertedFiles = 0;
|
||||||
|
let warningReverts = 0;
|
||||||
|
let rationaleReverts = 0;
|
||||||
|
|
||||||
|
for (const r of eligible.slice(0, MAX_FILES)) {
|
||||||
|
log(`${r.file} (conf_avg=${r.confidence_avg} tier=${r.gradient_tier})`);
|
||||||
|
const full = `${REPO}/${r.file}`;
|
||||||
|
if (!existsSync(full)) { log(` skip — file not found on disk`); continue; }
|
||||||
|
|
||||||
|
const source = await readFile(full, "utf8");
|
||||||
|
const patches = await requestPatches(r.file, source, r.suggestions_preview ?? "");
|
||||||
|
|
||||||
|
if (patches.length === 0) {
|
||||||
|
log(` no patches produced`);
|
||||||
|
await auditLog({ action: "no_patches", file: r.file, reviewer_model: r.accepted_model });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(` ${patches.length} candidate patches`);
|
||||||
|
const { applied, rejected } = await applyPatches(r.file, patches);
|
||||||
|
log(` applied ${applied}, rejected ${rejected.length}`);
|
||||||
|
for (const rj of rejected) log(` ✗ ${rj.reason}`);
|
||||||
|
|
||||||
|
if (applied === 0) {
|
||||||
|
await auditLog({ action: "all_rejected", file: r.file, rejected: rejected.map(x => x.reason) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(` running cargo check...`);
|
||||||
|
const { green, warnings } = await cargoCheck();
|
||||||
|
if (!green) {
|
||||||
|
log(` ✗ build red — reverting ${r.file}`);
|
||||||
|
await revertFile(r.file);
|
||||||
|
revertedFiles++;
|
||||||
|
await auditLog({ action: "build_red_reverted", file: r.file, patches_applied: applied });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New-warning gate. If a patch keeps the build green but adds a
|
||||||
|
// warning (unused import, dead_code after removed allow, etc), treat
|
||||||
|
// it the same as build red: revert. The cargo-green gate alone let
|
||||||
|
// 96b46cd land unused imports; this closes that door.
|
||||||
|
if (warnings > baselineWarnings) {
|
||||||
|
log(` ✗ warnings ${baselineWarnings} → ${warnings} (+${warnings - baselineWarnings}) — reverting ${r.file}`);
|
||||||
|
await revertFile(r.file);
|
||||||
|
warningReverts++;
|
||||||
|
await auditLog({
|
||||||
|
action: "warnings_increased_reverted",
|
||||||
|
file: r.file,
|
||||||
|
patches_applied: applied,
|
||||||
|
warnings_before: baselineWarnings,
|
||||||
|
warnings_after: warnings,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rationale-diff alignment heuristic. At least one non-stopword
|
||||||
|
// token from the applied patches' combined new_string must appear
|
||||||
|
// in the rationale, OR the rationale must name the file's module.
|
||||||
|
// This catches cases like "Add destructive SQL filter" rationale
|
||||||
|
// with a diff that only adds `use tracing`.
|
||||||
|
const applied_patches = patches.slice(0, applied);
|
||||||
|
const rationale_ok = applied_patches.some((p) => rationaleMatchesDiff(p));
|
||||||
|
if (!rationale_ok) {
|
||||||
|
log(` ✗ rationale doesn't name anything in diff — reverting ${r.file}`);
|
||||||
|
await revertFile(r.file);
|
||||||
|
rationaleReverts++;
|
||||||
|
await auditLog({
|
||||||
|
action: "rationale_mismatch_reverted",
|
||||||
|
file: r.file,
|
||||||
|
patches_applied: applied,
|
||||||
|
rationales: applied_patches.map((p) => p.rationale),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(` ✓ build green (warnings ${warnings}/${baselineWarnings})`);
|
||||||
|
|
||||||
|
// Dry-run MUST revert the file after a successful check so the
|
||||||
|
// workspace doesn't accumulate unpushed changes across dry-runs.
|
||||||
|
// Previously this left modified files, silently polluting state.
|
||||||
|
if (!COMMIT) {
|
||||||
|
log(` (dry-run) would commit ${r.file} — reverting workspace change`);
|
||||||
|
await revertFile(r.file);
|
||||||
|
await auditLog({
|
||||||
|
action: "dry_run_would_commit",
|
||||||
|
file: r.file,
|
||||||
|
patches_applied: applied,
|
||||||
|
patches_rejected: rejected.length,
|
||||||
|
confidence_avg: r.confidence_avg,
|
||||||
|
gradient_tier: r.gradient_tier,
|
||||||
|
reviewer_model: r.accepted_model,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await gitCommit(r.file, applied_patches);
|
||||||
|
if (ok) {
|
||||||
|
committedFiles++;
|
||||||
|
// Advance the warning baseline so a later patch on a different
|
||||||
|
// file is compared against the new (committed) state, not the
|
||||||
|
// pre-run state — otherwise every subsequent patch trips the gate.
|
||||||
|
baselineWarnings = warnings;
|
||||||
|
await auditLog({
|
||||||
|
action: "committed",
|
||||||
|
file: r.file,
|
||||||
|
patches_applied: applied,
|
||||||
|
patches_rejected: rejected.length,
|
||||||
|
confidence_avg: r.confidence_avg,
|
||||||
|
gradient_tier: r.gradient_tier,
|
||||||
|
reviewer_model: r.accepted_model,
|
||||||
|
warnings_after: warnings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`DONE · committed=${committedFiles} build_red=${revertedFiles} warning_revert=${warningReverts} rationale_revert=${rationaleReverts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -26,18 +26,25 @@ const SIDECAR = "http://localhost:3200";
|
|||||||
const CHUNK_SIZE = 800;
|
const CHUNK_SIZE = 800;
|
||||||
const CHUNK_OVERLAP = 120;
|
const CHUNK_OVERLAP = 120;
|
||||||
const TOP_K_CONTEXT = 5;
|
const TOP_K_CONTEXT = 5;
|
||||||
const MAX_ATTEMPTS = 6;
|
const MAX_ATTEMPTS = 9;
|
||||||
// Files larger than this get tree-split instead of truncated. Fixes the
|
// Files larger than this get tree-split instead of truncated. Fixes the
|
||||||
// 6KB false-positive class (model claiming a field is "missing" when
|
// 6KB false-positive class (model claiming a field is "missing" when
|
||||||
// it exists past the context cutoff).
|
// it exists past the context cutoff).
|
||||||
const FILE_TREE_SPLIT_THRESHOLD = 6000;
|
// Env-configurable so the pipeline can adapt to different repos:
|
||||||
const FILE_SHARD_SIZE = 3500;
|
// a 13K-line Python file like /root/llm-team-ui/llm_team_ui.py needs
|
||||||
|
// larger shards to avoid producing 200+ cloud calls per review.
|
||||||
|
// Defaults stay at 6000 / 3500 — tuned for Rust source files in
|
||||||
|
// crates/<crate>/src/*.rs.
|
||||||
|
const FILE_TREE_SPLIT_THRESHOLD = Number(process.env.LH_SCRUM_TREE_SPLIT_THRESHOLD ?? 6000);
|
||||||
|
const FILE_SHARD_SIZE = Number(process.env.LH_SCRUM_SHARD_SIZE ?? 3500);
|
||||||
// Appended jsonl so auditor's kb_query can surface scrum findings for
|
// Appended jsonl so auditor's kb_query can surface scrum findings for
|
||||||
// files touched by a PR under review. Part of cohesion plan Phase C.
|
// files touched by a PR under review. Part of cohesion plan Phase C.
|
||||||
const SCRUM_REVIEWS_JSONL = "/home/profit/lakehouse/data/_kb/scrum_reviews.jsonl";
|
const SCRUM_REVIEWS_JSONL = process.env.LH_SCRUM_REVIEWS_OUT
|
||||||
|
|| "/home/profit/lakehouse/data/_kb/scrum_reviews.jsonl";
|
||||||
const OUT_DIR = `/home/profit/lakehouse/tests/real-world/runs/scrum_${Date.now().toString(36)}`;
|
const OUT_DIR = `/home/profit/lakehouse/tests/real-world/runs/scrum_${Date.now().toString(36)}`;
|
||||||
|
|
||||||
const PRD_PATH = "/home/profit/lakehouse/docs/PRD.md";
|
const PRD_PATH = process.env.LH_SCRUM_PRD
|
||||||
|
|| "/home/profit/lakehouse/docs/PRD.md";
|
||||||
// Using CONTROL_PLANE_PRD as the "suggested changes" doc since it
|
// Using CONTROL_PLANE_PRD as the "suggested changes" doc since it
|
||||||
// describes the Phase 38-44 target architecture and is on main.
|
// describes the Phase 38-44 target architecture and is on main.
|
||||||
// Override via LH_SCRUM_PROPOSAL env to point at a fix-wave doc
|
// Override via LH_SCRUM_PROPOSAL env to point at a fix-wave doc
|
||||||
@@ -87,21 +94,27 @@ const TARGET_FILES: string[] = process.env.LH_SCRUM_FILES
|
|||||||
// Local fallbacks kept for cloud-down scenarios.
|
// Local fallbacks kept for cloud-down scenarios.
|
||||||
// Hot-path pipelines (scenario.ts / execution_loop) stay local per
|
// Hot-path pipelines (scenario.ts / execution_loop) stay local per
|
||||||
// Phase 20 t1_hot — this scrum is not hot path.
|
// Phase 20 t1_hot — this scrum is not hot path.
|
||||||
const LADDER: Array<{ provider: "ollama" | "ollama_cloud"; model: string; note: string }> = [
|
const LADDER: Array<{ provider: "ollama" | "ollama_cloud" | "openrouter"; model: string; note: string }> = [
|
||||||
{ provider: "ollama_cloud", model: "kimi-k2:1t", note: "cloud 1T — biggest available, 1.4s probe" },
|
{ provider: "ollama_cloud", model: "kimi-k2:1t", note: "cloud 1T — biggest available, 1.4s probe" },
|
||||||
|
{ provider: "ollama_cloud", model: "qwen3-coder:480b", note: "cloud 480B — coding specialist, 0.9s probe" },
|
||||||
{ provider: "ollama_cloud", model: "deepseek-v3.1:671b", note: "cloud 671B — fast reasoning (1.0s probe)" },
|
{ provider: "ollama_cloud", model: "deepseek-v3.1:671b", note: "cloud 671B — fast reasoning (1.0s probe)" },
|
||||||
{ provider: "ollama_cloud", model: "mistral-large-3:675b", note: "cloud 675B — deep analysis (0.9s probe)" },
|
{ provider: "ollama_cloud", model: "mistral-large-3:675b", note: "cloud 675B — deep analysis (0.9s probe)" },
|
||||||
{ provider: "ollama_cloud", model: "gpt-oss:120b", note: "cloud 120B — reliable workhorse (iter1 baseline)" },
|
{ provider: "ollama_cloud", model: "gpt-oss:120b", note: "cloud 120B — reliable workhorse (iter1 baseline)" },
|
||||||
{ provider: "ollama_cloud", model: "devstral-2:123b", note: "cloud 123B — coding specialist" },
|
{ provider: "ollama_cloud", model: "qwen3.5:397b", note: "cloud 397B dense — deep final thinker (J 2026-04-24)" },
|
||||||
// qwen3.5:397b is the deep final thinker — J's note 2026-04-24:
|
// Free-tier rescue — different provider backbone, different quota.
|
||||||
// "qwen3.5 is really smart maybe the last call use that one".
|
// Added 2026-04-24 after iter 5 hit repeated Ollama Cloud 502s on
|
||||||
// When every other cloud model has produced thin output, this dense
|
// kimi-k2:1t. These have lower parameter counts than the Ollama
|
||||||
// 397B reviewer is the one that tends to push through. Keeping it
|
// Cloud rungs but high availability: if upstream is down, we still
|
||||||
// LAST in cloud tier, before the local fallback.
|
// land a review instead of giving up.
|
||||||
{ provider: "ollama_cloud", model: "qwen3.5:397b", note: "cloud 397B dense — last-ditch smart reviewer" },
|
{ provider: "openrouter", model: "openai/gpt-oss-120b:free", note: "OpenRouter free 120B — substantive rescue, 2.8s probe" },
|
||||||
{ provider: "ollama", model: "gpt-oss:20b", note: "local 20B — cloud-down fallback" },
|
{ provider: "openrouter", model: "google/gemma-3-27b-it:free", note: "OpenRouter free 27B — fastest rescue, 1.4s probe" },
|
||||||
// kimi-k2.6 removed 2026-04-24: probe returned empty (not available
|
{ provider: "ollama", model: "qwen3.5:latest", note: "local qwen3.5 — best local model per J (2026-04-24), last-resort if all cloud down" },
|
||||||
// on current tier). Keeping note for when pro tier upgrade lands.
|
// Dropped from the ladder after 2026-04-24 probe:
|
||||||
|
// - kimi-k2.6 — not available on current tier (empty response)
|
||||||
|
// - devstral-2:123b — displaced by qwen3-coder:480b (better coding specialist)
|
||||||
|
// - minimax-m2.7 — 400 thinking tokens, 0 content output
|
||||||
|
// - openrouter qwen3-coder:free / llama-3.3 / hermes-3 — provider errors
|
||||||
|
// - openrouter minimax-m2.5:free — 45s timeout
|
||||||
];
|
];
|
||||||
|
|
||||||
type Chunk = { id: string; text: string; embedding: number[]; origin: string; offset: number };
|
type Chunk = { id: string; text: string; embedding: number[]; origin: string; offset: number };
|
||||||
@@ -151,6 +164,190 @@ async function embedBatch(texts: string[]): Promise<number[][]> {
|
|||||||
return (await r.json() as any).embeddings;
|
return (await r.json() as any).embeddings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Pathway memory (2026-04-24 consensus design) ───────────────────
|
||||||
|
//
|
||||||
|
// Mirrors vectord/src/pathway_memory.rs. The bucket-hash vector MUST
|
||||||
|
// byte-match the Rust implementation so traces written from TypeScript
|
||||||
|
// are searchable against the same embedding space. Verified by running
|
||||||
|
// both implementations on the same input tokens and asserting matching
|
||||||
|
// bucket indices.
|
||||||
|
|
||||||
|
function filePrefix(path: string): string {
|
||||||
|
return path.split("/").slice(0, 2).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePathwayId(taskClass: string, filePath: string, signalClass: string | null): string {
|
||||||
|
const h = createHash("sha256");
|
||||||
|
h.update(taskClass);
|
||||||
|
h.update("|");
|
||||||
|
h.update(filePrefix(filePath));
|
||||||
|
h.update("|");
|
||||||
|
h.update(signalClass ?? "");
|
||||||
|
return h.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 32-bucket L2-normalized token hash. Same algorithm as Rust.
|
||||||
|
function buildPathwayVec(tokens: string[]): number[] {
|
||||||
|
const buckets = new Array(32).fill(0);
|
||||||
|
for (const t of tokens) {
|
||||||
|
const d = createHash("sha256").update(t, "utf8").digest();
|
||||||
|
const b1 = d[0] % 32;
|
||||||
|
const b2 = d[8] % 32;
|
||||||
|
buckets[b1] += 1;
|
||||||
|
buckets[b2] += 1;
|
||||||
|
}
|
||||||
|
let norm = 0;
|
||||||
|
for (const v of buckets) norm += v * v;
|
||||||
|
norm = Math.sqrt(norm);
|
||||||
|
if (norm > 0) for (let i = 0; i < buckets.length; i++) buckets[i] /= norm;
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the minimal query vector for a pre-ladder hot-swap check. We
|
||||||
|
// don't yet know the ladder attempts or KB chunks — the query vec is
|
||||||
|
// computed from what we CAN know up front: task/file/signal. This is
|
||||||
|
// a weaker embedding than the one computed at trace-insert time, but
|
||||||
|
// similarity still discriminates between task/file/signal combinations.
|
||||||
|
function buildQueryVec(taskClass: string, filePath: string, signalClass: string | null): number[] {
|
||||||
|
const tokens = [taskClass, filePath];
|
||||||
|
if (signalClass) tokens.push(`signal:${signalClass}`);
|
||||||
|
return buildPathwayVec(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HotSwapCandidate {
|
||||||
|
pathway_id: string;
|
||||||
|
similarity: number;
|
||||||
|
replay_count: number;
|
||||||
|
success_rate: number;
|
||||||
|
recommended_rung: number;
|
||||||
|
recommended_model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryHotSwap(taskClass: string, filePath: string, signalClass: string | null): Promise<HotSwapCandidate | null> {
|
||||||
|
try {
|
||||||
|
const query_vec = buildQueryVec(taskClass, filePath, signalClass);
|
||||||
|
const r = await fetch(`${GATEWAY}/vectors/pathway/query`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ task_class: taskClass, file_path: filePath, signal_class: signalClass, query_vec }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const j = await r.json() as { candidate: HotSwapCandidate | null };
|
||||||
|
return j.candidate ?? null;
|
||||||
|
} catch {
|
||||||
|
// Pathway service unavailable → run full ladder. Hot-swap is
|
||||||
|
// always an optimization, never a correctness requirement.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LadderAttemptRec {
|
||||||
|
rung: number;
|
||||||
|
model: string;
|
||||||
|
latency_ms: number;
|
||||||
|
accepted: boolean;
|
||||||
|
reject_reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PathwayTracePayload {
|
||||||
|
pathway_id: string;
|
||||||
|
task_class: string;
|
||||||
|
file_path: string;
|
||||||
|
signal_class: string | null;
|
||||||
|
created_at: string;
|
||||||
|
ladder_attempts: LadderAttemptRec[];
|
||||||
|
kb_chunks: { source_doc: string; chunk_id: string; cosine_score: number; rank: number }[];
|
||||||
|
observer_signals: { class: string; priors: string[]; prior_iter_outcomes: string[] }[];
|
||||||
|
bridge_hits: { library: string; version: string }[];
|
||||||
|
sub_pipeline_calls: { pipeline: string; result_summary: string }[];
|
||||||
|
audit_consensus: { pass: boolean; models: string[]; disagreements: number } | null;
|
||||||
|
reducer_summary: string;
|
||||||
|
final_verdict: string;
|
||||||
|
pathway_vec: number[];
|
||||||
|
// ADR-021 semantic-correctness layer. `kind` field matches the Rust
|
||||||
|
// serde(tag = "kind") wire format — TS and Rust interop directly.
|
||||||
|
semantic_flags: { kind: string }[];
|
||||||
|
type_hints_used: { source: string; symbol: string; type_repr: string }[];
|
||||||
|
bug_fingerprints: { flag: { kind: string }; pattern_key: string; example: string; occurrences: number }[];
|
||||||
|
replay_count: number;
|
||||||
|
replays_succeeded: number;
|
||||||
|
retired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePathwayTrace(trace: PathwayTracePayload): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(`${GATEWAY}/vectors/pathway/insert`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(trace),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fire-and-forget: scrum runs shouldn't fail if pathway insert fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordPathwayReplay(pathwayId: string, succeeded: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(`${GATEWAY}/vectors/pathway/record_replay`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ pathway_id: pathwayId, succeeded }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fire-and-forget. Not critical.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-021 Phase C: pre-review enrichment. Fetch aggregated bug
|
||||||
|
// fingerprints for this narrow fingerprint (same key as hot-swap —
|
||||||
|
// task_class + file_prefix + signal_class) so the reviewer prompt
|
||||||
|
// can explicitly warn "this file area has had these bug patterns
|
||||||
|
// before." Empty on fresh install; grows as the matrix index learns.
|
||||||
|
interface BugFingerprintRow {
|
||||||
|
flag: { kind: string };
|
||||||
|
pattern_key: string;
|
||||||
|
example: string;
|
||||||
|
occurrences: number;
|
||||||
|
}
|
||||||
|
async function fetchBugFingerprints(taskClass: string, filePath: string, signalClass: string | null, limit: number): Promise<BugFingerprintRow[]> {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${GATEWAY}/vectors/pathway/bug_fingerprints`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ task_class: taskClass, file_path: filePath, signal_class: signalClass, limit }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!r.ok) return [];
|
||||||
|
const j = await r.json() as { fingerprints: BugFingerprintRow[] };
|
||||||
|
return j.fingerprints ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic signal_class lookup from scrum_reviews.jsonl history.
|
||||||
|
// First-time files get `null`. Files seen before get the signal class
|
||||||
|
// the observer assigned on their most-recent review (if any). Keeps the
|
||||||
|
// pathway fingerprint stable across iterations for LOOPING files.
|
||||||
|
async function lookupSignalClass(filePath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const raw = await readFile(SCRUM_REVIEWS_JSONL, "utf8").catch(() => "");
|
||||||
|
if (!raw) return null;
|
||||||
|
const lines = raw.trim().split("\n").reverse();
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const r = JSON.parse(line);
|
||||||
|
if (r.file === filePath && r.signal_class) return r.signal_class;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
async function chat(opts: {
|
async function chat(opts: {
|
||||||
provider: "ollama" | "ollama_cloud",
|
provider: "ollama" | "ollama_cloud",
|
||||||
model: string,
|
model: string,
|
||||||
@@ -203,10 +400,18 @@ function retrieveTopK(query_emb: number[], pool: Chunk[], k: number): Chunk[] {
|
|||||||
.map(x => ({ ...x.c, _score: x.score } as any));
|
.map(x => ({ ...x.c, _score: x.score } as any));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tree-split a large file: shard it, summarize each shard against
|
// Tree-split a large file: shard it, summarize each shard into a
|
||||||
// the review question, merge into a scratchpad. Uses cloud because
|
// running scratchpad, THEN run a reduce step that collapses the
|
||||||
// the summarization step needs quality > speed. Returns the
|
// scratchpad into one file-level synthesis with shard boundaries
|
||||||
// scratchpad (full-file distillation) and the cloud-call count.
|
// stripped. Returns the synthesis (not the raw scratchpad) so the
|
||||||
|
// final reviewer never sees "--- shard N ---" markers and can't
|
||||||
|
// leak them into its review title.
|
||||||
|
//
|
||||||
|
// Phase 21 design (aibridge/src/tree_split.rs) with the map → reduce
|
||||||
|
// shape. Earlier version concatenated per-shard digests directly into
|
||||||
|
// the reviewer prompt, which led to kimi-k2:1t writing review titles
|
||||||
|
// like "Forensic Audit Report – file.rs (shard 3)" because the shard
|
||||||
|
// markers bled through. Fix 2026-04-24 adds the reduce step.
|
||||||
async function treeSplitFile(
|
async function treeSplitFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
content: string,
|
content: string,
|
||||||
@@ -216,20 +421,25 @@ async function treeSplitFile(
|
|||||||
const end = Math.min(i + FILE_SHARD_SIZE, content.length);
|
const end = Math.min(i + FILE_SHARD_SIZE, content.length);
|
||||||
shards.push({ from: i, to: end, text: content.slice(i, end) });
|
shards.push({ from: i, to: end, text: content.slice(i, end) });
|
||||||
}
|
}
|
||||||
let scratchpad = "";
|
|
||||||
|
// MAP — each shard produces a digest that feeds the next shard's
|
||||||
|
// context. Internal markers are kept to help the reducer align
|
||||||
|
// overlapping observations across shards; they're stripped before
|
||||||
|
// the reviewer sees anything.
|
||||||
|
let workingScratchpad = "";
|
||||||
let cloud_calls = 0;
|
let cloud_calls = 0;
|
||||||
log(` tree-split: ${content.length} chars → ${shards.length} shards of ${FILE_SHARD_SIZE}`);
|
log(` tree-split: ${content.length} chars → ${shards.length} shards of ${FILE_SHARD_SIZE}`);
|
||||||
for (const [si, shard] of shards.entries()) {
|
for (const [si, shard] of shards.entries()) {
|
||||||
const prompt = `You are summarizing ONE SHARD of a source file as part of a multi-shard review. File: ${filePath}. Shard ${si + 1}/${shards.length} (bytes ${shard.from}..${shard.to}).
|
const prompt = `You are writing a SECTION of a full-file summary. File: ${filePath}. This is one piece (bytes ${shard.from}..${shard.to}) of a larger source file you are NOT seeing in its entirety right now.
|
||||||
|
|
||||||
─────── shard source ───────
|
─────── source ───────
|
||||||
${shard.text}
|
${shard.text}
|
||||||
─────── end shard ───────
|
─────── end source ───────
|
||||||
|
|
||||||
Scratchpad of prior shards (if empty, this is shard 1):
|
Prior-piece notes so far (if empty, this is the first piece):
|
||||||
${scratchpad || "(empty)"}
|
${workingScratchpad || "(empty)"}
|
||||||
|
|
||||||
Extract ONLY facts useful for reviewing this file against its PRD: function names + purposes, struct fields + types, invariants, edge cases, TODO markers, error-handling style. Under 150 words. No prose outside the extracted facts.`;
|
Extract facts about the code in this piece that will help review the FULL file later: function + struct names with brief purpose, struct fields + types, invariants, TODOs, error-handling style, obvious gaps. Under 150 words. Flat facts only, no headings, no phrases like "this shard" or "in my section".`;
|
||||||
const r = await chat({
|
const r = await chat({
|
||||||
provider: "ollama_cloud",
|
provider: "ollama_cloud",
|
||||||
model: "gpt-oss:120b",
|
model: "gpt-oss:120b",
|
||||||
@@ -238,10 +448,47 @@ Extract ONLY facts useful for reviewing this file against its PRD: function name
|
|||||||
});
|
});
|
||||||
cloud_calls += 1;
|
cloud_calls += 1;
|
||||||
if (r.content) {
|
if (r.content) {
|
||||||
scratchpad += `\n--- shard ${si + 1} (bytes ${shard.from}..${shard.to}) ---\n${r.content.trim()}`;
|
// Keep internal alignment markers for the reducer; stripped later.
|
||||||
|
workingScratchpad += `\n§${si + 1}§\n${r.content.trim()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { scratchpad, shards: shards.length, cloud_calls };
|
|
||||||
|
// REDUCE — collapse the per-shard digests into one coherent
|
||||||
|
// file-level summary. The reducer sees all digests at once and
|
||||||
|
// produces a single narrative the reviewer can treat as "the file".
|
||||||
|
// Shard markers are NOT in the output. This is what fixes the
|
||||||
|
// shard-leakage bug that affected both the scrum and the auditor.
|
||||||
|
const reducePrompt = `You are producing a SINGLE coherent summary of a Rust/TypeScript source file from a set of prior-piece notes. The notes were taken while walking the file in order but should be merged into one description of the whole file.
|
||||||
|
|
||||||
|
FILE: ${filePath} (${content.length} bytes, ${shards.length} pieces)
|
||||||
|
|
||||||
|
PRIOR-PIECE NOTES (markers §N§ delimit pieces but are artifacts — do not mention them):
|
||||||
|
${workingScratchpad}
|
||||||
|
|
||||||
|
Produce ONE coherent file-level summary:
|
||||||
|
1. One-sentence purpose of the file.
|
||||||
|
2. Key public types / functions / constants (names + one-line purpose each).
|
||||||
|
3. Known gaps, TODOs, or error-handling inconsistencies the notes surfaced.
|
||||||
|
4. Obvious invariants the file relies on.
|
||||||
|
|
||||||
|
Do NOT say "piece 1" or "shard N" or "section" — present the summary as if you read the whole file at once. Under 600 words.`;
|
||||||
|
|
||||||
|
const reduced = await chat({
|
||||||
|
provider: "ollama_cloud",
|
||||||
|
model: "gpt-oss:120b",
|
||||||
|
prompt: reducePrompt,
|
||||||
|
max_tokens: 900,
|
||||||
|
});
|
||||||
|
cloud_calls += 1;
|
||||||
|
const synthesis = reduced.content?.trim() ?? "";
|
||||||
|
|
||||||
|
// Safety: if the reducer returned thin output, fall back to the
|
||||||
|
// raw scratchpad stripped of markers — better than nothing.
|
||||||
|
const final = synthesis.length > 200
|
||||||
|
? synthesis
|
||||||
|
: workingScratchpad.replace(/§\d+§\n/g, "").trim();
|
||||||
|
|
||||||
|
return { scratchpad: final, shards: shards.length, cloud_calls };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reviewFile(
|
async function reviewFile(
|
||||||
@@ -326,6 +573,23 @@ Attach a self-assessed **Confidence: NN%** to every suggested change AND every g
|
|||||||
- <50%: genuinely uncertain — include regardless so downstream knows to investigate before applying
|
- <50%: genuinely uncertain — include regardless so downstream knows to investigate before applying
|
||||||
Format each finding as: \`**1.** <change>. **Confidence: NN%.**\` (in tables, add a final "Confidence" column.) Low confidence is valuable signal — do not round up.
|
Format each finding as: \`**1.** <change>. **Confidence: NN%.**\` (in tables, add a final "Confidence" column.) Low confidence is valuable signal — do not round up.
|
||||||
|
|
||||||
|
**Per-finding semantic-flag tag (ADR-021, required on every finding):**
|
||||||
|
Also attach a \`**Flag: <CATEGORY>**\` on each finding so the pathway-memory matrix index can cluster bug classes over time. Pick the ONE tag that best fits; if none fits, use \`None\`. Allowed categories:
|
||||||
|
- \`UnitMismatch\` — operation combines values with different units (e.g. row_count - file_count, bytes - rows)
|
||||||
|
- \`TypeConfusion\` — same type, wrong role (e.g. treating a PK as a row index)
|
||||||
|
- \`NullableConfusion\` — unwrap-without-check or nullable-treated-as-non-null
|
||||||
|
- \`OffByOne\` — loop / range / slice boundary mistake
|
||||||
|
- \`StaleReference\` — calls a deprecated / removed / moved symbol
|
||||||
|
- \`PseudoImpl\` — stub / todo!() / function named for work it doesn't do
|
||||||
|
- \`DeadCode\` — unreachable or uncalled code
|
||||||
|
- \`WarningNoise\` — compiles green but would add a cargo warning
|
||||||
|
- \`BoundaryViolation\` — crosses a crate/layer boundary it shouldn't
|
||||||
|
- \`None\` — improvement or nicety that doesn't fit a bug category
|
||||||
|
|
||||||
|
In tables, add a "Flag" column. Examples:
|
||||||
|
\`**1.** Rewrite base_rows calc. **Confidence: 90%.** **Flag: UnitMismatch.**\`
|
||||||
|
\`**2.** Extract retry loop. **Confidence: 75%.** **Flag: None.**\`
|
||||||
|
|
||||||
Respond with markdown. Be specific, not generic. Cite file-region + PRD-chunk-offset when relevant.`;
|
Respond with markdown. Be specific, not generic. Cite file-region + PRD-chunk-offset when relevant.`;
|
||||||
|
|
||||||
const history: FileReview["attempts_history"] = [];
|
const history: FileReview["attempts_history"] = [];
|
||||||
@@ -333,32 +597,81 @@ Respond with markdown. Be specific, not generic. Cite file-region + PRD-chunk-of
|
|||||||
let acceptedModel = "";
|
let acceptedModel = "";
|
||||||
let acceptedOn = 0;
|
let acceptedOn = 0;
|
||||||
|
|
||||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
// Pathway hot-swap pre-check. If a proven pathway exists for this
|
||||||
const n = i + 1;
|
// (task, file_prefix, signal) combo with ≥3 replays at ≥80% success,
|
||||||
|
// skip the ladder and try its winning rung first. On success we
|
||||||
|
// record a positive replay; on failure we fall through to the full
|
||||||
|
// ladder and record a negative replay. Fire-and-forget — pathway
|
||||||
|
// service unavailable → null candidate → business as usual.
|
||||||
|
const signalClass = await lookupSignalClass(rel);
|
||||||
|
const taskClass = "scrum_review";
|
||||||
|
const hotSwap = await queryHotSwap(taskClass, rel, signalClass);
|
||||||
|
|
||||||
|
// ADR-021 Phase C: pre-review enrichment. Pull aggregated bug
|
||||||
|
// fingerprints the matrix index has learned for this narrow
|
||||||
|
// fingerprint and prepend to the reviewer prompt as historical
|
||||||
|
// context. This is the compounding mechanism — iter-N reviewer
|
||||||
|
// sees what iter-(N-1) and earlier found, so the grammar of bugs
|
||||||
|
// accumulates instead of being re-discovered each iteration.
|
||||||
|
const pastFingerprints = await fetchBugFingerprints(taskClass, rel, signalClass, 5);
|
||||||
|
let pathwayPreamble = "";
|
||||||
|
if (pastFingerprints.length > 0) {
|
||||||
|
pathwayPreamble = "═══ PATHWAY MEMORY — BUGS PREVIOUSLY FOUND ON THIS FILE AREA (ADR-021) ═══\n" +
|
||||||
|
"The matrix index has flagged these patterns on the same task_class + file_prefix + signal_class before. Check this file for recurrences of the same shape:\n\n" +
|
||||||
|
pastFingerprints.map((fp, i) =>
|
||||||
|
`${i + 1}. [${fp.flag.kind}] pattern=\`${fp.pattern_key}\` occurrences=${fp.occurrences}\n example: ${fp.example.slice(0, 160)}`
|
||||||
|
).join("\n") +
|
||||||
|
"\n═══\n\n";
|
||||||
|
log(` 📚 pathway memory: ${pastFingerprints.length} historical bug pattern(s) prepended to prompt`);
|
||||||
|
}
|
||||||
|
let hotSwapOrderedIndices: number[] | null = null;
|
||||||
|
if (hotSwap) {
|
||||||
|
// Reorder the ladder to try the recommended model first. Rung
|
||||||
|
// indices are preserved in the output so the trace still reflects
|
||||||
|
// the true ladder position the model sits at.
|
||||||
|
const recommendedIdx = LADDER.findIndex(r => r.model === hotSwap.recommended_model);
|
||||||
|
if (recommendedIdx >= 0) {
|
||||||
|
log(` 🔥 hot-swap candidate: ${hotSwap.recommended_model} (rung ${hotSwap.recommended_rung}, sim=${hotSwap.similarity.toFixed(3)}, success_rate=${hotSwap.success_rate.toFixed(2)}, ${hotSwap.replay_count} replays)`);
|
||||||
|
hotSwapOrderedIndices = [recommendedIdx, ...LADDER.map((_, i) => i).filter(i => i !== recommendedIdx)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ladderOrder = hotSwapOrderedIndices ?? LADDER.map((_, i) => i);
|
||||||
|
|
||||||
|
// Collect attempts for the pathway trace sidecar.
|
||||||
|
const pathwayAttempts: LadderAttemptRec[] = [];
|
||||||
|
|
||||||
|
for (let step = 0; step < MAX_ATTEMPTS; step++) {
|
||||||
|
const i = ladderOrder[step];
|
||||||
|
const n = step + 1;
|
||||||
const rung = LADDER[i];
|
const rung = LADDER[i];
|
||||||
const learning = history.length > 0
|
const learning = history.length > 0
|
||||||
? `\n\n═══ PRIOR ATTEMPTS FAILED. Specific issues to fix: ═══\n${history.map(h => `Attempt ${h.n} (${h.model}, ${h.chars} chars): ${h.status} — ${h.error ?? "thin/unstructured answer"}`).join("\n")}\n═══`
|
? `\n\n═══ PRIOR ATTEMPTS FAILED. Specific issues to fix: ═══\n${history.map(h => `Attempt ${h.n} (${h.model}, ${h.chars} chars): ${h.status} — ${h.error ?? "thin/unstructured answer"}`).join("\n")}\n═══`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
log(` attempt ${n}/${MAX_ATTEMPTS}: ${rung.provider}::${rung.model}${learning ? " [w/ learning]" : ""}`);
|
log(` attempt ${n}/${MAX_ATTEMPTS}: ${rung.provider}::${rung.model}${learning ? " [w/ learning]" : ""}${pathwayPreamble ? " [w/ pathway memory]" : ""}`);
|
||||||
|
const attemptStarted = Date.now();
|
||||||
const r = await chat({
|
const r = await chat({
|
||||||
provider: rung.provider,
|
provider: rung.provider,
|
||||||
model: rung.model,
|
model: rung.model,
|
||||||
prompt: baseTask + learning,
|
prompt: pathwayPreamble + baseTask + learning,
|
||||||
max_tokens: 1500,
|
max_tokens: 1500,
|
||||||
});
|
});
|
||||||
|
const attemptMs = Date.now() - attemptStarted;
|
||||||
|
|
||||||
if (r.error) {
|
if (r.error) {
|
||||||
history.push({ n, model: rung.model, status: "error", chars: 0, error: r.error.slice(0, 180) });
|
history.push({ n, model: rung.model, status: "error", chars: 0, error: r.error.slice(0, 180) });
|
||||||
|
pathwayAttempts.push({ rung: i + 1, model: rung.model, latency_ms: attemptMs, accepted: false, reject_reason: `error: ${r.error.slice(0, 100)}` });
|
||||||
log(` ✗ error: ${r.error.slice(0, 80)}`);
|
log(` ✗ error: ${r.error.slice(0, 80)}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isAcceptable(r.content)) {
|
if (!isAcceptable(r.content)) {
|
||||||
history.push({ n, model: rung.model, status: "thin", chars: r.content.length, error: `thin/unstructured (${r.content.length} chars)` });
|
history.push({ n, model: rung.model, status: "thin", chars: r.content.length, error: `thin/unstructured (${r.content.length} chars)` });
|
||||||
|
pathwayAttempts.push({ rung: i + 1, model: rung.model, latency_ms: attemptMs, accepted: false, reject_reason: `thin (${r.content.length} chars)` });
|
||||||
log(` ✗ thin/unstructured (${r.content.length} chars)`);
|
log(` ✗ thin/unstructured (${r.content.length} chars)`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
history.push({ n, model: rung.model, status: "accepted", chars: r.content.length });
|
history.push({ n, model: rung.model, status: "accepted", chars: r.content.length });
|
||||||
|
pathwayAttempts.push({ rung: i + 1, model: rung.model, latency_ms: attemptMs, accepted: true, reject_reason: null });
|
||||||
accepted = r.content;
|
accepted = r.content;
|
||||||
acceptedModel = `${rung.provider}/${rung.model}`;
|
acceptedModel = `${rung.provider}/${rung.model}`;
|
||||||
acceptedOn = n;
|
acceptedOn = n;
|
||||||
@@ -366,6 +679,15 @@ Respond with markdown. Be specific, not generic. Cite file-region + PRD-chunk-of
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hot-swap bookkeeping: if we tried the recommended model first,
|
||||||
|
// report whether it worked so the pathway's success_rate updates.
|
||||||
|
if (hotSwap) {
|
||||||
|
const replaySucceeded = acceptedModel.endsWith(`/${hotSwap.recommended_model}`);
|
||||||
|
log(` pathway replay ${replaySucceeded ? "✓" : "✗"} (${hotSwap.pathway_id.slice(0, 12)}…)`);
|
||||||
|
// Fire and forget — don't await; observer can handle it.
|
||||||
|
recordPathwayReplay(hotSwap.pathway_id, replaySucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
const review: FileReview = {
|
const review: FileReview = {
|
||||||
file: rel,
|
file: rel,
|
||||||
file_bytes: content.length,
|
file_bytes: content.length,
|
||||||
@@ -412,6 +734,146 @@ Respond with markdown. Be specific, not generic. Cite file-region + PRD-chunk-of
|
|||||||
: null;
|
: null;
|
||||||
const conf_min = confidences.length ? Math.min(...confidences) : null;
|
const conf_min = confidences.length ? Math.min(...confidences) : null;
|
||||||
|
|
||||||
|
// ADR-021 Phase B: extract per-finding semantic flags. Reviewer is
|
||||||
|
// prompted to tag each finding with one of 9 categories plus None.
|
||||||
|
// Patterns: "**Flag: UnitMismatch**", "Flag: OffByOne", table cell
|
||||||
|
// with the flag word, or bare-word match. Deduplicated per-trace
|
||||||
|
// so repeats in one review count once.
|
||||||
|
const FLAG_VARIANTS = [
|
||||||
|
"UnitMismatch", "TypeConfusion", "NullableConfusion", "OffByOne",
|
||||||
|
"StaleReference", "PseudoImpl", "DeadCode", "WarningNoise", "BoundaryViolation",
|
||||||
|
];
|
||||||
|
const flagMatches = new Set<string>();
|
||||||
|
// Prefer matches anchored to the "Flag:" keyword; fall back to
|
||||||
|
// bare-word matches so older reviewers that mention a category
|
||||||
|
// without the "Flag:" prefix still contribute signal.
|
||||||
|
const patFlagLabeled = /(?:Flag[*:\s]*\s*)([A-Z][A-Za-z]+)/g;
|
||||||
|
for (const m of accepted.matchAll(patFlagLabeled)) {
|
||||||
|
if (FLAG_VARIANTS.includes(m[1])) flagMatches.add(m[1]);
|
||||||
|
}
|
||||||
|
// Second pass — bare-word matches for each variant, but ONLY if
|
||||||
|
// the labeled pass produced nothing. This avoids flagging every
|
||||||
|
// file that happens to mention "DeadCode" in a code sample.
|
||||||
|
if (flagMatches.size === 0) {
|
||||||
|
for (const v of FLAG_VARIANTS) {
|
||||||
|
const re = new RegExp(`\\b${v}\\b`);
|
||||||
|
if (re.test(accepted)) flagMatches.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const semantic_flags_arr = [...flagMatches].map(k => ({ kind: k }));
|
||||||
|
|
||||||
|
// ADR-021 Phase D: bug_fingerprint extraction.
|
||||||
|
//
|
||||||
|
// Walk per-finding rows (either table format with columns
|
||||||
|
// `Change | Flag | Confidence` OR bullet-list with inline
|
||||||
|
// `**Flag: X.**` tag) and pair each flag with the surrounding
|
||||||
|
// finding text. Then derive a stable pattern_key from code
|
||||||
|
// identifiers the finding cites in backticks, so future reviews
|
||||||
|
// of similar bugs cluster under the same key.
|
||||||
|
//
|
||||||
|
// v1 is heuristic (regex + identifier extraction + canonical
|
||||||
|
// sort). It's intentionally NOT a semantic extractor — just a
|
||||||
|
// deterministic "take the top code-shaped tokens and hash them
|
||||||
|
// with the flag." Stability comes from sorting tokens alphabetically
|
||||||
|
// before hashing so "row_count + QueryResponse" and "QueryResponse
|
||||||
|
// + row_count" produce the same key.
|
||||||
|
const bug_fingerprints_arr: Array<{
|
||||||
|
flag: { kind: string };
|
||||||
|
pattern_key: string;
|
||||||
|
example: string;
|
||||||
|
occurrences: number;
|
||||||
|
}> = [];
|
||||||
|
{
|
||||||
|
// Split into candidate finding blocks. Both formats are row-
|
||||||
|
// oriented, so a line split is a reasonable starting point.
|
||||||
|
// Findings tend to be one-line table rows OR multi-line bullets
|
||||||
|
// starting with **N.** — we handle both by looking at any line
|
||||||
|
// that mentions a Flag variant and treating it as a finding.
|
||||||
|
const lines = accepted.split(/\r?\n/);
|
||||||
|
const seenKeys = new Set<string>();
|
||||||
|
for (const line of lines) {
|
||||||
|
// Find the flag variant on this line (if any).
|
||||||
|
let variantOnLine: string | null = null;
|
||||||
|
for (const v of FLAG_VARIANTS) {
|
||||||
|
const re = new RegExp(`\\b${v}\\b`);
|
||||||
|
if (re.test(line)) { variantOnLine = v; break; }
|
||||||
|
}
|
||||||
|
if (!variantOnLine) continue;
|
||||||
|
|
||||||
|
// Extract identifier-shaped tokens from backticks. We try two
|
||||||
|
// levels: (a) whole-backtick match if it's a clean identifier
|
||||||
|
// or path, (b) for complex content like function signatures
|
||||||
|
// (`Foo::bar(&self) -> u64`) pull out the longest identifier
|
||||||
|
// substrings so we still capture the callable.
|
||||||
|
const codeTokens: string[] = [];
|
||||||
|
const idRe = /[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*/g;
|
||||||
|
for (const m of line.matchAll(/`([^`]+)`/g)) {
|
||||||
|
const raw = m[1].trim();
|
||||||
|
// Whole-backtick identifier or dotted path? (`row_count`,
|
||||||
|
// `AccessControl::can_access`, `foo.bar`).
|
||||||
|
if (/^[A-Za-z_][A-Za-z0-9_:]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(raw)) {
|
||||||
|
if (raw.length >= 3) codeTokens.push(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Fallback: scan for identifier substrings, take the longest
|
||||||
|
// meaningful ones (usually the function or type name comes
|
||||||
|
// first in a signature like `Foo::bar(&self)`).
|
||||||
|
const ids = [...raw.matchAll(idRe)]
|
||||||
|
.map(x => x[0])
|
||||||
|
.filter(id => id.length >= 3);
|
||||||
|
// Prefer ::-qualified paths first (they're more specific),
|
||||||
|
// then the top-2 longest; keeps the key stable under
|
||||||
|
// signature variation.
|
||||||
|
const ranked = ids
|
||||||
|
.map(id => ({ id, score: (id.includes("::") ? 1000 : 0) + id.length }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(x => x.id);
|
||||||
|
codeTokens.push(...ranked);
|
||||||
|
}
|
||||||
|
// Remove the flag variant name itself if it got captured (kimi
|
||||||
|
// and other reviewers often wrap the flag column in backticks).
|
||||||
|
// Also drop Rust + common keywords that slip through the
|
||||||
|
// identifier regex — "self", "mut", "async", "await", "pub"
|
||||||
|
// aren't bug-shape signal, they're grammar.
|
||||||
|
const FLAG_SET = new Set(FLAG_VARIANTS);
|
||||||
|
const KEYWORDS = new Set([
|
||||||
|
"self", "Self", "mut", "async", "await", "pub", "fn", "let",
|
||||||
|
"const", "static", "impl", "trait", "struct", "enum", "use",
|
||||||
|
"mod", "crate", "super", "match", "return", "Some", "None",
|
||||||
|
"Ok", "Err", "true", "false",
|
||||||
|
// Markdown table column headers kimi outputs for structured
|
||||||
|
// reviews — "Flag" / "Change" / "Confidence" are layout words,
|
||||||
|
// not identifiers. Seen as noise in iter 11 vectord extraction
|
||||||
|
// ("DeadCode:Flag" pattern_key).
|
||||||
|
"Flag", "Change", "Confidence", "PRD", "Plan",
|
||||||
|
]);
|
||||||
|
const filtered = codeTokens.filter(t => !FLAG_SET.has(t) && !KEYWORDS.has(t));
|
||||||
|
if (filtered.length === 0) continue;
|
||||||
|
|
||||||
|
// Canonicalize: dedupe, sort alphabetically, take top 3.
|
||||||
|
// Alphabetical sort gives stability across "A then B" / "B then A"
|
||||||
|
// variants. Top 3 keeps the key short while retaining enough
|
||||||
|
// signal for different bugs to separate.
|
||||||
|
const uniqTokens = [...new Set(filtered)].sort().slice(0, 3);
|
||||||
|
const pattern_key = `${variantOnLine}:${uniqTokens.join("-")}`;
|
||||||
|
if (seenKeys.has(pattern_key)) continue;
|
||||||
|
seenKeys.add(pattern_key);
|
||||||
|
|
||||||
|
// Example: the finding line, trimmed + truncated. Preserves
|
||||||
|
// just enough context that the pre-review preamble in the
|
||||||
|
// next iter can quote it back to the reviewer meaningfully.
|
||||||
|
const example = line.replace(/\s+/g, " ").trim().slice(0, 200);
|
||||||
|
|
||||||
|
bug_fingerprints_arr.push({
|
||||||
|
flag: { kind: variantOnLine },
|
||||||
|
pattern_key,
|
||||||
|
example,
|
||||||
|
occurrences: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Score extraction — regex accepts decimals ("Score: 4.5/10") and
|
// Score extraction — regex accepts decimals ("Score: 4.5/10") and
|
||||||
// surrounding punctuation ("4/10 — mid"). iter 3 had 4 unparseable
|
// surrounding punctuation ("4/10 — mid"). iter 3 had 4 unparseable
|
||||||
// scores because the prior regex /(\d)\s*\/\s*10/ missed decimals.
|
// scores because the prior regex /(\d)\s*\/\s*10/ missed decimals.
|
||||||
@@ -543,6 +1005,61 @@ Respond with markdown. Be specific, not generic. Cite file-region + PRD-chunk-of
|
|||||||
console.error(`[scrum] failed to append scrum_reviews.jsonl: ${(e as Error).message}`);
|
console.error(`[scrum] failed to append scrum_reviews.jsonl: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pathway trace sidecar (consensus-designed 2026-04-24). Captures
|
||||||
|
// FULL context (ladder attempts, KB chunks, observer signal, verdict)
|
||||||
|
// for similarity-based hot-swap in future iterations. First-review
|
||||||
|
// pathways start in probation (replay_count=0); they become
|
||||||
|
// hot-swappable only after ≥3 replays at ≥80% success.
|
||||||
|
try {
|
||||||
|
const pathwayTrace: PathwayTracePayload = {
|
||||||
|
pathway_id: computePathwayId(taskClass, rel, signalClass),
|
||||||
|
task_class: taskClass,
|
||||||
|
file_path: rel,
|
||||||
|
signal_class: signalClass,
|
||||||
|
created_at: row.reviewed_at,
|
||||||
|
ladder_attempts: pathwayAttempts,
|
||||||
|
kb_chunks: [
|
||||||
|
...topPrd.map((c, idx) => ({
|
||||||
|
source_doc: "PRD.md", chunk_id: `prd@${c.offset}`, cosine_score: (c as any)._score ?? 0, rank: idx,
|
||||||
|
})),
|
||||||
|
...topPlan.map((c, idx) => ({
|
||||||
|
source_doc: "cohesion_plan", chunk_id: `plan@${c.offset}`, cosine_score: (c as any)._score ?? 0, rank: idx,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
observer_signals: signalClass ? [{ class: signalClass, priors: [], prior_iter_outcomes: [] }] : [],
|
||||||
|
bridge_hits: [], // context7 not wired into scrum yet; empty for v1
|
||||||
|
sub_pipeline_calls: [], // LLM Team extract happens after this row; out of scope for v1
|
||||||
|
audit_consensus: null, // set by auditor's later N=3 pass, via /pathway/insert update
|
||||||
|
reducer_summary: accepted.slice(0, 4000),
|
||||||
|
final_verdict: verdict ?? "accepted",
|
||||||
|
// Vec built from the full attempts/chunks — richer than the
|
||||||
|
// query-time vector. The similarity gate will still discriminate
|
||||||
|
// between pathways with the same fingerprint but different
|
||||||
|
// ladder/KB profiles.
|
||||||
|
// Include semantic flag tokens in the embedding so traces with
|
||||||
|
// different bug histories cluster separately — matches Rust's
|
||||||
|
// build_pathway_vec exactly (flag:<Variant> token shape).
|
||||||
|
pathway_vec: buildPathwayVec([
|
||||||
|
taskClass,
|
||||||
|
rel,
|
||||||
|
...(signalClass ? [`signal:${signalClass}`] : []),
|
||||||
|
...pathwayAttempts.flatMap(a => [`rung:${a.rung}`, `model:${a.model}`, `accepted:${a.accepted}`]),
|
||||||
|
...topPrd.map(c => `kb:PRD.md`),
|
||||||
|
...topPlan.map(c => `kb:cohesion_plan`),
|
||||||
|
...semantic_flags_arr.map(f => `flag:${f.kind}`),
|
||||||
|
]),
|
||||||
|
semantic_flags: semantic_flags_arr,
|
||||||
|
type_hints_used: [], // Phase E — pre-review enrichment from catalogd/arrow/truth
|
||||||
|
bug_fingerprints: bug_fingerprints_arr, // ADR-021 Phase D
|
||||||
|
replay_count: 0,
|
||||||
|
replays_succeeded: 0,
|
||||||
|
retired: false,
|
||||||
|
};
|
||||||
|
writePathwayTrace(pathwayTrace); // fire-and-forget
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[scrum] pathway trace failed: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Close the scrum → observer loop (fix 2026-04-24). Architecture
|
// Close the scrum → observer loop (fix 2026-04-24). Architecture
|
||||||
// audit surfaced: observer ring had 2000 ops, 1999 from Langfuse,
|
// audit surfaced: observer ring had 2000 ops, 1999 from Langfuse,
|
||||||
// zero from scrum. Observer's analyzeErrors + PLAYBOOK_BUILDER loops
|
// zero from scrum. Observer's analyzeErrors + PLAYBOOK_BUILDER loops
|
||||||
@@ -587,6 +1104,20 @@ Respond with markdown. Be specific, not generic. Cite file-region + PRD-chunk-of
|
|||||||
critical_failures_count,
|
critical_failures_count,
|
||||||
verified_components_count,
|
verified_components_count,
|
||||||
missing_components_count,
|
missing_components_count,
|
||||||
|
// Pathway fields: emitted on every review so the observer
|
||||||
|
// can build a full picture of hot-swap performance over time.
|
||||||
|
// `pathway_hot_swap_hit` flags whether the first-tried rung
|
||||||
|
// this review was a pathway recommendation vs the default
|
||||||
|
// ladder top. `rungs_saved` quantifies the compute we avoided
|
||||||
|
// when a hot-swap landed — this is the value metric the VCP
|
||||||
|
// UI surfaces ("avg_rungs_saved_per_commit").
|
||||||
|
pathway_hot_swap_hit: hotSwap !== null,
|
||||||
|
pathway_id: hotSwap?.pathway_id ?? null,
|
||||||
|
pathway_similarity: hotSwap?.similarity ?? null,
|
||||||
|
pathway_success_rate: hotSwap?.success_rate ?? null,
|
||||||
|
rungs_saved: hotSwap && acceptedModel.endsWith(`/${hotSwap.recommended_model}`)
|
||||||
|
? Math.max(0, hotSwap.recommended_rung - 1)
|
||||||
|
: 0,
|
||||||
ts: row.reviewed_at,
|
ts: row.reviewed_at,
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(3000),
|
signal: AbortSignal.timeout(3000),
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Truth rules — file-backed policy
|
||||||
|
|
||||||
|
Phase 42 PRD: *"truth/ dir at repo root — rule files, versioned in git."*
|
||||||
|
|
||||||
|
This directory is the canonical home for TruthStore rules loaded from
|
||||||
|
disk. Each `*.toml` file holds a set of `TruthRule` records for one
|
||||||
|
task class. The truth crate's `load_from_dir(path)` walks this
|
||||||
|
directory, parses every `.toml` file, and registers the rules it finds.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
truth/
|
||||||
|
├── README.md ← this file
|
||||||
|
├── staffing.fill.toml ← rules for task_class="staffing.fill"
|
||||||
|
└── staffing.any.toml ← rules for task_class="staffing.any"
|
||||||
|
```
|
||||||
|
|
||||||
|
File naming is informational — `load_from_dir` respects whatever
|
||||||
|
`task_class` the rule declares internally, NOT the filename. Using
|
||||||
|
task-class-matching filenames is a convention for humans reading the
|
||||||
|
git tree.
|
||||||
|
|
||||||
|
## Rule shape
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[rule]]
|
||||||
|
id = "worker-active"
|
||||||
|
task_class = "staffing.fill"
|
||||||
|
description = "Worker must be active"
|
||||||
|
condition = { type = "FieldEquals", field = "worker.status", value = "active" }
|
||||||
|
action = { type = "Pass" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`condition.type` is one of:
|
||||||
|
- `Always` — always true
|
||||||
|
- `FieldEquals { field, value }`
|
||||||
|
- `FieldMismatch { field, value }`
|
||||||
|
- `FieldEmpty { field }`
|
||||||
|
- `FieldGreater { field, threshold }`
|
||||||
|
- `FieldContainsAny { field, needles }`
|
||||||
|
|
||||||
|
`action.type` is one of:
|
||||||
|
- `Pass` — rule informational; no enforcement
|
||||||
|
- `Reject { message }` — short-circuit with error
|
||||||
|
- `Redact { fields }` — mutate the context, strip fields
|
||||||
|
- `Block { message }` — hard stop, alert
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
The crate's `default_truth_store()` continues to register rules
|
||||||
|
**in code** for backward-compat. Operators can layer file-backed
|
||||||
|
rules ON TOP via `load_from_dir`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let store = truth::default_truth_store();
|
||||||
|
let store = truth::load_from_dir(&store, "/home/profit/lakehouse/truth")?;
|
||||||
|
```
|
||||||
|
|
||||||
|
File-loaded rules are additive — they do NOT replace in-code rules.
|
||||||
|
This lets the staffing team tune rules at the file level (edit a
|
||||||
|
threshold, add a new `FieldContainsAny` blocklist) without waiting
|
||||||
|
for a code deploy.
|
||||||
|
|
||||||
|
## Explicit non-goals
|
||||||
|
|
||||||
|
- **No hot reload** — per Phase 42 PRD ("Truth reload is explicit
|
||||||
|
in this phase"). Operators bounce the gateway or POST `/v1/context`
|
||||||
|
refresh endpoint (future) to pick up changes.
|
||||||
|
- **No inheritance** — each file stands alone; rule IDs must be unique
|
||||||
|
across all files. Duplicate-ID detection is a load-time error.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Phase 42 — staffing.any task class rules (file-backed).
|
||||||
|
#
|
||||||
|
# Rules that apply across ALL staffing task classes (fill, rescue,
|
||||||
|
# sms_draft, etc). The router registers them once and evaluates them
|
||||||
|
# on every staffing.* call.
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "any.no-destructive-sql-in-context"
|
||||||
|
task_class = "staffing.any"
|
||||||
|
description = "Reject staffing calls whose SQL context contains destructive verbs"
|
||||||
|
action = { type = "Reject", message = "destructive SQL rejected by staffing.any gate" }
|
||||||
|
|
||||||
|
[rule.condition]
|
||||||
|
type = "FieldContainsAny"
|
||||||
|
field = "sql_context"
|
||||||
|
needles = ["drop table", "truncate", "delete from", "drop schema", "drop database"]
|
||||||
|
|
||||||
|
# Additional staffing.any rules (e.g. PII scrubbing, rate limits) can be
|
||||||
|
# layered here; see the in-code staffing_rules() for the pii-redact rule
|
||||||
|
# that ships by default.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Phase 42 — staffing.fill task class rules (file-backed).
|
||||||
|
#
|
||||||
|
# These rules mirror what crates/truth/src/staffing.rs registers in code
|
||||||
|
# via staffing_rules(). Both sets load at gateway startup; rule IDs MUST
|
||||||
|
# be unique across the combined set, so operator-file overrides require
|
||||||
|
# changing the in-code registration (or vice versa).
|
||||||
|
#
|
||||||
|
# Edit a threshold / add a needle / change a description? File-level
|
||||||
|
# edits land without a code deploy. Schema changes (new RuleCondition
|
||||||
|
# variants) still need a code bump.
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "fill.endorsed-count-matches-target"
|
||||||
|
task_class = "staffing.fill"
|
||||||
|
description = "endorsed_names.length must equal target_count"
|
||||||
|
action = { type = "Reject", message = "endorsed count does not match target_count" }
|
||||||
|
|
||||||
|
[rule.condition]
|
||||||
|
type = "FieldEmpty"
|
||||||
|
field = "contract.target_count"
|
||||||
|
# When target_count is absent entirely, the rule fires and we reject.
|
||||||
|
# Callers must include target_count; this is the first gate.
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "fill.city-required"
|
||||||
|
task_class = "staffing.fill"
|
||||||
|
description = "contract.target_city must be present and non-empty"
|
||||||
|
action = { type = "Reject", message = "target_city is required" }
|
||||||
|
|
||||||
|
[rule.condition]
|
||||||
|
type = "FieldEmpty"
|
||||||
|
field = "contract.target_city"
|
||||||
+128
@@ -103,6 +103,93 @@ const REFACTOR_PHRASES = [
|
|||||||
"split this file", "too large",
|
"split this file", "too large",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Signal-class classifier — per file, given 2+ consecutive iterations'
|
||||||
|
// reviews, tag the file's behavior:
|
||||||
|
// CONVERGING — resolved > novel, score ↑
|
||||||
|
// LOOPING — 3+ same findings repeat, novel = 0, score flat
|
||||||
|
// ORBITING — novel findings each iter, no resolved (healthy depth)
|
||||||
|
// PLATEAU — score flat + findings flat (diminishing returns)
|
||||||
|
// MIXED — partial/unclear
|
||||||
|
// This is the foundation for iter-6+ auto-routing: each class gets a
|
||||||
|
// different sub-pipeline (specialist model, reviewer rotation, etc).
|
||||||
|
const SIGNAL_PHRASES = [
|
||||||
|
"pseudocode", "placeholder", "stub", "unwired", "missing", "dead code", "orphaned",
|
||||||
|
"duplicate", "redundant", "refactor", "rewrite", "remove", "unused", "unnecessary",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function signalClasses(): Promise<any> {
|
||||||
|
const runsDir = `${REPO}/tests/real-world/runs`;
|
||||||
|
// Load every review, group by file, sort by timestamp
|
||||||
|
const perFile: Record<string, Array<{run: string, phrases: Set<string>, score: number | null, conf_avg: number | null, findings: number, ts: number}>> = {};
|
||||||
|
try {
|
||||||
|
const dirs = await Array.fromAsync(new Bun.Glob("scrum_*").scan({ cwd: runsDir, onlyFiles: false }));
|
||||||
|
for (const d of dirs) {
|
||||||
|
const files = await Array.fromAsync(new Bun.Glob("review_*.json").scan({ cwd: `${runsDir}/${d}` }));
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const p = `${runsDir}/${d}/${f}`;
|
||||||
|
const j = JSON.parse(await Bun.file(p).text());
|
||||||
|
const key = j.file?.replace("/home/profit/lakehouse/", "") ?? "?";
|
||||||
|
const sug = (j.suggestions ?? "").toLowerCase();
|
||||||
|
const phrases = new Set<string>();
|
||||||
|
for (const ph of SIGNAL_PHRASES) if (sug.includes(ph)) phrases.add(ph);
|
||||||
|
const scoreMatch = sug.match(/(\d(?:\.\d)?)\s*\/\s*10\b/);
|
||||||
|
const score = scoreMatch ? parseFloat(scoreMatch[1]) : null;
|
||||||
|
const mconf = [...sug.matchAll(/(?:confidence[*:\s]*\s*|\|\s*)(\d{1,3})\s*%/gi)].map(m=>parseInt(m[1],10));
|
||||||
|
const jconf = [...sug.matchAll(/"confidence"\s*:\s*(\d{1,3})(?!\d)/gi)].map(m=>parseInt(m[1],10));
|
||||||
|
const all = [...mconf, ...jconf].filter(x => 0 <= x && x <= 100);
|
||||||
|
const conf_avg = all.length ? Math.round(all.reduce((a,b)=>a+b,0)/all.length) : null;
|
||||||
|
const ts = (await Bun.file(p).stat()).mtime.getTime();
|
||||||
|
(perFile[key] ??= []).push({ run: d, phrases, score, conf_avg, findings: all.length, ts });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { error: String(e), classes: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes: Record<string, any> = {};
|
||||||
|
for (const [file, runs] of Object.entries(perFile)) {
|
||||||
|
runs.sort((a, b) => a.ts - b.ts);
|
||||||
|
if (runs.length < 2) { classes[file] = { cls: "NEW", runs: runs.length }; continue; }
|
||||||
|
const last = runs[runs.length - 1];
|
||||||
|
const prev = runs[runs.length - 2];
|
||||||
|
const novel = [...last.phrases].filter(p => !prev.phrases.has(p));
|
||||||
|
const resolved = [...prev.phrases].filter(p => !last.phrases.has(p));
|
||||||
|
const looping = [...prev.phrases].filter(p => last.phrases.has(p));
|
||||||
|
const dScore = (last.score != null && prev.score != null) ? last.score - prev.score : null;
|
||||||
|
const dConf = (last.conf_avg != null && prev.conf_avg != null) ? last.conf_avg - prev.conf_avg : null;
|
||||||
|
const dFindings = last.findings - prev.findings;
|
||||||
|
|
||||||
|
let cls: string;
|
||||||
|
if (dScore != null && dScore > 0 && resolved.length > novel.length) cls = "CONVERGING";
|
||||||
|
else if (looping.length >= 3 && novel.length === 0 && (dScore == null || Math.abs(dScore) < 0.5)) cls = "LOOPING";
|
||||||
|
else if (novel.length >= 2 && resolved.length === 0) cls = "ORBITING";
|
||||||
|
else if (Math.abs(dFindings) <= 1 && (dScore == null || Math.abs(dScore) < 0.5)) cls = "PLATEAU";
|
||||||
|
else cls = "MIXED";
|
||||||
|
|
||||||
|
classes[file] = {
|
||||||
|
cls,
|
||||||
|
runs: runs.length,
|
||||||
|
iter_span: `${runs[0].run}…${last.run}`,
|
||||||
|
prev_score: prev.score,
|
||||||
|
last_score: last.score,
|
||||||
|
delta_score: dScore,
|
||||||
|
delta_conf: dConf,
|
||||||
|
delta_findings: dFindings,
|
||||||
|
novel,
|
||||||
|
resolved,
|
||||||
|
looping,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary counts
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const v of Object.values(classes)) counts[v.cls] = (counts[v.cls] ?? 0) + 1;
|
||||||
|
|
||||||
|
return { generated_at: new Date().toISOString(), counts, classes };
|
||||||
|
}
|
||||||
|
|
||||||
async function refactorSignals(): Promise<any> {
|
async function refactorSignals(): Promise<any> {
|
||||||
// Walk every accepted review across all scrum runs. For each file,
|
// Walk every accepted review across all scrum runs. For each file,
|
||||||
// count how many times its suggestions mention a refactor phrase.
|
// count how many times its suggestions mention a refactor phrase.
|
||||||
@@ -262,6 +349,44 @@ Bun.serve({
|
|||||||
if (path === "/data/outcomes") return Response.json(await tailJsonl(`${KB}/outcomes.jsonl`, 30));
|
if (path === "/data/outcomes") return Response.json(await tailJsonl(`${KB}/outcomes.jsonl`, 30));
|
||||||
if (path === "/data/audit_facts") return Response.json(await tailJsonl(`${KB}/audit_facts.jsonl`, 30));
|
if (path === "/data/audit_facts") return Response.json(await tailJsonl(`${KB}/audit_facts.jsonl`, 30));
|
||||||
|
|
||||||
|
// Pathway memory — consensus-designed sidecar (2026-04-24). Two
|
||||||
|
// exposed metrics: reuse_rate (activity — is it firing?) and
|
||||||
|
// avg_rungs_saved_per_commit (value — is it earning its keep?).
|
||||||
|
// Round-3 consensus (qwen3.5:397b) pointed out that activity
|
||||||
|
// without value tells us nothing; the UI needs both to judge the
|
||||||
|
// health of the hot-swap learning loop.
|
||||||
|
if (path === "/data/pathway_stats") {
|
||||||
|
try {
|
||||||
|
const r = await fetch("http://localhost:3100/vectors/pathway/stats", { signal: AbortSignal.timeout(3000) });
|
||||||
|
if (!r.ok) return Response.json({ error: `vectord ${r.status}`, stats: null });
|
||||||
|
const stats = await r.json();
|
||||||
|
// Tail recent scrum events to compute avg_rungs_saved_per_commit
|
||||||
|
// (a committed review = any row in scrum_reviews.jsonl; rungs_saved
|
||||||
|
// only populates when pathway memory fired AND the recommended
|
||||||
|
// model actually produced the accept).
|
||||||
|
const reviews = await tailJsonl(`${KB}/scrum_reviews.jsonl`, 200);
|
||||||
|
let totalCommits = 0;
|
||||||
|
let totalRungsSaved = 0;
|
||||||
|
let hotSwapHits = 0;
|
||||||
|
for (const r of reviews) {
|
||||||
|
totalCommits++;
|
||||||
|
if (r.pathway_hot_swap_hit) hotSwapHits++;
|
||||||
|
if (typeof r.rungs_saved === "number") totalRungsSaved += r.rungs_saved;
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
stats,
|
||||||
|
scrum_window: {
|
||||||
|
reviews: totalCommits,
|
||||||
|
hot_swap_hits: hotSwapHits,
|
||||||
|
pathway_reuse_rate: totalCommits ? hotSwapHits / totalCommits : 0,
|
||||||
|
avg_rungs_saved_per_commit: totalCommits ? totalRungsSaved / totalCommits : 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return Response.json({ error: (e as Error).message, stats: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (path.startsWith("/data/file/")) {
|
if (path.startsWith("/data/file/")) {
|
||||||
const relpath = decodeURIComponent(path.slice("/data/file/".length));
|
const relpath = decodeURIComponent(path.slice("/data/file/".length));
|
||||||
return Response.json(await fileHistory(relpath));
|
return Response.json(await fileHistory(relpath));
|
||||||
@@ -269,6 +394,9 @@ Bun.serve({
|
|||||||
if (path === "/data/refactor_signals") {
|
if (path === "/data/refactor_signals") {
|
||||||
return Response.json(await refactorSignals());
|
return Response.json(await refactorSignals());
|
||||||
}
|
}
|
||||||
|
if (path === "/data/signal_classes") {
|
||||||
|
return Response.json(await signalClasses());
|
||||||
|
}
|
||||||
if (path === "/data/search") {
|
if (path === "/data/search") {
|
||||||
const q = url.searchParams.get("q") ?? "";
|
const q = url.searchParams.get("q") ?? "";
|
||||||
return Response.json(await reverseIndex(q, 30));
|
return Response.json(await reverseIndex(q, 30));
|
||||||
|
|||||||
@@ -405,3 +405,36 @@ main {
|
|||||||
.traj-hit-meta { font-family: var(--mono); font-size: 10px; color: var(--fg-muted); }
|
.traj-hit-meta { font-family: var(--mono); font-size: 10px; color: var(--fg-muted); }
|
||||||
.traj-hit-snip { font-family: var(--mono); font-size: 11px; color: var(--fg-dim); line-height: 1.5; }
|
.traj-hit-snip { font-family: var(--mono); font-size: 11px; color: var(--fg-dim); line-height: 1.5; }
|
||||||
|
|
||||||
|
/* signal classes */
|
||||||
|
.signal-class-row { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.signal-chip {
|
||||||
|
font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em;
|
||||||
|
padding: 4px 10px; border: 1px solid var(--border); font-weight: 700;
|
||||||
|
}
|
||||||
|
.signal-converging { color: var(--green); border-color: var(--green); }
|
||||||
|
.signal-looping { color: var(--red); border-color: var(--red); }
|
||||||
|
.signal-orbiting { color: var(--purple); border-color: var(--purple); }
|
||||||
|
.signal-plateau { color: var(--yellow); border-color: var(--yellow); }
|
||||||
|
.signal-mixed { color: var(--blue); border-color: var(--blue); }
|
||||||
|
.signal-new { color: var(--fg-muted); border-color: var(--fg-muted); }
|
||||||
|
.signal-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(480px, 1fr)); gap: 8px;
|
||||||
|
}
|
||||||
|
.signal-card {
|
||||||
|
border: 1px solid var(--border); background: var(--bg-1);
|
||||||
|
padding: 10px 12px; cursor: pointer; border-left: 3px solid var(--fg-muted);
|
||||||
|
}
|
||||||
|
.signal-card.signal-converging { border-left-color: var(--green); }
|
||||||
|
.signal-card.signal-looping { border-left-color: var(--red); }
|
||||||
|
.signal-card.signal-orbiting { border-left-color: var(--purple); }
|
||||||
|
.signal-card.signal-plateau { border-left-color: var(--yellow); }
|
||||||
|
.signal-card.signal-mixed { border-left-color: var(--blue); }
|
||||||
|
.signal-card:hover { border-color: var(--border-hi); background: var(--bg-2); }
|
||||||
|
.signal-card-top { display: flex; gap: 10px; align-items: center; margin-bottom: 6px; }
|
||||||
|
.signal-card-file { font-family: var(--mono); font-size: 11px; color: var(--fg); }
|
||||||
|
.signal-card-body { font-family: var(--mono); font-size: 10px; color: var(--fg-dim); line-height: 1.55; }
|
||||||
|
.signal-card-body > div { margin-top: 2px; }
|
||||||
|
.signal-novel { color: var(--purple); }
|
||||||
|
.signal-resolved { color: var(--green); }
|
||||||
|
.signal-loop { color: var(--red); }
|
||||||
|
|
||||||
|
|||||||
@@ -444,6 +444,54 @@ async function drawTrajectory() {
|
|||||||
const statsEl = document.getElementById("traj-stats");
|
const statsEl = document.getElementById("traj-stats");
|
||||||
clear(statsEl);
|
clear(statsEl);
|
||||||
|
|
||||||
|
// SECTION 0 — signal classes (CONVERGING/LOOPING/ORBITING/PLATEAU/MIXED)
|
||||||
|
try {
|
||||||
|
const sc = await fetch("/data/signal_classes").then(r => r.json());
|
||||||
|
body.append(el("div", { className: "traj-section-head", text: "SIGNAL CLASSES · iter-to-iter behavior per file" }));
|
||||||
|
body.append(el("div", { className: "traj-section-explain", text:
|
||||||
|
"Each file compared iter-to-iter: CONVERGING = fix landed (resolved > novel + score↑), " +
|
||||||
|
"LOOPING = same findings repeating (deadlock candidate for hyper-focus), " +
|
||||||
|
"ORBITING = novel findings every iter (healthy depth-first), " +
|
||||||
|
"PLATEAU = score+findings flat (diminishing returns, needs different angle), " +
|
||||||
|
"MIXED = partial movement, NEW = only 1 iter so far."
|
||||||
|
}));
|
||||||
|
const classRow = el("div", { className: "signal-class-row" });
|
||||||
|
for (const [cls, n] of Object.entries(sc.counts ?? {})) {
|
||||||
|
const chip = el("span", { className: `signal-chip signal-${cls.toLowerCase()}`, text: `${cls} ${n}` });
|
||||||
|
classRow.append(chip);
|
||||||
|
}
|
||||||
|
body.append(classRow);
|
||||||
|
const grid = el("div", { className: "signal-grid" });
|
||||||
|
const sorted = Object.entries(sc.classes ?? {}).sort((a, b) => {
|
||||||
|
const order = { CONVERGING: 0, LOOPING: 1, ORBITING: 2, MIXED: 3, PLATEAU: 4, NEW: 5 };
|
||||||
|
return (order[a[1].cls] ?? 9) - (order[b[1].cls] ?? 9);
|
||||||
|
});
|
||||||
|
for (const [file, info] of sorted) {
|
||||||
|
const card = el("div", { className: `signal-card signal-${info.cls.toLowerCase()}` });
|
||||||
|
card.append(el("div", { className: "signal-card-top" },
|
||||||
|
el("span", { className: `signal-chip signal-${info.cls.toLowerCase()}`, text: info.cls }),
|
||||||
|
el("span", { className: "signal-card-file", text: file })
|
||||||
|
));
|
||||||
|
const body2 = el("div", { className: "signal-card-body" });
|
||||||
|
if (info.prev_score != null || info.last_score != null) {
|
||||||
|
body2.append(el("div", { text: `score ${info.prev_score ?? "?"} → ${info.last_score ?? "?"} (Δ ${info.delta_score != null ? (info.delta_score > 0 ? "+" : "") + info.delta_score.toFixed(1) : "?"})` }));
|
||||||
|
}
|
||||||
|
if (info.novel?.length) body2.append(el("div", { className: "signal-novel", text: `NEW: ${info.novel.join(", ")}` }));
|
||||||
|
if (info.resolved?.length) body2.append(el("div", { className: "signal-resolved", text: `RESOLVED: ${info.resolved.join(", ")}` }));
|
||||||
|
if (info.looping?.length) body2.append(el("div", { className: "signal-loop", text: `LOOPING: ${info.looping.join(", ")}` }));
|
||||||
|
card.append(body2);
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
state.selected = { type: "file", id: `/home/profit/lakehouse/${file}` };
|
||||||
|
renderContext();
|
||||||
|
document.querySelector('#views button[data-view="trace"]').click();
|
||||||
|
});
|
||||||
|
grid.append(card);
|
||||||
|
}
|
||||||
|
body.append(grid);
|
||||||
|
} catch (e) {
|
||||||
|
body.append(el("div", { className: "ctx-hint", text: `signal classes error: ${e}` }));
|
||||||
|
}
|
||||||
|
|
||||||
// SECTION 1 — refactor signals
|
// SECTION 1 — refactor signals
|
||||||
const sig = await fetch("/data/refactor_signals").then(r => r.json());
|
const sig = await fetch("/data/refactor_signals").then(r => r.json());
|
||||||
const sigs = sig.signals ?? [];
|
const sigs = sig.signals ?? [];
|
||||||
@@ -541,6 +589,37 @@ function metricBox(label, big, kind, opts = {}) {
|
|||||||
function drawMetrics() {
|
function drawMetrics() {
|
||||||
const grid = document.getElementById("metric-grid");
|
const grid = document.getElementById("metric-grid");
|
||||||
clear(grid);
|
clear(grid);
|
||||||
|
// Kick off pathway fetch in parallel; render when it resolves so the
|
||||||
|
// rest of the metrics grid appears immediately. The cards append to
|
||||||
|
// the grid after the synchronous block below — they'll show up at
|
||||||
|
// the bottom of the grid within a tick of first render.
|
||||||
|
fetch("/data/pathway_stats").then(r => r.ok ? r.json() : null).then(j => {
|
||||||
|
if (!j || !j.stats) return;
|
||||||
|
const s = j.stats;
|
||||||
|
const w = j.scrum_window ?? {};
|
||||||
|
// Activity metric — is the hot-swap firing at all?
|
||||||
|
grid.append(metricBox("pathway reuse rate", `${Math.round((w.pathway_reuse_rate ?? 0) * 100)}%`,
|
||||||
|
(w.pathway_reuse_rate ?? 0) > 0.1 ? "good" : (w.pathway_reuse_rate ?? 0) > 0 ? "warn" : "bad", {
|
||||||
|
explain: "% of recent reviews where a pathway hot-swap fired (narrow fingerprint match + 0.80 success rate + ≥3 replays + audit_consensus pass + 0.90 similarity).",
|
||||||
|
source: `scrum_reviews.jsonl .pathway_hot_swap_hit over last ${w.reviews ?? 0} reviews (${w.hot_swap_hits ?? 0} hits)`,
|
||||||
|
good: "≥10% sustained = index earning its keep. <10% over many iters = fingerprint too narrow or probation too strict. 0% on fresh install is expected (no replays yet).",
|
||||||
|
}));
|
||||||
|
// Value metric — how much compute did hot-swap actually save?
|
||||||
|
const saved = w.avg_rungs_saved_per_commit ?? 0;
|
||||||
|
grid.append(metricBox("avg rungs saved", saved.toFixed(2),
|
||||||
|
saved >= 1 ? "good" : saved > 0 ? "warn" : "bad", {
|
||||||
|
explain: "Average ladder rungs skipped per committed review by hot-swap. Rungs_saved = recommended_rung - 1 when the recommended model succeeded (otherwise 0).",
|
||||||
|
source: "scrum_reviews.jsonl .rungs_saved averaged",
|
||||||
|
good: "Every 1.0 here ≈ one less model call per review. At 21 files/iter, 1.0 saved = 21 cloud calls avoided. Value only counts when the replay actually succeeded.",
|
||||||
|
}));
|
||||||
|
// Stability metric — retired pathways indicate the learning loop is correcting itself.
|
||||||
|
grid.append(metricBox("pathways tracked", String(s.total_pathways),
|
||||||
|
s.total_pathways > 0 ? "good" : "warn", {
|
||||||
|
explain: `Total pathway traces stored. ${s.retired} retired (below 0.80 success after ≥3 replays). ${s.with_audit_pass} audit-passed, eligible for hot-swap probation.`,
|
||||||
|
source: "/vectors/pathway/stats",
|
||||||
|
good: `Grows monotonically with scrum runs. Retired=${s.retired} is HEALTHY — it means the learning loop is pruning pathways that stopped working. replay_success_rate=${(s.replay_success_rate*100).toFixed(0)}% aggregates all historical replays.`,
|
||||||
|
}));
|
||||||
|
}).catch(() => {});
|
||||||
const byTier = { auto:0, dry_run:0, simulation:0, block:0, unknown:0 };
|
const byTier = { auto:0, dry_run:0, simulation:0, block:0, unknown:0 };
|
||||||
state.reviews.forEach(r => { const t = r.gradient_tier ?? "unknown"; if (byTier[t] != null) byTier[t]++; });
|
state.reviews.forEach(r => { const t = r.gradient_tier ?? "unknown"; if (byTier[t] != null) byTier[t]++; });
|
||||||
const total = state.reviews.length || 1;
|
const total = state.reviews.length || 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user