Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
779158a09b | ||
|
|
6ac7f61819 | ||
|
|
ed83754f20 | ||
|
|
a496ced848 | ||
|
|
d187bcd8ac | ||
|
|
6432465e2c | ||
|
|
4ac56564c0 | ||
|
|
e79e51ed70 | ||
|
|
3f166a5558 | ||
|
|
c90a509f49 | ||
|
|
9ecc5848fa | ||
|
|
b843a23433 | ||
|
|
a6c83b03e5 | ||
|
|
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 | ||
|
|
39a2856851 | ||
|
|
bb4a8dff34 | ||
|
|
21fd3b9c61 | ||
|
|
4251e94531 | ||
|
|
f59ddbebd4 | ||
|
|
e442d401d2 | ||
|
|
55f8e0fe6e | ||
|
|
e27a17e950 | ||
|
|
e2ccddd8d2 | ||
|
|
5ff3213a37 | ||
|
|
21e8015b60 | ||
|
|
79108e30ac | ||
|
|
7c1745611a | ||
|
|
156dae6732 | ||
|
|
6d7b251607 |
@@ -7,6 +7,14 @@
|
||||
"LAKEHOUSE_URL": "http://localhost:3100",
|
||||
"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
+27
@@ -46,6 +46,7 @@ dependencies = [
|
||||
name = "aibridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -4090,6 +4091,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
"truth",
|
||||
"vectord",
|
||||
]
|
||||
|
||||
@@ -4678,6 +4680,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"croner",
|
||||
"csv",
|
||||
"journald",
|
||||
"lopdf",
|
||||
"mysql_async",
|
||||
"object_store",
|
||||
@@ -6895,6 +6898,7 @@ dependencies = [
|
||||
"storaged",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"truth",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -8726,6 +8730,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -8892,6 +8907,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "validator"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -8919,6 +8945,7 @@ dependencies = [
|
||||
"object_store",
|
||||
"parquet 55.2.0",
|
||||
"queryd",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -14,6 +14,8 @@ members = [
|
||||
"crates/ui",
|
||||
"crates/lance-bench",
|
||||
"crates/vectord-lance",
|
||||
"crates/truth",
|
||||
"crates/validator",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -33,6 +33,13 @@ Defaults: polls every 90s, stops on `auditor.paused` file present.
|
||||
|
||||
- `data/_auditor/state.json` — last-audited head SHA per PR
|
||||
- `data/_auditor/verdicts/{pr}-{sha}.json` — per-run verdict record
|
||||
- `data/_kb/audit_lessons.jsonl` — one row per block/warn finding,
|
||||
path-agnostic signature for dedup. Tailed by kb_query on each audit
|
||||
to surface recurring patterns (2+ distinct PRs with same signature
|
||||
→ info, 3-4 → warn, 5+ → block). This is how the auditor learns.
|
||||
- `data/_kb/scrum_reviews.jsonl` — scrum-master per-file reviews. If
|
||||
a file in the current PR has been scrum-reviewed, kb_query surfaces
|
||||
the review as a finding with the accepted model and attempt count.
|
||||
|
||||
## Where YOU edit
|
||||
|
||||
|
||||
+54
-3
@@ -12,7 +12,8 @@
|
||||
// review — reviews have self-review restrictions on Gitea and the
|
||||
// auditor currently uses the same PAT as the PR author).
|
||||
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import type { PrSnapshot, Verdict, Finding } from "./types.ts";
|
||||
import { getPrDiff, postCommitStatus, postIssueComment } from "./gitea.ts";
|
||||
@@ -24,6 +25,10 @@ import { runInferenceCheck } from "./checks/inference.ts";
|
||||
import { runKbCheck } from "./checks/kb_query.ts";
|
||||
|
||||
const VERDICTS_DIR = "/home/profit/lakehouse/data/_auditor/verdicts";
|
||||
// Playbook for audit findings — one row per block/warn finding from a
|
||||
// verdict. kb_query tails this next audit and escalates recurrences.
|
||||
// Structured as JSONL so it's cheap to append and cheap to tail.
|
||||
const AUDIT_LESSONS_JSONL = "/home/profit/lakehouse/data/_kb/audit_lessons.jsonl";
|
||||
|
||||
export interface AuditOptions {
|
||||
// Skip the cloud inference call (fast path for iteration). Default false.
|
||||
@@ -51,8 +56,8 @@ export async function auditPr(pr: PrSnapshot, opts: AuditOptions = {}): Promise<
|
||||
const [staticFindings, dynamicFindings, inferenceFindings, kbFindings] = await Promise.all([
|
||||
runStaticCheck(diff),
|
||||
opts.skip_dynamic ? Promise.resolve(stubFinding("dynamic", "skipped by options")) : runDynamicCheck(),
|
||||
opts.skip_inference ? Promise.resolve(stubFinding("inference", "skipped by options")) : runInferenceCheck(claims, diff),
|
||||
runKbCheck(claims),
|
||||
opts.skip_inference ? Promise.resolve(stubFinding("inference", "skipped by options")) : runInferenceCheck(claims, diff, { pr_number: pr.number, head_sha: pr.head_sha }),
|
||||
runKbCheck(claims, pr.files.map(f => f.path)),
|
||||
]);
|
||||
|
||||
const allFindings: Finding[] = [
|
||||
@@ -72,6 +77,7 @@ export async function auditPr(pr: PrSnapshot, opts: AuditOptions = {}): Promise<
|
||||
claims_strong: claims.filter(c => c.strength === "strong").length,
|
||||
claims_moderate: claims.filter(c => c.strength === "moderate").length,
|
||||
claims_weak: claims.filter(c => c.strength === "weak").length,
|
||||
claims_empirical: claims.filter(c => c.strength === "empirical").length,
|
||||
claims_total: claims.length,
|
||||
diff_bytes: diff.length,
|
||||
};
|
||||
@@ -80,6 +86,15 @@ export async function auditPr(pr: PrSnapshot, opts: AuditOptions = {}): Promise<
|
||||
|
||||
await persistVerdict(verdict);
|
||||
|
||||
// Feedback loop — every block/warn finding becomes a row in
|
||||
// audit_lessons.jsonl, dedup-keyed by (check, normalized-summary).
|
||||
// The next audit's kb_query reads these and escalates recurring
|
||||
// findings so we don't lose the "this pattern has been flagged
|
||||
// before" signal across runs. Fire-and-forget; failure here must
|
||||
// not break the audit.
|
||||
appendAuditLessons(verdict).catch(e =>
|
||||
console.error(`[audit] audit_lessons append failed: ${(e as Error).message}`));
|
||||
|
||||
if (!opts.dry_run) {
|
||||
await postToGitea(verdict);
|
||||
}
|
||||
@@ -87,6 +102,42 @@ export async function auditPr(pr: PrSnapshot, opts: AuditOptions = {}): Promise<
|
||||
return verdict;
|
||||
}
|
||||
|
||||
// Normalizes a finding summary for dedup: strips path-specific tails
|
||||
// ("in path/to/file.ts" → "in <file>"), line numbers, and long
|
||||
// commit-hash snippets. The goal is: the SAME class of finding on
|
||||
// DIFFERENT files should share a signature, so we can measure
|
||||
// "this pattern keeps showing up."
|
||||
function normalizedSignature(f: Finding): string {
|
||||
const summary = String(f.summary)
|
||||
.replace(/\bin\s+\S+\.(ts|rs|js|py|md)\b/gi, "in <file>")
|
||||
.replace(/:\+?\d+\b/g, ":<line>")
|
||||
.replace(/[0-9a-f]{8,}/gi, "<hash>")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 240);
|
||||
const src = `${f.check}::${f.severity}::${summary}`;
|
||||
return createHash("sha256").update(src).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
async function appendAuditLessons(v: Verdict): Promise<void> {
|
||||
const actionable = v.findings.filter(f => f.severity === "block" || f.severity === "warn");
|
||||
if (actionable.length === 0) return;
|
||||
await mkdir(join(AUDIT_LESSONS_JSONL, ".."), { recursive: true });
|
||||
const rows: string[] = [];
|
||||
for (const f of actionable) {
|
||||
rows.push(JSON.stringify({
|
||||
signature: normalizedSignature(f),
|
||||
check: f.check,
|
||||
severity: f.severity,
|
||||
summary: f.summary,
|
||||
pr_number: v.pr_number,
|
||||
head_sha: v.head_sha,
|
||||
audited_at: v.audited_at,
|
||||
}));
|
||||
}
|
||||
await appendFile(AUDIT_LESSONS_JSONL, rows.join("\n") + "\n");
|
||||
}
|
||||
|
||||
async function persistVerdict(v: Verdict): Promise<void> {
|
||||
await mkdir(VERDICTS_DIR, { recursive: true });
|
||||
const filename = `${v.pr_number}-${v.head_sha.slice(0, 12)}.json`;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// One-shot dry-run audit of a single PR. Useful for verifying check
|
||||
// behavior (kb_query scrum surfacing, inference prompts, etc.) without
|
||||
// posting to Gitea. Does NOT touch state.json and does NOT post
|
||||
// commit status or PR comments.
|
||||
//
|
||||
// Run: bun run auditor/audit_one.ts <pr-number>
|
||||
|
||||
import { getPrSnapshot } from "./gitea.ts";
|
||||
import { auditPr } from "./audit.ts";
|
||||
|
||||
async function main() {
|
||||
const prNumRaw = process.argv[2];
|
||||
if (!prNumRaw) {
|
||||
console.error("usage: bun run auditor/audit_one.ts <pr-number>");
|
||||
process.exit(2);
|
||||
}
|
||||
const prNum = Number(prNumRaw);
|
||||
if (!Number.isFinite(prNum)) {
|
||||
console.error(`invalid PR number: ${prNumRaw}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log(`[audit_one] fetching PR #${prNum}...`);
|
||||
const pr = await getPrSnapshot(prNum);
|
||||
console.log(`[audit_one] PR #${pr.number}: "${pr.title}" (head=${pr.head_sha.slice(0, 12)})`);
|
||||
console.log(`[audit_one] files in diff: ${pr.files.length}`);
|
||||
for (const f of pr.files) console.log(` - ${f.path} (+${f.additions}/-${f.deletions})`);
|
||||
console.log("");
|
||||
|
||||
const verdict = await auditPr(pr, {
|
||||
dry_run: true, // no Gitea posting
|
||||
skip_dynamic: true, // don't run fixture
|
||||
skip_inference: process.env.LH_AUDITOR_SKIP_INFERENCE === "1",
|
||||
});
|
||||
|
||||
console.log("\n═══ VERDICT ═══");
|
||||
console.log(`overall: ${verdict.overall}`);
|
||||
console.log(`one-liner: ${verdict.one_liner}`);
|
||||
console.log(`findings: total=${verdict.metrics.findings_total} block=${verdict.metrics.findings_block} warn=${verdict.metrics.findings_warn} info=${verdict.metrics.findings_info}`);
|
||||
console.log("");
|
||||
|
||||
// Print findings, highlighting kb_query scrum surfacing
|
||||
const byCheck: Record<string, typeof verdict.findings> = {};
|
||||
for (const f of verdict.findings) (byCheck[f.check] ||= []).push(f);
|
||||
|
||||
for (const [check, findings] of Object.entries(byCheck)) {
|
||||
console.log(`── ${check} (${findings.length}) ──`);
|
||||
for (const f of findings) {
|
||||
const tag = f.severity === "block" ? "🛑" : f.severity === "warn" ? "⚠️ " : "ℹ️ ";
|
||||
console.log(` ${tag} [${f.severity}] ${f.summary}`);
|
||||
if (f.summary.includes("scrum-master")) {
|
||||
for (const e of f.evidence) {
|
||||
console.log(` → ${e.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrumFindings = verdict.findings.filter(f => f.summary.includes("scrum-master"));
|
||||
console.log("");
|
||||
console.log(`═══ SCRUM WIRE CHECK: ${scrumFindings.length} scrum-master findings surfaced by kb_query ═══`);
|
||||
if (scrumFindings.length === 0) {
|
||||
console.log(" (none — either no matching scrum_reviews.jsonl rows, or files didn't match PR diff)");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error("[audit_one] fatal:", e); process.exit(1); });
|
||||
+525
-65
@@ -13,17 +13,55 @@
|
||||
// with a 15KB diff + claim list).
|
||||
|
||||
import type { Claim, Finding } from "../types.ts";
|
||||
import { Glob } from "bun";
|
||||
import { readFile, mkdir, appendFile } from "node:fs/promises";
|
||||
import { extractFacts } from "../fact_extractor.ts";
|
||||
|
||||
const GATEWAY = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
|
||||
const MODEL = process.env.LH_AUDITOR_REVIEW_MODEL ?? "gpt-oss:120b";
|
||||
// Tie-breaker for claims where the N=3 consensus produces a 1-1-1
|
||||
// split (genuinely borderline). Different architecture from the
|
||||
// primary reviewer (gpt-oss) so the tie-break isn't correlated with
|
||||
// the original disagreement. qwen3-coder:480b is a newer coding
|
||||
// specialist at 480B params, well-suited to PR-diff claim verification
|
||||
// and distinct in training lineage from gpt-oss.
|
||||
const TIEBREAKER_MODEL = process.env.LH_AUDITOR_TIEBREAKER_MODEL ?? "qwen3-coder:480b";
|
||||
const N_CONSENSUS = Number(process.env.LH_AUDITOR_CONSENSUS_N ?? 3);
|
||||
const AUDIT_DISCREPANCIES_JSONL = "/home/profit/lakehouse/data/_kb/audit_discrepancies.jsonl";
|
||||
// 40KB comfortably fits gpt-oss:120b's context. PR #1 (~39KB) was
|
||||
// previously truncated at 15KB causing the reviewer to miss later
|
||||
// files (gitea.ts, policy.ts) and flag "no Gitea client present" as a
|
||||
// block finding when the file was simply outside the truncation window.
|
||||
//
|
||||
// Above this threshold we curate via tree-split rather than truncate,
|
||||
// following the scrum_master pattern: shard the diff, summarize each
|
||||
// shard against the claim-verification task, merge into a compact
|
||||
// scratchpad, then ask the cloud to verify claims against the
|
||||
// scratchpad. This gives the cloud full-PR fidelity without bursting
|
||||
// its context window (observed failure mode: empty response or
|
||||
// unparseable output when prompt exceeds model's comfortable range).
|
||||
const MAX_DIFF_CHARS = 40000;
|
||||
// Tree-split kicks in above this. 30KB is below MAX_DIFF_CHARS so we
|
||||
// curate BEFORE truncation would happen — never lose signal to a hard
|
||||
// cut. Shard size is chosen so ~10 shards cover PR #8-size diffs in a
|
||||
// reasonable round-trip budget.
|
||||
const CURATION_THRESHOLD = 30000;
|
||||
const DIFF_SHARD_SIZE = 4500;
|
||||
const CALL_TIMEOUT_MS = 120_000;
|
||||
const REPO_ROOT = "/home/profit/lakehouse";
|
||||
|
||||
export async function runInferenceCheck(claims: Claim[], diff: string): Promise<Finding[]> {
|
||||
export interface InferenceContext {
|
||||
pr_number: number;
|
||||
head_sha: string;
|
||||
}
|
||||
|
||||
const AUDIT_FACTS_JSONL = "/home/profit/lakehouse/data/_kb/audit_facts.jsonl";
|
||||
|
||||
export async function runInferenceCheck(
|
||||
claims: Claim[],
|
||||
diff: string,
|
||||
ctx?: InferenceContext,
|
||||
): Promise<Finding[]> {
|
||||
if (claims.length === 0) {
|
||||
return [{
|
||||
check: "inference",
|
||||
@@ -33,9 +71,41 @@ export async function runInferenceCheck(claims: Claim[], diff: string): Promise<
|
||||
}];
|
||||
}
|
||||
|
||||
const truncated = diff.length > MAX_DIFF_CHARS
|
||||
? diff.slice(0, MAX_DIFF_CHARS) + `\n...[${diff.length - MAX_DIFF_CHARS} more chars truncated]`
|
||||
: diff;
|
||||
// Empirical claims (runtime metrics / observed outcomes) can't be
|
||||
// verified from the diff. Drop them from the cloud prompt so the
|
||||
// reviewer doesn't chase ghosts. A future `runtime_evidence` check
|
||||
// can validate these against data/_kb/*/summary.json outputs.
|
||||
const verifiable = claims.filter(c => c.strength !== "empirical");
|
||||
const empiricalCount = claims.length - verifiable.length;
|
||||
if (verifiable.length === 0) {
|
||||
return [{
|
||||
check: "inference",
|
||||
severity: "info",
|
||||
summary: `all ${claims.length} claims are empirical (runtime metrics) — skipping cloud inference`,
|
||||
evidence: [`empirical claims can't be verified from a static diff; needs runtime-evidence check`],
|
||||
}];
|
||||
}
|
||||
|
||||
// Diff source for the cloud prompt — either the raw diff (small
|
||||
// enough to fit), or a tree-split scratchpad (curation layer). We
|
||||
// prefer curation to truncation: truncation silently drops files
|
||||
// past the window; curation summarizes them so the cloud still sees
|
||||
// what changed, just densified.
|
||||
let diffForPrompt: string;
|
||||
let curationNote = "";
|
||||
if (diff.length > CURATION_THRESHOLD) {
|
||||
const ts = await treeSplitDiff(diff, verifiable);
|
||||
diffForPrompt = ts.scratchpad;
|
||||
curationNote = ` (curated: ${diff.length} chars → ${ts.shards} shards → scratchpad ${ts.scratchpad.length} chars)`;
|
||||
} else {
|
||||
diffForPrompt = diff;
|
||||
}
|
||||
// Belt-and-suspenders truncation — even a tree-split scratchpad
|
||||
// shouldn't exceed MAX_DIFF_CHARS in practice, but guard anyway so
|
||||
// pathological inputs can't burst the prompt.
|
||||
const truncated = diffForPrompt.length > MAX_DIFF_CHARS
|
||||
? diffForPrompt.slice(0, MAX_DIFF_CHARS) + `\n...[${diffForPrompt.length - MAX_DIFF_CHARS} more chars truncated]`
|
||||
: diffForPrompt;
|
||||
|
||||
// Build the reviewer prompt in the same shape as run_codereview's
|
||||
// review stage (llm_team_ui.py:10950), adapted for claim verification:
|
||||
@@ -43,6 +113,30 @@ export async function runInferenceCheck(claims: Claim[], diff: string): Promise<
|
||||
// "Code: ..."
|
||||
// "Review: bugs/security/perf/style/edge. Provide corrected code."
|
||||
// We add: claim list upfront + ask for structured JSON verdict.
|
||||
//
|
||||
// When the diff was curated (tree-split scratchpad), we add an
|
||||
// explicit anti-false-positive instruction: the scratchpad is a
|
||||
// distillation, not the full source, so absence-from-scratchpad is
|
||||
// NOT evidence of absence-from-diff. Mirrors the fix we made in
|
||||
// scrum_master's review prompt for the same class of error.
|
||||
const isCurated = curationNote.length > 0;
|
||||
const curationGuard = isCurated
|
||||
? [
|
||||
"",
|
||||
"CRITICAL: the 'Diff' below is a curated multi-shard scratchpad,",
|
||||
"NOT the full raw diff. The scratchpad distills each shard down",
|
||||
"to facts useful for claim verification and drops the rest.",
|
||||
"DO NOT flag a function/field/feature as 'missing' or 'not",
|
||||
"implemented' based solely on its absence from the scratchpad —",
|
||||
"absence in a distillation is NOT evidence of absence in the",
|
||||
"actual diff. Only judge a claim NOT BACKED when the scratchpad",
|
||||
"DIRECTLY contradicts it (e.g. scratchpad shows the function was",
|
||||
"added empty, or shows the claimed code path is a stub).",
|
||||
"Skip the unflagged_gaps section entirely when operating on a",
|
||||
"curated scratchpad — you can't reliably detect gaps from a",
|
||||
"distillation, and false positives there are worse than misses.",
|
||||
].join("\n")
|
||||
: "";
|
||||
const systemMsg = [
|
||||
"You review pull-request diffs against the author's own ship-claims.",
|
||||
"For each claim, decide: is it backed by actual code in the diff, or is",
|
||||
@@ -56,6 +150,7 @@ export async function runInferenceCheck(claims: Claim[], diff: string): Promise<
|
||||
" - the claim claims integration but the integration point is a stub",
|
||||
" - the diff contains unimplemented!() / todo!() / TODO comments",
|
||||
" - the claim says 'works end-to-end' but the diff has no end-to-end test",
|
||||
curationGuard,
|
||||
"",
|
||||
"Respond with strict JSON only. No prose before or after. Shape:",
|
||||
"{",
|
||||
@@ -70,7 +165,7 @@ export async function runInferenceCheck(claims: Claim[], diff: string): Promise<
|
||||
|
||||
const userMsg = [
|
||||
`Ship-claims the author made (numbered 0..N-1):`,
|
||||
claims.map((c, i) => ` ${i}. [${c.strength}] "${c.text}" at ${c.location}`).join("\n"),
|
||||
verifiable.map((c, i) => ` ${i}. [${c.strength}] "${c.text}" at ${c.location}`).join("\n"),
|
||||
"",
|
||||
`Diff:`,
|
||||
"```",
|
||||
@@ -82,79 +177,131 @@ export async function runInferenceCheck(claims: Claim[], diff: string): Promise<
|
||||
`Strict JSON only, matching the shape described. No prose outside JSON.`,
|
||||
].join("\n");
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${GATEWAY}/v1/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "ollama_cloud",
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: systemMsg },
|
||||
{ role: "user", content: userMsg },
|
||||
],
|
||||
max_tokens: 3000,
|
||||
temperature: 0.2,
|
||||
think: true, // T3 overseer should reason — JSON shape is still required
|
||||
}),
|
||||
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
// Cloud unreachable → soft-fail. Don't block a PR because the
|
||||
// reviewer model is down. Static + dynamic + kb still run.
|
||||
// N=3 consensus — run the primary reviewer in parallel, collect
|
||||
// all three parsed responses, majority-vote per claim. Parallel
|
||||
// (Promise.all) because each call is ~20-30s and they're independent;
|
||||
// wall-clock stays ~same as single call, cost 3x tokens. Empirical
|
||||
// justification: in 3-run determinism tests, 7/8 findings were
|
||||
// stable but 1 flipped across runs — majority vote stabilizes the
|
||||
// flipping class without losing the stable signal.
|
||||
const primaryRuns = await Promise.all(
|
||||
Array.from({ length: N_CONSENSUS }, () =>
|
||||
runCloudInference(systemMsg, userMsg, MODEL)),
|
||||
);
|
||||
|
||||
const parsedRuns = primaryRuns.filter(r => r.parsed !== null);
|
||||
if (parsedRuns.length === 0) {
|
||||
// All N calls failed. Surface the first-run diagnostic so the
|
||||
// operator sees *why* (unreachable / non-200 / unparseable).
|
||||
const first = primaryRuns[0];
|
||||
return [{
|
||||
check: "inference",
|
||||
severity: "info",
|
||||
summary: "cloud inference unreachable — skipped",
|
||||
evidence: [`fetch failed: ${(e as Error).message.slice(0, 180)}`],
|
||||
}];
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
return [{
|
||||
check: "inference",
|
||||
severity: "info",
|
||||
summary: `cloud inference returned ${resp.status} — skipped`,
|
||||
evidence: [`body: ${(await resp.text()).slice(0, 200)}`],
|
||||
}];
|
||||
}
|
||||
|
||||
const body: any = await resp.json();
|
||||
const content: string = body?.choices?.[0]?.message?.content ?? "";
|
||||
const usage = body?.usage ?? {};
|
||||
|
||||
const parsed = extractJson(content);
|
||||
if (!parsed) {
|
||||
return [{
|
||||
check: "inference",
|
||||
severity: "info",
|
||||
summary: "cloud returned unparseable output — skipped",
|
||||
summary: `cloud inference all ${N_CONSENSUS} consensus runs failed — ${first.error ?? "unknown"}`,
|
||||
evidence: [
|
||||
`head: ${content.slice(0, 200)}`,
|
||||
`tokens: ${usage.total_tokens ?? "?"}`,
|
||||
`first-run diagnostic: ${first.diagnostic ?? "(none)"}`,
|
||||
`successful runs: 0 / ${N_CONSENSUS}`,
|
||||
],
|
||||
}];
|
||||
}
|
||||
|
||||
// Aggregate votes per claim_idx.
|
||||
interface Votes { trues: number; falses: number; evidences: string[] }
|
||||
const votesByClaim = new Map<number, Votes>();
|
||||
const unflaggedByRun: any[][] = [];
|
||||
let totalTokens = 0;
|
||||
for (const run of parsedRuns) {
|
||||
totalTokens += run.tokens;
|
||||
unflaggedByRun.push(Array.isArray(run.parsed?.unflagged_gaps) ? run.parsed.unflagged_gaps : []);
|
||||
for (const v of run.parsed?.claim_verdicts ?? []) {
|
||||
const idx = Number(v?.claim_idx);
|
||||
if (!Number.isFinite(idx)) continue;
|
||||
const rec = votesByClaim.get(idx) ?? { trues: 0, falses: 0, evidences: [] };
|
||||
if (v.backed === false) {
|
||||
rec.falses++;
|
||||
rec.evidences.push(String(v.evidence ?? ""));
|
||||
} else if (v.backed === true) {
|
||||
rec.trues++;
|
||||
}
|
||||
votesByClaim.set(idx, rec);
|
||||
}
|
||||
}
|
||||
|
||||
const findings: Finding[] = [];
|
||||
|
||||
// One summary info finding so the verdict layer knows the check ran.
|
||||
// Summary finding so the verdict layer knows the check ran.
|
||||
findings.push({
|
||||
check: "inference",
|
||||
severity: "info",
|
||||
summary: `cloud review completed (model=${MODEL}, tokens=${usage.total_tokens ?? "?"})`,
|
||||
summary: `cloud review completed (model=${MODEL}, consensus=${parsedRuns.length}/${N_CONSENSUS}, tokens=${totalTokens})${curationNote}`,
|
||||
evidence: [
|
||||
`claim_verdicts: ${parsed.claim_verdicts?.length ?? 0}, unflagged_gaps: ${parsed.unflagged_gaps?.length ?? 0}`,
|
||||
`claims voted: ${votesByClaim.size}`,
|
||||
`parsed runs: ${parsedRuns.length} / ${N_CONSENSUS}`,
|
||||
],
|
||||
});
|
||||
|
||||
for (const v of parsed.claim_verdicts ?? []) {
|
||||
if (v?.backed === false) {
|
||||
const idx = typeof v.claim_idx === "number" ? v.claim_idx : -1;
|
||||
const claim = claims[idx];
|
||||
if (!claim) continue;
|
||||
// Strong+unbacked = BLOCK. That's the whole point of the auditor.
|
||||
// Per-claim majority vote; tie-break if no majority.
|
||||
const discrepancies: Array<{
|
||||
claim_idx: number;
|
||||
claim_text: string;
|
||||
votes: { trues: number; falses: number };
|
||||
resolution: "majority_backed" | "majority_not_backed" | "tiebreaker_backed" | "tiebreaker_not_backed" | "unresolved";
|
||||
tiebreaker_model?: string;
|
||||
}> = [];
|
||||
|
||||
for (const [idx, votes] of votesByClaim) {
|
||||
const claim = verifiable[idx];
|
||||
if (!claim) continue;
|
||||
const totalVotes = votes.trues + votes.falses;
|
||||
let notBacked: boolean | null = null;
|
||||
let resolution: typeof discrepancies[number]["resolution"] = "majority_backed";
|
||||
let evidenceText = "";
|
||||
let tbModel: string | undefined;
|
||||
|
||||
if (votes.falses > votes.trues) {
|
||||
notBacked = true;
|
||||
resolution = "majority_not_backed";
|
||||
evidenceText = votes.evidences[0] ?? "(no reason given)";
|
||||
} else if (votes.trues > votes.falses) {
|
||||
notBacked = false;
|
||||
resolution = "majority_backed";
|
||||
} else {
|
||||
// Tie. Run tie-breaker with a different-architecture model.
|
||||
const tb = await runCloudInference(systemMsg, userMsg, TIEBREAKER_MODEL);
|
||||
if (tb.parsed) {
|
||||
const tv = (tb.parsed.claim_verdicts ?? []).find((v: any) => Number(v?.claim_idx) === idx);
|
||||
if (tv?.backed === false) {
|
||||
notBacked = true;
|
||||
resolution = "tiebreaker_not_backed";
|
||||
evidenceText = `(tie-breaker ${TIEBREAKER_MODEL}) ${String(tv.evidence ?? "")}`;
|
||||
tbModel = TIEBREAKER_MODEL;
|
||||
} else if (tv?.backed === true) {
|
||||
notBacked = false;
|
||||
resolution = "tiebreaker_backed";
|
||||
tbModel = TIEBREAKER_MODEL;
|
||||
} else {
|
||||
resolution = "unresolved";
|
||||
}
|
||||
} else {
|
||||
resolution = "unresolved";
|
||||
}
|
||||
}
|
||||
|
||||
// Log every case where the N runs disagreed — discrepancies are
|
||||
// signal, not noise. Separate from audit_lessons.jsonl because
|
||||
// they're about the *auditor's* quality, not the PR's quality.
|
||||
const disagreed = totalVotes >= 2 && votes.trues > 0 && votes.falses > 0;
|
||||
if (disagreed || resolution.startsWith("tiebreaker") || resolution === "unresolved") {
|
||||
discrepancies.push({
|
||||
claim_idx: idx,
|
||||
claim_text: claim.text,
|
||||
votes: { trues: votes.trues, falses: votes.falses },
|
||||
resolution,
|
||||
tiebreaker_model: tbModel,
|
||||
});
|
||||
}
|
||||
|
||||
if (notBacked === true) {
|
||||
const sev: Finding["severity"] = claim.strength === "strong" ? "block"
|
||||
: claim.strength === "moderate" ? "warn"
|
||||
: "info";
|
||||
@@ -165,24 +312,337 @@ export async function runInferenceCheck(claims: Claim[], diff: string): Promise<
|
||||
summary: `cloud: claim not backed — "${claim.text.slice(0, 100)}"`,
|
||||
evidence: [
|
||||
`at ${claim.location}`,
|
||||
`cloud reason: ${String(v.evidence ?? "no reason given").slice(0, 200)}`,
|
||||
`consensus: ${votes.falses}/${totalVotes} not-backed (resolution: ${resolution})`,
|
||||
`cloud reason: ${evidenceText.slice(0, 200)}`,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const g of parsed.unflagged_gaps ?? []) {
|
||||
// Persist discrepancies so we can measure consensus drift over time.
|
||||
if (discrepancies.length > 0 && ctx) {
|
||||
persistDiscrepancies(ctx, discrepancies).catch(e =>
|
||||
console.error(`[inference] discrepancy log failed: ${(e as Error).message}`));
|
||||
}
|
||||
|
||||
// Use first run's parsed for downstream unflagged_gaps processing.
|
||||
const parsed = parsedRuns[0].parsed;
|
||||
|
||||
// Route the curated scratchpad through llm_team's extract-facts
|
||||
// pipeline when we have (a) a curated scratchpad (best signal about
|
||||
// what the PR actually changed) and (b) PR context to scope facts.
|
||||
// AWAITED (not fire-and-forget) so CLI callers like audit_one.ts
|
||||
// don't exit before extraction lands; the systemd poller has plenty
|
||||
// of headroom (90s cycle vs ~15s extraction). A failure inside
|
||||
// extractAndPersistFacts is caught + logged but never throws.
|
||||
if (isCurated && ctx && process.env.LH_AUDITOR_SKIP_EXTRACT !== "1") {
|
||||
try {
|
||||
await extractAndPersistFacts(diffForPrompt, ctx);
|
||||
} catch (e) {
|
||||
console.error(`[inference] fact extraction failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: when operating on a curated scratchpad, drop
|
||||
// the unflagged_gaps section entirely. The distillation can't
|
||||
// reliably ground gap-detection, and false positives are worse than
|
||||
// misses for this signal class. The systemMsg already asks the
|
||||
// cloud to skip this section when curated — but the model may still
|
||||
// emit it, so we filter here too.
|
||||
const gapsToEmit = isCurated ? [] : (parsed.unflagged_gaps ?? []);
|
||||
for (const g of gapsToEmit) {
|
||||
const summary = String(g?.summary ?? "?");
|
||||
const location = String(g?.location ?? "?");
|
||||
// False-positive guard — when the cloud says "X not defined in this
|
||||
// diff" or "missing implementation of X", the cloud may just mean
|
||||
// "X is not in the added lines," not "X doesn't exist in the repo."
|
||||
// Extract candidate symbol names and grep the repo. If any symbol
|
||||
// is defined elsewhere, drop the finding — it's a known-symbol
|
||||
// reference, not a placeholder.
|
||||
if (/not\s+defined|missing\s+implementation|never\s+referenced\s+or\s+integrated/i.test(summary)) {
|
||||
const symbols = extractSymbols(summary);
|
||||
if (symbols.length > 0) {
|
||||
const resolved = await symbolsExistInRepo(symbols);
|
||||
if (resolved.length === symbols.length) {
|
||||
// Every named symbol exists somewhere in the repo — silent drop.
|
||||
continue;
|
||||
}
|
||||
if (resolved.length > 0) {
|
||||
// Partially resolved — demote to info with a note.
|
||||
findings.push({
|
||||
check: "inference",
|
||||
severity: "info",
|
||||
summary: `cloud gap partially resolved by repo grep: ${summary.slice(0, 120)}`,
|
||||
evidence: [
|
||||
`location: ${location.slice(0, 140)}`,
|
||||
`resolved via grep: ${resolved.join(",")}`,
|
||||
`unresolved: ${symbols.filter(s => !resolved.includes(s)).join(",")}`,
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
findings.push({
|
||||
check: "inference",
|
||||
severity: "warn",
|
||||
summary: `cloud-flagged gap not in any claim: ${String(g?.summary ?? "?").slice(0, 120)}`,
|
||||
evidence: [`location: ${String(g?.location ?? "?").slice(0, 140)}`],
|
||||
summary: `cloud-flagged gap not in any claim: ${summary.slice(0, 120)}`,
|
||||
evidence: [`location: ${location.slice(0, 140)}`],
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Single cloud call — the consensus loop calls this N times in
|
||||
// parallel. Returns the parsed JSON shape + token usage + any error
|
||||
// diagnostic. NEVER throws; the consensus aggregator handles partial
|
||||
// failures by dropping non-parsed runs from the vote.
|
||||
interface CloudRunResult {
|
||||
parsed: any | null;
|
||||
tokens: number;
|
||||
error?: string; // "unreachable" | "non_200" | "unparseable"
|
||||
diagnostic?: string; // first 200 chars for debugging
|
||||
model: string;
|
||||
}
|
||||
|
||||
async function runCloudInference(systemMsg: string, userMsg: string, model: string): Promise<CloudRunResult> {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${GATEWAY}/v1/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "ollama_cloud",
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemMsg },
|
||||
{ role: "user", content: userMsg },
|
||||
],
|
||||
// temp=0 (greedy) + think=true. think=true is required for
|
||||
// gpt-oss:120b — without it the model returns empty content
|
||||
// on large prompts. Variance from the think trace is observed
|
||||
// in practice, which is why we use N=3 consensus, not single-
|
||||
// call determinism.
|
||||
max_tokens: 3000,
|
||||
temperature: 0,
|
||||
think: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
return { parsed: null, tokens: 0, error: "unreachable", diagnostic: (e as Error).message.slice(0, 200), model };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
return { parsed: null, tokens: 0, error: "non_200", diagnostic: `${resp.status}: ${(await resp.text()).slice(0, 160)}`, model };
|
||||
}
|
||||
let body: any;
|
||||
try { body = await resp.json(); }
|
||||
catch (e) { return { parsed: null, tokens: 0, error: "unparseable", diagnostic: (e as Error).message, model }; }
|
||||
const content: string = body?.choices?.[0]?.message?.content ?? "";
|
||||
const tokens: number = body?.usage?.total_tokens ?? 0;
|
||||
const parsed = extractJson(content);
|
||||
if (!parsed) {
|
||||
return { parsed: null, tokens, error: "unparseable", diagnostic: content.slice(0, 200), model };
|
||||
}
|
||||
return { parsed, tokens, model };
|
||||
}
|
||||
|
||||
async function persistDiscrepancies(ctx: InferenceContext, discrepancies: any[]): Promise<void> {
|
||||
await mkdir("/home/profit/lakehouse/data/_kb", { recursive: true });
|
||||
const rows = discrepancies.map(d => JSON.stringify({
|
||||
pr_number: ctx.pr_number,
|
||||
head_sha: ctx.head_sha,
|
||||
logged_at: new Date().toISOString(),
|
||||
...d,
|
||||
}));
|
||||
await appendFile(AUDIT_DISCREPANCIES_JSONL, rows.join("\n") + "\n");
|
||||
}
|
||||
|
||||
// Extract structured knowledge from the curated scratchpad and append
|
||||
// to data/_kb/audit_facts.jsonl — one row per extract run, keyed by
|
||||
// PR number + head SHA for scope tracking. kb_query tails this next
|
||||
// audit to surface recurring entities/relationships across PRs.
|
||||
async function extractAndPersistFacts(scratchpad: string, ctx: InferenceContext): Promise<void> {
|
||||
const ex = await extractFacts(scratchpad);
|
||||
if (ex.error && ex.entities.length === 0 && ex.facts.length === 0) {
|
||||
// Full failure — log but don't write an empty row.
|
||||
console.error(`[inference] extractFacts skipped row: ${ex.error}`);
|
||||
return;
|
||||
}
|
||||
const row = {
|
||||
pr_number: ctx.pr_number,
|
||||
head_sha: ctx.head_sha,
|
||||
extracted_at: ex.extracted_at,
|
||||
extractor: ex.extractor_model,
|
||||
verifier: ex.verifier_model,
|
||||
llm_team_run_id: ex.llm_team_run_id ?? null,
|
||||
facts: ex.facts,
|
||||
entities: ex.entities,
|
||||
relationships: ex.relationships,
|
||||
verification_preview: ex.verification.slice(0, 400),
|
||||
verifier_verdicts: ex.verifier_verdicts,
|
||||
facts_dropped_by_verifier: ex.facts_dropped_by_verifier ?? 0,
|
||||
schema_version: 2,
|
||||
source: "audit_inference",
|
||||
};
|
||||
await mkdir("/home/profit/lakehouse/data/_kb", { recursive: true });
|
||||
await appendFile(AUDIT_FACTS_JSONL, JSON.stringify(row) + "\n");
|
||||
}
|
||||
|
||||
// Curation via tree-split — ports the scrum_master pattern into the
|
||||
// inference check. Shards the raw diff into DIFF_SHARD_SIZE chunks,
|
||||
// summarizes each shard *against the claim-verification task* so the
|
||||
// summary preserves exactly what the cloud needs to judge claims
|
||||
// (function signatures, struct fields, deletions, new files), drops
|
||||
// everything else. Merges into a compact scratchpad.
|
||||
//
|
||||
// Cost: N cloud calls for the shard summaries + 1 cloud call for the
|
||||
// final verification = N+1 calls instead of 1. Mitigation: shards run
|
||||
// serially (not parallel) to keep gateway load bounded; summary calls
|
||||
// use max_tokens=400 so they're fast (~2s each on gpt-oss:120b).
|
||||
//
|
||||
// Determinism: each shard summary call uses temp=0 + think=true (same
|
||||
// as the top-level inference call), so identical input yields
|
||||
// identical scratchpad. The final verification call then sees a
|
||||
// stable scratchpad, giving stable verdicts.
|
||||
async function treeSplitDiff(
|
||||
fullDiff: string,
|
||||
claims: Claim[],
|
||||
): Promise<{ scratchpad: string; shards: number }> {
|
||||
const shards: Array<{ from: number; to: number; text: string }> = [];
|
||||
for (let i = 0; i < fullDiff.length; i += DIFF_SHARD_SIZE) {
|
||||
const end = Math.min(i + DIFF_SHARD_SIZE, fullDiff.length);
|
||||
shards.push({ from: i, to: end, text: fullDiff.slice(i, end) });
|
||||
}
|
||||
// Curate the claim list into a short form the summary prompt can
|
||||
// use to bias extraction toward relevant facts.
|
||||
const claimDigest = claims.map((c, i) =>
|
||||
`${i}. [${c.strength}] "${c.text.slice(0, 100)}"`
|
||||
).join("\n");
|
||||
|
||||
let scratchpad = "";
|
||||
for (const [si, shard] of shards.entries()) {
|
||||
const prompt = [
|
||||
`You are summarizing shard ${si + 1}/${shards.length} (chars ${shard.from}..${shard.to}) of a PR diff.`,
|
||||
`The downstream task will verify these ship-claims against the full-PR summary. Extract ONLY facts that could confirm or refute these claims:`,
|
||||
"",
|
||||
claimDigest,
|
||||
"",
|
||||
"Extract: new function/method signatures, struct fields, deletions, new files, wiring (function X calls Y), absence-of-implementation markers, TODO comments on added lines.",
|
||||
"Skip: comment-only edits, whitespace, import reordering, unrelated cosmetic changes.",
|
||||
"",
|
||||
"─────── shard diff ───────",
|
||||
shard.text,
|
||||
"─────── end shard ───────",
|
||||
"",
|
||||
"Output: up to 180 words of facts in bullet form. No prose preamble, no claim verdicts (that's for the downstream step).",
|
||||
].join("\n");
|
||||
|
||||
const r = await callCloud(prompt, 400);
|
||||
if (r.content) {
|
||||
scratchpad += `\n--- shard ${si + 1} (chars ${shard.from}..${shard.to}) ---\n${r.content.trim()}\n`;
|
||||
}
|
||||
}
|
||||
return { scratchpad: scratchpad.trim(), shards: shards.length };
|
||||
}
|
||||
|
||||
// Minimal cloud caller used only by treeSplitDiff — same gateway +
|
||||
// model as the top-level call, but think=false. Shards are small
|
||||
// (≤DIFF_SHARD_SIZE ~4500 chars) and the task is pure fact
|
||||
// extraction, not reasoning. think=true on the shards introduced
|
||||
// variance in reasoning traces that compounded across 23 calls into
|
||||
// a non-deterministic scratchpad (observed during curation
|
||||
// validation: same-SHA runs produced 5/7/8 final findings).
|
||||
// think=false on small prompts is stable — only breaks at the main
|
||||
// call's 10K+ prompt size, which keeps think=true.
|
||||
async function callCloud(prompt: string, maxTokens: number): Promise<{ content: string }> {
|
||||
try {
|
||||
const r = await fetch(`${GATEWAY}/v1/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "ollama_cloud",
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: maxTokens,
|
||||
temperature: 0,
|
||||
think: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
||||
});
|
||||
if (!r.ok) return { content: "" };
|
||||
const j: any = await r.json();
|
||||
return { content: j?.choices?.[0]?.message?.content ?? "" };
|
||||
} catch {
|
||||
return { content: "" };
|
||||
}
|
||||
}
|
||||
|
||||
// Pull out plausible code-symbol names from a summary string.
|
||||
// Matches:
|
||||
// - identifier with backticks: `foo_bar`
|
||||
// - identifier followed by parens: foo_bar()
|
||||
// - CamelCase types
|
||||
// - snake_case_functions
|
||||
// Filters out common English words that could be matched accidentally.
|
||||
const STOPWORDS = new Set([
|
||||
"not","the","and","for","this","that","with","but","are","was","has",
|
||||
"have","been","any","missing","implementation","diff","defined","never",
|
||||
"referenced","integrated","flow","code","file","some","only","when",
|
||||
]);
|
||||
function extractSymbols(text: string): string[] {
|
||||
const out = new Set<string>();
|
||||
// `backticked` symbols
|
||||
for (const m of text.matchAll(/`([A-Za-z_][A-Za-z0-9_]{2,})`/g)) out.add(m[1]);
|
||||
// foo() or foo_bar() calls
|
||||
for (const m of text.matchAll(/\b([A-Za-z_][A-Za-z0-9_]{2,})\s*\(/g)) out.add(m[1]);
|
||||
// CamelCase types (3+ chars, must start with uppercase)
|
||||
for (const m of text.matchAll(/\b([A-Z][A-Za-z0-9]{2,})\b/g)) out.add(m[1]);
|
||||
return Array.from(out).filter(s => !STOPWORDS.has(s.toLowerCase()));
|
||||
}
|
||||
|
||||
// Scan the repo for at least one definition of each symbol. Uses Bun's
|
||||
// Glob to walk TS/Rust/Python/JS sources; ignores node_modules, data/,
|
||||
// and target/. Skips files > 500KB — those are fixtures/snapshots that
|
||||
// won't contain a definition line and slurping them slows the audit.
|
||||
async function symbolsExistInRepo(symbols: string[]): Promise<string[]> {
|
||||
const patterns = ["**/*.ts", "**/*.tsx", "**/*.rs", "**/*.py", "**/*.js"];
|
||||
const skip = (p: string) => p.includes("/node_modules/") || p.startsWith("data/") || p.includes("/target/") || p.startsWith("dist/");
|
||||
const MAX_FILE_BYTES = 500_000;
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const resolved = new Set<string>();
|
||||
const toFind = new Set(symbols);
|
||||
for (const pat of patterns) {
|
||||
if (toFind.size === 0) break;
|
||||
const glob = new Glob(pat);
|
||||
for await (const f of glob.scan({ cwd: REPO_ROOT, onlyFiles: true })) {
|
||||
if (skip(f)) continue;
|
||||
try { const s = await stat(`${REPO_ROOT}/${f}`); if (s.size > MAX_FILE_BYTES) continue; } catch { continue; }
|
||||
let content: string;
|
||||
try { content = await readFile(`${REPO_ROOT}/${f}`, "utf8"); } catch { continue; }
|
||||
for (const sym of Array.from(toFind)) {
|
||||
// Definition heuristics: `function sym`, `fn sym`, `const sym`,
|
||||
// `let sym`, `def sym`, `class sym`, `struct sym`, `enum sym`,
|
||||
// `trait sym`, `async function sym`, `pub (async )?fn sym`.
|
||||
const re = new RegExp(
|
||||
`\\b(function|async\\s+function|const|let|var|def|class|struct|enum|trait|impl|type|interface|fn|pub\\s+(async\\s+)?fn)\\s+${escapeRe(sym)}\\b`
|
||||
);
|
||||
if (re.test(content)) {
|
||||
resolved.add(sym);
|
||||
toFind.delete(sym);
|
||||
if (toFind.size === 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(resolved);
|
||||
}
|
||||
|
||||
function escapeRe(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// Lift the first balanced JSON object out of the response. Tolerates
|
||||
// leading prose, code fences, and model reasoning preamble when the
|
||||
// cloud model ignored "strict JSON only."
|
||||
|
||||
+215
-1
@@ -8,6 +8,7 @@
|
||||
// What this check reads (all file-backed, append-only or periodic):
|
||||
// data/_kb/outcomes.jsonl — per-scenario outcomes (kb.ts)
|
||||
// data/_kb/error_corrections.jsonl — fail→succeed deltas on same sig
|
||||
// data/_kb/scrum_reviews.jsonl — scrum-master accepted reviews
|
||||
// data/_observer/ops.jsonl — observer ring → disk stream
|
||||
// data/_bot/cycles/*.json — bot cycle results
|
||||
//
|
||||
@@ -17,14 +18,18 @@
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Claim, Finding } from "../types.ts";
|
||||
import { aggregate, ratingSeverity, formatAgg } from "../kb_index.ts";
|
||||
|
||||
const KB_DIR = "/home/profit/lakehouse/data/_kb";
|
||||
const OBSERVER_OPS = "/home/profit/lakehouse/data/_observer/ops.jsonl";
|
||||
const BOT_CYCLES_DIR = "/home/profit/lakehouse/data/_bot/cycles";
|
||||
const SCRUM_REVIEWS_JSONL = "/home/profit/lakehouse/data/_kb/scrum_reviews.jsonl";
|
||||
const AUDIT_LESSONS_JSONL = "/home/profit/lakehouse/data/_kb/audit_lessons.jsonl";
|
||||
const AUDIT_FACTS_JSONL = "/home/profit/lakehouse/data/_kb/audit_facts.jsonl";
|
||||
const TAIL_LINES = 500;
|
||||
const MAX_BOT_CYCLE_FILES = 30;
|
||||
|
||||
export async function runKbCheck(claims: Claim[]): Promise<Finding[]> {
|
||||
export async function runKbCheck(claims: Claim[], prFiles: string[] = []): Promise<Finding[]> {
|
||||
const findings: Finding[] = [];
|
||||
|
||||
// 1. Recent scenario outcomes: are strong-claim-style phrases showing
|
||||
@@ -48,6 +53,35 @@ export async function runKbCheck(claims: Claim[]): Promise<Finding[]> {
|
||||
const obsFindings = await checkObserverStream();
|
||||
findings.push(...obsFindings);
|
||||
|
||||
// 5. Scrum-master reviews — surface prior accepted reviews for any
|
||||
// file in this PR's diff. Cohesion plan Phase C wire: the
|
||||
// auditor gets to "borrow" the scrum-master's deeper per-file
|
||||
// analysis instead of re-doing that work.
|
||||
if (prFiles.length > 0) {
|
||||
const scrumFindings = await checkScrumReviews(prFiles);
|
||||
findings.push(...scrumFindings);
|
||||
}
|
||||
|
||||
// 6b. Audit-facts (llm_team extract pipeline output) — surface
|
||||
// entities that recur across multiple PRs. These are the
|
||||
// "core system entities" accumulating in the knowledge base;
|
||||
// showing them as info on future audits gives reviewers
|
||||
// architectural context the raw diff doesn't convey.
|
||||
const factFindings = await checkAuditFacts();
|
||||
findings.push(...factFindings);
|
||||
|
||||
// 6. Audit-lessons feedback loop — summarize the top recurring
|
||||
// patterns from prior audits' block/warn findings. If the same
|
||||
// pattern signature has fired 3+ times across prior audits,
|
||||
// emit it as a block-severity finding so reviewers know this
|
||||
// is a known-recurring class, not a one-off. Does NOT couple
|
||||
// to the current audit's static/inference findings (those run
|
||||
// in parallel and we can't see them here) — the amplification
|
||||
// is emergent: if the current audit's finding-summary matches
|
||||
// a top recurrence, the reviewer sees both.
|
||||
const auditLessonFindings = await checkAuditLessons();
|
||||
findings.push(...auditLessonFindings);
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
@@ -181,3 +215,183 @@ function observerBySource(ops: any[]): string {
|
||||
}
|
||||
return Object.entries(c).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}=${v}`).join(", ") || "empty";
|
||||
}
|
||||
|
||||
// Audit-facts — reads data/_kb/audit_facts.jsonl (populated by every
|
||||
// curated inference run via llm_team's extract pipeline). Each row
|
||||
// has arrays: facts, entities, relationships. We explode entities and
|
||||
// aggregate them across PRs using kb_index. An entity seen in 3+ PRs
|
||||
// is a "core system entity" — we surface the top N as info context.
|
||||
//
|
||||
// Filters out short names (<3 chars, likely qwen2.5 truncation
|
||||
// artifacts) and generic types ("string", "number") that would
|
||||
// otherwise dominate the ranking.
|
||||
const ENTITY_NAME_MIN_LEN = 3;
|
||||
const GENERIC_ENTITY_NAMES = new Set([
|
||||
"string", "number", "boolean", "any", "void", "unknown", "never",
|
||||
"object", "array", "function", "const", "let", "var", "true", "false",
|
||||
"null", "undefined", "promise", "map", "set", "record",
|
||||
]);
|
||||
|
||||
async function checkAuditFacts(): Promise<Finding[]> {
|
||||
// Read raw rows — each row has multiple entities, so we can't just
|
||||
// use aggregate() directly (it's one-signature-per-row). Explode
|
||||
// entities into (row, entity) pairs, then aggregate by entity name.
|
||||
let raw: string;
|
||||
try { raw = await (await import("node:fs/promises")).readFile(AUDIT_FACTS_JSONL, "utf8"); }
|
||||
catch { return []; }
|
||||
const lines = raw.split("\n").filter(l => l.length > 0);
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
interface EntityRow { entity_key: string; pr_number: number; type: string; name: string; description: string }
|
||||
const entityRows: EntityRow[] = [];
|
||||
for (const line of lines.slice(-TAIL_LINES * 2)) {
|
||||
let row: any;
|
||||
try { row = JSON.parse(line); } catch { continue; }
|
||||
const prNum = Number(row?.pr_number);
|
||||
if (!Number.isFinite(prNum)) continue;
|
||||
for (const e of Array.isArray(row?.entities) ? row.entities : []) {
|
||||
const name = String(e?.name ?? "").trim();
|
||||
if (name.length < ENTITY_NAME_MIN_LEN) continue;
|
||||
if (GENERIC_ENTITY_NAMES.has(name.toLowerCase())) continue;
|
||||
entityRows.push({
|
||||
entity_key: name.toLowerCase(),
|
||||
pr_number: prNum,
|
||||
type: String(e?.type ?? "?"),
|
||||
name,
|
||||
description: String(e?.description ?? "").slice(0, 160),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entityRows.length === 0) return [];
|
||||
|
||||
// Aggregate manually — one key per entity name, distinct_scopes by PR.
|
||||
type Agg = { count: number; scopes: Set<number>; types: Set<string>; last_name: string; last_desc: string };
|
||||
const byEntity = new Map<string, Agg>();
|
||||
for (const r of entityRows) {
|
||||
const a = byEntity.get(r.entity_key) ?? {
|
||||
count: 0, scopes: new Set<number>(), types: new Set<string>(), last_name: "", last_desc: "",
|
||||
};
|
||||
a.count += 1;
|
||||
a.scopes.add(r.pr_number);
|
||||
a.types.add(r.type);
|
||||
a.last_name = r.name;
|
||||
a.last_desc = r.description;
|
||||
byEntity.set(r.entity_key, a);
|
||||
}
|
||||
|
||||
// Rank: require 2+ distinct PRs (same-PR entity-repeats don't count
|
||||
// as "cross-cutting"). Take the top 5 to avoid flooding the verdict.
|
||||
const ranked = Array.from(byEntity.entries())
|
||||
.filter(([_, a]) => a.scopes.size >= 2)
|
||||
.sort((a, b) => b[1].scopes.size - a[1].scopes.size || b[1].count - a[1].count)
|
||||
.slice(0, 5);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
// Useful to know the KB is being populated — emit a single
|
||||
// summary so operators see fact extraction is alive.
|
||||
return [{
|
||||
check: "kb_query",
|
||||
severity: "info",
|
||||
summary: `audit_facts KB has ${entityRows.length} entity-observations across ${new Set(entityRows.map(r => r.pr_number)).size} PRs (no cross-PR recurrences yet)`,
|
||||
evidence: [`source: ${AUDIT_FACTS_JSONL}`],
|
||||
}];
|
||||
}
|
||||
|
||||
return ranked.map(([_, a]) => ({
|
||||
check: "kb_query" as const,
|
||||
severity: "info" as const,
|
||||
summary: `core entity \`${a.last_name}\` recurs in ${a.scopes.size} PRs (types: ${Array.from(a.types).join(",")})`,
|
||||
evidence: [
|
||||
`count=${a.count} distinct_PRs=${a.scopes.size}`,
|
||||
`description: ${a.last_desc.slice(0, 200)}`,
|
||||
`PRs: ${Array.from(a.scopes).sort((x, y) => x - y).join(",")}`,
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
// Audit-lessons — reads data/_kb/audit_lessons.jsonl (populated by
|
||||
// every audit's appendAuditLessons). Uses the shared kb_index
|
||||
// aggregator: groups by `signature`, distinct-scopes keyed by PR
|
||||
// number, severity from ratingSeverity(agg) which applies the
|
||||
// confidence × count rating (see kb_index.ts). This is the same
|
||||
// aggregation any other KB reader uses — shared discipline, not
|
||||
// per-check custom logic.
|
||||
async function checkAuditLessons(): Promise<Finding[]> {
|
||||
const bySig = await aggregate<any>(AUDIT_LESSONS_JSONL, {
|
||||
keyFn: (r) => r?.signature,
|
||||
scopeFn: (r) => (r?.pr_number !== undefined ? `pr-${r.pr_number}` : undefined),
|
||||
checkFn: (r) => r?.check,
|
||||
tailLimit: TAIL_LINES * 4,
|
||||
});
|
||||
if (bySig.size === 0) return [];
|
||||
|
||||
const findings: Finding[] = [];
|
||||
for (const [sig, agg] of bySig) {
|
||||
// Silent on first-ever occurrence — not yet signal.
|
||||
if (agg.count < 2) continue;
|
||||
const sev = ratingSeverity(agg);
|
||||
findings.push({
|
||||
check: "kb_query",
|
||||
severity: sev,
|
||||
summary: `recurring audit pattern (${agg.distinct_scopes} distinct PRs, ${agg.count} flaggings, conf=${agg.confidence.toFixed(2)}): ${agg.representative_summary.slice(0, 160)}`,
|
||||
evidence: [
|
||||
`signature=${sig}`,
|
||||
`checks: ${agg.checks.join(",")}`,
|
||||
`scopes: ${agg.scopes.slice(-6).join(",")}`,
|
||||
formatAgg(agg),
|
||||
],
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Scrum-master reviews — the scrum pipeline writes one row per
|
||||
// accepted per-file review. We match reviews whose `file` matches
|
||||
// any path in the PR's diff, then surface the *preview* + which
|
||||
// model the escalation ladder had to reach. If the scrum-master
|
||||
// needed the 123B specialist or larger to resolve a file, that's
|
||||
// a meaningful signal about the code's complexity — and it's
|
||||
// surfaced to the PR without the auditor having to re-run the
|
||||
// escalation ladder itself.
|
||||
async function checkScrumReviews(prFiles: string[]): Promise<Finding[]> {
|
||||
const rows = await tailJsonl<any>(SCRUM_REVIEWS_JSONL, TAIL_LINES);
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Match by exact file OR filename suffix — PR files arrive as
|
||||
// `auditor/audit.ts`-style relative paths; scrum stores the same.
|
||||
const norm = (p: string) => p.replace(/^\/+/, "").replace(/^home\/profit\/lakehouse\//, "");
|
||||
const prSet = new Set(prFiles.map(norm));
|
||||
|
||||
// Keep only the most recent review per file (last-wins).
|
||||
const latestByFile = new Map<string, any>();
|
||||
for (const r of rows) {
|
||||
const f = norm(String(r.file ?? ""));
|
||||
if (!f) continue;
|
||||
if (!prSet.has(f)) continue;
|
||||
latestByFile.set(f, r);
|
||||
}
|
||||
if (latestByFile.size === 0) return [];
|
||||
|
||||
const findings: Finding[] = [];
|
||||
for (const [file, r] of latestByFile) {
|
||||
const model = String(r.accepted_model ?? "?");
|
||||
const attempt = r.accepted_on_attempt ?? "?";
|
||||
const treeSplit = !!r.tree_split_fired;
|
||||
// Heuristic: if the scrum-master had to escalate past attempt 3,
|
||||
// or had to tree-split, that's context the PR reviewer should see.
|
||||
// Severity: info for low-escalation, warn if escalated far up
|
||||
// the ladder (cloud specialist required).
|
||||
const heavyEscalation = Number(attempt) >= 4;
|
||||
const sev: "warn" | "info" = heavyEscalation ? "warn" : "info";
|
||||
findings.push({
|
||||
check: "kb_query",
|
||||
severity: sev,
|
||||
summary: `scrum-master review for \`${file}\` — accepted on attempt ${attempt} by \`${model}\`${treeSplit ? " (tree-split)" : ""}`,
|
||||
evidence: [
|
||||
`reviewed_at: ${r.reviewed_at ?? "?"}`,
|
||||
`preview: ${String(r.suggestions_preview ?? "").slice(0, 300).replace(/\n/g, " ")}`,
|
||||
],
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,13 @@ export function runStaticCheck(diff: string): Finding[] {
|
||||
|
||||
if (!isAuditorCheckerFile) {
|
||||
for (const { re, why } of BLOCK_PATTERNS) {
|
||||
if (re.test(added)) {
|
||||
const m = added.match(re);
|
||||
if (m && typeof m.index === "number") {
|
||||
// Skip if the match sits inside a quoted string literal —
|
||||
// this is how rubric files (tests/real-world/*, prompt
|
||||
// templates) legitimately reference the patterns they
|
||||
// guard against, without actually executing them.
|
||||
if (isInsideQuotedString(added, m.index)) continue;
|
||||
findings.push({
|
||||
check: "static",
|
||||
severity: "block",
|
||||
@@ -154,6 +160,25 @@ function extractNewFields(addedLines: string[]): string[] {
|
||||
return Array.from(fields);
|
||||
}
|
||||
|
||||
// True if `pos` falls inside a double- or single-quoted string on this
|
||||
// line (backtick template literals too). Walks left→right toggling the
|
||||
// "in quote" state on each unescaped quote. Good enough for single-
|
||||
// line matches; multi-line strings aren't parsed (they're extremely
|
||||
// rare in the patterns we're blocking on, and would require a proper
|
||||
// tokenizer to handle correctly).
|
||||
function isInsideQuotedString(line: string, pos: number): boolean {
|
||||
let inDouble = false, inSingle = false, inBacktick = false;
|
||||
for (let i = 0; i < pos; i++) {
|
||||
const c = line[i];
|
||||
const esc = i > 0 && line[i - 1] === "\\";
|
||||
if (esc) continue;
|
||||
if (c === '"' && !inSingle && !inBacktick) inDouble = !inDouble;
|
||||
else if (c === "'" && !inDouble && !inBacktick) inSingle = !inSingle;
|
||||
else if (c === "`" && !inDouble && !inSingle) inBacktick = !inBacktick;
|
||||
}
|
||||
return inDouble || inSingle || inBacktick;
|
||||
}
|
||||
|
||||
function escape(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
+98
-7
@@ -49,6 +49,58 @@ const WEAK_PATTERNS: RegExp[] = [
|
||||
/\bprobably\b/i,
|
||||
];
|
||||
|
||||
// Empirical claims: runtime measurements / observed outcomes that can't
|
||||
// be verified from a diff (only from the actual run that produced
|
||||
// them). Classifying as empirical lets the inference check skip
|
||||
// diff-verification and saves the ladder for falsifiable claims.
|
||||
//
|
||||
// Two classes share this bucket because they share the skip discipline:
|
||||
//
|
||||
// 1. Runtime metrics — "58 cloud calls", "306s end-to-end"
|
||||
// 2. History/proof refs — "verified on PR #8", "was flipping across runs"
|
||||
//
|
||||
// Both are assertions about state outside the current diff. The cloud
|
||||
// would flag them as "not backed" — but that's a false positive: the
|
||||
// proof lives in the referenced run, prior commit, or test output, not
|
||||
// in the added lines the cloud is reading.
|
||||
const EMPIRICAL_PATTERNS: RegExp[] = [
|
||||
// ─── Runtime metrics ───
|
||||
// Iteration / attempt counts: "6/6 iterations", "attempt 5", "accepted on attempt 3"
|
||||
/\b\d+\s*\/\s*\d+\s+(iterations?|attempts?|cycles?|runs?|shards?)\b/i,
|
||||
/\b(accepted|resolved|converged)\s+on\s+attempt\s+\d+\b/i,
|
||||
// Runtime metrics: "58 cloud calls", "306s end-to-end", "245s total", "5931 chars"
|
||||
/\b\d+\s+(cloud\s+)?calls?\b/i,
|
||||
/\b\d+\s*(ms|s|seconds?|minutes?|m)\s+(end[- ]to[- ]end|total|elapsed|duration)\b/i,
|
||||
/\b\d+\s+chars?\b.*\b(accepted|generated|produced)\b/i,
|
||||
// "escalated through N tiers", "N distinct models"
|
||||
/\bescalated\s+through\s+\d+\b/i,
|
||||
/\b\d+\s+distinct\s+(model|tier)s?\b/i,
|
||||
|
||||
// ─── History / proof references ───
|
||||
// "verified on PR #8", "verified end-to-end on PR 8", "tested against PR #4"
|
||||
// Require PR#N / commit-hash / "prior <word>" to avoid matching
|
||||
// "verified ... in production" (PR without \b-ish anchor previously
|
||||
// consumed "pr" of "production").
|
||||
/\bverified\s+(?:end[- ]to[- ]end\s+)?(?:on|against|in)\s+(?:PR\s*#?\d+|commit\s+[0-9a-f]{6,}|prior\s+\w+|the\s+\w+\s+audit)\b/i,
|
||||
/\btested\s+(?:against|in|on)\s+(?:PR\s*#?\d+|commit\s+[0-9a-f]{6,}|prior\s+\w+)\b/i,
|
||||
// Direct PR/commit references: "PR #8", "on PR 9", "from commit abc123"
|
||||
/\b(?:on|from|in|via|per)\s+PR\s*#?\d+\b/i,
|
||||
/\b(?:from|in|per|against)\s+commit\s+[0-9a-f]{6,}/i,
|
||||
// Observational descriptions of prior behavior: "was flipping", "was X before", "previously observed"
|
||||
/\b(?:was|were)\s+(?:flipping|drifting|inconsistent|non[- ]deterministic|creeping)\b/i,
|
||||
/\bpreviously\s+(?:observed|flagged|reported|seen|landed)\b/i,
|
||||
/\bused\s+to\s+(?:flip|fail|flag|reject|block)\b/i,
|
||||
/\bobserved\s+(?:in|during|on|across)\s+(?:PR|prior|\d+\s+(?:runs?|audits?))/i,
|
||||
// "flipping/drifting across N runs" — historical variance description
|
||||
/\b(?:flipping|drifting|varying|oscillating)\s+across\s+(?:\d+\s+)?(?:runs?|audits?|iterations?)\b/i,
|
||||
// "the proven X" referring to prior work (proven is a STRONG pattern
|
||||
// but in context "the proven FOO" is usually a historical reference,
|
||||
// not a fresh claim). We catch it here so the empirical skip wins.
|
||||
/\bthe\s+proven\s+(?:escalation\s+ladder|pipeline|flow|loop|tier|path)/i,
|
||||
// "from the 9-run test", "across the 5-run validation"
|
||||
/\b(?:from|across|in|during)\s+the\s+\d+[- ]run\s+(?:test|validation|probe|experiment)/i,
|
||||
];
|
||||
|
||||
export interface ParsedClaims {
|
||||
claims: Claim[];
|
||||
commits_scanned: number;
|
||||
@@ -77,9 +129,22 @@ function scanText(text: string, location_prefix: string, commit_sha: string, out
|
||||
const line = lines[i];
|
||||
if (line.length < 3) continue;
|
||||
|
||||
// Strong patterns first — if a line matches strong, it's strong,
|
||||
// don't double-count as moderate.
|
||||
const strong = firstMatch(line, STRONG_PATTERNS);
|
||||
// Empirical match wins over everything else — if a line ALSO
|
||||
// contains a moderate word like "complete", we still want to
|
||||
// classify it as empirical so the inference check doesn't ask
|
||||
// the cloud to prove "58 cloud calls" from the diff. Order:
|
||||
// empirical → strong → moderate → weak.
|
||||
const empirical = firstUnquotedMatch(line, EMPIRICAL_PATTERNS);
|
||||
if (empirical) {
|
||||
out.push({
|
||||
text: line.trim().slice(0, 200),
|
||||
commit_sha,
|
||||
location: `${location_prefix}:${i + 1}`,
|
||||
strength: "empirical",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const strong = firstUnquotedMatch(line, STRONG_PATTERNS);
|
||||
if (strong) {
|
||||
out.push({
|
||||
text: line.trim().slice(0, 200),
|
||||
@@ -89,7 +154,7 @@ function scanText(text: string, location_prefix: string, commit_sha: string, out
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const moderate = firstMatch(line, MODERATE_PATTERNS);
|
||||
const moderate = firstUnquotedMatch(line, MODERATE_PATTERNS);
|
||||
if (moderate) {
|
||||
out.push({
|
||||
text: line.trim().slice(0, 200),
|
||||
@@ -99,7 +164,7 @@ function scanText(text: string, location_prefix: string, commit_sha: string, out
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const weak = firstMatch(line, WEAK_PATTERNS);
|
||||
const weak = firstUnquotedMatch(line, WEAK_PATTERNS);
|
||||
if (weak) {
|
||||
out.push({
|
||||
text: line.trim().slice(0, 200),
|
||||
@@ -111,9 +176,35 @@ function scanText(text: string, location_prefix: string, commit_sha: string, out
|
||||
}
|
||||
}
|
||||
|
||||
function firstMatch(text: string, patterns: RegExp[]): RegExp | null {
|
||||
// Match a pattern only when its match position is NOT inside a quoted
|
||||
// string on the line. Mirrors the same guard in auditor/checks/static.ts
|
||||
// — the two files have the same false-positive class: PR authors
|
||||
// quote pattern examples in commit message bodies (e.g. `"Phase 45
|
||||
// shipped"` as a test example) and without this guard those quoted
|
||||
// references get flagged as fresh ship-claims. Only skips when the
|
||||
// match itself falls inside quotes; real (unquoted) uses of the same
|
||||
// vocabulary still classify correctly.
|
||||
function firstUnquotedMatch(text: string, patterns: RegExp[]): RegExp | null {
|
||||
for (const p of patterns) {
|
||||
if (p.test(text)) return p;
|
||||
const m = text.match(p);
|
||||
if (!m || typeof m.index !== "number") continue;
|
||||
if (isInsideQuotedString(text, m.index)) continue;
|
||||
return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Walks left→right toggling in-quote state on each unescaped quote.
|
||||
// Good enough for single-line claims; multi-line strings aren't parsed.
|
||||
function isInsideQuotedString(line: string, pos: number): boolean {
|
||||
let inDouble = false, inSingle = false, inBacktick = false;
|
||||
for (let i = 0; i < pos; i++) {
|
||||
const c = line[i];
|
||||
const esc = i > 0 && line[i - 1] === "\\";
|
||||
if (esc) continue;
|
||||
if (c === '"' && !inSingle && !inBacktick) inDouble = !inDouble;
|
||||
else if (c === "'" && !inDouble && !inBacktick) inSingle = !inSingle;
|
||||
else if (c === "`" && !inDouble && !inSingle) inBacktick = !inBacktick;
|
||||
}
|
||||
return inDouble || inSingle || inBacktick;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// fact_extractor — routes curated TEXT through llm_team_ui's
|
||||
// "knowledge extract facts" mode (mode=extract at /api/run).
|
||||
//
|
||||
// What it gives us: structured {facts, entities, relationships} from
|
||||
// whatever curated blob we send. Auditor sends the tree-split
|
||||
// inference scratchpad (the best distillation of what a PR changed).
|
||||
// Scrum_master will later send its accepted review bodies.
|
||||
//
|
||||
// Why route through llm_team and not just extract directly from our
|
||||
// own checks: llm_team's extract uses a local EXTRACTOR model
|
||||
// (qwen2.5) + a separate VERIFIER (gemma2). This cross-check is the
|
||||
// discipline J wants for knowledge going into the playbook — facts
|
||||
// go in only after a second model has rated them CORRECT /
|
||||
// UNVERIFIABLE. Fast (local models, ~10-20s), free, and matches the
|
||||
// codereview pattern J already trusts.
|
||||
//
|
||||
// SSE parsing: llm_team streams SSE events. We're only interested in
|
||||
// the final "response" event with role="final" + the extraction
|
||||
// response (role="extraction N"). Parse the JSON from the extractor's
|
||||
// response text.
|
||||
|
||||
const LLM_TEAM = process.env.LH_LLM_TEAM_URL ?? "http://localhost:5000";
|
||||
const EXTRACTOR = process.env.LH_FACT_EXTRACTOR ?? "qwen2.5:latest";
|
||||
const VERIFIER = process.env.LH_FACT_VERIFIER ?? "gemma2:latest";
|
||||
const EXTRACT_TIMEOUT_MS = 120_000;
|
||||
const PROJECT_CONTEXT_FILE = process.env.LH_AUDITOR_CONTEXT_FILE
|
||||
?? "/home/profit/lakehouse/docs/AUDITOR_CONTEXT.md";
|
||||
|
||||
let cachedContext: string | null = null;
|
||||
async function loadProjectContext(): Promise<string> {
|
||||
if (cachedContext !== null) return cachedContext;
|
||||
try {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const raw = await readFile(PROJECT_CONTEXT_FILE, "utf8");
|
||||
// Cap at 4KB — anything past that is more noise than signal for
|
||||
// the extractor/verifier's attention budget.
|
||||
cachedContext = raw.slice(0, 4000);
|
||||
} catch {
|
||||
cachedContext = ""; // context file missing → extractor runs without preamble
|
||||
}
|
||||
return cachedContext;
|
||||
}
|
||||
|
||||
export interface Entity {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Relationship {
|
||||
from: string;
|
||||
to: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ExtractedFacts {
|
||||
facts: string[];
|
||||
entities: Entity[];
|
||||
relationships: Relationship[];
|
||||
verification: string;
|
||||
extractor_model: string;
|
||||
verifier_model: string;
|
||||
source_preview: string;
|
||||
// Populated when the extract run completed server-side (llm_team
|
||||
// persists to its own team_runs; this is for our own cross-ref).
|
||||
llm_team_run_id?: number;
|
||||
extracted_at: string;
|
||||
// Per-fact verdicts from the verifier pass (CORRECT/INCORRECT/
|
||||
// UNVERIFIABLE/UNCHECKED). Aligned 1:1 with the *raw* fact list
|
||||
// pre-drop so operators can see which verdicts mapped to dropped
|
||||
// facts if needed.
|
||||
verifier_verdicts?: Array<"CORRECT" | "INCORRECT" | "UNVERIFIABLE" | "UNCHECKED">;
|
||||
facts_dropped_by_verifier?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the llm_team extract pipeline on `source` text. Returns
|
||||
* structured {facts, entities, relationships}.
|
||||
*
|
||||
* Returns an object with `error` set if the pipeline failed — never
|
||||
* throws, because fact extraction is best-effort enrichment (the
|
||||
* primary audit must not break if llm_team is down).
|
||||
*/
|
||||
export async function extractFacts(source: string): Promise<ExtractedFacts> {
|
||||
const base: ExtractedFacts = {
|
||||
facts: [],
|
||||
entities: [],
|
||||
relationships: [],
|
||||
verification: "",
|
||||
extractor_model: EXTRACTOR,
|
||||
verifier_model: VERIFIER,
|
||||
source_preview: source.slice(0, 240),
|
||||
extracted_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Prepend project context to the source so the extractor + verifier
|
||||
// know what codebase/framework these facts belong to. Without this,
|
||||
// the verifier marks most domain-specific facts as UNVERIFIABLE ("I
|
||||
// don't know what Lakehouse is"). With it, the verifier can CORRECT-
|
||||
// stamp facts that align with the stated architecture.
|
||||
const context = await loadProjectContext();
|
||||
const prompt = context.length > 0
|
||||
? `=== PROJECT CONTEXT (for grounding facts; do NOT extract facts from this section) ===\n${context}\n\n=== CONTENT TO EXTRACT FACTS FROM ===\n${source}`
|
||||
: source;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${LLM_TEAM}/api/run`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "extract",
|
||||
prompt,
|
||||
extractor: EXTRACTOR,
|
||||
verifier: VERIFIER,
|
||||
source: "prompt",
|
||||
skip_cache: true, // cache by prompt would dedup identical
|
||||
// scratchpads, but we want fresh extraction
|
||||
// for per-audit facts; cheap since local.
|
||||
}),
|
||||
signal: AbortSignal.timeout(EXTRACT_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
return { ...base, error: `fetch failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => "");
|
||||
return { ...base, error: `llm_team /api/run ${resp.status}: ${body.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
// Stream SSE lines; collect the one extraction response + the run_saved event
|
||||
// so we can capture the team-runs ID for cross-ref.
|
||||
const decoder = new TextDecoder();
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) return { ...base, error: "no response body" };
|
||||
|
||||
let buffer = "";
|
||||
let extractionText = "";
|
||||
let verifierText = "";
|
||||
let runId: number | undefined = undefined;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buffer.indexOf("\n\n")) >= 0) {
|
||||
const chunk = buffer.slice(0, nl);
|
||||
buffer = buffer.slice(nl + 2);
|
||||
const dataLine = chunk.split("\n").find(l => l.startsWith("data: "));
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
const ev = JSON.parse(dataLine.slice(6));
|
||||
if (ev.type === "response") {
|
||||
const role = String(ev.role ?? "");
|
||||
if (role.startsWith("extraction")) extractionText = String(ev.text ?? "");
|
||||
else if (role === "verifier") verifierText = String(ev.text ?? "");
|
||||
} else if (ev.type === "run_saved") {
|
||||
const id = Number(ev.run_id);
|
||||
if (Number.isFinite(id)) runId = id;
|
||||
}
|
||||
} catch { /* skip malformed SSE */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return { ...base, error: `SSE read failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// Pull the JSON object out of extractionText (may be wrapped in ```json fences).
|
||||
const parsed = extractFirstJsonObject(extractionText);
|
||||
if (!parsed) {
|
||||
return { ...base, error: "extractor returned no parseable JSON", verification: verifierText };
|
||||
}
|
||||
|
||||
const rawFacts: string[] = Array.isArray(parsed.facts)
|
||||
? parsed.facts.slice(0, 50).map(String)
|
||||
: [];
|
||||
|
||||
// Parse the verifier's free-form prose into per-fact verdicts, then
|
||||
// drop any fact the verifier explicitly marked INCORRECT. Leave
|
||||
// UNVERIFIABLE in place: many of our extractions are domain-specific
|
||||
// (Lakehouse internals) and the verifier has no prior-knowledge
|
||||
// anchor, so UNVERIFIABLE is the expected verdict for new signal,
|
||||
// not a quality fail. This is verifier-gated persistence: drop only
|
||||
// what's affirmatively wrong, not what's novel.
|
||||
const verdicts = parseVerifierVerdicts(verifierText, rawFacts.length);
|
||||
const incorrectIdx = new Set<number>();
|
||||
verdicts.forEach((v, i) => { if (v === "INCORRECT") incorrectIdx.add(i); });
|
||||
const kept = rawFacts.filter((_, i) => !incorrectIdx.has(i));
|
||||
|
||||
return {
|
||||
...base,
|
||||
facts: kept,
|
||||
entities: Array.isArray(parsed.entities)
|
||||
? parsed.entities.slice(0, 30).map((e: any) => ({
|
||||
name: String(e?.name ?? ""),
|
||||
type: String(e?.type ?? ""),
|
||||
description: typeof e?.description === "string" ? e.description.slice(0, 240) : undefined,
|
||||
})).filter(e => e.name.length > 0)
|
||||
: [],
|
||||
relationships: Array.isArray(parsed.relationships)
|
||||
? parsed.relationships.slice(0, 30).map((r: any) => ({
|
||||
from: String(r?.from ?? ""),
|
||||
to: String(r?.to ?? ""),
|
||||
type: String(r?.type ?? ""),
|
||||
})).filter(r => r.from.length > 0 && r.to.length > 0)
|
||||
: [],
|
||||
verification: verifierText.slice(0, 1500),
|
||||
facts_dropped_by_verifier: incorrectIdx.size,
|
||||
verifier_verdicts: verdicts,
|
||||
llm_team_run_id: runId,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse verifier's free-form output into a per-fact verdict array.
|
||||
// Gemma2 uses several formats depending on prompt mood:
|
||||
// Format A: **1.** claim... * **Verdict:** CORRECT
|
||||
// Format B: **1.** claim... * **CORRECT** (no "Verdict:" label)
|
||||
// Format C: 1. claim... CORRECT
|
||||
// Strategy: split on fact numbers, then find the first
|
||||
// CORRECT|INCORRECT|UNVERIFIABLE token in each section. Handles all
|
||||
// three formats without regex gymnastics.
|
||||
function parseVerifierVerdicts(
|
||||
verifierText: string,
|
||||
numFacts: number,
|
||||
): Array<"CORRECT" | "INCORRECT" | "UNVERIFIABLE" | "UNCHECKED"> {
|
||||
const out: Array<"CORRECT" | "INCORRECT" | "UNVERIFIABLE" | "UNCHECKED"> =
|
||||
Array(numFacts).fill("UNCHECKED");
|
||||
if (!verifierText) return out;
|
||||
|
||||
// Find each fact section start — "**N.**" or "N." at line start —
|
||||
// and slice out the content up to the NEXT fact number. Each section
|
||||
// gets scanned for the first CORRECT/INCORRECT/UNVERIFIABLE token.
|
||||
const starts: Array<{ idx: number; pos: number }> = [];
|
||||
const header = /(?:^|\n)\s*(?:\*\*)?(\d+)[.)]/g;
|
||||
for (const m of verifierText.matchAll(header)) {
|
||||
const factNum = Number(m[1]);
|
||||
if (!Number.isFinite(factNum)) continue;
|
||||
starts.push({ idx: factNum - 1, pos: m.index! });
|
||||
}
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
const s = starts[i];
|
||||
const end = i + 1 < starts.length ? starts[i + 1].pos : verifierText.length;
|
||||
if (s.idx < 0 || s.idx >= numFacts) continue;
|
||||
const section = verifierText.slice(s.pos, end);
|
||||
const v = section.match(/\b(CORRECT|INCORRECT|UNVERIFIABLE)\b/i);
|
||||
if (v) out[s.idx] = v[1].toUpperCase() as "CORRECT" | "INCORRECT" | "UNVERIFIABLE";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Lift the first balanced JSON object out of (possibly fenced) text.
|
||||
// Same discipline as inference.ts::extractJson.
|
||||
function extractFirstJsonObject(text: string): any | null {
|
||||
const cleaned = text.replace(/^```(?:json)?\s*/im, "").replace(/```\s*$/im, "");
|
||||
let depth = 0, start = -1;
|
||||
for (let i = 0; i < cleaned.length; i++) {
|
||||
const c = cleaned[i];
|
||||
if (c === "{") { if (depth === 0) start = i; depth++; }
|
||||
else if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0 && start >= 0) {
|
||||
try { return JSON.parse(cleaned.slice(start, i + 1)); } catch { start = -1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// kb_index — generic on-the-fly aggregation over append-only JSONL
|
||||
// scratchpads (audit_lessons, scrum_reviews, outcomes, observer ops).
|
||||
//
|
||||
// The mem0 insight: raw rows are CHEAP and tell the full story, but
|
||||
// downstream prompts need a DEFINITION, not a log. A definition is
|
||||
// the aggregate: "this signature has fired N times across M distinct
|
||||
// scopes, first_seen=X, last_seen=Y, confidence=M/N."
|
||||
//
|
||||
// This library is the single shared aggregator. Every KB writer keeps
|
||||
// appending raw rows; every KB reader uses aggregate() instead of
|
||||
// tailing the raw stream. No second file to sync, no ADD/UPDATE/NOOP
|
||||
// routing — the stats roll up from the raw rows every time.
|
||||
//
|
||||
// Why this works past hundreds of runs:
|
||||
// - aggregate() is bounded by distinct_signatures, not total_rows.
|
||||
// - confidence = distinct_scopes / count — low for same-scope noise,
|
||||
// high for cross-scope patterns. Downstream severity ramps on
|
||||
// confidence × count, not raw count, so one unfixed PR can't
|
||||
// inflate its own recurrence score (the classic mem0 failure).
|
||||
// - rotation (later) moves old raw to archive files; aggregate()
|
||||
// can still read both to compute lifetime counts when needed.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
export interface AggregateRow {
|
||||
signature: string;
|
||||
count: number;
|
||||
distinct_scopes: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
confidence: number; // distinct_scopes / count — capped at 1.0
|
||||
representative_summary: string; // most-recent summary for this signature
|
||||
scopes: string[]; // up to 20 most-recent scopes for debugging
|
||||
checks: string[]; // distinct `check` values (audit_lessons-specific)
|
||||
}
|
||||
|
||||
export interface AggregateOptions<T> {
|
||||
/** How to extract the dedup key from a row. */
|
||||
keyFn: (row: T) => string | undefined;
|
||||
/** How to extract the "scope" — distinct scopes count gives confidence. */
|
||||
scopeFn: (row: T) => string | undefined;
|
||||
/** How to extract the timestamp (defaults to row.audited_at / row.reviewed_at / row.timestamp). */
|
||||
timeFn?: (row: T) => string | undefined;
|
||||
/** How to extract a representative summary (defaults to row.summary). */
|
||||
summaryFn?: (row: T) => string | undefined;
|
||||
/** Max rows to read from the JSONL tail; 0 = read all. */
|
||||
tailLimit?: number;
|
||||
/** Include per-row check field (for multi-check aggregates). */
|
||||
checkFn?: (row: T) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a JSONL file and produce the aggregate map keyed by signature.
|
||||
* Safe on missing or malformed files — returns empty map.
|
||||
*/
|
||||
export async function aggregate<T = any>(
|
||||
jsonlPath: string,
|
||||
opts: AggregateOptions<T>,
|
||||
): Promise<Map<string, AggregateRow>> {
|
||||
const out = new Map<string, AggregateRow>();
|
||||
let raw: string;
|
||||
try { raw = await readFile(jsonlPath, "utf8"); } catch { return out; }
|
||||
const lines = raw.split("\n").filter(l => l.length > 0);
|
||||
const sliceFrom = opts.tailLimit && opts.tailLimit > 0 ? Math.max(0, lines.length - opts.tailLimit) : 0;
|
||||
|
||||
const timeFn = opts.timeFn ?? ((r: any) => r?.audited_at ?? r?.reviewed_at ?? r?.timestamp ?? r?.ran_at);
|
||||
const summaryFn = opts.summaryFn ?? ((r: any) => r?.summary ?? r?.representative_summary);
|
||||
|
||||
// Per-signature scope tracking — need counts by scope to compute
|
||||
// distinct_scopes without double-counting a scope that appears 50
|
||||
// times. Using a Set<scope> per signature.
|
||||
const scopeSets = new Map<string, Set<string>>();
|
||||
const checkSets = new Map<string, Set<string>>();
|
||||
|
||||
for (let i = sliceFrom; i < lines.length; i++) {
|
||||
let row: T;
|
||||
try { row = JSON.parse(lines[i]) as T; } catch { continue; }
|
||||
const sig = opts.keyFn(row);
|
||||
if (!sig) continue;
|
||||
|
||||
let agg = out.get(sig);
|
||||
if (!agg) {
|
||||
agg = {
|
||||
signature: sig,
|
||||
count: 0,
|
||||
distinct_scopes: 0,
|
||||
first_seen: "",
|
||||
last_seen: "",
|
||||
confidence: 0,
|
||||
representative_summary: "",
|
||||
scopes: [],
|
||||
checks: [],
|
||||
};
|
||||
out.set(sig, agg);
|
||||
scopeSets.set(sig, new Set<string>());
|
||||
checkSets.set(sig, new Set<string>());
|
||||
}
|
||||
|
||||
agg.count += 1;
|
||||
|
||||
const scope = opts.scopeFn(row);
|
||||
if (scope !== undefined && scope !== null && scope !== "") {
|
||||
scopeSets.get(sig)!.add(String(scope));
|
||||
// Keep scopes array ordered by recency (newest wins — shift
|
||||
// oldest when at cap).
|
||||
const arr = agg.scopes;
|
||||
const s = String(scope);
|
||||
const existing = arr.indexOf(s);
|
||||
if (existing >= 0) arr.splice(existing, 1);
|
||||
arr.push(s);
|
||||
if (arr.length > 20) arr.shift();
|
||||
}
|
||||
|
||||
if (opts.checkFn) {
|
||||
const c = opts.checkFn(row);
|
||||
if (c) checkSets.get(sig)!.add(String(c));
|
||||
}
|
||||
|
||||
const t = timeFn(row);
|
||||
if (t) {
|
||||
if (!agg.first_seen || t < agg.first_seen) agg.first_seen = t;
|
||||
if (!agg.last_seen || t > agg.last_seen) agg.last_seen = t;
|
||||
}
|
||||
|
||||
const s = summaryFn(row);
|
||||
if (s) agg.representative_summary = String(s);
|
||||
}
|
||||
|
||||
// Finalize derived fields.
|
||||
for (const [sig, agg] of out) {
|
||||
const scopes = scopeSets.get(sig) ?? new Set<string>();
|
||||
agg.distinct_scopes = scopes.size;
|
||||
agg.confidence = agg.count > 0 ? Math.min(1, agg.distinct_scopes / agg.count) : 0;
|
||||
const checks = checkSets.get(sig);
|
||||
if (checks) agg.checks = Array.from(checks).sort();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity policy derived from aggregate stats. The rating lives here
|
||||
* (not in each check) so all KB readers ramp severity consistently.
|
||||
*
|
||||
* - confidence × count product is the real signal.
|
||||
* - Low confidence (< 0.3) = same-scope noise → info regardless of count.
|
||||
* - Mid confidence (0.3-0.6) = mixed signal → warn at count ≥ 3.
|
||||
* - High confidence (> 0.6) with count ≥ 5 = block-worthy cross-cutting pattern.
|
||||
*
|
||||
* Callers can override by reading agg directly; this is the default
|
||||
* policy that matches the "don't escalate one unfixed PR" discipline.
|
||||
*/
|
||||
export function ratingSeverity(agg: AggregateRow): "info" | "warn" | "block" {
|
||||
if (agg.confidence >= 0.6 && agg.count >= 5) return "block";
|
||||
if (agg.confidence >= 0.3 && agg.count >= 3) return "warn";
|
||||
return "info";
|
||||
}
|
||||
|
||||
/** Human-friendly one-line summary of an aggregate row for finding evidence. */
|
||||
export function formatAgg(agg: AggregateRow): string {
|
||||
return `count=${agg.count} distinct_scopes=${agg.distinct_scopes} confidence=${agg.confidence.toFixed(2)} seen=[${agg.first_seen.slice(0, 10)}..${agg.last_seen.slice(0, 10)}]`;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// kb_stats — on-demand dashboard numbers from the KB scratchpad
|
||||
// files. Reads data/_auditor/verdicts/*, data/_kb/audit_lessons.jsonl,
|
||||
// data/_kb/audit_facts.jsonl, data/_kb/audit_discrepancies.jsonl,
|
||||
// data/_kb/scrum_reviews.jsonl and prints:
|
||||
//
|
||||
// - verdict flip-flop rate (same SHA re-audited, verdict changed?)
|
||||
// - consensus discrepancy rate (N runs disagreed on a claim)
|
||||
// - confidence distribution from kb_index aggregator
|
||||
// - top N recurring entities from audit_facts
|
||||
// - fact growth over time
|
||||
// - scrum vs inference KB split
|
||||
//
|
||||
// Run: bun run auditor/kb_stats.ts
|
||||
// bun run auditor/kb_stats.ts --top 15 # show top 15 entities
|
||||
// bun run auditor/kb_stats.ts --json # machine-readable
|
||||
//
|
||||
// This is the "dashboard" without running Grafana. If someone really
|
||||
// wants a dashboard, wire this output into a static HTML page + cron.
|
||||
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { aggregate } from "./kb_index.ts";
|
||||
|
||||
const REPO = "/home/profit/lakehouse";
|
||||
const VERDICTS_DIR = `${REPO}/data/_auditor/verdicts`;
|
||||
const AUDIT_LESSONS = `${REPO}/data/_kb/audit_lessons.jsonl`;
|
||||
const AUDIT_FACTS = `${REPO}/data/_kb/audit_facts.jsonl`;
|
||||
const AUDIT_DISCREPANCIES = `${REPO}/data/_kb/audit_discrepancies.jsonl`;
|
||||
const SCRUM_REVIEWS = `${REPO}/data/_kb/scrum_reviews.jsonl`;
|
||||
|
||||
interface Args {
|
||||
top: number;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const a: Args = { top: 10, json: false };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === "--top") a.top = Number(argv[++i] ?? 10);
|
||||
else if (argv[i] === "--json") a.json = true;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
async function readJsonl<T = any>(path: string): Promise<T[]> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return raw.split("\n").filter(l => l.length > 0).map(l => {
|
||||
try { return JSON.parse(l) as T; } catch { return null as any; }
|
||||
}).filter(r => r !== null);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function loadVerdicts(): Promise<Array<{ pr: number; sha: string; overall: string; findings_total: number; findings_block: number; findings_warn: number }>> {
|
||||
let files: string[] = [];
|
||||
try { files = await readdir(VERDICTS_DIR); } catch { return []; }
|
||||
const out = [];
|
||||
for (const f of files) {
|
||||
if (!f.endsWith(".json")) continue;
|
||||
const m = f.match(/^(\d+)-([0-9a-f]+)\.json$/);
|
||||
if (!m) continue;
|
||||
try {
|
||||
const v = JSON.parse(await readFile(join(VERDICTS_DIR, f), "utf8"));
|
||||
out.push({
|
||||
pr: Number(m[1]),
|
||||
sha: m[2],
|
||||
overall: String(v.overall),
|
||||
findings_total: Number(v.metrics?.findings_total ?? 0),
|
||||
findings_block: Number(v.metrics?.findings_block ?? 0),
|
||||
findings_warn: Number(v.metrics?.findings_warn ?? 0),
|
||||
});
|
||||
} catch { /* skip corrupt */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
audit_count: number;
|
||||
verdict_distribution: Record<string, number>;
|
||||
// Same PR with multiple SHAs — if verdicts differ, that's drift across
|
||||
// the PR's commit history. Not a flip-flop in the classical sense,
|
||||
// but worth surfacing (e.g. "PR #8 was block block req req block").
|
||||
per_pr_verdict_sequences: Record<number, string[]>;
|
||||
// For each PR with ≥ 2 audits, how many distinct verdicts did it
|
||||
// produce? 1 = stable; 2+ = some flipping.
|
||||
verdict_instability: { pr_count: number; pr_with_multiple_verdicts: number; pr_with_3plus: number };
|
||||
consensus: { discrepancy_count: number; tiebreaker_used: number; unresolved: number };
|
||||
kb: {
|
||||
audit_lessons_rows: number;
|
||||
audit_facts_rows: number;
|
||||
scrum_reviews_rows: number;
|
||||
distinct_finding_signatures: number;
|
||||
distinct_entities_across_prs: number;
|
||||
entities_in_2plus_prs: number;
|
||||
entities_in_5plus_prs: number;
|
||||
};
|
||||
fact_quality: {
|
||||
verifier_verdict_distribution: Record<string, number>;
|
||||
facts_dropped_by_verifier_total: number;
|
||||
extraction_success_rate: number;
|
||||
};
|
||||
top_entities: Array<{ name: string; distinct_prs: number; count: number; types: string[] }>;
|
||||
kb_by_source: Record<string, number>;
|
||||
}
|
||||
|
||||
async function collect(args: Args): Promise<Stats> {
|
||||
const verdicts = await loadVerdicts();
|
||||
const lessons = await readJsonl<any>(AUDIT_LESSONS);
|
||||
const facts = await readJsonl<any>(AUDIT_FACTS);
|
||||
const disc = await readJsonl<any>(AUDIT_DISCREPANCIES);
|
||||
const reviews = await readJsonl<any>(SCRUM_REVIEWS);
|
||||
|
||||
// Verdict stability
|
||||
const byPr: Record<number, string[]> = {};
|
||||
const verdictDist: Record<string, number> = {};
|
||||
for (const v of verdicts) {
|
||||
(byPr[v.pr] ??= []).push(v.overall);
|
||||
verdictDist[v.overall] = (verdictDist[v.overall] ?? 0) + 1;
|
||||
}
|
||||
let multi = 0, tri = 0;
|
||||
for (const [_, seq] of Object.entries(byPr)) {
|
||||
const distinct = new Set(seq);
|
||||
if (distinct.size >= 2) multi++;
|
||||
if (distinct.size >= 3) tri++;
|
||||
}
|
||||
|
||||
// Consensus drift
|
||||
const consensus = {
|
||||
discrepancy_count: disc.length,
|
||||
tiebreaker_used: disc.filter(d => String(d.resolution).startsWith("tiebreaker")).length,
|
||||
unresolved: disc.filter(d => d.resolution === "unresolved").length,
|
||||
};
|
||||
|
||||
// Lesson signatures
|
||||
const lessonAgg = await aggregate<any>(AUDIT_LESSONS, {
|
||||
keyFn: r => r?.signature,
|
||||
scopeFn: r => (r?.pr_number !== undefined ? `pr-${r.pr_number}` : undefined),
|
||||
});
|
||||
|
||||
// Entity aggregation across audit_facts rows
|
||||
interface EntAgg { distinct_prs: Set<number>; count: number; types: Set<string>; name: string; sources: Set<string> }
|
||||
const entAgg = new Map<string, EntAgg>();
|
||||
const sourceCount: Record<string, number> = {};
|
||||
let totalVerdictDist: Record<string, number> = { CORRECT: 0, INCORRECT: 0, UNVERIFIABLE: 0, UNCHECKED: 0 };
|
||||
let factsDroppedTotal = 0;
|
||||
let extractionsWithFacts = 0;
|
||||
|
||||
for (const row of facts) {
|
||||
const src = String(row.source ?? "unknown");
|
||||
sourceCount[src] = (sourceCount[src] ?? 0) + 1;
|
||||
const pr = Number(row.pr_number);
|
||||
if (Array.isArray(row.verifier_verdicts)) {
|
||||
for (const v of row.verifier_verdicts) {
|
||||
totalVerdictDist[v] = (totalVerdictDist[v] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
factsDroppedTotal += Number(row.facts_dropped_by_verifier ?? 0);
|
||||
if ((Array.isArray(row.facts) && row.facts.length > 0) || (Array.isArray(row.entities) && row.entities.length > 0)) {
|
||||
extractionsWithFacts++;
|
||||
}
|
||||
for (const e of Array.isArray(row.entities) ? row.entities : []) {
|
||||
const name = String(e?.name ?? "").trim();
|
||||
if (name.length < 3) continue;
|
||||
const key = name.toLowerCase();
|
||||
const agg = entAgg.get(key) ?? { distinct_prs: new Set(), count: 0, types: new Set(), name, sources: new Set() };
|
||||
agg.count++;
|
||||
if (Number.isFinite(pr) && pr > 0) agg.distinct_prs.add(pr);
|
||||
if (e?.type) agg.types.add(String(e.type));
|
||||
agg.sources.add(src);
|
||||
entAgg.set(key, agg);
|
||||
}
|
||||
}
|
||||
|
||||
const entitiesIn2Plus = Array.from(entAgg.values()).filter(a => a.distinct_prs.size >= 2).length;
|
||||
const entitiesIn5Plus = Array.from(entAgg.values()).filter(a => a.distinct_prs.size >= 5).length;
|
||||
const topEntities = Array.from(entAgg.values())
|
||||
.sort((a, b) => b.distinct_prs.size - a.distinct_prs.size || b.count - a.count)
|
||||
.slice(0, args.top)
|
||||
.map(a => ({
|
||||
name: a.name,
|
||||
distinct_prs: a.distinct_prs.size,
|
||||
count: a.count,
|
||||
types: Array.from(a.types),
|
||||
}));
|
||||
|
||||
const stats: Stats = {
|
||||
audit_count: verdicts.length,
|
||||
verdict_distribution: verdictDist,
|
||||
per_pr_verdict_sequences: byPr,
|
||||
verdict_instability: {
|
||||
pr_count: Object.keys(byPr).length,
|
||||
pr_with_multiple_verdicts: multi,
|
||||
pr_with_3plus: tri,
|
||||
},
|
||||
consensus,
|
||||
kb: {
|
||||
audit_lessons_rows: lessons.length,
|
||||
audit_facts_rows: facts.length,
|
||||
scrum_reviews_rows: reviews.length,
|
||||
distinct_finding_signatures: lessonAgg.size,
|
||||
distinct_entities_across_prs: entAgg.size,
|
||||
entities_in_2plus_prs: entitiesIn2Plus,
|
||||
entities_in_5plus_prs: entitiesIn5Plus,
|
||||
},
|
||||
fact_quality: {
|
||||
verifier_verdict_distribution: totalVerdictDist,
|
||||
facts_dropped_by_verifier_total: factsDroppedTotal,
|
||||
extraction_success_rate: facts.length > 0 ? extractionsWithFacts / facts.length : 0,
|
||||
},
|
||||
top_entities: topEntities,
|
||||
kb_by_source: sourceCount,
|
||||
};
|
||||
return stats;
|
||||
}
|
||||
|
||||
function renderHuman(s: Stats): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("═══ KB STATS ═══");
|
||||
lines.push("");
|
||||
lines.push(`Audits: ${s.audit_count} total across ${s.verdict_instability.pr_count} distinct PRs`);
|
||||
lines.push(`Verdicts: ${Object.entries(s.verdict_distribution).map(([k, v]) => `${k}=${v}`).join(" ")}`);
|
||||
const multiplePct = s.verdict_instability.pr_count > 0
|
||||
? Math.round(100 * s.verdict_instability.pr_with_multiple_verdicts / s.verdict_instability.pr_count)
|
||||
: 0;
|
||||
lines.push(`Verdict instability: ${s.verdict_instability.pr_with_multiple_verdicts}/${s.verdict_instability.pr_count} PRs had 2+ distinct verdicts (${multiplePct}%) — 3+ distinct: ${s.verdict_instability.pr_with_3plus}`);
|
||||
lines.push("");
|
||||
lines.push("─── Consensus ───");
|
||||
lines.push(` discrepancies logged: ${s.consensus.discrepancy_count}`);
|
||||
lines.push(` tiebreaker used: ${s.consensus.tiebreaker_used}`);
|
||||
lines.push(` unresolved: ${s.consensus.unresolved}`);
|
||||
const dRate = s.audit_count > 0 ? (100 * s.consensus.discrepancy_count / s.audit_count).toFixed(1) : "0";
|
||||
lines.push(` discrepancy rate: ${dRate}% of audits`);
|
||||
lines.push("");
|
||||
lines.push("─── KB size ───");
|
||||
lines.push(` audit_lessons.jsonl: ${s.kb.audit_lessons_rows} rows, ${s.kb.distinct_finding_signatures} distinct signatures`);
|
||||
lines.push(` audit_facts.jsonl: ${s.kb.audit_facts_rows} rows, ${s.kb.distinct_entities_across_prs} distinct entities`);
|
||||
lines.push(` scrum_reviews.jsonl: ${s.kb.scrum_reviews_rows} rows`);
|
||||
lines.push(` entities in 2+ PRs: ${s.kb.entities_in_2plus_prs}`);
|
||||
lines.push(` entities in 5+ PRs: ${s.kb.entities_in_5plus_prs} ← strong cross-cutting signal`);
|
||||
lines.push("");
|
||||
lines.push("─── Fact quality ───");
|
||||
const v = s.fact_quality.verifier_verdict_distribution;
|
||||
lines.push(` verifier verdicts: CORRECT=${v.CORRECT ?? 0} UNVERIFIABLE=${v.UNVERIFIABLE ?? 0} UNCHECKED=${v.UNCHECKED ?? 0} INCORRECT=${v.INCORRECT ?? 0}`);
|
||||
lines.push(` facts dropped by verifier: ${s.fact_quality.facts_dropped_by_verifier_total}`);
|
||||
lines.push(` extraction success rate: ${(s.fact_quality.extraction_success_rate * 100).toFixed(1)}%`);
|
||||
lines.push("");
|
||||
lines.push("─── KB sources ───");
|
||||
for (const [src, n] of Object.entries(s.kb_by_source)) {
|
||||
lines.push(` ${src}: ${n}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`─── Top ${s.top_entities.length} recurring entities ───`);
|
||||
for (const e of s.top_entities) {
|
||||
lines.push(` [${e.distinct_prs} PRs × ${e.count} obs] ${e.name} (${e.types.join(",")})`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const stats = await collect(args);
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(stats, (_, v) => v instanceof Set ? Array.from(v) : v, 2));
|
||||
} else {
|
||||
console.log(renderHuman(stats));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error("[kb_stats] fatal:", e); process.exit(1); });
|
||||
+8
-1
@@ -18,7 +18,14 @@ export interface Claim {
|
||||
// Heuristic rating of how strong the claim is. "green+tested"
|
||||
// is strong; "should work" is weak. Drives sensitivity — stronger
|
||||
// claims get harder-blocked on weak evidence.
|
||||
strength: "weak" | "moderate" | "strong";
|
||||
//
|
||||
// "empirical" is a separate class: runtime measurements like
|
||||
// "N cloud calls" / "306s end-to-end" / "accepted on attempt N".
|
||||
// These cannot be verified from a static diff — only from the test
|
||||
// output that produced them. Inference skips diff-verification for
|
||||
// empirical claims; they become info-level context unless a future
|
||||
// runtime_evidence check contradicts them.
|
||||
strength: "weak" | "moderate" | "strong" | "empirical";
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,61 @@
|
||||
# Phase 40: Routing Engine Configuration
|
||||
#
|
||||
# Human-editable rules for model → provider routing.
|
||||
# Matching order: first match wins.
|
||||
|
||||
[[rule]]
|
||||
model_pattern = "gpt-4*"
|
||||
provider = "openrouter"
|
||||
max_tokens = 4096
|
||||
temperature = 0.7
|
||||
|
||||
[[rule]]
|
||||
model_pattern = "claude*"
|
||||
provider = "claude"
|
||||
max_tokens = 4096
|
||||
temperature = 0.7
|
||||
|
||||
[[rule]]
|
||||
model_pattern = "gemini*"
|
||||
provider = "gemini"
|
||||
max_tokens = 8192
|
||||
temperature = 0.9
|
||||
|
||||
[[rule]]
|
||||
model_pattern = "qwen3.5*"
|
||||
provider = "ollama"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
|
||||
[[rule]]
|
||||
model_pattern = "qwen3*"
|
||||
provider = "ollama"
|
||||
max_tokens = 2048
|
||||
temperature = 0.3
|
||||
|
||||
[[rule]]
|
||||
model_pattern = "gpt-oss*"
|
||||
provider = "ollama"
|
||||
temperature = 0.1
|
||||
|
||||
[[rule]]
|
||||
model_pattern = "*"
|
||||
provider = "ollama"
|
||||
temperature = 0.5
|
||||
|
||||
# Fallback chain: if primary fails, try these in order
|
||||
fallback = ["ollama", "openrouter"]
|
||||
|
||||
# Cost gating (tokens = cents per 1M)
|
||||
[cost]
|
||||
ollama = 0
|
||||
openrouter = 15
|
||||
claude = 15
|
||||
gemini = 0
|
||||
|
||||
# Daily budget per provider (cents)
|
||||
[daily_budget]
|
||||
ollama = 0
|
||||
openrouter = 1000
|
||||
claude = 500
|
||||
gemini = 0
|
||||
@@ -11,3 +11,4 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -13,11 +13,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Rough token count. `chars / 4` ceiling. See module docs for why
|
||||
/// this heuristic is sufficient.
|
||||
pub fn estimate_tokens(text: &str) -> usize {
|
||||
(text.chars().count() + 3) / 4
|
||||
}
|
||||
// `estimate_tokens` moved to `shared::model_matrix::ModelMatrix::estimate_tokens`
|
||||
// (cdc24d8). All callers migrated; the deprecated wrapper that stood in its
|
||||
// place has been removed since it had zero external consumers.
|
||||
|
||||
/// Phase 21 — per-model context windows, mirroring the TS table in
|
||||
/// `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 safety = opts.safety_margin.unwrap_or(DEFAULT_SAFETY_MARGIN);
|
||||
let max_tokens = opts.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS);
|
||||
let sys_tokens = opts.system.map(estimate_tokens).unwrap_or(0);
|
||||
let estimated = estimate_tokens(prompt) + sys_tokens + max_tokens;
|
||||
let sys_tokens = opts.system.map(shared::model_matrix::ModelMatrix::estimate_tokens).unwrap_or(0);
|
||||
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 check = BudgetCheck { estimated, window, remaining };
|
||||
if remaining < 0 && !opts.bypass {
|
||||
@@ -109,14 +107,10 @@ pub fn overflow_message(model: &str, check: &BudgetCheck, over_by: usize, safety
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn estimate_tokens_ceiling_divides_by_four() {
|
||||
assert_eq!(estimate_tokens(""), 0);
|
||||
assert_eq!(estimate_tokens("abc"), 1); // 3 → ceil(3/4) = 1
|
||||
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);
|
||||
}
|
||||
// Deprecated-function behavior is now canonically tested in
|
||||
// crates/shared/src/model_matrix.rs. This test was the legacy
|
||||
// pin that preceded the migration; delete when the deprecated
|
||||
// wrapper itself goes (see the #[deprecated] attribute).
|
||||
|
||||
#[test]
|
||||
fn context_window_known_and_fallback() {
|
||||
@@ -179,7 +173,7 @@ mod tests {
|
||||
).unwrap();
|
||||
assert!(with_sys.estimated > without_sys.estimated,
|
||||
"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]
|
||||
|
||||
@@ -138,6 +138,17 @@ pub struct ContinuableOutcome {
|
||||
pub empty_retries: usize,
|
||||
pub continuations: usize,
|
||||
pub final_complete: bool,
|
||||
/// Sum of `prompt_tokens` across every generator call made to
|
||||
/// produce this outcome — including empty retries and continuations.
|
||||
/// Lets callers (gateway execution loop, observability) stamp
|
||||
/// accurate per-task usage without second-guessing the retry fan-out.
|
||||
pub prompt_tokens: u32,
|
||||
/// Sum of `completion_tokens` across every generator call.
|
||||
pub completion_tokens: u32,
|
||||
/// Total number of generator calls. `1 + empty_retries +
|
||||
/// continuations` in the normal case; the field is explicit so
|
||||
/// callers don't have to re-derive it.
|
||||
pub calls: u32,
|
||||
}
|
||||
|
||||
fn make_request(opts: &ContinuableOpts, prompt: String, current_max: u32) -> GenerateRequest {
|
||||
@@ -175,11 +186,17 @@ pub async fn generate_continuable<G: TextGenerator>(
|
||||
let mut combined = String::new();
|
||||
let mut empty_retries = 0usize;
|
||||
let mut continuations = 0usize;
|
||||
let mut prompt_tokens: u32 = 0;
|
||||
let mut completion_tokens: u32 = 0;
|
||||
let mut calls: u32 = 0;
|
||||
|
||||
// Phase 21(a) — empty-response backoff loop.
|
||||
for retry in 0..opts.max_empty_retries {
|
||||
let req = make_request(opts, prompt.to_string(), current_max);
|
||||
let resp = generator.generate_text(req).await?;
|
||||
calls += 1;
|
||||
prompt_tokens = prompt_tokens.saturating_add(resp.tokens_evaluated.unwrap_or(0) as u32);
|
||||
completion_tokens = completion_tokens.saturating_add(resp.tokens_generated.unwrap_or(0) as u32);
|
||||
if !resp.text.trim().is_empty() {
|
||||
combined = resp.text;
|
||||
break;
|
||||
@@ -188,9 +205,7 @@ pub async fn generate_continuable<G: TextGenerator>(
|
||||
current_max = (current_max.saturating_mul(2)).min(opts.budget_cap);
|
||||
}
|
||||
|
||||
// Phase 21(b) — structural-completion continuation loop. Runs on
|
||||
// the truncated-non-empty case; empty + exhausted retries falls
|
||||
// through with empty combined and final_complete=false.
|
||||
// Phase 21(b) — structural-completion continuation loop.
|
||||
for _ in 0..opts.max_continuations {
|
||||
if is_structurally_complete(&combined, opts.shape) {
|
||||
return Ok(ContinuableOutcome {
|
||||
@@ -198,17 +213,22 @@ pub async fn generate_continuable<G: TextGenerator>(
|
||||
empty_retries,
|
||||
continuations,
|
||||
final_complete: true,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
calls,
|
||||
});
|
||||
}
|
||||
if combined.trim().is_empty() {
|
||||
// Nothing to continue from — continuing "" is identical to
|
||||
// the initial call and would loop. Bail so the caller sees
|
||||
// the failure rather than burning N extra calls.
|
||||
// the initial call and would loop.
|
||||
break;
|
||||
}
|
||||
let cont_prompt = continuation_prompt(prompt, &combined);
|
||||
let req = make_request(opts, cont_prompt, current_max.min(opts.budget_cap));
|
||||
let resp = generator.generate_text(req).await?;
|
||||
calls += 1;
|
||||
prompt_tokens = prompt_tokens.saturating_add(resp.tokens_evaluated.unwrap_or(0) as u32);
|
||||
completion_tokens = completion_tokens.saturating_add(resp.tokens_generated.unwrap_or(0) as u32);
|
||||
combined.push_str(&resp.text);
|
||||
continuations += 1;
|
||||
}
|
||||
@@ -219,6 +239,9 @@ pub async fn generate_continuable<G: TextGenerator>(
|
||||
empty_retries,
|
||||
continuations,
|
||||
final_complete,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
calls,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
pub mod client;
|
||||
pub mod context;
|
||||
pub mod continuation;
|
||||
pub mod provider;
|
||||
pub mod providers;
|
||||
pub mod routing;
|
||||
pub mod service;
|
||||
pub mod tree_split;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::client::{GenerateRequest, GenerateResponse, EmbedRequest, EmbedResponse};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProviderAdapter: Send + Sync {
|
||||
/// Name for routing (ollama, openrouter, etc.)
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Chat completion — returns text, model, token counts
|
||||
async fn chat(&self, req: GenerateRequest) -> Result<GenerateResponse, String>;
|
||||
|
||||
/// Embeddings — returns vectors, model, dimensions
|
||||
async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String>;
|
||||
|
||||
/// Unload model from VRAM (optional, no-op if not supported)
|
||||
async fn unload(&self, _model: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Health check
|
||||
async fn health(&self) -> Result<serde_json::Value, String>;
|
||||
}
|
||||
|
||||
/// Routing key extracted from model name.
|
||||
/// - "qwen3.5:latest" → "ollama"
|
||||
/// - "openrouter/anthropic/claude-3.5-sonnet" → "openrouter"
|
||||
/// - "gpt-4o" → "ollama" (default)
|
||||
pub fn provider_key(model: &str) -> &'static str {
|
||||
let lower = model.to_lowercase();
|
||||
if lower.starts_with("openrouter/") {
|
||||
"openrouter"
|
||||
} else if lower.starts_with("gemini") {
|
||||
"gemini"
|
||||
} else if lower.starts_with("claude") {
|
||||
"claude"
|
||||
} else {
|
||||
"ollama" // default: local Ollama
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod ollama;
|
||||
pub mod openrouter;
|
||||
@@ -0,0 +1,37 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::client::{AiClient, GenerateRequest, GenerateResponse, EmbedRequest, EmbedResponse};
|
||||
use crate::provider::ProviderAdapter;
|
||||
|
||||
pub struct OllamaAdapter {
|
||||
client: AiClient,
|
||||
}
|
||||
|
||||
impl OllamaAdapter {
|
||||
pub fn new(client: AiClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for OllamaAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"ollama"
|
||||
}
|
||||
|
||||
async fn chat(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
|
||||
self.client.generate(req).await
|
||||
}
|
||||
|
||||
async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String> {
|
||||
self.client.embed(req).await
|
||||
}
|
||||
|
||||
async fn unload(&self, model: &str) -> Result<(), String> {
|
||||
let _: serde_json::Value = self.client.unload_model(model).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health(&self) -> Result<serde_json::Value, String> {
|
||||
self.client.health().await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::client::{GenerateRequest, GenerateResponse, EmbedRequest, EmbedResponse};
|
||||
use crate::provider::ProviderAdapter;
|
||||
|
||||
pub struct OpenRouterAdapter {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
default_model: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenRouterChatRequest {
|
||||
model: String,
|
||||
messages: Vec<OpenRouterMessage>,
|
||||
temperature: Option<f64>,
|
||||
max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenRouterMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenRouterChatResponse {
|
||||
choices: Vec<OpenRouterChoice>,
|
||||
usage: OpenRouterUsage,
|
||||
model: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenRouterChoice {
|
||||
message: OpenRouterMessageOut,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OpenRouterMessageOut {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OpenRouterUsage {
|
||||
prompt_tokens: Option<u32>,
|
||||
completion_tokens: Option<u32>,
|
||||
total_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
impl OpenRouterAdapter {
|
||||
pub fn new(base_url: &str, api_key: String, default_model: &str) -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(180))
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
Self {
|
||||
client,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
api_key,
|
||||
default_model: default_model.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_model(&self, model: &str) -> String {
|
||||
// Strip "openrouter/" prefix if present
|
||||
let m = model.trim_start_matches("openrouter/");
|
||||
if m.is_empty() || m == model {
|
||||
self.default_model.clone()
|
||||
} else {
|
||||
m.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn to_openrouter_messages(req: &GenerateRequest) -> Vec<OpenRouterMessage> {
|
||||
let mut out = vec![];
|
||||
if let Some(sys) = &req.system {
|
||||
out.push(OpenRouterMessage { role: "system".into(), content: sys.clone() });
|
||||
}
|
||||
out.push(OpenRouterMessage {
|
||||
role: "user".into(),
|
||||
content: req.prompt.clone(),
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for OpenRouterAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"openrouter"
|
||||
}
|
||||
|
||||
async fn chat(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
|
||||
let model = self.chat_model(req.model.as_deref().unwrap_or(""));
|
||||
let or_req = OpenRouterChatRequest {
|
||||
model: model.clone(),
|
||||
messages: OpenRouterAdapter::to_openrouter_messages(&req),
|
||||
temperature: req.temperature,
|
||||
max_tokens: req.max_tokens,
|
||||
};
|
||||
|
||||
let resp = self.client
|
||||
.post(format!("{}/chat/completions", self.base_url))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&or_req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("openrouter request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("openrouter error ({}): {}", status, body));
|
||||
}
|
||||
|
||||
let or_resp: OpenRouterChatResponse = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("openrouter parse error: {e}"))?;
|
||||
|
||||
let choice = or_resp.choices.into_iter().next()
|
||||
.ok_or("no completion choice returned")?;
|
||||
let usage = or_resp.usage;
|
||||
|
||||
Ok(GenerateResponse {
|
||||
text: choice.message.content,
|
||||
model: or_resp.model,
|
||||
tokens_evaluated: usage.prompt_tokens.map(|n| n as u64),
|
||||
tokens_generated: usage.completion_tokens.map(|n| n as u64),
|
||||
})
|
||||
}
|
||||
|
||||
async fn embed(&self, _req: EmbedRequest) -> Result<EmbedResponse, String> {
|
||||
Err("openrouter: embed not implemented".into())
|
||||
}
|
||||
|
||||
async fn health(&self) -> Result<serde_json::Value, String> {
|
||||
// OpenRouter doesn't have a dedicated health endpoint,
|
||||
// so we just return a healthy response if the client works.
|
||||
Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"provider": "openrouter",
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct RoutingRule {
|
||||
pub model_pattern: String,
|
||||
pub provider: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RoutingEngine {
|
||||
rules: Vec<RoutingRule>,
|
||||
fallback_chain: Vec<String>,
|
||||
}
|
||||
|
||||
impl RoutingEngine {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_rules(mut self, rules: Vec<RoutingRule>) -> Self {
|
||||
self.rules = rules;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_fallback(mut self, chain: Vec<String>) -> Self {
|
||||
self.fallback_chain = chain;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn route(&self, model: &str) -> RouteDecision {
|
||||
let lower = model.to_lowercase();
|
||||
|
||||
for rule in &self.rules {
|
||||
if glob_match(&rule.model_pattern.to_lowercase(), &lower) {
|
||||
return RouteDecision {
|
||||
provider: rule.provider.clone(),
|
||||
model: model.to_string(),
|
||||
max_tokens: rule.max_tokens,
|
||||
temperature: rule.temperature,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(first) = self.fallback_chain.first() {
|
||||
RouteDecision {
|
||||
provider: first.clone(),
|
||||
model: model.to_string(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
}
|
||||
} else {
|
||||
RouteDecision {
|
||||
provider: "ollama".to_string(),
|
||||
model: model.to_string(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RouteDecision {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f64>,
|
||||
}
|
||||
|
||||
fn glob_match(pattern: &str, name: &str) -> bool {
|
||||
if !pattern.contains('*') { return pattern == name; }
|
||||
let parts: Vec<&str> = pattern.split('*').collect();
|
||||
// Multi-* support: first must be prefix, last must be suffix, each
|
||||
// interior piece must appear in order. Fixes the iter-9 finding
|
||||
// where gpt-*-large* silently fell through to an exact-match path.
|
||||
// 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).
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model_pattern: "*".to_string(),
|
||||
provider: "ollama".to_string(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
//! 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};
|
||||
use shared::model_matrix::ModelMatrix;
|
||||
use crate::continuation::{generate_continuable, ContinuableOpts, ResponseShape, TextGenerator};
|
||||
|
||||
/// 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
|
||||
/// chop everything before its successor.
|
||||
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.
|
||||
const DELIM_PREFIX: &str = "\n— shard ";
|
||||
let mut cursor = 0;
|
||||
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
|
||||
// a previous iteration), then find the next.
|
||||
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
|
||||
// (post-truncation); the estimator uses chars/4 so the bound
|
||||
// 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,
|
||||
"scratchpad {} tokens vs budget {}", scratchpad_tokens, opts.scratchpad_budget);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ pub fn router(registry: Registry) -> Router {
|
||||
// Phase 17: model profiles
|
||||
.route("/profiles", post(create_profile).get(list_profiles))
|
||||
.route("/profiles/{id}", get(get_profile).delete(delete_profile))
|
||||
// Phase 41: profile-type routes
|
||||
.route("/profiles/retrieval", get(list_retrieval_profiles))
|
||||
.route("/profiles/memory", get(list_memory_profiles))
|
||||
.route("/profiles/observer", get(list_observer_profiles))
|
||||
.with_state(registry)
|
||||
}
|
||||
|
||||
@@ -398,6 +402,9 @@ struct CreateProfileRequest {
|
||||
/// indexes to. Defaults to Parquet+HNSW.
|
||||
#[serde(default)]
|
||||
vector_backend: shared::types::VectorBackend,
|
||||
/// Phase 41: Profile type for routing + activation behavior.
|
||||
#[serde(default)]
|
||||
profile_type: shared::types::ProfileType,
|
||||
}
|
||||
|
||||
fn default_embed_model_req() -> String { "nomic-embed-text".to_string() }
|
||||
@@ -417,6 +424,7 @@ async fn create_profile(
|
||||
created_by: req.created_by,
|
||||
bucket: req.bucket,
|
||||
vector_backend: req.vector_backend,
|
||||
profile_type: req.profile_type,
|
||||
};
|
||||
match registry.put_profile(profile).await {
|
||||
Ok(p) => Ok((StatusCode::CREATED, Json(p))),
|
||||
@@ -428,6 +436,24 @@ async fn list_profiles(State(registry): State<Registry>) -> impl IntoResponse {
|
||||
Json(registry.list_profiles().await)
|
||||
}
|
||||
|
||||
async fn list_retrieval_profiles(State(registry): State<Registry>) -> impl IntoResponse {
|
||||
let all = registry.list_profiles().await;
|
||||
let retrieval: Vec<_> = all.into_iter().filter(|p| p.profile_type == shared::types::ProfileType::Retrieval).collect();
|
||||
Json(retrieval)
|
||||
}
|
||||
|
||||
async fn list_memory_profiles(State(registry): State<Registry>) -> impl IntoResponse {
|
||||
let all = registry.list_profiles().await;
|
||||
let memory: Vec<_> = all.into_iter().filter(|p| p.profile_type == shared::types::ProfileType::Memory).collect();
|
||||
Json(memory)
|
||||
}
|
||||
|
||||
async fn list_observer_profiles(State(registry): State<Registry>) -> impl IntoResponse {
|
||||
let all = registry.list_profiles().await;
|
||||
let observer: Vec<_> = all.into_iter().filter(|p| p.profile_type == shared::types::ProfileType::Observer).collect();
|
||||
Json(observer)
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(registry): State<Registry>,
|
||||
Path(id): Path<String>,
|
||||
|
||||
@@ -12,6 +12,7 @@ aibridge = { path = "../aibridge" }
|
||||
ingestd = { path = "../ingestd" }
|
||||
vectord = { path = "../vectord" }
|
||||
journald = { path = "../journald" }
|
||||
truth = { path = "../truth" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -93,7 +93,7 @@ impl AccessControl {
|
||||
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}`.
|
||||
pub async fn get_role(&self, agent: &str) -> Option<AgentRole> {
|
||||
self.roles.read().await.get(agent).cloned()
|
||||
}
|
||||
@@ -113,6 +113,7 @@ impl AccessControl {
|
||||
}
|
||||
|
||||
/// Determine which fields should be masked for an agent.
|
||||
#[allow(dead_code)]
|
||||
pub async fn masked_fields(
|
||||
&self,
|
||||
agent: &str,
|
||||
@@ -138,6 +139,7 @@ impl AccessControl {
|
||||
}
|
||||
|
||||
/// Log a query for audit.
|
||||
#[allow(dead_code)]
|
||||
pub async fn log_query(&self, audit: QueryAudit) {
|
||||
self.audit_log.write().await.push(audit);
|
||||
}
|
||||
@@ -149,6 +151,9 @@ impl AccessControl {
|
||||
log[start..].iter().rev().cloned().collect()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
@@ -13,6 +13,12 @@ pub fn router(ac: AccessControl) -> Router {
|
||||
Router::new()
|
||||
.route("/roles", get(list_roles))
|
||||
.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("/check", post(check_access))
|
||||
.with_state(ac)
|
||||
@@ -60,3 +66,17 @@ async fn check_access(
|
||||
"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() }))
|
||||
}
|
||||
|
||||
+39
-18
@@ -5,30 +5,51 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
|
||||
/// API key auth middleware. Checks X-API-Key header against configured key.
|
||||
// API key auth middleware. Checks X-API-Key header against configured key.
|
||||
// Fixed P5-001 (2026-04-23): previously #[allow(dead_code)] — the function
|
||||
// existed but was never layered onto the router, so [auth] enabled=true
|
||||
// silently enforced nothing. Now wired via from_fn_with_state in main.rs.
|
||||
pub async fn api_key_auth(
|
||||
axum::extract::State(expected): axum::extract::State<ApiKey>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Get the expected key from the request extensions (set by the layer)
|
||||
let expected_key = request.extensions().get::<ApiKey>().cloned();
|
||||
|
||||
if let Some(expected) = expected_key {
|
||||
let provided = request
|
||||
.headers()
|
||||
.get("x-api-key")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
match provided {
|
||||
Some(key) if key == expected.0 => {}
|
||||
_ => {
|
||||
tracing::warn!("unauthorized request: missing or invalid API key");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
// /health stays public (LB/systemd probes). Every other route is gated.
|
||||
if request.uri().path() == "/health" {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
let provided = request
|
||||
.headers()
|
||||
.get("x-api-key")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
// Constant-time-ish eq on the raw bytes; good enough for a shared-secret
|
||||
// X-API-Key. Timing-attack resistance here matters less than the
|
||||
// equivalent HMAC check would; adopt subtle crate if key-space grows.
|
||||
match provided {
|
||||
Some(key) if eq_ct(key.as_bytes(), expected.0.as_bytes()) => {
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
path = %request.uri().path(),
|
||||
"unauthorized request: missing or invalid API key",
|
||||
);
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn eq_ct(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff: u8 = 0;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// Wrapper type for the API key, stored in request extensions.
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! KB context loader — reads recent signal from `data/_kb/*.jsonl` for
|
||||
//! a given sig_hash + task_class and returns a compact summary.
|
||||
//!
|
||||
//! This is the "pipe to the overviewer" from the 2026-04-23 session:
|
||||
//! the overseer tier (T3, gpt-oss:120b) consumes this context before
|
||||
//! generating a correction, so its suggestions are informed by
|
||||
//! historical cost / latency / outcome / prior-correction patterns
|
||||
//! across ALL profiles that have run this task class — not just the
|
||||
//! single current loop.
|
||||
//!
|
||||
//! Hot-swap profiles read the SAME pool. When a profile activates and
|
||||
//! starts iterating, its KB context is the shared surface — one
|
||||
//! profile's learning becomes every profile's starting point.
|
||||
//!
|
||||
//! Best-effort throughout: missing files, corrupt rows, empty
|
||||
//! directories all produce an empty KbContext. The overseer works
|
||||
//! fine with no history; we just can't seed it then.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
/// Compact summary returned to the overseer. Bounded size — recent
|
||||
/// outcomes + corrections plus rolled-up rates. Goal is to fit in a
|
||||
/// prompt without eating the overseer's context budget.
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct KbContext {
|
||||
pub sig_hash: String,
|
||||
pub task_class: String,
|
||||
pub recent_outcomes: Vec<OutcomeSummary>,
|
||||
pub recent_corrections: Vec<CorrectionSummary>,
|
||||
pub success_rate: Option<f64>,
|
||||
pub avg_turns: Option<f64>,
|
||||
pub avg_latency_ms: Option<u64>,
|
||||
pub total_observed: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OutcomeSummary {
|
||||
pub created_at: String,
|
||||
pub ok: bool,
|
||||
pub polarity: String,
|
||||
pub turns: u32,
|
||||
pub latency_ms: u64,
|
||||
pub total_tokens: u64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CorrectionSummary {
|
||||
pub created_at: String,
|
||||
pub reason: String,
|
||||
pub correction_preview: String, // first 300 chars
|
||||
pub applied_at_turn: u32,
|
||||
}
|
||||
|
||||
const OUTCOMES_PATH: &str = "data/_kb/outcomes.jsonl";
|
||||
const CORRECTIONS_PATH: &str = "data/_kb/overseer_corrections.jsonl";
|
||||
const RECENT_OUTCOME_LIMIT: usize = 5;
|
||||
const RECENT_CORRECTION_LIMIT: usize = 3;
|
||||
const AGGREGATE_WINDOW: usize = 50;
|
||||
|
||||
impl KbContext {
|
||||
/// Build context from the default KB paths.
|
||||
pub async fn load_for(sig_hash: &str, task_class: &str) -> Self {
|
||||
Self::load_from(
|
||||
sig_hash, task_class,
|
||||
Path::new(OUTCOMES_PATH), Path::new(CORRECTIONS_PATH),
|
||||
).await
|
||||
}
|
||||
|
||||
/// Path-taking variant — tests inject tmp files without touching
|
||||
/// the real KB directory (same pattern as append_outcomes_row_at).
|
||||
pub async fn load_from(
|
||||
sig_hash: &str,
|
||||
task_class: &str,
|
||||
outcomes_path: &Path,
|
||||
corrections_path: &Path,
|
||||
) -> Self {
|
||||
let mut ctx = KbContext {
|
||||
sig_hash: sig_hash.to_string(),
|
||||
task_class: task_class.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Scan outcomes — matches on sig_hash primary, task_class
|
||||
// secondary (so different geos for the same task_class still
|
||||
// contribute to aggregate rates even though they won't make
|
||||
// the top-5 recent). The bounded window keeps scan cost
|
||||
// linear in file size — we're reading tail only.
|
||||
let outcome_rows = tail_matching(
|
||||
outcomes_path, AGGREGATE_WINDOW * 4,
|
||||
|row| {
|
||||
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let row_tc = row.get("task_class").and_then(|v| v.as_str()).unwrap_or("");
|
||||
row_sig == sig_hash || row_tc == task_class
|
||||
},
|
||||
).await;
|
||||
|
||||
// Recent outcomes: exact sig_hash match first (strongest
|
||||
// signal), then task_class fallback up to the limit.
|
||||
let mut exact: Vec<OutcomeSummary> = Vec::new();
|
||||
let mut loose: Vec<OutcomeSummary> = Vec::new();
|
||||
for row in &outcome_rows {
|
||||
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let summary = summarize_outcome(row);
|
||||
if row_sig == sig_hash { exact.push(summary); }
|
||||
else { loose.push(summary); }
|
||||
}
|
||||
ctx.recent_outcomes = exact.into_iter().rev().take(RECENT_OUTCOME_LIMIT).collect();
|
||||
if ctx.recent_outcomes.len() < RECENT_OUTCOME_LIMIT {
|
||||
let need = RECENT_OUTCOME_LIMIT - ctx.recent_outcomes.len();
|
||||
ctx.recent_outcomes.extend(loose.into_iter().rev().take(need));
|
||||
}
|
||||
|
||||
// Aggregate rates across the full matched window (both
|
||||
// sig_hash and task_class matches — gives a stable rate even
|
||||
// on sparse sig_hash history).
|
||||
let window = outcome_rows.iter().rev().take(AGGREGATE_WINDOW);
|
||||
let mut ok_count = 0u32;
|
||||
let mut total = 0u32;
|
||||
let mut turn_sum = 0u32;
|
||||
let mut latency_sum = 0u64;
|
||||
for row in window {
|
||||
total += 1;
|
||||
if row.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { ok_count += 1; }
|
||||
turn_sum += row.get("turns").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
latency_sum += row.get("usage")
|
||||
.and_then(|u| u.get("latency_ms"))
|
||||
.and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
}
|
||||
if total > 0 {
|
||||
ctx.total_observed = total;
|
||||
ctx.success_rate = Some(ok_count as f64 / total as f64);
|
||||
ctx.avg_turns = Some(turn_sum as f64 / total as f64);
|
||||
ctx.avg_latency_ms = Some(latency_sum / total as u64);
|
||||
}
|
||||
|
||||
// Overseer corrections. Prefer sig_hash match; fall back to
|
||||
// task_class. The overseer reading its OWN prior corrections
|
||||
// is the main point — if the last 3 attempts produced
|
||||
// corrections X, Y, Z, the new correction should acknowledge
|
||||
// those patterns rather than suggest X for the fourth time.
|
||||
let correction_rows = tail_matching(
|
||||
corrections_path, RECENT_CORRECTION_LIMIT * 4,
|
||||
|row| {
|
||||
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let row_tc = row.get("task_class").and_then(|v| v.as_str()).unwrap_or("");
|
||||
row_sig == sig_hash || row_tc == task_class
|
||||
},
|
||||
).await;
|
||||
let mut c_exact: Vec<CorrectionSummary> = Vec::new();
|
||||
let mut c_loose: Vec<CorrectionSummary> = Vec::new();
|
||||
for row in &correction_rows {
|
||||
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let summary = summarize_correction(row);
|
||||
if row_sig == sig_hash { c_exact.push(summary); }
|
||||
else { c_loose.push(summary); }
|
||||
}
|
||||
ctx.recent_corrections = c_exact.into_iter().rev().take(RECENT_CORRECTION_LIMIT).collect();
|
||||
if ctx.recent_corrections.len() < RECENT_CORRECTION_LIMIT {
|
||||
let need = RECENT_CORRECTION_LIMIT - ctx.recent_corrections.len();
|
||||
ctx.recent_corrections.extend(c_loose.into_iter().rev().take(need));
|
||||
}
|
||||
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Compact string form for the overseer prompt. Deterministic
|
||||
/// ordering + bounded length so prompt caching stays stable
|
||||
/// across iterations on the same task.
|
||||
pub fn to_prompt_section(&self) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("## Knowledge Base Context\n");
|
||||
if let (Some(rate), Some(turns), Some(lat)) = (self.success_rate, self.avg_turns, self.avg_latency_ms) {
|
||||
s.push_str(&format!(
|
||||
"Across {} prior similar runs: success_rate={:.1}%, avg_turns={:.1}, avg_latency_ms={}\n",
|
||||
self.total_observed, rate * 100.0, turns, lat,
|
||||
));
|
||||
} else {
|
||||
s.push_str("No prior similar runs recorded.\n");
|
||||
}
|
||||
|
||||
if !self.recent_outcomes.is_empty() {
|
||||
s.push_str(&format!("\nRecent {} outcomes:\n", self.recent_outcomes.len()));
|
||||
for o in &self.recent_outcomes {
|
||||
let err = o.error.as_deref().map(|e| format!(" — {}", truncate(e, 80))).unwrap_or_default();
|
||||
s.push_str(&format!(
|
||||
" [{}] ok={} turns={} tokens={} lat={}ms{}\n",
|
||||
&o.created_at[..19.min(o.created_at.len())],
|
||||
o.ok, o.turns, o.total_tokens, o.latency_ms, err,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.recent_corrections.is_empty() {
|
||||
s.push_str(&format!("\nRecent {} overseer corrections (yours — don't repeat):\n", self.recent_corrections.len()));
|
||||
for c in &self.recent_corrections {
|
||||
s.push_str(&format!(
|
||||
" [{}] turn={} reason={} correction={}\n",
|
||||
&c.created_at[..19.min(c.created_at.len())],
|
||||
c.applied_at_turn,
|
||||
truncate(&c.reason, 40),
|
||||
truncate(&c.correction_preview, 200),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_outcome(row: &serde_json::Value) -> OutcomeSummary {
|
||||
OutcomeSummary {
|
||||
created_at: row.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
ok: row.get("ok").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
polarity: row.get("polarity").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
turns: row.get("turns").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
latency_ms: row.get("usage").and_then(|u| u.get("latency_ms"))
|
||||
.and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
total_tokens: row.get("usage").and_then(|u| u.get("total_tokens"))
|
||||
.and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
error: row.get("error").and_then(|v| v.as_str()).map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_correction(row: &serde_json::Value) -> CorrectionSummary {
|
||||
let preview = row.get("correction").and_then(|v| v.as_str()).unwrap_or("");
|
||||
CorrectionSummary {
|
||||
created_at: row.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
reason: row.get("reason").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
correction_preview: truncate(preview, 300),
|
||||
applied_at_turn: row.get("applied_at_turn").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.len() <= n { s.to_string() } else { format!("{}…", &s[..n]) }
|
||||
}
|
||||
|
||||
/// Read a JSONL file from the tail, returning at most `limit` rows
|
||||
/// that match `filter`. Missing file returns empty. Corrupt lines are
|
||||
/// skipped. Limit is honored from the tail — a full-file scan with an
|
||||
/// in-memory ring would be wasteful for large outcomes histories, but
|
||||
/// we cap at reading the whole file and filtering post-hoc for now
|
||||
/// (reverse-seek line iteration is a real engineering task and the
|
||||
/// file is bounded by ingest rate; revisit when it bites).
|
||||
async fn tail_matching<F>(
|
||||
path: &Path,
|
||||
limit: usize,
|
||||
filter: F,
|
||||
) -> Vec<serde_json::Value>
|
||||
where
|
||||
F: Fn(&serde_json::Value) -> bool,
|
||||
{
|
||||
let Ok(file) = tokio::fs::File::open(path).await else { return Vec::new(); };
|
||||
let reader = tokio::io::BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let mut matches: Vec<serde_json::Value> = Vec::new();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
|
||||
if filter(&v) {
|
||||
matches.push(v);
|
||||
if matches.len() > limit {
|
||||
// Keep the most-recent window only — drop from the
|
||||
// front as we go rather than buffering everything.
|
||||
matches.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
async fn write_fixture(path: &Path, rows: Vec<serde_json::Value>) {
|
||||
if let Some(dir) = path.parent() {
|
||||
tokio::fs::create_dir_all(dir).await.unwrap();
|
||||
}
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.create(true).write(true).truncate(true).open(path).await.unwrap();
|
||||
for r in rows {
|
||||
let mut line = serde_json::to_string(&r).unwrap();
|
||||
line.push('\n');
|
||||
f.write_all(line.as_bytes()).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp_path(name: &str) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
std::env::temp_dir().join(format!("lh_kb_ctx_{}_{}_{}", std::process::id(), nanos, name))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_files_produce_empty_context() {
|
||||
let op = tmp_path("outcomes.jsonl");
|
||||
let cp = tmp_path("corrections.jsonl");
|
||||
let ctx = KbContext::load_from("sig123", "staffing.fill", &op, &cp).await;
|
||||
assert!(ctx.recent_outcomes.is_empty());
|
||||
assert!(ctx.recent_corrections.is_empty());
|
||||
assert!(ctx.success_rate.is_none());
|
||||
assert_eq!(ctx.total_observed, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exact_sig_hash_matches_take_priority() {
|
||||
let op = tmp_path("outcomes.jsonl");
|
||||
let cp = tmp_path("corrections.jsonl");
|
||||
write_fixture(&op, vec![
|
||||
// Other sig_hash, same task_class — loose match
|
||||
serde_json::json!({
|
||||
"sig_hash": "other", "task_class": "staffing.fill",
|
||||
"ok": false, "polarity": "failure_pattern", "turns": 1,
|
||||
"usage": {"latency_ms": 1000, "total_tokens": 100},
|
||||
"created_at": "2026-04-22T10:00:00Z",
|
||||
}),
|
||||
// Exact sig_hash — should lead
|
||||
serde_json::json!({
|
||||
"sig_hash": "sig123", "task_class": "staffing.fill",
|
||||
"ok": true, "polarity": "success_confirmation", "turns": 3,
|
||||
"usage": {"latency_ms": 2000, "total_tokens": 500},
|
||||
"created_at": "2026-04-23T10:00:00Z",
|
||||
}),
|
||||
]).await;
|
||||
write_fixture(&cp, vec![]).await;
|
||||
|
||||
let ctx = KbContext::load_from("sig123", "staffing.fill", &op, &cp).await;
|
||||
assert_eq!(ctx.recent_outcomes.len(), 2);
|
||||
assert_eq!(ctx.recent_outcomes[0].created_at, "2026-04-23T10:00:00Z");
|
||||
assert_eq!(ctx.recent_outcomes[0].ok, true);
|
||||
assert_eq!(ctx.total_observed, 2);
|
||||
assert!((ctx.success_rate.unwrap() - 0.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_rows_are_skipped() {
|
||||
let op = tmp_path("outcomes.jsonl");
|
||||
let cp = tmp_path("corrections.jsonl");
|
||||
// Mix valid + invalid — invalid should be silently skipped.
|
||||
if let Some(dir) = op.parent() { tokio::fs::create_dir_all(dir).await.unwrap(); }
|
||||
tokio::fs::write(&op, "not json\n{\"sig_hash\":\"sig1\",\"task_class\":\"tc\",\"ok\":true,\"turns\":1,\"usage\":{}}\ngarbage\n").await.unwrap();
|
||||
write_fixture(&cp, vec![]).await;
|
||||
let ctx = KbContext::load_from("sig1", "tc", &op, &cp).await;
|
||||
assert_eq!(ctx.recent_outcomes.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrections_preview_is_truncated() {
|
||||
let op = tmp_path("outcomes.jsonl");
|
||||
let cp = tmp_path("corrections.jsonl");
|
||||
let long = "x".repeat(500);
|
||||
write_fixture(&op, vec![]).await;
|
||||
write_fixture(&cp, vec![serde_json::json!({
|
||||
"sig_hash": "sig1", "task_class": "tc",
|
||||
"reason": "abort", "correction": long, "applied_at_turn": 3,
|
||||
"created_at": "2026-04-23T10:00:00Z",
|
||||
})]).await;
|
||||
let ctx = KbContext::load_from("sig1", "tc", &op, &cp).await;
|
||||
assert_eq!(ctx.recent_corrections.len(), 1);
|
||||
// 300-char cap + 3-byte UTF-8 ellipsis character = 303-byte worst case.
|
||||
assert!(ctx.recent_corrections[0].correction_preview.len() <= 303);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_section_is_stable_for_empty_context() {
|
||||
let ctx = KbContext::default();
|
||||
let s = ctx.to_prompt_section();
|
||||
assert!(s.contains("No prior similar runs recorded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_section_reports_aggregate_rates() {
|
||||
let ctx = KbContext {
|
||||
total_observed: 10,
|
||||
success_rate: Some(0.7),
|
||||
avg_turns: Some(4.2),
|
||||
avg_latency_ms: Some(45000),
|
||||
..Default::default()
|
||||
};
|
||||
let s = ctx.to_prompt_section();
|
||||
assert!(s.contains("success_rate=70.0%"));
|
||||
assert!(s.contains("avg_turns=4.2"));
|
||||
assert!(s.contains("avg_latency_ms=45000"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
mod access;
|
||||
mod access_service;
|
||||
mod auth;
|
||||
mod execution_loop;
|
||||
mod observability;
|
||||
mod tools;
|
||||
mod v1;
|
||||
@@ -67,6 +68,27 @@ async fn main() {
|
||||
let access = access::AccessControl::new(config.auth.enabled);
|
||||
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
|
||||
let workspace_mgr = queryd::workspace::WorkspaceManager::new(store.clone());
|
||||
if let Err(e) = workspace_mgr.rebuild().await {
|
||||
@@ -92,6 +114,12 @@ async fn main() {
|
||||
// operators call POST /vectors/playbook_memory/rebuild to populate.
|
||||
let pbm = vectord::playbook_memory::PlaybookMemory::new(store.clone());
|
||||
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
|
||||
// this returns a handle that drops triggers silently — no surprise load.
|
||||
@@ -153,6 +181,11 @@ async fn main() {
|
||||
agent_handle: agent_handle.clone(),
|
||||
index_registry: index_reg.clone(),
|
||||
schedules: sched_store,
|
||||
// P9-001 fix 2026-04-23: journal reference flows into ingest so
|
||||
// successful uploads emit a record_ingest event. Journal is Clone
|
||||
// (Arc<RwLock> inside) so the /journal nest below still sees the
|
||||
// same buffer + persistence.
|
||||
journal: Some(journal.clone()),
|
||||
}))
|
||||
.nest("/vectors", vectord::service::router(vectord::service::VectorState {
|
||||
store: store.clone(),
|
||||
@@ -172,17 +205,19 @@ async fn main() {
|
||||
bucket_registry.clone(), index_reg.clone(),
|
||||
),
|
||||
playbook_memory: pbm,
|
||||
pathway_memory: pwm,
|
||||
embed_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
|
||||
}))
|
||||
.nest("/workspaces", queryd::workspace_service::router(workspace_mgr))
|
||||
.nest("/journal", journald::service::router(journal))
|
||||
.nest("/access", access_service::router(access))
|
||||
.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;
|
||||
tools::ToolState {
|
||||
registry: tool_reg,
|
||||
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
|
||||
@@ -204,6 +239,38 @@ async fn main() {
|
||||
}
|
||||
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.
|
||||
// Defaults match mcp-server/tracing.ts conventions so
|
||||
// gateway traces land in the same staffing project.
|
||||
@@ -218,14 +285,19 @@ async fn main() {
|
||||
},
|
||||
}));
|
||||
|
||||
// Auth middleware (if enabled)
|
||||
// Auth middleware (if enabled) — P5-001 fix 2026-04-23:
|
||||
// previously only inserted the ApiKey as an extension and never layered
|
||||
// the middleware, so auth.enabled=true enforced nothing. Now wraps the
|
||||
// router with from_fn_with_state, which calls api_key_auth on every
|
||||
// request. /health is exempted inside the middleware (LB probes).
|
||||
if config.auth.enabled {
|
||||
if let Some(ref key) = config.auth.api_key {
|
||||
tracing::info!("API key auth enabled");
|
||||
tracing::info!("API key auth enabled — enforcing on all routes except /health");
|
||||
let api_key = auth::ApiKey(key.clone());
|
||||
app = app.layer(axum::Extension(api_key));
|
||||
// Note: auth middleware applied per-route in production
|
||||
// For now, the ApiKey extension is available for handlers to check
|
||||
app = app.layer(axum::middleware::from_fn_with_state(
|
||||
api_key,
|
||||
auth::api_key_auth,
|
||||
));
|
||||
} else {
|
||||
tracing::warn!("auth enabled but no api_key set — all requests allowed");
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@ pub mod service;
|
||||
|
||||
use queryd::context::QueryEngine;
|
||||
use arrow::json::writer::{JsonArray, Writer as JsonWriter};
|
||||
use std::sync::Arc;
|
||||
use truth::TruthStore;
|
||||
|
||||
/// State for the tool system.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolState {
|
||||
pub registry: registry::ToolRegistry,
|
||||
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.
|
||||
|
||||
@@ -67,24 +67,14 @@ pub struct ToolRegistry {
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
/// 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 {
|
||||
let registry = Self {
|
||||
Self {
|
||||
tools: Arc::new(RwLock::new(HashMap::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.
|
||||
|
||||
@@ -7,7 +7,7 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::registry::{Permission, ToolInvocation, ToolRegistry};
|
||||
use super::registry::{ToolInvocation, ToolRegistry};
|
||||
use crate::tools::ToolState;
|
||||
|
||||
pub fn router(state: ToolState) -> Router {
|
||||
@@ -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
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+195
-11
@@ -13,7 +13,12 @@
|
||||
|
||||
pub mod ollama;
|
||||
pub mod ollama_cloud;
|
||||
pub mod openrouter;
|
||||
pub mod gemini;
|
||||
pub mod claude;
|
||||
pub mod langfuse_trace;
|
||||
pub mod respond;
|
||||
pub mod truth;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -34,6 +39,19 @@ pub struct V1State {
|
||||
/// Ollama Cloud bearer token. Loaded at startup via
|
||||
/// `ollama_cloud::resolve_cloud_key()`. None = cloud routes 503.
|
||||
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
|
||||
/// disabled (keys missing or container unreachable). Traces are
|
||||
/// fire-and-forget: never block the response path.
|
||||
@@ -46,13 +64,25 @@ pub struct Usage {
|
||||
pub prompt_tokens: u64,
|
||||
pub completion_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
#[serde(default)]
|
||||
pub by_provider: std::collections::HashMap<String, ProviderUsage>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize)]
|
||||
pub struct ProviderUsage {
|
||||
pub requests: u64,
|
||||
pub prompt_tokens: u64,
|
||||
pub completion_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
pub fn router(state: V1State) -> Router {
|
||||
Router::new()
|
||||
.route("/chat", post(chat))
|
||||
.route("/respond", post(respond::respond))
|
||||
.route("/usage", get(usage))
|
||||
.route("/sessions", get(sessions))
|
||||
.route("/context", get(truth::context))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -64,7 +94,7 @@ pub struct Message {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
@@ -120,6 +150,96 @@ pub struct UsageBlock {
|
||||
|
||||
// -- 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(
|
||||
State(state): State<V1State>,
|
||||
Json(req): Json<ChatRequest>,
|
||||
@@ -131,27 +251,84 @@ async fn chat(
|
||||
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_instant = std::time::Instant::now();
|
||||
|
||||
let resp = match provider.as_str() {
|
||||
"ollama" | "local" | "" => ollama::chat(&state.ai_client, &req)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama local: {e}")))?,
|
||||
// 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() {
|
||||
"ollama" | "local" | "" => {
|
||||
let r = ollama::chat(&state.ai_client, &*req_for_adapter)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama local: {e}")))?;
|
||||
(r, "ollama".to_string())
|
||||
}
|
||||
"ollama_cloud" | "cloud" => {
|
||||
let key = state.ollama_cloud_key.as_deref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"OLLAMA_CLOUD_KEY not configured — set env or populate /root/llm_team_config.json".to_string(),
|
||||
"OLLAMA_CLOUD_KEY not configured".to_string(),
|
||||
))?;
|
||||
ollama_cloud::chat(key, &req)
|
||||
let r = ollama_cloud::chat(key, &*req_for_adapter)
|
||||
.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())
|
||||
}
|
||||
"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 => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("unknown provider '{other}' — supported: ollama, ollama_cloud"),
|
||||
format!("unknown provider '{other}' — supported: ollama, ollama_cloud, openrouter, gemini, claude"),
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -168,7 +345,7 @@ async fn chat(
|
||||
.map(|c| c.message.content.clone())
|
||||
.unwrap_or_default();
|
||||
lf.emit_chat(langfuse_trace::ChatTrace {
|
||||
provider: provider.clone(),
|
||||
provider: used_provider.clone(),
|
||||
model: resp.model.clone(),
|
||||
input: req.messages.clone(),
|
||||
output,
|
||||
@@ -183,12 +360,19 @@ async fn chat(
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 40: per-provider usage tracking
|
||||
{
|
||||
let mut u = state.usage.write().await;
|
||||
u.requests += 1;
|
||||
u.prompt_tokens += resp.usage.prompt_tokens as u64;
|
||||
u.completion_tokens += resp.usage.completion_tokens as u64;
|
||||
u.total_tokens += resp.usage.total_tokens as u64;
|
||||
|
||||
let provider_usage = u.by_provider.entry(used_provider).or_default();
|
||||
provider_usage.requests += 1;
|
||||
provider_usage.prompt_tokens += resp.usage.prompt_tokens as u64;
|
||||
provider_usage.completion_tokens += resp.usage.completion_tokens as u64;
|
||||
provider_usage.total_tokens += resp.usage.total_tokens as u64;
|
||||
}
|
||||
|
||||
Ok(Json(resp))
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! `/v1/respond` — the **execution** API (distinct from `/v1/chat`, the
|
||||
//! completion API).
|
||||
//!
|
||||
//! This is the consolidation move called out in the 2026-04-23 session:
|
||||
//! lift the proven pipeline from `tests/multi-agent/orchestrator.ts`
|
||||
//! (executor → reviewer → escalate → validate → seal playbook →
|
||||
//! write-through to KB) into the gateway, so the production path has
|
||||
//! the intelligence the tests already proved.
|
||||
//!
|
||||
//! `/v1/chat` stays a naive completion proxy for callers that want one.
|
||||
//! `/v1/respond` is where the loop lives. Every orchestrator-style
|
||||
//! caller migrates here and the TS harnesses become thin clients.
|
||||
//!
|
||||
//! This file holds the HTTP surface + request/response shapes. The loop
|
||||
//! itself lives in `execution_loop::ExecutionLoop`.
|
||||
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::V1State;
|
||||
use crate::execution_loop::{ExecutionLoop, LogEntry, RespondOutcome};
|
||||
|
||||
/// A structured task — mirrors `TaskSpec` in `tests/multi-agent/agent.ts`.
|
||||
/// Kept deliberately open so non-staffing task classes (code-gen,
|
||||
/// DevOps-long-horizon) can land without a schema fight.
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct RespondRequest {
|
||||
/// Task class — routes to the right truth rules + validator. For the
|
||||
/// staffing substrate: `staffing.fill`, `staffing.rescue`,
|
||||
/// `staffing.sms_draft`. Truth-layer lookup is a no-op until a rule
|
||||
/// set is registered for the class.
|
||||
pub task_class: String,
|
||||
|
||||
/// Human-readable operation description — becomes the playbook
|
||||
/// `operation` field on seal, and the primary signal for
|
||||
/// playbook_memory embedding.
|
||||
pub operation: String,
|
||||
|
||||
/// Free-form structured context. Passed to the executor prompt and
|
||||
/// to the playbook seeder. Staffing tasks expect
|
||||
/// `{target_role, target_count, target_city, target_state, approach_hint}`
|
||||
/// but nothing here validates that — the validator crate will (Phase 43).
|
||||
#[serde(default)]
|
||||
pub spec: serde_json::Value,
|
||||
|
||||
/// Executor model. Defaults to the hot-path local model if omitted.
|
||||
/// See orchestrator.ts:28 (`EXECUTOR_MODEL = "qwen3.5:latest"`).
|
||||
#[serde(default)]
|
||||
pub executor_model: Option<String>,
|
||||
|
||||
/// Reviewer model. Defaults to the hot-path local reviewer.
|
||||
/// See orchestrator.ts:29 (`REVIEWER_MODEL = "qwen3:latest"`).
|
||||
#[serde(default)]
|
||||
pub reviewer_model: Option<String>,
|
||||
|
||||
/// Hard cap on executor turns. Default matches orchestrator.ts:30
|
||||
/// (`MAX_TURNS = 12`). Cloud escalation counts as a turn.
|
||||
#[serde(default)]
|
||||
pub max_turns: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RespondResponse {
|
||||
/// `ok` = consensus reached, playbook sealed. `failed` = loop ran
|
||||
/// out of turns or hit the drift cap. `blocked` = truth-layer
|
||||
/// veto (Phase 42 rule citation in `error`).
|
||||
pub status: &'static str,
|
||||
|
||||
/// The final artifact — for staffing fills, `{fills: [{candidate_id, name}]}`.
|
||||
/// Empty on failure / block.
|
||||
pub artifact: serde_json::Value,
|
||||
|
||||
/// Structured cross-turn log. Same shape as orchestrator.ts LogEntry
|
||||
/// so existing tooling (kb extractors, fact_extractor.ts) reads it
|
||||
/// without change.
|
||||
pub log: Vec<LogEntry>,
|
||||
|
||||
/// Iteration count actually used. ≤ max_turns. Stamped on
|
||||
/// outcomes.jsonl per the indicator audit (2026-04-23).
|
||||
pub iterations: u32,
|
||||
|
||||
/// Error message on non-ok status. Truth-rule citations land here
|
||||
/// when `status == "blocked"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn respond(
|
||||
State(state): State<V1State>,
|
||||
Json(req): Json<RespondRequest>,
|
||||
) -> Result<Json<RespondResponse>, (StatusCode, String)> {
|
||||
if req.operation.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "operation must be non-empty".into()));
|
||||
}
|
||||
if req.task_class.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "task_class must be non-empty".into()));
|
||||
}
|
||||
|
||||
let mut loop_runner = ExecutionLoop::new(state, req);
|
||||
let outcome = loop_runner.run().await.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("execution loop: {e}"))
|
||||
})?;
|
||||
|
||||
let (status, error) = match &outcome {
|
||||
RespondOutcome::Ok { .. } => ("ok", None),
|
||||
RespondOutcome::Failed { reason, .. } => ("failed", Some(reason.clone())),
|
||||
RespondOutcome::Blocked { reason, .. } => ("blocked", Some(reason.clone())),
|
||||
};
|
||||
|
||||
Ok(Json(RespondResponse {
|
||||
status,
|
||||
artifact: outcome.artifact(),
|
||||
log: outcome.into_log(),
|
||||
iterations: loop_runner.turns_used(),
|
||||
error,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn respond_request_parses_minimal() {
|
||||
let raw = r#"{
|
||||
"task_class": "staffing.fill",
|
||||
"operation": "fill: Welder x2 in Toledo, OH"
|
||||
}"#;
|
||||
let r: RespondRequest = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(r.task_class, "staffing.fill");
|
||||
assert_eq!(r.executor_model, None);
|
||||
assert_eq!(r.max_turns, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respond_request_parses_full() {
|
||||
let raw = r#"{
|
||||
"task_class": "staffing.fill",
|
||||
"operation": "fill: Welder x2 in Toledo, OH",
|
||||
"spec": {"target_role": "Welder", "target_count": 2, "target_city": "Toledo", "target_state": "OH"},
|
||||
"executor_model": "qwen3.5:latest",
|
||||
"reviewer_model": "qwen3:latest",
|
||||
"max_turns": 12
|
||||
}"#;
|
||||
let r: RespondRequest = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(r.executor_model.as_deref(), Some("qwen3.5:latest"));
|
||||
assert_eq!(r.max_turns, Some(12));
|
||||
assert_eq!(r.spec["target_count"], 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use serde::Serialize;
|
||||
use truth::default_truth_store;
|
||||
|
||||
// Note: truth_router() was a stub wrapper around a single /context route
|
||||
// that nothing called — v1/mod.rs wires get(truth::context) directly
|
||||
// onto its own router. Removed 2026-04-24 along with its #[allow(dead_code)]
|
||||
// attribute; the handler below is the real surface.
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ContextResponse {
|
||||
pub task_classes: Vec<String>,
|
||||
pub rules: Vec<RuleInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RuleInfo {
|
||||
pub id: String,
|
||||
pub task_class: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub async fn context() -> axum::Json<ContextResponse> {
|
||||
let store = default_truth_store();
|
||||
|
||||
let task_classes: Vec<String> = vec![
|
||||
"staffing.fill".to_string(),
|
||||
"staffing.rescue".to_string(),
|
||||
"staffing.sms_draft".to_string(),
|
||||
"staffing.any".to_string(),
|
||||
];
|
||||
|
||||
let mut rules = Vec::new();
|
||||
for tc in &task_classes {
|
||||
for rule in store.get_rules(tc) {
|
||||
rules.push(RuleInfo {
|
||||
id: rule.id.clone(),
|
||||
task_class: rule.task_class.clone(),
|
||||
description: rule.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
axum::Json(ContextResponse {
|
||||
task_classes,
|
||||
rules,
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ shared = { path = "../shared" }
|
||||
storaged = { path = "../storaged" }
|
||||
catalogd = { path = "../catalogd" }
|
||||
vectord = { path = "../vectord" }
|
||||
journald = { path = "../journald" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true, features = ["multipart"] }
|
||||
lopdf = { workspace = true }
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
/// 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.
|
||||
|
||||
use arrow::datatypes::{DataType, Schema, SchemaRef};
|
||||
use arrow::datatypes::{DataType, SchemaRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A detected change between two schema versions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -223,7 +222,8 @@ fn find_similar_column<'a>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use arrow::datatypes::Field;
|
||||
use arrow::datatypes::{Field, Schema};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn schema(fields: Vec<(&str, DataType)>) -> SchemaRef {
|
||||
Arc::new(Schema::new(
|
||||
|
||||
@@ -3,7 +3,7 @@ use axum::{
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, patch, post},
|
||||
routing::{get, post},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use object_store::ObjectStore;
|
||||
@@ -33,6 +33,11 @@ pub struct IngestState {
|
||||
/// Scheduled-ingest registry. The scheduler task runs against this
|
||||
/// store; HTTP CRUD endpoints write through it.
|
||||
pub schedules: schedule::ScheduleStore,
|
||||
/// Event journal for ADR-012 mutation history. Optional for back-compat
|
||||
/// with callers (like scheduled ingest tests) that don't wire it yet.
|
||||
/// When present, successful ingests emit a record_ingest event — closes
|
||||
/// P9-001 on the file-upload path. (2026-04-23)
|
||||
pub journal: Option<journald::journal::Journal>,
|
||||
}
|
||||
|
||||
/// Push `DatasetAppended` triggers for every HNSW index bound to this
|
||||
@@ -136,6 +141,22 @@ async fn ingest_file(
|
||||
Ok(result) => {
|
||||
if !result.deduplicated {
|
||||
notify_agent_on_append(&state, &result.dataset_name).await;
|
||||
// P9-001 fix (2026-04-23): emit a mutation event on every
|
||||
// non-deduplicated ingest. Dedup no-ops don't need events
|
||||
// (ADR-020 register() is already idempotent on same fingerprint).
|
||||
if let Some(ref journal) = state.journal {
|
||||
if let Err(e) = journal.record_ingest(
|
||||
&result.dataset_name,
|
||||
result.rows as usize,
|
||||
"ingest_api",
|
||||
&filename,
|
||||
).await {
|
||||
tracing::warn!(
|
||||
"journal record_ingest failed for '{}': {}",
|
||||
result.dataset_name, e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.deduplicated {
|
||||
Ok((StatusCode::OK, Json(result)))
|
||||
@@ -630,3 +651,108 @@ async fn run_schedule_now(
|
||||
}
|
||||
Ok(Json(outcome))
|
||||
}
|
||||
|
||||
// ─── Tests ───
|
||||
|
||||
#[cfg(test)]
|
||||
mod journal_integration_tests {
|
||||
//! P9-001 integration test: prove that a successful ingest produces a
|
||||
//! journal.record_ingest event. Block 2 on PR #10 was "journal event
|
||||
//! verified live" being unbacked by the diff. This test makes the
|
||||
//! verification committed and reproducible.
|
||||
|
||||
use journald::journal::{Event, Journal};
|
||||
use object_store::memory::InMemory;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Helper: build a bare Journal against an in-memory object store.
|
||||
// Flush threshold 1 so every recorded event is persisted immediately.
|
||||
fn test_journal() -> Journal {
|
||||
let store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
|
||||
Journal::new(store, 1)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_record_ingest_increments_counter() {
|
||||
// Arrange — fresh journal, counter starts at zero.
|
||||
let journal = test_journal();
|
||||
let stats0 = journal.stats().await;
|
||||
assert_eq!(stats0.total_events_created, 0);
|
||||
assert_eq!(stats0.buffer_events, 0);
|
||||
|
||||
// Act — simulate what the /ingest/file success path does.
|
||||
journal
|
||||
.record_ingest("test_dataset", 42, "ingest_api", "probe.csv")
|
||||
.await
|
||||
.expect("record_ingest should succeed");
|
||||
|
||||
// Assert — counter advanced, event exists. With threshold=1 the
|
||||
// event flushed to store; with threshold>N it would be in-buffer.
|
||||
let stats1 = journal.stats().await;
|
||||
assert_eq!(stats1.total_events_created, 1, "counter should reflect one recorded event");
|
||||
|
||||
// Assert — the event is retrievable by entity.
|
||||
let history = journal
|
||||
.get_entity_history("batch:42")
|
||||
.await
|
||||
.expect("history lookup");
|
||||
assert_eq!(history.len(), 1, "one event should be visible in history");
|
||||
let ev = &history[0];
|
||||
assert_eq!(ev.action, "ingest");
|
||||
assert_eq!(ev.entity_type, "test_dataset");
|
||||
assert_eq!(ev.actor, "ingest_api");
|
||||
assert!(
|
||||
ev.new_value.contains("probe.csv"),
|
||||
"new_value should carry source filename, got: {}",
|
||||
ev.new_value
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_journal_field_none_is_valid_back_compat() {
|
||||
// IngestState.journal is Option<Journal>. Back-compat path: when
|
||||
// the field is None, the ingest handler MUST still succeed — the
|
||||
// journal call is fire-and-forget, never load-bearing.
|
||||
//
|
||||
// This test asserts the type shape: Option<Journal> is what we
|
||||
// expect. If a refactor makes it mandatory, this test forces an
|
||||
// explicit re-consideration.
|
||||
let none_journal: Option<Journal> = None;
|
||||
assert!(none_journal.is_none());
|
||||
|
||||
let some_journal: Option<Journal> = Some(test_journal());
|
||||
assert!(some_journal.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_record_event_fields_match_adr_012_schema() {
|
||||
// ADR-012 locks the event schema: entity_type, entity_id, field,
|
||||
// action, old_value, new_value, actor, source, workspace_id plus
|
||||
// the auto-assigned event_id + timestamp. This test pins the
|
||||
// field names so a future refactor can't silently drop one.
|
||||
let journal = test_journal();
|
||||
let base = Event {
|
||||
event_id: String::new(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
entity_type: "candidate".into(),
|
||||
entity_id: "CAND-0001".into(),
|
||||
field: "phone".into(),
|
||||
action: "update".into(),
|
||||
old_value: "555-0000".into(),
|
||||
new_value: "555-9999".into(),
|
||||
actor: "recruiter".into(),
|
||||
source: "api".into(),
|
||||
workspace_id: "ws-x".into(),
|
||||
};
|
||||
journal.record(base).await.expect("record should accept full-schema event");
|
||||
let h = journal
|
||||
.get_entity_history("CAND-0001")
|
||||
.await
|
||||
.expect("lookup");
|
||||
assert_eq!(h.len(), 1);
|
||||
assert_eq!(h[0].field, "phone");
|
||||
assert_eq!(h[0].old_value, "555-0000");
|
||||
assert_eq!(h[0].new_value, "555-9999");
|
||||
assert_eq!(h[0].workspace_id, "ws-x");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +72,14 @@ async fn process_inbox(
|
||||
|
||||
let path = entry.path();
|
||||
|
||||
// Skip directories and hidden files
|
||||
if path.is_dir() || path.file_name().map_or(true, |n| n.to_string_lossy().starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
// Skip directories and hidden files. Bind filename once via
|
||||
// let-else so the subsequent use is unwrap-free — previous
|
||||
// version relied on a map_or guard above + an .unwrap() here
|
||||
// being consistent, which is a fragile invariant.
|
||||
if path.is_dir() { continue; }
|
||||
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);
|
||||
|
||||
// Read file
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/// Storage: events buffer in memory, flush to Parquet periodically.
|
||||
/// Query: load Parquet files, filter by entity/field/actor/time.
|
||||
|
||||
use arrow::array::{ArrayRef, RecordBatch, StringArray, UInt64Array};
|
||||
use arrow::array::{ArrayRef, RecordBatch, StringArray};
|
||||
use arrow::datatypes::{DataType, Field, Schema};
|
||||
use chrono::{DateTime, Utc};
|
||||
use object_store::ObjectStore;
|
||||
|
||||
@@ -7,6 +7,7 @@ edition = "2024"
|
||||
shared = { path = "../shared" }
|
||||
catalogd = { path = "../catalogd" }
|
||||
storaged = { path = "../storaged" }
|
||||
truth = { path = "../truth" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -84,14 +84,17 @@ pub async fn compact(
|
||||
// Load deltas
|
||||
let delta_batches = load_deltas(store, dataset_name).await?;
|
||||
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 nothing_to_do = delta_batches.is_empty() && !has_tombstones;
|
||||
if nothing_to_do {
|
||||
return Ok(CompactResult {
|
||||
base_rows: base_batches.iter().map(|b| b.num_rows()).sum(),
|
||||
base_rows: base_row_count,
|
||||
delta_rows: 0,
|
||||
final_rows: base_batches.iter().map(|b| b.num_rows()).sum(),
|
||||
final_rows: base_row_count,
|
||||
deltas_merged: 0,
|
||||
tombstones_applied: 0,
|
||||
rows_dropped_by_tombstones: 0,
|
||||
@@ -99,7 +102,7 @@ pub async fn compact(
|
||||
}
|
||||
|
||||
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)
|
||||
let merged_batches = if let Some(_pk) = primary_key_col {
|
||||
@@ -183,8 +186,8 @@ pub async fn compact(
|
||||
);
|
||||
|
||||
Ok(CompactResult {
|
||||
base_rows: pre_filter_rows - delta_count, // rough base-before-deltas
|
||||
delta_rows: delta_count,
|
||||
base_rows: base_row_count,
|
||||
delta_rows: delta_row_count,
|
||||
final_rows,
|
||||
deltas_merged: delta_count,
|
||||
tombstones_applied: tombstones.len(),
|
||||
|
||||
@@ -9,7 +9,9 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cache::CacheStats;
|
||||
use std::sync::Arc;
|
||||
use truth::{RuleAction, TruthStore};
|
||||
|
||||
use crate::context::QueryEngine;
|
||||
use crate::delta;
|
||||
use crate::paged::ResultStore;
|
||||
@@ -18,12 +20,26 @@ use crate::paged::ResultStore;
|
||||
pub struct QueryState {
|
||||
pub engine: QueryEngine,
|
||||
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 {
|
||||
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 {
|
||||
engine: engine.clone(),
|
||||
result_store: ResultStore::new(100, 50), // 100 rows/page, keep 50 results
|
||||
truth,
|
||||
};
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
@@ -53,6 +69,11 @@ struct QueryResponse {
|
||||
columns: Vec<ColumnInfo>,
|
||||
rows: serde_json::Value,
|
||||
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)]
|
||||
@@ -72,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}"))
|
||||
}
|
||||
|
||||
/// 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(
|
||||
State(state): State<QueryState>,
|
||||
Json(req): Json<QueryRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let started = std::time::Instant::now();
|
||||
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 {
|
||||
Ok(batches) => {
|
||||
if batches.is_empty() {
|
||||
@@ -85,6 +135,7 @@ async fn execute_query(
|
||||
columns: vec![],
|
||||
rows: serde_json::Value::Array(vec![]),
|
||||
row_count: 0,
|
||||
latency_ms: started.elapsed().as_millis() as u64,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -103,6 +154,7 @@ async fn execute_query(
|
||||
columns,
|
||||
rows,
|
||||
row_count,
|
||||
latency_ms: started.elapsed().as_millis() as u64,
|
||||
}))
|
||||
}
|
||||
Err(e) => Err((StatusCode::BAD_REQUEST, e)),
|
||||
@@ -116,6 +168,10 @@ async fn paged_query(
|
||||
Json(req): Json<QueryRequest>,
|
||||
) -> impl IntoResponse {
|
||||
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 {
|
||||
Ok(handle) => Ok(Json(handle)),
|
||||
Err(e) => Err((StatusCode::BAD_REQUEST, e)),
|
||||
@@ -212,3 +268,65 @@ async fn compact_dataset(
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
/// Each workspace tracks an agent's activity on a specific contract or search,
|
||||
/// with daily/weekly/monthly tiers and instant handoff capability.
|
||||
|
||||
use arrow::array::{ArrayRef, RecordBatch, StringArray, Int64Array};
|
||||
use arrow::datatypes::{DataType, Field, Schema};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::delta;
|
||||
use object_store::ObjectStore;
|
||||
|
||||
/// Retention tier for workspace data.
|
||||
|
||||
@@ -4,3 +4,5 @@ pub mod arrow_helpers;
|
||||
pub mod config;
|
||||
pub mod pii;
|
||||
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 }
|
||||
@@ -269,6 +269,20 @@ pub struct ModelProfile {
|
||||
/// / 5M+ vector workloads.
|
||||
#[serde(default)]
|
||||
pub vector_backend: VectorBackend,
|
||||
/// Phase 41: Profile type for routing + activation behavior.
|
||||
/// Default Execution for backward compatibility.
|
||||
#[serde(default)]
|
||||
pub profile_type: ProfileType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProfileType {
|
||||
#[default]
|
||||
Execution,
|
||||
Retrieval,
|
||||
Memory,
|
||||
Observer,
|
||||
}
|
||||
|
||||
fn default_embed_model() -> String { "nomic-embed-text".to_string() }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "truth"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub mod staffing;
|
||||
pub mod devops;
|
||||
pub mod loader;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TruthRule {
|
||||
pub id: String,
|
||||
pub task_class: String,
|
||||
pub description: String,
|
||||
pub condition: RuleCondition,
|
||||
pub action: RuleAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum RuleCondition {
|
||||
Always,
|
||||
FieldEquals { field: String, value: String },
|
||||
FieldMismatch { field: String, value: String },
|
||||
FieldEmpty { field: String },
|
||||
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)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum RuleAction {
|
||||
Pass,
|
||||
Reject { message: String },
|
||||
Redact { fields: Vec<String> },
|
||||
Block { message: String },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TruthStore {
|
||||
rules: HashMap<String, Vec<TruthRule>>,
|
||||
}
|
||||
|
||||
impl TruthStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_rule(&mut self, rule: TruthRule) {
|
||||
self.rules
|
||||
.entry(rule.task_class.clone())
|
||||
.or_default()
|
||||
.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> {
|
||||
self.rules
|
||||
.get(task_class)
|
||||
.map(|v| v.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Legacy API: returns the list of actions registered for a task class
|
||||
/// without evaluating conditions. Retained for backward compatibility
|
||||
/// with callers that only want the action catalog. New callers should
|
||||
/// prefer `evaluate()`, which actually walks `RuleCondition` against
|
||||
/// a context and reports per-rule pass/fail.
|
||||
pub fn check(&self, task_class: &str) -> Vec<RuleAction> {
|
||||
let rules = self.get_rules(task_class);
|
||||
rules
|
||||
.into_iter()
|
||||
.map(|r| r.action.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Evaluate every rule registered for `task_class` against `ctx`,
|
||||
/// returning one `RuleOutcome` per rule. `passed = true` means the
|
||||
/// rule's `condition` held; the rule's action is still attached so
|
||||
/// callers can distinguish "passed and therefore no-op" (RuleAction::Pass)
|
||||
/// from "passed and apply Redact". `passed = false` means the condition
|
||||
/// failed — callers should treat the attached action as the enforcement
|
||||
/// response (Reject/Block).
|
||||
///
|
||||
/// Fixed P42-001 (2026-04-23): previously `check()` returned all actions
|
||||
/// unconditionally — the `RuleCondition` field was ignored. Now every
|
||||
/// rule is actually walked against the provided context.
|
||||
pub fn evaluate(&self, task_class: &str, ctx: &serde_json::Value) -> Vec<RuleOutcome> {
|
||||
self.get_rules(task_class)
|
||||
.into_iter()
|
||||
.map(|r| RuleOutcome {
|
||||
rule_id: r.id.clone(),
|
||||
passed: evaluate_condition(&r.condition, ctx),
|
||||
action: r.action.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of evaluating one rule against a context. `passed` reports
|
||||
/// whether the condition held; `action` is the rule's declared action
|
||||
/// regardless (callers decide how to apply it based on `passed`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuleOutcome {
|
||||
pub rule_id: String,
|
||||
pub passed: bool,
|
||||
pub action: RuleAction,
|
||||
}
|
||||
|
||||
fn evaluate_condition(cond: &RuleCondition, ctx: &serde_json::Value) -> bool {
|
||||
match cond {
|
||||
RuleCondition::Always => true,
|
||||
RuleCondition::FieldEquals { field, value } => {
|
||||
field_as_string(ctx, field)
|
||||
.map(|s| s == *value)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
RuleCondition::FieldMismatch { field, value } => {
|
||||
field_as_string(ctx, field)
|
||||
.map(|s| s != *value)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
RuleCondition::FieldEmpty { field } => {
|
||||
match lookup(ctx, field) {
|
||||
None => true,
|
||||
Some(v) => v.is_null() || v.as_str().map(|s| s.is_empty()).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
RuleCondition::FieldGreater { field, threshold } => {
|
||||
lookup(ctx, field)
|
||||
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
|
||||
.map(|n| n > *threshold)
|
||||
.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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a dot-separated path through a serde_json::Value. `"worker.status"`
|
||||
/// → `ctx["worker"]["status"]`. Returns None if any segment is missing or
|
||||
/// a non-object is encountered mid-path.
|
||||
fn lookup<'a>(ctx: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
|
||||
let mut cur = ctx;
|
||||
for seg in path.split('.') {
|
||||
cur = cur.get(seg)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
fn field_as_string(ctx: &serde_json::Value, path: &str) -> Option<String> {
|
||||
lookup(ctx, path).and_then(|v| match v {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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();
|
||||
store.add_rule(TruthRule {
|
||||
id: "no-destructive-sql".to_string(),
|
||||
task_class: "sql_query".to_string(),
|
||||
description: "SQL must not contain destructive verbs".to_string(),
|
||||
condition: RuleCondition::FieldContainsAny {
|
||||
field: "sql".to_string(),
|
||||
needles: vec![
|
||||
"drop table".to_string(),
|
||||
"drop schema".to_string(),
|
||||
"drop database".to_string(),
|
||||
"truncate".to_string(),
|
||||
"delete from".to_string(),
|
||||
],
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "destructive SQL rejected by truth.sql_query_guard".to_string(),
|
||||
},
|
||||
});
|
||||
store.add_rule(TruthRule {
|
||||
id: "sql-not-empty".to_string(),
|
||||
task_class: "sql_query".to_string(),
|
||||
description: "SQL must not be empty".to_string(),
|
||||
condition: RuleCondition::FieldEmpty {
|
||||
field: "sql".to_string(),
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "empty SQL rejected".to_string(),
|
||||
},
|
||||
});
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truth_store_new_is_empty() {
|
||||
let store = TruthStore::new();
|
||||
assert!(store.rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_inserts_into_correct_task_class() {
|
||||
let mut store = TruthStore::new();
|
||||
store.add_rule(TruthRule {
|
||||
id: "test-rule".to_string(),
|
||||
task_class: "test.task".to_string(),
|
||||
description: "Test rule".to_string(),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
let rules = store.get_rules("test.task");
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert_eq!(rules[0].id, "test-rule");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_rules_returns_empty_for_unknown_class() {
|
||||
let store = TruthStore::new();
|
||||
let rules = store.get_rules("unknown.class");
|
||||
assert!(rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_returns_actions_for_task_class() {
|
||||
let mut store = TruthStore::new();
|
||||
store.add_rule(TruthRule {
|
||||
id: "a1".to_string(),
|
||||
task_class: "test".to_string(),
|
||||
description: "A1".to_string(),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
store.add_rule(TruthRule {
|
||||
id: "a2".to_string(),
|
||||
task_class: "test".to_string(),
|
||||
description: "A2".to_string(),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Reject {
|
||||
message: "test reject".to_string(),
|
||||
},
|
||||
});
|
||||
let actions = store.check("test");
|
||||
assert_eq!(actions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_condition_serialize_always() {
|
||||
let cond = RuleCondition::Always;
|
||||
let json = serde_json::to_string(&cond).unwrap();
|
||||
assert!(json.contains(r#""type":"Always"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_condition_serialize_field_equals() {
|
||||
let cond = RuleCondition::FieldEquals {
|
||||
field: "foo".to_string(),
|
||||
value: "bar".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&cond).unwrap();
|
||||
assert!(json.contains(r#""type":"FieldEquals""#));
|
||||
assert!(json.contains(r#""field":"foo""#));
|
||||
assert!(json.contains(r#""value":"bar""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_action_serialize_redact() {
|
||||
let action = RuleAction::Redact {
|
||||
fields: vec!["ssn".to_string()],
|
||||
};
|
||||
let json = serde_json::to_string(&action).unwrap();
|
||||
assert!(json.contains(r#""type":"Redact""#));
|
||||
assert!(json.contains("ssn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_action_serialize_reject() {
|
||||
let action = RuleAction::Reject {
|
||||
message: "test".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&action).unwrap();
|
||||
assert!(json.contains(r#""type":"Reject""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_truth_store_has_staffing_rules() {
|
||||
let store = default_truth_store();
|
||||
let fill_rules = store.get_rules("staffing.fill");
|
||||
assert!(!fill_rules.is_empty());
|
||||
let any_rules = store.get_rules("staffing.any");
|
||||
assert!(!any_rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_rules_same_task_class() {
|
||||
let mut store = TruthStore::new();
|
||||
for i in 0..5 {
|
||||
store.add_rule(TruthRule {
|
||||
id: format!("rule-{}", i),
|
||||
task_class: "test".to_string(),
|
||||
description: format!("Rule {}", i),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
}
|
||||
let rules = store.get_rules("test");
|
||||
assert_eq!(rules.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truth_rule_clone_preserves_data() {
|
||||
let rule = TruthRule {
|
||||
id: "clone-test".to_string(),
|
||||
task_class: "clone.task".to_string(),
|
||||
description: "Clone test".to_string(),
|
||||
condition: RuleCondition::FieldEquals {
|
||||
field: "x".to_string(),
|
||||
value: "y".to_string(),
|
||||
},
|
||||
action: RuleAction::Block {
|
||||
message: "blocked".to_string(),
|
||||
},
|
||||
};
|
||||
let cloned = rule.clone();
|
||||
assert_eq!(cloned.id, rule.id);
|
||||
assert_eq!(cloned.condition, rule.condition);
|
||||
assert_eq!(cloned.action, rule.action);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_greater_condition_parse() {
|
||||
let json = r#"{"type":"FieldGreater","field":"count","threshold":10}"#;
|
||||
let cond: RuleCondition = serde_json::from_str(json).unwrap();
|
||||
match cond {
|
||||
RuleCondition::FieldGreater { field, threshold } => {
|
||||
assert_eq!(field, "count");
|
||||
assert_eq!(threshold, 10);
|
||||
}
|
||||
_ => panic!("Expected FieldGreater"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_action_blocks_with_message() {
|
||||
let action = RuleAction::Block {
|
||||
message: "Rate limited".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&action).unwrap();
|
||||
assert!(json.contains("Rate limited"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store_check_returns_empty() {
|
||||
let store = TruthStore::new();
|
||||
let actions = store.check("empty.class");
|
||||
assert!(actions.is_empty());
|
||||
}
|
||||
|
||||
// ── P42-001 evaluate() tests — actually walk RuleCondition ──
|
||||
|
||||
fn fill_store() -> TruthStore {
|
||||
let mut s = TruthStore::new();
|
||||
s.add_rule(TruthRule {
|
||||
id: "active".into(),
|
||||
task_class: "t".into(),
|
||||
description: "must be active".into(),
|
||||
condition: RuleCondition::FieldEquals {
|
||||
field: "worker.status".into(),
|
||||
value: "active".into(),
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "worker not active".into(),
|
||||
},
|
||||
});
|
||||
s.add_rule(TruthRule {
|
||||
id: "deadline".into(),
|
||||
task_class: "t".into(),
|
||||
description: "deadline required".into(),
|
||||
condition: RuleCondition::FieldEmpty {
|
||||
field: "contract.deadline".into(),
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "missing deadline".into(),
|
||||
},
|
||||
});
|
||||
s.add_rule(TruthRule {
|
||||
id: "budget".into(),
|
||||
task_class: "t".into(),
|
||||
description: "budget positive".into(),
|
||||
condition: RuleCondition::FieldGreater {
|
||||
field: "contract.budget".into(),
|
||||
threshold: 0,
|
||||
},
|
||||
action: RuleAction::Block {
|
||||
message: "budget must be positive".into(),
|
||||
},
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_equals_pass_on_match() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({"worker": {"status": "active"}});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
|
||||
assert!(active.passed, "active condition should hold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_equals_fail_on_mismatch() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({"worker": {"status": "terminated"}});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
|
||||
assert!(!active.passed, "terminated should fail active condition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_equals_fail_on_missing() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
|
||||
assert!(!active.passed, "missing worker.status should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_empty_pass_when_absent() {
|
||||
let s = fill_store();
|
||||
// FieldEmpty passes when the field is missing/null/empty string.
|
||||
// Deadline rule says "field empty means action fires" — so passed=true
|
||||
// here means the rule's condition held (deadline IS empty).
|
||||
let ctx = serde_json::json!({});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let deadline = o.iter().find(|r| r.rule_id == "deadline").unwrap();
|
||||
assert!(deadline.passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_empty_fail_when_present() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({"contract": {"deadline": "2026-05-01"}});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let deadline = o.iter().find(|r| r.rule_id == "deadline").unwrap();
|
||||
assert!(!deadline.passed, "non-empty deadline should fail FieldEmpty check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_greater_pass_and_fail() {
|
||||
let s = fill_store();
|
||||
let ctx_ok = serde_json::json!({"contract": {"budget": 100}});
|
||||
let ctx_bad = serde_json::json!({"contract": {"budget": 0}});
|
||||
let ok = s.evaluate("t", &ctx_ok);
|
||||
let bad = s.evaluate("t", &ctx_bad);
|
||||
assert!(ok.iter().find(|r| r.rule_id == "budget").unwrap().passed);
|
||||
assert!(!bad.iter().find(|r| r.rule_id == "budget").unwrap().passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_always_condition_passes_unconditionally() {
|
||||
let mut s = TruthStore::new();
|
||||
s.add_rule(TruthRule {
|
||||
id: "always".into(),
|
||||
task_class: "x".into(),
|
||||
description: "".into(),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
let o = s.evaluate("x", &serde_json::json!(null));
|
||||
assert!(o[0].passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_preserves_action_regardless_of_outcome() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({"worker": {"status": "active"}});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
|
||||
// Action is attached whether the rule passed or not — the consumer
|
||||
// decides how to use it.
|
||||
assert_eq!(
|
||||
active.action,
|
||||
RuleAction::Reject {
|
||||
message: "worker not active".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_on_unknown_task_class_returns_empty() {
|
||||
let s = fill_store();
|
||||
let o = s.evaluate("nonexistent", &serde_json::json!({}));
|
||||
assert!(o.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_still_returns_actions_unconditionally_for_back_compat() {
|
||||
// Legacy API should still behave the same — no condition walking.
|
||||
let s = fill_store();
|
||||
let actions = s.check("t");
|
||||
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));
|
||||
}
|
||||
}
|
||||
Err(e) => pg_tables.set(None),
|
||||
Err(_) => pg_tables.set(None),
|
||||
}
|
||||
pg_loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
let mut import_table = move |table: String| {
|
||||
let import_table = move |table: String| {
|
||||
let host = pg_host.read().clone();
|
||||
let db = pg_db.read().clone();
|
||||
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.
|
||||
#[serde(default)]
|
||||
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() }
|
||||
@@ -128,4 +143,139 @@ impl IndexRegistry {
|
||||
self.indexes.write().await.remove(index_name);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+66
-39
@@ -8,6 +8,13 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JobType {
|
||||
Embed,
|
||||
ProfileActivation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JobStatus {
|
||||
@@ -19,24 +26,61 @@ pub enum JobStatus {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Job {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_type: Option<JobType>,
|
||||
pub status: JobStatus,
|
||||
pub index_name: String,
|
||||
pub index_name: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub total_chunks: usize,
|
||||
/// How many chunks have been embedded so far.
|
||||
/// Also serialized as "processed" for backward-compat with monitoring scripts.
|
||||
pub embedded_chunks: usize,
|
||||
#[serde(rename = "processed")]
|
||||
pub processed_alias: usize,
|
||||
pub total: usize,
|
||||
pub progress_pct: f32,
|
||||
pub storage_key: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub started_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
pub chunks_per_sec: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
pub fn new_embed(index_name: &str, total_chunks: usize) -> Self {
|
||||
Self {
|
||||
id: format!("job-{}", chrono::Utc::now().timestamp_millis()),
|
||||
job_type: Some(JobType::Embed),
|
||||
status: JobStatus::Running,
|
||||
index_name: Some(index_name.to_string()),
|
||||
profile_id: None,
|
||||
total_chunks,
|
||||
processed_alias: 0,
|
||||
progress_pct: 0.0,
|
||||
storage_key: None,
|
||||
error: None,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
completed_at: None,
|
||||
result: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_profile_activation(profile_id: &str) -> Self {
|
||||
Self {
|
||||
id: format!("job-{}", chrono::Utc::now().timestamp_millis()),
|
||||
job_type: Some(JobType::ProfileActivation),
|
||||
status: JobStatus::Running,
|
||||
index_name: None,
|
||||
profile_id: Some(profile_id.to_string()),
|
||||
total_chunks: 0,
|
||||
processed_alias: 0,
|
||||
progress_pct: 0.0,
|
||||
storage_key: None,
|
||||
error: None,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
completed_at: None,
|
||||
result: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared progress tracker that background tasks update.
|
||||
#[derive(Clone)]
|
||||
pub struct JobTracker {
|
||||
jobs: Arc<RwLock<HashMap<String, Job>>>,
|
||||
@@ -49,57 +93,42 @@ impl JobTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new job. Returns the job ID.
|
||||
pub async fn create(&self, index_name: &str, total_chunks: usize) -> String {
|
||||
let id = format!("job-{}", chrono::Utc::now().timestamp_millis());
|
||||
let job = Job {
|
||||
id: id.clone(),
|
||||
status: JobStatus::Running,
|
||||
index_name: index_name.to_string(),
|
||||
total_chunks,
|
||||
embedded_chunks: 0,
|
||||
processed_alias: 0,
|
||||
total: total_chunks,
|
||||
progress_pct: 0.0,
|
||||
storage_key: None,
|
||||
error: None,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
completed_at: None,
|
||||
chunks_per_sec: 0.0,
|
||||
};
|
||||
pub async fn create_embed(&self, index_name: &str, total_chunks: usize) -> String {
|
||||
let job = Job::new_embed(index_name, total_chunks);
|
||||
let id = job.id.clone();
|
||||
self.jobs.write().await.insert(id.clone(), job);
|
||||
id
|
||||
}
|
||||
|
||||
/// Update progress.
|
||||
pub async fn update_progress(&self, id: &str, embedded: usize, rate: f32) {
|
||||
pub async fn create_profile_activation(&self, profile_id: &str) -> String {
|
||||
let job = Job::new_profile_activation(profile_id);
|
||||
let id = job.id.clone();
|
||||
self.jobs.write().await.insert(id.clone(), job);
|
||||
id
|
||||
}
|
||||
|
||||
pub async fn update_embed_progress(&self, id: &str, embedded: usize, _rate: f32) {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
if let Some(job) = jobs.get_mut(id) {
|
||||
job.embedded_chunks = embedded;
|
||||
job.processed_alias = embedded; // keep alias in sync
|
||||
job.processed_alias = embedded;
|
||||
job.progress_pct = if job.total_chunks > 0 {
|
||||
(embedded as f32 / job.total_chunks as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
job.chunks_per_sec = rate;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark job as completed.
|
||||
pub async fn complete(&self, id: &str, storage_key: String) {
|
||||
pub async fn complete(&self, id: &str, result: Option<serde_json::Value>) {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
if let Some(job) = jobs.get_mut(id) {
|
||||
job.status = JobStatus::Completed;
|
||||
job.embedded_chunks = job.total_chunks;
|
||||
job.processed_alias = job.total_chunks;
|
||||
job.progress_pct = 100.0;
|
||||
job.storage_key = Some(storage_key);
|
||||
job.completed_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
job.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark job as failed.
|
||||
pub async fn fail(&self, id: &str, error: String) {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
if let Some(job) = jobs.get_mut(id) {
|
||||
@@ -109,13 +138,11 @@ impl JobTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get job status.
|
||||
pub async fn get(&self, id: &str) -> Option<Job> {
|
||||
self.jobs.read().await.get(id).cloned()
|
||||
}
|
||||
|
||||
/// List all jobs.
|
||||
pub async fn list(&self) -> Vec<Job> {
|
||||
self.jobs.read().await.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ pub mod harness;
|
||||
pub mod hnsw;
|
||||
pub mod index_registry;
|
||||
pub mod jobs;
|
||||
pub mod activation;
|
||||
pub mod playbook_memory;
|
||||
pub mod pathway_memory;
|
||||
pub mod doc_drift;
|
||||
pub mod promotion;
|
||||
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 future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
|
||||
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();
|
||||
let boosts = pm.compute_boost_for_filtered_with_role(
|
||||
&[1.0, 0.0, 0.0], 100, 0.5,
|
||||
|
||||
@@ -131,6 +131,11 @@ impl PromotionRegistry {
|
||||
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.index_name = index_name.to_string();
|
||||
|
||||
@@ -140,10 +145,12 @@ impl PromotionRegistry {
|
||||
ops::put(&store, &key, json.into()).await?;
|
||||
|
||||
self.cache.write().await.insert(index_name.to_string(), file.clone());
|
||||
tracing::info!(
|
||||
"promoted '{}' to config {:?} (trial={})",
|
||||
index_name, file.current.as_ref().unwrap().config, file.current.as_ref().unwrap().trial_id,
|
||||
);
|
||||
if let Some(cur) = &file.current {
|
||||
tracing::info!(
|
||||
"promoted '{}' to config {:?} (trial={})",
|
||||
index_name, cur.config, cur.trial_id,
|
||||
);
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
|
||||
@@ -308,6 +308,8 @@ async fn try_update_index_meta(
|
||||
bucket: "primary".to_string(),
|
||||
vector_backend: shared::types::VectorBackend::Parquet,
|
||||
id_prefix: None,
|
||||
last_used: None,
|
||||
build_signature: None,
|
||||
};
|
||||
index_registry.register(meta).await
|
||||
}
|
||||
|
||||
+359
-251
@@ -7,12 +7,13 @@ use axum::{
|
||||
};
|
||||
use object_store::ObjectStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use aibridge::client::{AiClient, EmbedRequest, GenerateRequest};
|
||||
use catalogd::registry::Registry as CatalogRegistry;
|
||||
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;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -54,6 +55,11 @@ pub struct VectorState {
|
||||
/// and, when `use_playbook_memory` is set on /vectors/hybrid, boosts
|
||||
/// workers that were actually filled in semantically-similar past ops.
|
||||
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
|
||||
/// concurrent socket collisions with the Python sidecar.
|
||||
pub embed_semaphore: Arc<Semaphore>,
|
||||
@@ -77,6 +83,10 @@ pub fn router(state: VectorState) -> Router {
|
||||
.route("/indexes/{name}/bucket", axum::routing::patch(migrate_index_bucket))
|
||||
.route("/jobs", get(list_jobs))
|
||||
.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("/rag", post(rag_query))
|
||||
.route("/hybrid", post(hybrid_search))
|
||||
@@ -136,6 +146,22 @@ pub fn router(state: VectorState) -> Router {
|
||||
// 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/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))
|
||||
// Mem0 ops (J 2026-04-25): upsert/retire/revise/history.
|
||||
.route("/pathway/upsert", post(pathway_upsert))
|
||||
.route("/pathway/retire", post(pathway_retire))
|
||||
.route("/pathway/revise", post(pathway_revise))
|
||||
.route("/pathway/history/{trace_uid}", get(pathway_history))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -196,7 +222,7 @@ async fn create_index(
|
||||
let bucket = req.bucket.clone().unwrap_or_else(|| "primary".to_string());
|
||||
|
||||
// Create job and return immediately
|
||||
let job_id = state.job_tracker.create(&index_name, n_chunks).await;
|
||||
let job_id = state.job_tracker.create_embed(&index_name, n_chunks).await;
|
||||
tracing::info!("job {job_id}: indexing '{}' — {} docs → {} chunks (background)", index_name, n_docs, n_chunks);
|
||||
|
||||
// Spawn supervised dual-pipeline embedding
|
||||
@@ -236,11 +262,13 @@ async fn create_index(
|
||||
chunks_per_sec: rate,
|
||||
bucket: bucket.clone(),
|
||||
vector_backend: shared::types::VectorBackend::Parquet,
|
||||
id_prefix: None,
|
||||
id_prefix: None,
|
||||
last_used: None,
|
||||
build_signature: None,
|
||||
};
|
||||
let _ = registry.register(meta).await;
|
||||
|
||||
tracker.complete(&jid, key).await;
|
||||
tracker.complete(&jid, Some(json!({ "storage_key": key }))).await;
|
||||
tracing::info!("job {jid}: completed — {n_chunks} chunks in {elapsed:.0}s ({rate:.0}/sec)");
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -453,51 +481,6 @@ async fn copy_key(
|
||||
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_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 ---
|
||||
|
||||
async fn list_jobs(State(state): State<VectorState>) -> impl IntoResponse {
|
||||
@@ -1371,239 +1354,221 @@ async fn activate_profile(
|
||||
State(state): State<VectorState>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
tracing::info!("[activate_profile] START profile_id={}", profile_id);
|
||||
let profile = match state.catalog.get_profile(&profile_id).await {
|
||||
Some(p) => p,
|
||||
None => return Err((StatusCode::NOT_FOUND, format!("profile not found: {profile_id}"))),
|
||||
};
|
||||
|
||||
let mut warmed = Vec::new();
|
||||
let mut failures = Vec::new();
|
||||
let mut total_vectors = 0usize;
|
||||
let job_id = state.job_tracker.create_profile_activation(&profile_id).await;
|
||||
let job_id_for_response = job_id.clone();
|
||||
let tracker = state.job_tracker.clone();
|
||||
let _catalog = state.catalog.clone();
|
||||
let index_registry = state.index_registry.clone();
|
||||
let bucket_registry = state.bucket_registry.clone();
|
||||
let lance = state.lance.clone();
|
||||
let embedding_cache = state.embedding_cache.clone();
|
||||
let hnsw_store = state.hnsw_store.clone();
|
||||
let promotion_registry = state.promotion_registry.clone();
|
||||
let ai_client = state.ai_client.clone();
|
||||
let active_profile = state.active_profile.clone();
|
||||
let profile_name = profile.ollama_name.clone();
|
||||
let profile_id_clone = profile.id.clone();
|
||||
let profile_bucket = profile.bucket.clone();
|
||||
let profile_bound = profile.bound_datasets.clone();
|
||||
let profile_hnsw = profile.hnsw_config.clone();
|
||||
let profile_backend = profile.vector_backend.clone();
|
||||
let _profile_full = profile.clone();
|
||||
|
||||
// Phase 17 / C: VRAM-aware swap. If another profile currently holds
|
||||
// the GPU and uses a DIFFERENT Ollama model than the one being
|
||||
// activated, unload it first (keep_alive=0). Same-model activations
|
||||
// skip the unload — no point churning a model that's already loaded.
|
||||
let previous_slot = {
|
||||
let guard = state.active_profile.read().await;
|
||||
guard.clone()
|
||||
};
|
||||
if let Some(prev) = &previous_slot {
|
||||
if prev.ollama_name != profile.ollama_name {
|
||||
match state.ai_client.unload_model(&prev.ollama_name).await {
|
||||
Ok(_) => tracing::info!(
|
||||
"profile swap: unloaded '{}' ({} -> {})",
|
||||
prev.ollama_name, prev.profile_id, profile.id,
|
||||
),
|
||||
Err(e) => failures.push(format!(
|
||||
"unload previous model '{}': {e}", prev.ollama_name,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut warmed = Vec::new();
|
||||
let mut failures = Vec::new();
|
||||
let mut total_vectors = 0usize;
|
||||
let job_id = job_id;
|
||||
|
||||
// Federation layer 2: if this profile declares its own bucket and
|
||||
// that bucket isn't registered yet, auto-provision it under the
|
||||
// configured profile_root. This is the moment a "dormant" profile
|
||||
// becomes live — its bucket exists and is readable/writable.
|
||||
if let Some(bucket_name) = profile.bucket.clone() {
|
||||
if !state.bucket_registry.contains(&bucket_name) {
|
||||
let root = format!(
|
||||
"{}/{}",
|
||||
state.bucket_registry.profile_root().trim_end_matches('/'),
|
||||
bucket_name.replace(':', "_"),
|
||||
);
|
||||
let bc = shared::config::BucketConfig {
|
||||
name: bucket_name.clone(),
|
||||
backend: "local".to_string(),
|
||||
root: Some(root.clone()),
|
||||
bucket: None,
|
||||
region: None,
|
||||
endpoint: None,
|
||||
secret_ref: None,
|
||||
};
|
||||
match state.bucket_registry.add_bucket(bc).await {
|
||||
Ok(info) => {
|
||||
tracing::info!(
|
||||
"profile '{}' activated bucket '{}' (root={}, reachable={})",
|
||||
profile.id, bucket_name, root, info.reachable,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
failures.push(format!(
|
||||
"auto-provision bucket '{}': {}", bucket_name, e,
|
||||
));
|
||||
let previous_slot = {
|
||||
let guard = active_profile.read().await;
|
||||
guard.clone()
|
||||
};
|
||||
if let Some(prev) = &previous_slot {
|
||||
if prev.ollama_name != profile_name {
|
||||
match ai_client.unload_model(&prev.ollama_name).await {
|
||||
Ok(_) => tracing::info!(
|
||||
"profile swap: unloaded '{}' ({} -> {})",
|
||||
prev.ollama_name, prev.profile_id, profile_id_clone,
|
||||
),
|
||||
Err(e) => failures.push(format!("unload previous model '{}': {e}", prev.ollama_name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let all_indexes = state.index_registry.list(None, None).await;
|
||||
let use_lance = profile.vector_backend == shared::types::VectorBackend::Lance;
|
||||
|
||||
for binding in &profile.bound_datasets {
|
||||
let matched: Vec<_> = all_indexes
|
||||
.iter()
|
||||
.filter(|m| &m.source == binding)
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
failures.push(format!(
|
||||
"no vector index found for binding '{}'", binding,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
for meta in matched {
|
||||
if use_lance {
|
||||
// --- Lance activation path ---
|
||||
// Ensure a Lance dataset exists for this index. If it
|
||||
// doesn't, auto-migrate from the Parquet blob. Then
|
||||
// ensure an IVF_PQ index is built.
|
||||
let bucket = meta.bucket.clone();
|
||||
let lance_store = match state.lance.store_for_new(&meta.index_name, &bucket).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
failures.push(format!("{}: lance store init: {e}", meta.index_name));
|
||||
continue;
|
||||
}
|
||||
if let Some(bucket_name) = profile_bucket.clone() {
|
||||
if !bucket_registry.contains(&bucket_name) {
|
||||
let root = format!(
|
||||
"{}/{}",
|
||||
bucket_registry.profile_root().trim_end_matches('/'),
|
||||
bucket_name.replace(':', "_"),
|
||||
);
|
||||
let bc = shared::config::BucketConfig {
|
||||
name: bucket_name.clone(),
|
||||
backend: "local".to_string(),
|
||||
root: Some(root.clone()),
|
||||
bucket: None,
|
||||
region: None,
|
||||
endpoint: None,
|
||||
secret_ref: None,
|
||||
};
|
||||
let count = lance_store.count().await.unwrap_or(0);
|
||||
if count == 0 {
|
||||
// Auto-migrate from existing Parquet.
|
||||
let pq_store = match state.bucket_registry.get(&bucket) {
|
||||
match bucket_registry.add_bucket(bc).await {
|
||||
Ok(info) => {
|
||||
tracing::info!(
|
||||
"profile '{}' activated bucket '{}' (root={}, reachable={})",
|
||||
profile_id_clone, bucket_name, root, info.reachable,
|
||||
);
|
||||
}
|
||||
Err(e) => failures.push(format!("auto-provision bucket '{}': {}", bucket_name, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let all_indexes = index_registry.list(None, None).await;
|
||||
let use_lance = profile_backend == shared::types::VectorBackend::Lance;
|
||||
|
||||
for binding in &profile_bound {
|
||||
let matched: Vec<_> = all_indexes
|
||||
.iter()
|
||||
.filter(|m| &m.source == binding)
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
failures.push(format!("no vector index found for binding '{}'", binding));
|
||||
continue;
|
||||
}
|
||||
for meta in matched {
|
||||
if use_lance {
|
||||
let bucket = meta.bucket.clone();
|
||||
let lance_store = match lance.store_for_new(&meta.index_name, &bucket).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => { failures.push(format!("{}: bucket: {e}", meta.index_name)); continue; }
|
||||
Err(e) => { failures.push(format!("{}: lance store init: {e}", meta.index_name)); continue; }
|
||||
};
|
||||
match storaged::ops::get(&pq_store, &meta.storage_key).await {
|
||||
Ok(bytes) => {
|
||||
let build_t = std::time::Instant::now();
|
||||
match lance_store.migrate_from_parquet_bytes(&bytes).await {
|
||||
Ok(ms) => {
|
||||
total_vectors += ms.rows_written;
|
||||
tracing::info!(
|
||||
"lance auto-migrate '{}': {} rows in {:.2}s",
|
||||
meta.index_name, ms.rows_written, ms.duration_secs,
|
||||
);
|
||||
warmed.push(WarmedIndex {
|
||||
index_name: meta.index_name.clone(),
|
||||
source: meta.source.clone(),
|
||||
vectors: ms.rows_written,
|
||||
hnsw_build_secs: build_t.elapsed().as_secs_f32(),
|
||||
});
|
||||
let count = lance_store.count().await.unwrap_or(0);
|
||||
if count == 0 {
|
||||
let pq_store = match bucket_registry.get(&bucket) {
|
||||
Ok(s) => s,
|
||||
Err(e) => { failures.push(format!("{}: bucket: {e}", meta.index_name)); continue; }
|
||||
};
|
||||
match storaged::ops::get(&pq_store, &meta.storage_key).await {
|
||||
Ok(bytes) => {
|
||||
let build_t = std::time::Instant::now();
|
||||
match lance_store.migrate_from_parquet_bytes(&bytes).await {
|
||||
Ok(ms) => {
|
||||
total_vectors += ms.rows_written;
|
||||
tracing::info!("lance auto-migrate '{}': {} rows in {:.2}s", meta.index_name, ms.rows_written, ms.duration_secs);
|
||||
warmed.push(WarmedIndex {
|
||||
index_name: meta.index_name.clone(),
|
||||
source: meta.source.clone(),
|
||||
vectors: ms.rows_written,
|
||||
hnsw_build_secs: build_t.elapsed().as_secs_f32(),
|
||||
});
|
||||
}
|
||||
Err(e) => failures.push(format!("{}: lance migrate: {e}", meta.index_name)),
|
||||
}
|
||||
Err(e) => failures.push(format!("{}: lance migrate: {e}", meta.index_name)),
|
||||
}
|
||||
Err(e) => failures.push(format!("{}: read parquet: {e}", meta.index_name)),
|
||||
}
|
||||
Err(e) => failures.push(format!("{}: read parquet: {e}", meta.index_name)),
|
||||
}
|
||||
} else {
|
||||
total_vectors += count;
|
||||
warmed.push(WarmedIndex {
|
||||
index_name: meta.index_name.clone(),
|
||||
source: meta.source.clone(),
|
||||
vectors: count,
|
||||
hnsw_build_secs: 0.0,
|
||||
});
|
||||
}
|
||||
// Ensure IVF_PQ vector index exists.
|
||||
if !lance_store.has_vector_index().await.unwrap_or(false) {
|
||||
match lance_store.build_index(316, 8, 48).await {
|
||||
Ok(ix) => tracing::info!(
|
||||
"lance auto-index '{}': IVF_PQ built in {:.1}s",
|
||||
meta.index_name, ix.build_time_secs,
|
||||
),
|
||||
Err(e) => failures.push(format!("{}: lance IVF_PQ build: {e}", meta.index_name)),
|
||||
}
|
||||
}
|
||||
// Ensure scalar btree on doc_id for O(log N) random fetch.
|
||||
if !lance_store.has_scalar_index("doc_id").await.unwrap_or(false) {
|
||||
match lance_store.build_scalar_index("doc_id").await {
|
||||
Ok(ix) => tracing::info!(
|
||||
"lance auto-index '{}': doc_id btree built in {:.2}s",
|
||||
meta.index_name, ix.build_time_secs,
|
||||
),
|
||||
Err(e) => failures.push(format!("{}: lance doc_id btree: {e}", meta.index_name)),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --- Parquet + HNSW activation path (existing) ---
|
||||
let embeddings = match state.embedding_cache.get_or_load(&meta.index_name).await {
|
||||
Ok(arc) => arc,
|
||||
Err(e) => {
|
||||
failures.push(format!("{}: load failed: {}", meta.index_name, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
total_vectors += embeddings.len();
|
||||
|
||||
let profile_default = trial::HnswConfig {
|
||||
ef_construction: profile.hnsw_config.ef_construction,
|
||||
ef_search: profile.hnsw_config.ef_search,
|
||||
seed: profile.hnsw_config.seed,
|
||||
};
|
||||
let cfg = state
|
||||
.promotion_registry
|
||||
.config_or(&meta.index_name, profile_default)
|
||||
.await;
|
||||
let build_t = std::time::Instant::now();
|
||||
match state
|
||||
.hnsw_store
|
||||
.build_index_with_config(&meta.index_name, (*embeddings).clone(), &cfg)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
} else {
|
||||
total_vectors += count;
|
||||
warmed.push(WarmedIndex {
|
||||
index_name: meta.index_name.clone(),
|
||||
source: meta.source.clone(),
|
||||
vectors: embeddings.len(),
|
||||
hnsw_build_secs: build_t.elapsed().as_secs_f32(),
|
||||
vectors: count,
|
||||
hnsw_build_secs: 0.0,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
failures.push(format!("{}: HNSW build failed: {}", meta.index_name, e));
|
||||
if !lance_store.has_vector_index().await.unwrap_or(false) {
|
||||
match lance_store.build_index(316, 8, 48).await {
|
||||
Ok(ix) => tracing::info!("lance auto-index '{}': IVF_PQ built in {:.1}s", meta.index_name, ix.build_time_secs),
|
||||
Err(e) => failures.push(format!("{}: lance IVF_PQ build: {e}", meta.index_name)),
|
||||
}
|
||||
}
|
||||
if !lance_store.has_scalar_index("doc_id").await.unwrap_or(false) {
|
||||
match lance_store.build_scalar_index("doc_id").await {
|
||||
Ok(ix) => tracing::info!("lance auto-index '{}': doc_id btree built in {:.2}s", meta.index_name, ix.build_time_secs),
|
||||
Err(e) => failures.push(format!("{}: lance doc_id btree: {e}", meta.index_name)),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let embeddings = match embedding_cache.get_or_load(&meta.index_name).await {
|
||||
Ok(arc) => arc,
|
||||
Err(e) => { failures.push(format!("{}: load failed: {}", meta.index_name, e)); continue; }
|
||||
};
|
||||
total_vectors += embeddings.len();
|
||||
|
||||
let profile_default = trial::HnswConfig {
|
||||
ef_construction: profile_hnsw.ef_construction,
|
||||
ef_search: profile_hnsw.ef_search,
|
||||
seed: profile_hnsw.seed,
|
||||
};
|
||||
let cfg = promotion_registry
|
||||
.config_or(&meta.index_name, profile_default)
|
||||
.await;
|
||||
let build_t = std::time::Instant::now();
|
||||
match hnsw_store
|
||||
.build_index_with_config(&meta.index_name, (*embeddings).clone(), &cfg)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
warmed.push(WarmedIndex {
|
||||
index_name: meta.index_name.clone(),
|
||||
source: meta.source.clone(),
|
||||
vectors: embeddings.len(),
|
||||
hnsw_build_secs: build_t.elapsed().as_secs_f32(),
|
||||
});
|
||||
}
|
||||
Err(e) => failures.push(format!("{}: HNSW build failed: {}", meta.index_name, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preload the new profile's Ollama model proactively. Same-model
|
||||
// re-activations are cheap (Ollama no-ops if already loaded).
|
||||
let mut model_preloaded = false;
|
||||
match state.ai_client.preload_model(&profile.ollama_name).await {
|
||||
Ok(_) => {
|
||||
model_preloaded = true;
|
||||
tracing::info!("profile '{}' preloaded ollama model '{}'",
|
||||
profile.id, profile.ollama_name);
|
||||
let mut model_preloaded = false;
|
||||
match ai_client.preload_model(&profile_name).await {
|
||||
Ok(_) => {
|
||||
model_preloaded = true;
|
||||
tracing::info!("profile '{}' preloaded ollama model '{}'", profile_id_clone, profile_name);
|
||||
}
|
||||
Err(e) => failures.push(format!("preload ollama model '{}': {e}", profile_name)),
|
||||
}
|
||||
Err(e) => failures.push(format!(
|
||||
"preload ollama model '{}': {e}", profile.ollama_name,
|
||||
)),
|
||||
}
|
||||
|
||||
// Take the GPU slot.
|
||||
{
|
||||
let mut guard = state.active_profile.write().await;
|
||||
*guard = Some(ActiveProfileSlot {
|
||||
profile_id: profile.id.clone(),
|
||||
ollama_name: profile.ollama_name.clone(),
|
||||
activated_at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
{
|
||||
let mut guard = active_profile.write().await;
|
||||
*guard = Some(ActiveProfileSlot {
|
||||
profile_id: profile_id_clone.clone(),
|
||||
ollama_name: profile_name.clone(),
|
||||
activated_at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
|
||||
let report = ActivateReport {
|
||||
profile_id: profile.id,
|
||||
ollama_name: profile.ollama_name,
|
||||
indexes_warmed: warmed,
|
||||
failures,
|
||||
total_vectors,
|
||||
duration_secs: t0.elapsed().as_secs_f32(),
|
||||
model_preloaded,
|
||||
previous_profile: previous_slot.map(|s| s.profile_id),
|
||||
};
|
||||
let result = serde_json::to_value(ActivateReport {
|
||||
profile_id: profile_id_clone,
|
||||
ollama_name: profile_name,
|
||||
indexes_warmed: warmed,
|
||||
failures,
|
||||
total_vectors,
|
||||
duration_secs: t0.elapsed().as_secs_f32(),
|
||||
model_preloaded,
|
||||
previous_profile: previous_slot.map(|s| s.profile_id),
|
||||
}).ok();
|
||||
|
||||
Ok(Json(report))
|
||||
tracker.complete(&job_id, result).await;
|
||||
});
|
||||
|
||||
// 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,
|
||||
"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
|
||||
@@ -2853,6 +2818,149 @@ 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 }))
|
||||
}
|
||||
|
||||
// ─── Mem0 ops endpoints (J 2026-04-25) ───
|
||||
|
||||
async fn pathway_upsert(
|
||||
State(state): State<VectorState>,
|
||||
Json(trace): Json<pathway_memory::PathwayTrace>,
|
||||
) -> impl IntoResponse {
|
||||
match state.pathway_memory.upsert(trace).await {
|
||||
Ok(outcome) => Ok(Json(json!({"ok": true, "outcome": outcome}))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PathwayRetireRequest {
|
||||
trace_uid: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
async fn pathway_retire(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<PathwayRetireRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state.pathway_memory.retire(&req.trace_uid, &req.reason).await {
|
||||
Ok(touched) => Ok(Json(json!({"ok": true, "retired": touched}))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PathwayReviseRequest {
|
||||
parent_trace_uid: String,
|
||||
new_trace: pathway_memory::PathwayTrace,
|
||||
}
|
||||
|
||||
async fn pathway_revise(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<PathwayReviseRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state.pathway_memory.revise(&req.parent_trace_uid, req.new_trace).await {
|
||||
Ok(outcome) => Ok(Json(json!({"ok": true, "outcome": outcome}))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn pathway_history(
|
||||
State(state): State<VectorState>,
|
||||
axum::extract::Path(trace_uid): axum::extract::Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let chain = state.pathway_memory.history(&trace_uid).await;
|
||||
Json(json!({"trace_uid": trace_uid, "chain_len": chain.len(), "chain": chain}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extractor_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
/// Each embedding index is stored as: source, doc_id, chunk_idx, chunk_text, vector (binary blob).
|
||||
/// Vectors are stored as raw f32 bytes for compact storage and fast loading.
|
||||
|
||||
use arrow::array::{ArrayRef, BinaryArray, Float32Array, Int32Array, RecordBatch, StringArray};
|
||||
use arrow::array::{ArrayRef, BinaryArray, Int32Array, RecordBatch, StringArray};
|
||||
use arrow::datatypes::{DataType, Field, Schema};
|
||||
use bytes::Bytes;
|
||||
use object_store::ObjectStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ pub async fn run_supervised(
|
||||
// Update job tracker
|
||||
let elapsed = start_time.elapsed().as_secs_f32();
|
||||
let rate = if elapsed > 0.0 { done as f32 / elapsed } else { 0.0 };
|
||||
tracker.update_progress(&jid, done, rate).await;
|
||||
tracker.update_embed_progress(&jid, done, rate).await;
|
||||
|
||||
// Update checkpoint
|
||||
let mut ckpt = ckpt.write().await;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
/// JSONL on every event. See `append_log.rs` for the full rationale.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use object_store::ObjectStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Auditor Context — project preamble for fact extraction
|
||||
|
||||
This file is read by `auditor/fact_extractor.ts` and prepended to the
|
||||
extract-facts prompt sent to llm_team. The goal: give the extractor +
|
||||
verifier enough grounding to ground domain-specific facts instead of
|
||||
marking them UNVERIFIABLE by default.
|
||||
|
||||
Keep this short (< 400 words). Verifier only reads the first ~4KB of
|
||||
the prompt alongside the facts. Longer = noise, not signal.
|
||||
|
||||
Update when: a new Phase lands, a crate is added/removed, the project's
|
||||
primary domain shifts (e.g. staffing → DevOps).
|
||||
|
||||
---
|
||||
|
||||
## What Lakehouse is
|
||||
|
||||
Lakehouse is a Rust-first data platform over S3-compatible object
|
||||
storage. Primary use: a staffing company ingesting legacy CRM data for
|
||||
AI-powered worker matching, contract fulfillment, and playbook-driven
|
||||
coordination.
|
||||
|
||||
Architecture: 13 Rust crates + a Python sidecar (Ollama) + TypeScript
|
||||
sub-agents (auditor, scrum_master, bot). Runs on a single server
|
||||
(Nvidia A4000, 128GB RAM). All services on localhost: gateway :3100,
|
||||
sidecar :3200, UI :3300, MCP :3700, observer :3800, MinIO :9000.
|
||||
|
||||
## Key crates (each maps to a responsibility)
|
||||
|
||||
- **shared** — types, Arrow helpers, PII utilities, SecretsProvider
|
||||
- **proto** — gRPC definitions
|
||||
- **storaged** — S3/MinIO I/O, AppendLog, ErrorJournal
|
||||
- **catalogd** — metadata authority (manifests, views, tombstones)
|
||||
- **queryd** — DataFusion SQL, MemTable cache, compaction
|
||||
- **ingestd** — CSV/JSON/PDF/Postgres/MySQL ingest
|
||||
- **vectord** — embeddings, HNSW index, **playbook_memory meta-index** (Phase 19+)
|
||||
- **vectord-lance** — Lance 4.0 firewall crate (separate Arrow version)
|
||||
- **journald** — append-only mutation event log
|
||||
- **aibridge** — Rust↔Python sidecar bridge, context budget + continuation
|
||||
- **gateway** — Axum HTTP :3100 + gRPC :3101 (Phase 38+ adds /v1/chat)
|
||||
- **ui** — Dioxus WASM (stale, pre-Phase-9)
|
||||
- **lance-bench** — standalone benchmark
|
||||
|
||||
## Current architectural direction (Phase 38-44)
|
||||
|
||||
Universal AI Control Plane: a `/v1/chat` OpenAI-compatible API that
|
||||
routes all LLM traffic through one layer for token accounting + provider
|
||||
fallback. Truth Layer + Validation Pipeline enforce staffing-domain
|
||||
invariants (worker eligibility, PII, contract rules). The Auditor
|
||||
(Phase A of cohesion plan) hard-blocks PR merges on placeholder code.
|
||||
|
||||
## Auditor sub-agent role
|
||||
|
||||
`auditor/` (TypeScript, Bun runtime) polls Gitea every 90s for open PRs.
|
||||
For each fresh head SHA it runs 4 checks in parallel: static (grep-style
|
||||
placeholder detection), dynamic (runs the hybrid fixture), inference
|
||||
(gpt-oss:120b cloud review with N=3 consensus + qwen3-coder:480b
|
||||
tie-breaker), and kb_query (reads `data/_kb/*.jsonl` for prior evidence).
|
||||
Verdicts post to Gitea as commit status + review comment. Findings
|
||||
append to `data/_kb/audit_lessons.jsonl` (path-agnostic signatures for
|
||||
dedup). Curated scratchpads from tree-split get routed through this
|
||||
extract-facts pipeline to populate `audit_facts.jsonl` — which is what
|
||||
you (the extractor) are currently producing.
|
||||
|
||||
## Things that are NOT the auditor
|
||||
|
||||
- The LLM Team UI at `/root/llm_team_ui.py` (devop.live:5000) — a separate product for human-facing multi-model experimentation
|
||||
- The scrum_master pipeline at `tests/real-world/scrum_master_pipeline.ts` — reviews files, not claims
|
||||
- The bot at `bot/` — will apply fixes, doesn't audit
|
||||
@@ -99,3 +99,8 @@
|
||||
**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).
|
||||
**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.
|
||||
|
||||
@@ -324,6 +324,31 @@
|
||||
- Mem0-style upsert: `/seed` with `append=true` (default) routes through `upsert_entry`, which decides ADD / UPDATE / NOOP on (operation, day, city, state). Same-day re-seed merges names (union, stable order) instead of duplicating the row. Identical re-seed is a no-op. Different-day same-operation is a fresh ADD. Playbook_id stays stable on UPDATE so prior citations remain valid.
|
||||
- Letta-style hot cache: `PlaybookMemory` now holds a `geo_index: HashMap<(city_lower, state_upper), Vec<entry_idx>>` rebuilt on every mutation. Geo-filtered boost queries skip the full scan and hit the O(1) key lookup. At 1.9K entries the full scan was sub-ms; the index scales the same path to 100K+ without code changes.
|
||||
- `UpsertOutcome` enum reported back to callers — `{mode: added|updated|noop, playbook_id, merged_names?}` + `entries_after`.
|
||||
- [x] **Phase 37: Hot-swap async** (2026-04-22)
|
||||
- Extended `JobTracker` with `JobType::ProfileActivation` + `Embed` enum variants
|
||||
- Made `activate_profile` return immediately with `job_id`, work runs in background via `tokio::spawn`
|
||||
- Background jobs tracked via `POST /vectors/jobs/{id}` + `GET /vectors/jobs`
|
||||
- [x] **Phase 38: Universal API Skeleton** (2026-04-23)
|
||||
- `/v1/chat` — OpenAI-compatible POST, forwards to local Ollama or Ollama Cloud
|
||||
- `/v1/usage` — returns `{requests, prompt_tokens, completion_tokens, total_tokens}`
|
||||
- `/v1/sessions` — returns `{data: [], note: "Phase 38: stateless"}`
|
||||
- Langfuse trace integration (fire-and-forget, Phase 40 early)
|
||||
- 12 unit tests green, curl gates pass
|
||||
- [x] **Phase 39: Provider Adapter Refactor** (2026-04-23)
|
||||
- `ProviderAdapter` trait with `chat()` + `embed()` + `unload()` + `health()`
|
||||
- `OllamaAdapter` — wraps existing AiClient
|
||||
- `OpenRouterAdapter` — HTTP client to openrouter.ai
|
||||
- `provider_key()` routing by model prefix (openrouter/* → OpenRouter)
|
||||
- [x] **Phase 40: Routing & Policy Engine** (2026-04-23)
|
||||
- `RoutingEngine` with `RouteDecision` in aibridge::routing
|
||||
- `config/routing.toml` — rules by model_pattern, fallback chain, cost gating
|
||||
- Per-provider usage tracking: Usage.by_provider
|
||||
- 12 gateway tests green, curl gates pass
|
||||
- [x] **Phase 41: Profile System Expansion** (2026-04-23)
|
||||
- `ProfileType` enum: Execution, Retrieval, Memory, Observer
|
||||
- Per-type endpoints: `/profiles/retrieval`, `/profiles/memory`, `/profiles/observer`
|
||||
- `profile_type` field on ModelProfile
|
||||
- Guard fix: automated scrumaudit.py finds real issues
|
||||
- [ ] Fine-tuned domain models (Phase 25+)
|
||||
- [ ] Multi-node query distribution (only if ceilings bite)
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Scrum Fix Wave — Phase-Sweep 2026-04-23
|
||||
|
||||
**Purpose:** Direct the scrum-master pipeline at concrete findings from the Phase 0→42 audit sweep, not at the high-level vision alone. Findings live in `data/_kb/phase_sweep_findings.jsonl` (19 items).
|
||||
|
||||
## What the auditor expects you to produce per file
|
||||
|
||||
For each file the scrum sees: concrete code-level suggestions that close the listed findings. Not rewrites for style. Not vision drift. Land the invariant or admit the checkbox was premature.
|
||||
|
||||
## Meta-pattern to fix (read this first)
|
||||
|
||||
The sweep surfaced **one root cause repeated across 5 phases**: primitives exist, cross-cutting enforcement doesn't. Auth, journal, access control, truth rules — the machinery is built, nothing calls it from the actual request path.
|
||||
|
||||
One PR-cluster retires the pattern:
|
||||
1. **Identity.** Auth middleware wires X-API-Key → extension `AgentIdentity { name, role, api_key_hash }`. (P5-001)
|
||||
2. **Request pipeline.** `/query/sql` and `/tools/*/call` read `AgentIdentity`, pass into queryd + tools handlers.
|
||||
3. **Access enforcement.** Handlers call `access.can_access()` / `masked_columns()` before returning data; log via `access.log_query()`. (P13-001)
|
||||
4. **Mutation journaling.** Every ingest / delta-write / tombstone-add / catalog-register calls the corresponding `journal.record_*`. (P9-001)
|
||||
5. **Truth enforcement.** `TruthStore::check()` rewritten to `evaluate(task_class, ctx) -> Vec<RuleOutcome>`, actually walking `RuleCondition` against context. (P42-001, P42-002)
|
||||
|
||||
After this cluster lands, Phases 5, 9, 13, 42 become "truly shipped" rather than "machinery shipped."
|
||||
|
||||
## Findings by severity
|
||||
|
||||
### 🔴 High
|
||||
|
||||
- **P9-001** `journald/src/journal.rs`, `crates/gateway/src/main.rs`, all mutation sites. Journal has zero internal callers. Every `ingestd::service::upload_file` success, every `queryd::delta::write_delta`, every `catalogd::tombstones::add`, every `catalogd::registry::register` should emit a journal event. Plus fix: `event_counter` resets on process restart — seed from max existing `event_id` on rebuild or switch to UUID v7.
|
||||
- **P13-001** `crates/gateway/src/main.rs` + `crates/queryd/src/service.rs` + `crates/gateway/src/tools/service.rs`. `AccessControl.can_access` / `masked_columns` / `log_query` have zero callers. Query path ignores role. Phase 17's `profile_scoped_search` (service.rs:1641) is the template — copy that shape.
|
||||
- **P42-001** `crates/truth/src/lib.rs:56`. `TruthStore::check(task_class)` ignores `RuleCondition` entirely. Signature needs `evaluate(task_class, ctx: &serde_json::Value) -> Vec<RuleOutcome>` that actually walks conditions. Update all 14 tests to exercise fail/pass semantics, not just storage.
|
||||
|
||||
### 🟡 Medium
|
||||
|
||||
- **P5-001** `crates/gateway/src/auth.rs` + `crates/gateway/src/main.rs:222-233`. `api_key_auth` is `#[allow(dead_code)]`. Wire with `axum::middleware::from_fn_with_state(api_key, auth::api_key_auth)` on protected routes. `/health` stays public.
|
||||
- **P10-001** Legacy datasets (including `candidates`, 2.47M rows) have no PII flags. Add `POST /catalog/resync-metadata` mirroring `/catalog/resync-missing`.
|
||||
- **P14-001** `crates/ingestd/src/schema_evolution.rs`. Module has 5 passing tests and zero callers. Add `POST /catalog/datasets/by-name/{name}/schema-diff`. When ADR-020 `register()` returns 409, include a `migration_rules[]` body.
|
||||
- **P20-001** `config/models.json` is spec-only — never loaded by Rust. Load into `shared::model_matrix::ModelMatrix` at startup; delegate `aibridge::context::context_window_for` to matrix.
|
||||
- **P21-001** `generate_continuable` has one prod caller (`rag.rs:171`). Audit every `ai_client.generate()` site. Convert the truncation-prone + thinking-empty-prone sites (auditor paths, reranker, autotune) to `generate_continuable`.
|
||||
- **P39-001** `ProviderAdapter` trait + adapters ship, zero callers. `/v1/chat` in `v1/mod.rs:152` uses hardcoded `match req.provider`. Replace with adapter dispatch.
|
||||
- **P40-001** `config/routing.toml` is spec-only. `RoutingEngine::new` has no callers. Add `RoutingEngine::from_toml(path)`; `V1State` carries `routing: Arc<RoutingEngine>`; `/v1/chat` consults it before provider match.
|
||||
- **P42-002** Truth has no enforcement site. `/v1/chat` or execution_loop should call `truth.evaluate(task_class, ctx)` post-response.
|
||||
|
||||
### 🟢 Low
|
||||
|
||||
- **P1-001** `crates/storaged/src/federation_service.rs:34-35`. Bucket-qualified routes wire only PUT + GET. Add DELETE + LIST on `/buckets/{bucket}/objects/{*key}`.
|
||||
- **P4-001** UI deploy stale. Either rebuild (`just ui-build` + restart `lakehouse-ui.service`) or amend `PHASES.md` to note pre-Phase-9 drift.
|
||||
- **P7-001** `vectord::index_registry`. Orphan index registrations (parquet deleted) still list in `/vectors/indexes`. Add a startup sweep + `POST /vectors/resync-missing`.
|
||||
- **P12-001** `crates/gateway/src/tools/service.rs`. Audit row has `row_count=null`. Propagate `QueryResponse.row_count` + add `latency_ms`.
|
||||
- **P20-002** `crates/gateway/src/v1/mod.rs`. No model-prefix auto-routing. Caller must set `provider` explicitly. Tie to P39-001 + P40-001 fix.
|
||||
- **P21-002** `crates/aibridge/src/context.rs`. `context_window_for` hardcoded HashMap duplicates `config/models.json`. Delegate once P20-001 lands.
|
||||
- **P38-001** `crates/gateway/src/execution_loop/mod.rs:1523`. Test `executor_prompt_includes_surfaced_candidates` fails on "W-1 Alice Smith" assertion. Either update prompt formatter or update test.
|
||||
- **P40-002** Cost gating absent. Add `cost_ceiling_usd_per_hour` to `RoutingEngine` rule, pre-request check against `Usage.by_provider`.
|
||||
|
||||
## What "done" looks like for each file the scrum touches
|
||||
|
||||
- Name the specific finding(s) the file participates in.
|
||||
- Show code-level diff (minimum: function signature + first 5 lines of body) for the fix.
|
||||
- Call out any test that needs updating + one new test that would catch the bug on reintroduction.
|
||||
- Flag if the fix is too big for one PR and should be split (most of the cross-cutting cluster wants a shared identity/middleware PR first, per-service PRs after).
|
||||
|
||||
## Out of scope for this wave
|
||||
|
||||
- New features beyond what the findings describe.
|
||||
- UI work (Phase 4 stale is known).
|
||||
- DevOps / long-horizon domain work (Terraform/Ansible — Phase 43+).
|
||||
@@ -0,0 +1,198 @@
|
||||
# Scrum Master PR Loop — Forensic Validation Prompt (iter 2+)
|
||||
|
||||
Adopted 2026-04-23 from J. Replaces the default scrum prompt starting iter 2. Iter 1 used the softer "fix-wave" framing; iter 2 onward uses this adversarial one.
|
||||
|
||||
---
|
||||
|
||||
You are acting as an adversarial **Scrum Master + Systems Auditor**.
|
||||
|
||||
Your job is to **prove whether this system actually works**, not to describe it.
|
||||
|
||||
You are auditing a system with the following architecture:
|
||||
|
||||
- AI Gateway with per-model adapters
|
||||
- Output normalization + schema validation layer
|
||||
- Execution pipeline (Terraform / Ansible / shell)
|
||||
- Task-scoped execution memory (S3 + Apache Arrow/Parquet)
|
||||
- Relevance orchestration (context filtering, freshness validation, fact extraction)
|
||||
- Local → Cloud fallback loop for failed tasks
|
||||
- Iterative repair loop with stored execution evidence
|
||||
|
||||
---
|
||||
|
||||
## PRIMARY OBJECTIVE
|
||||
|
||||
Determine if the system is:
|
||||
|
||||
1. Executable (real, not pseudocode)
|
||||
2. Aligned with PRD contracts
|
||||
3. Deterministic enough to trust
|
||||
4. Protected from model output drift
|
||||
5. Actually closing the loop (fail → repair → reuse)
|
||||
|
||||
---
|
||||
|
||||
## NON-NEGOTIABLE RULES
|
||||
|
||||
- Do NOT summarize
|
||||
- Do NOT explain architecture unless tied to failure
|
||||
- Do NOT assume code works — verify
|
||||
- Every claim MUST reference files, functions, or execution evidence
|
||||
- If something is unclear → mark as FAIL
|
||||
|
||||
---
|
||||
|
||||
## AUDIT PASSES (RUN ALL)
|
||||
|
||||
### 1. PSEUDOCODE / FAKE IMPLEMENTATION DETECTION
|
||||
Find any:
|
||||
- TODO / stub / placeholder
|
||||
- hardcoded outputs where AI should decide
|
||||
- mocked execution paths
|
||||
- fake success returns
|
||||
|
||||
Output exact file + line references.
|
||||
|
||||
### 2. PRD CONTRACT VALIDATION
|
||||
Verify implementation exists for:
|
||||
|
||||
- Gateway routing logic
|
||||
- Per-model adapters
|
||||
- Output normalization (strip, parse, canonicalize)
|
||||
- Schema validation layer
|
||||
- Repair loop (retry with modification)
|
||||
- Raw output storage
|
||||
- Execution memory persistence
|
||||
- Retrieval based on prior failures
|
||||
- Relevance filtering (freshness / protocol awareness)
|
||||
- Execution permission gate
|
||||
|
||||
For each component:
|
||||
- status: implemented | partial | missing
|
||||
- include file references
|
||||
|
||||
### 3. NORMALIZATION + VALIDATION PIPELINE
|
||||
Prove that:
|
||||
|
||||
- Raw model output is NEVER executed directly
|
||||
- JSON extraction is enforced
|
||||
- Unknown fields are rejected or handled
|
||||
- Schema validation blocks bad output
|
||||
- Repair loop triggers on failure
|
||||
|
||||
If any path bypasses validation → FAIL
|
||||
|
||||
### 4. FAILURE → CLOUD → REPAIR LOOP
|
||||
Trace the loop:
|
||||
|
||||
- Local model fails
|
||||
- Failure is classified
|
||||
- Context is packaged
|
||||
- Cloud model returns corrective instruction
|
||||
- Local model retries
|
||||
- Result is validated
|
||||
- Successful pattern is stored
|
||||
|
||||
If any step is missing or non-deterministic → FAIL
|
||||
|
||||
### 5. EXECUTION MEMORY (S3 / ARROW)
|
||||
Verify:
|
||||
|
||||
- Raw runs are stored (input, raw output, normalized output)
|
||||
- Failures are recorded with signatures
|
||||
- Successful retries are recorded
|
||||
- Retrieval pulls based on:
|
||||
- task similarity
|
||||
- failure signature
|
||||
- execution success history
|
||||
|
||||
If memory is only logs and not reused → FAIL
|
||||
|
||||
### 6. RELEVANCE ORCHESTRATION
|
||||
Verify:
|
||||
|
||||
- Context is filtered before model input
|
||||
- Freshness or version awareness exists
|
||||
- Fact extraction reduces noise
|
||||
- Context inclusion is explainable
|
||||
|
||||
If system blindly injects context → FAIL
|
||||
|
||||
### 7. EXECUTION SAFETY
|
||||
Verify:
|
||||
|
||||
- No shell / terraform / ansible execution without validation gate
|
||||
- No direct model-to-command execution
|
||||
- Clear permission boundary exists
|
||||
|
||||
If AI can execute commands unchecked → CRITICAL FAIL
|
||||
|
||||
### 8. TESTING + EVIDENCE
|
||||
Find:
|
||||
|
||||
- real tests (not mocks)
|
||||
- execution logs
|
||||
- validation results
|
||||
- success/failure traces
|
||||
|
||||
If no proof of execution → FAIL
|
||||
|
||||
---
|
||||
|
||||
## OUTPUT FORMAT (STRICT)
|
||||
|
||||
Each finding in any array MUST include a `confidence` field (integer 0–100). The confidence represents your self-assessed probability that the finding is correct and actionable. Low confidence is valuable — do not inflate. A finding with confidence < 50 is still recorded (it signals investigation needed) but downstream consumers will weight it less.
|
||||
|
||||
```json
|
||||
{
|
||||
"verdict": "pass | fail | needs_patch",
|
||||
"critical_failures": [
|
||||
{"id": "CF-1", "file": "path:line", "description": "...", "confidence": 95}
|
||||
],
|
||||
"pseudocode_flags": [
|
||||
{"file": "path:line", "reason": "...", "confidence": 88}
|
||||
],
|
||||
"prd_mismatches": [
|
||||
{"component": "...", "status": "partial|missing", "file_ref": "...", "confidence": 80}
|
||||
],
|
||||
"broken_pipelines": [
|
||||
{"pipeline": "...", "break_point": "...", "confidence": 70}
|
||||
],
|
||||
"missing_components": [
|
||||
{"component": "...", "required_by": "PRD section X", "confidence": 85}
|
||||
],
|
||||
"risk_points": [
|
||||
{"area": "...", "risk": "...", "confidence": 60}
|
||||
],
|
||||
"verified_components": [
|
||||
{"component": "...", "evidence": "file:line or test name", "confidence": 95}
|
||||
],
|
||||
"evidence": {
|
||||
"files_inspected": [],
|
||||
"execution_paths_traced": [],
|
||||
"tests_found": [],
|
||||
"tests_missing": []
|
||||
},
|
||||
"required_next_actions": [
|
||||
{"action": "...", "file_hint": "...", "confidence": 75}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Calibration guide:**
|
||||
- 90–100: pattern seen repeatedly in shipped code; mechanical; low regression risk
|
||||
- 70–89: confident in direction, API shape or naming may vary
|
||||
- 50–69: plausible fix but may not match conventions, could cascade
|
||||
- <50: genuinely uncertain — record anyway so downstream knows to investigate
|
||||
|
||||
---
|
||||
|
||||
## FINAL DIRECTIVE
|
||||
|
||||
You are not reviewing code.
|
||||
|
||||
You are answering:
|
||||
|
||||
> "Can this system be trusted to execute real-world DevOps tasks without hallucinating, bypassing validation, or collapsing under edge cases?"
|
||||
|
||||
If the answer is not provably yes, the verdict is FAIL.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Scrum Loop Notes — Observations across iterations
|
||||
|
||||
Running notes from the 6x scrum loop (started 2026-04-23). One section per iteration. "Fix next loop" items accumulate here so the next scrum run picks them up — do not fix inline during a running iteration.
|
||||
|
||||
## Iteration tracker
|
||||
|
||||
| Iter | Status | Scrum started | Scrum finished | Fixes applied | Build green | Re-sweep findings |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 🟡 scrum running | 2026-04-23 (brqz3jxgo) | - | - | - | - (baseline = 19) |
|
||||
| 2 | 🟡 scrum running | 2026-04-23 (bzs6miehr) | - | - | - | pending |
|
||||
| 3 | ⬜ queued | - | - | - | - | - |
|
||||
| 4 | ⬜ queued | - | - | - | - | - |
|
||||
| 5 | ⬜ queued | - | - | - | - | - |
|
||||
| 6 | ⬜ queued | - | - | - | - | - |
|
||||
|
||||
## Iteration 1 — in flight
|
||||
|
||||
**Target files:** 21 source files extracted from the 19 Phase 0→42 findings.
|
||||
**Ladder:** cloud-first per feedback_scrum_cloud_first.md (gpt-oss:120b → qwen3.5:397b → devstral-2:123b → mistral-large-3:675b → gpt-oss:20b → qwen3.5:latest).
|
||||
**Proposal:** `docs/SCRUM_FIX_WAVE.md` (via LH_SCRUM_PROPOSAL env).
|
||||
|
||||
### Fix next loop — observations accumulating
|
||||
|
||||
*Add items here as the scrum runs. Keep each item to one line with a pointer to file + reason. Don't fix inline.*
|
||||
|
||||
**[ITER 2 OBSERVATIONS]**
|
||||
- **[FORENSIC vs thin-detector mismatch]** iter 2 first attempt on auth.rs triggered "thin/unstructured" rejection at 2031 chars. Cause: forensic prompt asks for strict JSON verdict output, scrum's thin-answer detector expects markdown with score + table. The detector logic needs a forensic-aware branch OR the forensic prompt should preserve markdown output shape while still applying the 8 audit passes. File: `tests/real-world/scrum_master_pipeline.ts`, function that scores accepted vs thin. Fix next loop: add `isForensicAcceptable(text)` that checks for `"verdict"` field + at least one of `critical_failures`/`pseudocode_flags`/`required_next_actions`.
|
||||
|
||||
- **[OBSERVATION metric]** 11 `#[allow(dead_code)]` markers cluster in crates/gateway/{auth,access,tools/registry,execution_loop,v1/truth} + crates/aibridge/providers/openrouter + crates/vectord/service. Each one maps cleanly to an audit finding. The `execution_loop/mod.rs:85` comment even admits it: `// reserved for Phase 42 truth-gate (step 6)`. **Metric:** fewer `#[allow(dead_code)]` markers per iteration = less pseudo-real code. Baseline = 11. Target after iter 6: ≤ 2 (only ones that are genuinely optional helpers).
|
||||
- **[OBSERVATION gateway-as-router]** scrum_master_pipeline currently fetches `GATEWAY/v1/chat` directly but its LADDER is still a hardcoded const. Should be driven by `config/routing.toml` via RoutingEngine (blocked by P40-001 until iter 1 lands fix). File: `tests/real-world/scrum_master_pipeline.ts:53`.
|
||||
- **[OBSERVATION file-type]** iter 1 target list is `.rs` only. Iter 2 must include `tests/multi-agent/*.ts` (executor, observer, kb consumer), `auditor/checks/*.ts`, `sidecar/sidecar/*.py`, and `config/*.{json,toml}`. The scrum pipeline handles any text file.
|
||||
- **[OBSERVATION triangulation]** auth.rs scrum review (first file out) independently identified P5-001 exactly: flagged `#[allow(dead_code)]`, scored alignment 4/10, prescribed an `AgentIdentity { name, role, hashed_key }` type matching SCRUM_FIX_WAVE. Audit + scrum converged without seeing each other's output — strong signal the findings are real, not artifacts of one method.
|
||||
- **[RULE from J 2026-04-23]** Wiring-gap fixes happen AFTER the scrum completes, not inline. Accumulate observations, apply in one coherent pass. Matches feedback_audit_findings_log.md.
|
||||
- **[OBSERVATION oversize-file]** `crates/gateway/src/execution_loop/mod.rs` is 80,901 chars → 24 shards (scrum pipeline's tree-split kicks in at 6KB threshold). A single-file-of-this-size for an execution module is itself a smell — it's the Phase 43 scaffold we kept piling into. Split candidates: executor prompts, reviewer prompts, budget accounting, truth-gate hook, fixtures. Not a fix for this iter, but queue for iter 3.
|
||||
- **[OBSERVATION cost-tracking]** zero escalations across first 8 files — 0.0 dollar cloud spend above the minimum. Per-request cost on gpt-oss:120b via Ollama Cloud is effectively $0 in this environment (self-hosted or flat-rate per the llm_team_config key). If we add per-iter token totals to scrum_loop_metrics.jsonl we can show trajectory even when cost is flat.
|
||||
|
||||
**[ITER 3 OBSERVATIONS]**
|
||||
- **[LARGE-HANDLER thin]** kimi-k2:1t went thin on `crates/gateway/src/tools/service.rs` (~11KB, single large axum handler). deepseek-v3.1:671b rescued on attempt 2 (92.8s, 5408 chars, accepted). Pattern: very large routing files challenge even 1T models. Fix next loop: raise tree-split threshold for handler files OR shard by function boundaries not byte count.
|
||||
- **[WRITE-ONLY INDICATORS STILL]** 8 KB files write-only after iter 3: `audits.jsonl` (189 rows/1.9MB — biggest waste), `phase_sweep_findings.jsonl` (35), `distilled_facts.jsonl` (17), `human_overrides.jsonl` (8), `classifications.jsonl` (5), `scrum_loop_metrics.jsonl` (2), `distilled_config_hints.jsonl` (2), `distilled_procedures.jsonl` (2). Fix next loop: extend `auditor/checks/kb_query.ts` to surface these on PR review, OR build a single "KB health dashboard" reader.
|
||||
- **[ISOLATED AUTOTUNE]** `crates/vectord/src/agent.rs` has zero refs to scrum/audit/human_override KB. It tunes HNSW but doesn't know which indexes are attached to files the scrum flagged. Fix next loop: add `TriggerEvent::CodeReviewFlag { index_name, gradient_tier }` that biases trial budget toward indexes of flagged files.
|
||||
- **[CONFIDENCE WELL-CALIBRATED]** kimi-k2:1t confidences span 75-98 across iter3 files, cluster 85-95. No 100% inflation; min 75 = honest edge-case uncertainty. Good signal — the model is calibrating, not performance-signaling. Do NOT "fix" this by prompt-boosting confidence.
|
||||
- **[SCRUM→OBSERVER WIRED]** 2026-04-24 fix landed in iter-3 source but applies to iter 4+ (bun loaded code pre-edit). Verify next loop: `curl :3800/stats` should show `by_source.scrum > 0` after iter 4 runs. If zero, observer /event payload schema mismatch.
|
||||
- **[LOW-CONFIDENCE BLOCK flag WORKING]** `crates/storaged/src/federation_service.rs` scored 3/10 with confidences **[40, 35, 50]** → avg 42, min 35. Permission Gradient correctly classifies this file as `block` tier (min <50) — exactly the signal we want. kimi-k2:1t is honestly saying "I'm not confident about these suggestions." Human must review before any auto-apply. Validates Layer #6 working end-to-end. **DO NOT fix this** — it's the system protecting us.
|
||||
- **[CONFIDENCE-COLUMN DROPOUT]** `crates/gateway/src/main.rs` accepted review has 0 confidences extracted despite the prompt requiring them. Either kimi-k2:1t omitted the Confidence column in its table format (regex didn't match), or the specific content ran into token budget and confidence got trimmed. Fix next loop: strengthen the prompt instruction to say "if you omit the Confidence column you MUST provide Confidence: NN% inline after every suggestion" AND post-accept sanity-check the row (reject if findings_count=0 across 5+ suggestions).
|
||||
- **[SCORE-PARSER MISS on some files]** `crates/queryd/src/service.rs` and `crates/queryd/src/delta.rs` reviews return score='?' — regex `(\d)\s*/\s*10` isn't matching. Models may have written score as "Score: 4.5/10" or "4/10 (mid)" which the parser can't see. Fix next loop: regex accepts decimals + surrounding context. Not critical — confidences still landed.
|
||||
- **[SCORE DISTRIBUTION]** across 12 iter-3 files: 3/10 (7 files), 4/10 (2), 6/10 (1), ?/? (2). vectord/index_registry.rs alone scored 6/10 — meaningfully higher. That's worth a closer look: what does that file do right? If we find the pattern there and replicate to peers, scores should rise. This is learning-loop signal.
|
||||
|
||||
- **[FORENSIC JSON OUTPUT IS RICHER THAN MARKDOWN]** kimi-k2:1t emitted strict JSON-schema output (matching SCRUM_FORENSIC_PROMPT.md) for `vectord/src/index_registry.rs`. Structure separates critical_failures / pseudocode_flags / prd_mismatches / broken_pipelines / missing_components / risk_points / **verified_components** / required_next_actions — each with confidence per entry. Markdown reviews never captured `verified_components` (what's confirmed working). Fix next loop: scrum pipeline detects JSON format in accepted output and extracts structured fields into KB row (`verified_components_count`, `critical_failures_count`, `missing_components_count`, `verdict`). Downstream consumers then filter PR files by `verdict: needs_patch` or prioritize by `missing_components_count`. **This is the biggest next-iter KB quality jump available** — goes from "confidence as a scalar" to "confidence per specific claim with evidence field." Verified_components in particular is the PROOF-OF-LIFE signal that tells us what's real — flipside of P9/P13/P42 gaps.
|
||||
|
||||
- **[🔴 TREE-SPLIT REVIEW SHARD-LEAKAGE]** `ingestd/src/service.rs` (7 shards, 24.3KB) accepted output is titled "Forensic Audit Report – ... (shard 3)". The review covers only the Postgres-import path (shard 3). The `ingest_file` handler where my P9-001 fix lives (journal.record_ingest call) is in shard 1 or 2 — that reviewer never saw the fix. **`tree_split_fired: true` is supposed to mean the output is the reducer-merged summary of all shards**, but this review retained shard-specific scope. Either (a) the reduce step didn't integrate shard summaries, (b) the accepted attempt was one individual shard response that slipped past the reducer, or (c) the reducer prompt doesn't instruct the model to present the file holistically. **This is a real correctness bug** — it means file-level findings can be ghost-negative (fix applied, reviewer blind to it) and ghost-positive (gap exists in unreviewed shard, reviewer gives clean bill). Fix next loop: examine the reduce-step prompt in tree-split path, ensure accepted output comes from reduce step not from any individual shard. Validate by running on a file with a known fix and confirming the review notices it.
|
||||
|
||||
**[ITER 4 OBSERVATIONS]**
|
||||
- **[🔴 OBSERVER DROPS SCRUM RICH FIELDS]** Scrum→observer wiring works (by_source.scrum appears in /stats on iter 4 file 1). BUT observer.ts:262-283 `ObservedOp = {...}` literal only spreads known keys (endpoint, success, duration_ms, role, city, state, count, rescue_*). My scrum-specific fields (confidence_avg, confidence_min, gradient_tier, verdict, critical_failures_count, verified_components_count, missing_components_count, alignment_score, output_format, findings_count, attempts_made, thin_rejections) are silently discarded. Observer knows the scrum event happened but loses review-quality data. Fix next loop: add `metadata?: Record<string, any>` passthrough on ObservedOp, or declare scrum-specific fields explicitly. Preferred: metadata passthrough so future sources (auditor, kb_extractor) land the same way.
|
||||
- **[SCHEMA V4 LANDING CORRECTLY]** main.rs iter-4 KB row has alignment_score=3 (decimal parser fixed), output_format="markdown" (classifier works), verdict=null (correct — only forensic_json produces verdict), confidence_avg=91 (previous iter got 0 due to column dropout — run-to-run variance self-healed this). Structured counters (critical/verified/missing) = 0 on markdown rows, populated on forensic_json rows.
|
||||
- **[RING BUFFER EVICTING LANGFUSE]** observer ring hit 2000 cap; first 2 scrum events pushed 2 langfuse entries out (1999 → 1997). Not a bug — ring works as designed — but means old-context retention is bounded. If we care about historical Langfuse traces we need a larger ring OR a separate per-source ring.
|
||||
- **[UI Playwright probe found 2 real bugs]** (fixed 2026-04-24): (a) ui/server.ts tryFetch relied on content-type header to decide JSON vs text; observer Bun.serve returns JSON without `application/json` content-type, so stats were strings — UI showed "0 ops" instead of 2000. Fixed: always attempt JSON.parse, fall back to raw text. (b) ui.js renderNodeContext used Object.entries(n.health) which iterates characters on a string — gateway /health returns "lakehouse ok" and the panel showed rows like `0=l, 1=a, 2=k, ...`. Fixed: primitive-vs-object guard. **Both were invisible in functional tests — only a real browser render exposed them.** Worth adding a Playwright smoke test to CI for any future UI changes.
|
||||
|
||||
### Iter 1 results
|
||||
|
||||
*Populated after scrum finishes.*
|
||||
|
||||
## Iteration 2 — queued
|
||||
|
||||
**Prompt shape change (from J 2026-04-23):** iter 2+ uses `docs/SCRUM_FORENSIC_PROMPT.md` as the system prompt, replacing the softer iter-1 framing. Adversarial auditor tone with 8 audit passes. Strict JSON output format with `verdict: pass|fail|needs_patch`. If system can't prove itself, verdict is FAIL.
|
||||
|
||||
**Scrum pipeline change:** `scrum_master_pipeline.ts` needs an env `LH_SCRUM_SYSTEM_PROMPT` (new) to inject the forensic frame alongside the proposal doc. The file-level loop still asks for suggestions per file but under the 8-pass adversarial lens.
|
||||
|
||||
**Goal:** Self-host. Pipeline loads its ladder from `config/routing.toml` via the RoutingEngine that iter 1 wired. If that still isn't loaded, note gap, proceed with hardcoded ladder, flag for iter 3.
|
||||
**Target expansion:** beyond `.rs` to `.ts` (tests/multi-agent, auditor/), `.py` (sidecar), `.md` (docs).
|
||||
|
||||
## Iterations 3-6 — queued
|
||||
|
||||
**Goal:** measure trajectory. Each iteration reduces finding count, raises unit test count, reduces grep-for-fake-markers count. If any iteration doesn't improve, that's the data point.
|
||||
|
||||
## Metrics per iteration
|
||||
|
||||
Capture after each re-sweep:
|
||||
|
||||
- `findings_total` (baseline: 19)
|
||||
- `findings_by_severity` (baseline: 3h / 8m / 8l)
|
||||
- `phases_partial_count` (baseline: 9)
|
||||
- `phases_real_count` (baseline: 25 of 35)
|
||||
- `rust_test_count` (baseline: 194+)
|
||||
- `gateway_test_fail_count` (baseline: 1 — P38-001)
|
||||
- `grep_hits_unimplemented` run: `grep -rEc 'todo!\(\)|unimplemented!\(\)|FIXME' crates/`
|
||||
- `grep_hits_pseudo` run: `grep -rEc '\"placeholder\"|\"stub\"|\"mock\"|\"fake\"' crates/`
|
||||
|
||||
## Rules for this loop
|
||||
|
||||
1. **Cloud-first for every iteration.** Per feedback_scrum_cloud_first.md, strategic review uses 120B+ tier.
|
||||
2. **One cross-cutting PR per iteration when possible.** Meta-pattern from audit: identity+auth+access+journal+truth share a pipe. Fix them together.
|
||||
3. **Build must be green before next iteration starts.** A broken build is evidence the last iteration regressed, not progressed.
|
||||
4. **Log findings to the jsonl as new rows per iteration** with `sweep_id: phase_sweep_2026-04-23-iterN`. Never overwrite prior iteration's findings — the trajectory is the whole point.
|
||||
5. **Don't fix things during an iteration.** Every observation goes into "Fix next loop" section above. Next iteration's scrum picks them up.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Future Expansion — Advanced System Evolution Layers
|
||||
|
||||
Adopted 2026-04-24 from J. The system stops optimizing for task completion. It optimizes for **provable execution, repeatable outcomes, resilience under drift, failure, and adversarial conditions.**
|
||||
|
||||
## Layer roster + iteration mapping
|
||||
|
||||
| # | Layer | Short form | Target iter |
|
||||
|---|---|---|---:|
|
||||
| 1 | Counterfactual Execution | Generate synthetic failure variants from each success | iter 5 |
|
||||
| 2 | Model Trust Profiling | Per-(model, task_type) success rate → routing weight | **iter 3** |
|
||||
| 3 | Execution DNA | Compress successful runs into reusable patterns | iter 4 |
|
||||
| 4 | Drift Sentinel | Re-validate historical tasks on a schedule | iter 5 |
|
||||
| 5 | Adversarial Injection | Inject poisoned context / malformed outputs / conflicts | iter 6 |
|
||||
| 6 | Permission Gradient | Confidence → execution tier (≥0.9 full, ≥0.7 dry-run, ≥0.5 sim, <0.5 block) | **iter 3** |
|
||||
| 7 | Multi-Agent Disagreement | Planner/Critic/Validator — disagreement = signal | iter 4 |
|
||||
| 8 | Temporal Context | Time-aware memory with decay_score + last_validated_at | iter 4 |
|
||||
| 9 | Execution Cost Intelligence | Tokens, iterations, cloud_calls, latency per task | **iter 3** |
|
||||
| 10 | Human Override as Data | Capture manual fixes as jsonl rows | **iter 3** |
|
||||
|
||||
## Detail (J's original framing preserved)
|
||||
|
||||
### 1. Counterfactual Execution Layer
|
||||
Simulate alternate failure paths for every successful task. Real Execution → Success → Generate Variations (env, version, inputs) → Simulate Failure Cases → Store Synthetic Failure Signatures. **Purpose:** pre-train against unseen failures before real exposure.
|
||||
|
||||
### 2. Model Trust Profiling ← iter 3
|
||||
Per-(model, task_type) performance tracking.
|
||||
```
|
||||
{ "model": "...", "task_type": "...", "success_rate": 0.0, "failure_modes": [], "trust_score": 0.0 }
|
||||
```
|
||||
**Usage:** route by trust score, adjust validation strictness dynamically, per-model risk budgets.
|
||||
|
||||
### 3. Execution DNA (Trace Compression)
|
||||
Successful executions → reusable fragments.
|
||||
```
|
||||
{ "dna_id": "hash", "task_signature": "...", "critical_steps": [], "failure_avoidance": [] }
|
||||
```
|
||||
Replaces doc retrieval with pattern retrieval; faster convergence on similar tasks.
|
||||
|
||||
### 4. Drift Sentinel
|
||||
Select Historical Task → Re-run Current Env → Compare → If Failure → Mark Drifted → Trigger Re-learning. Detect silent decay; maintain long-term reliability.
|
||||
|
||||
### 5. Adversarial Injection Engine
|
||||
Inject malformed outputs / outdated docs / conflicting instructions / poisoned memory. Verify validation catches, execution blocks unsafe actions, memory rejects corrupted data. Build system immunity.
|
||||
|
||||
### 6. Permission Gradient Execution ← iter 3
|
||||
Confidence-based control replacing binary:
|
||||
- confidence ≥ 0.9 → full execution
|
||||
- confidence ≥ 0.7 → dry-run + diff
|
||||
- confidence ≥ 0.5 → simulation only
|
||||
- confidence < 0.5 → block
|
||||
Inputs: validation score, model trust score, memory match confidence. Risk-aware control; reduced catastrophic-failure surface.
|
||||
|
||||
### 7. Multi-Agent Disagreement Engine
|
||||
Planner / Critic / Validator; disagreement triggers more context, bigger model, stricter validation. Disagreement is signal, not noise.
|
||||
|
||||
### 8. Temporal Context Layer
|
||||
```
|
||||
{ "created_at": "ts", "last_validated_at": "ts", "decay_score": 0.0 }
|
||||
```
|
||||
Retrieval priority: recent + validated + high success rate. Avoid stale knowledge.
|
||||
|
||||
### 9. Execution Cost Intelligence ← iter 3
|
||||
```
|
||||
{ "task": "...", "tokens_used": 0, "iterations": 0, "cloud_calls": 0, "latency_ms": 0 }
|
||||
```
|
||||
Optimize local vs cloud; reduce unnecessary iterations.
|
||||
|
||||
### 10. Human Override as Data ← iter 3
|
||||
```
|
||||
{ "human_fix": "...", "reason": "...", "task_signature": "...", "validated": true }
|
||||
```
|
||||
Manual fixes become reusable knowledge.
|
||||
|
||||
## Final Principle
|
||||
|
||||
Memory is not passive recall. It is operational substrate:
|
||||
- failures become structured knowledge
|
||||
- successes become reusable execution patterns
|
||||
- all outputs are validated before reuse
|
||||
|
||||
## System Directive
|
||||
|
||||
Not speed. Not convenience. **Correctness. Verifiability. Resilience under change.**
|
||||
+289
-25
@@ -18,6 +18,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { z } from "zod";
|
||||
import { startTrace, logSpan, logGeneration, scoreTrace, flush as flushTraces } from "./tracing.js";
|
||||
import { buildPermitBrief } from "./entity.js";
|
||||
|
||||
const BASE = process.env.LAKEHOUSE_URL || "http://localhost:3100";
|
||||
const PORT = parseInt(process.env.MCP_PORT || "3700");
|
||||
@@ -39,6 +40,29 @@ async function api(method: string, path: string, body?: any, retries = 2) {
|
||||
const ms = Date.now() - t0;
|
||||
let parsed: any;
|
||||
try { parsed = JSON.parse(text); } catch { parsed = { raw: text, status: resp.status }; }
|
||||
|
||||
// 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) {
|
||||
const isGen = path.includes("/generate");
|
||||
if (isGen) {
|
||||
logGeneration(activeTrace, `lakehouse${path}`, {
|
||||
model: body?.model || "unknown",
|
||||
prompt: typeof body?.prompt === "string" ? body.prompt.slice(0, 500) : JSON.stringify(body).slice(0, 300),
|
||||
completion: typeof parsed?.text === "string" ? parsed.text.slice(0, 500) : JSON.stringify(parsed).slice(0, 300),
|
||||
duration_ms: ms,
|
||||
tokens_in: parsed?.prompt_eval_count,
|
||||
tokens_out: parsed?.eval_count,
|
||||
});
|
||||
} else {
|
||||
logSpan(activeTrace, `lakehouse${path}`, body, {
|
||||
rows: parsed?.row_count, sources: parsed?.sources?.length,
|
||||
sql_matches: parsed?.sql_matches, method: parsed?.method,
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (e: any) {
|
||||
if (attempt === retries) throw e;
|
||||
@@ -52,29 +76,6 @@ async function api(method: string, path: string, body?: any, retries = 2) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
// Trace the call if we have an active trace
|
||||
if (activeTrace) {
|
||||
const isGen = path.includes("/generate");
|
||||
if (isGen) {
|
||||
logGeneration(activeTrace, `lakehouse${path}`, {
|
||||
model: body?.model || "unknown",
|
||||
prompt: typeof body?.prompt === "string" ? body.prompt.slice(0, 500) : JSON.stringify(body).slice(0, 300),
|
||||
completion: typeof parsed?.text === "string" ? parsed.text.slice(0, 500) : JSON.stringify(parsed).slice(0, 300),
|
||||
duration_ms: ms,
|
||||
tokens_in: parsed?.prompt_eval_count,
|
||||
tokens_out: parsed?.eval_count,
|
||||
});
|
||||
} else {
|
||||
logSpan(activeTrace, `lakehouse${path}`, body, {
|
||||
rows: parsed?.row_count, sources: parsed?.sources?.length,
|
||||
sql_matches: parsed?.sql_matches, method: parsed?.method,
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: "lakehouse", version: "1.0.0" });
|
||||
|
||||
server.tool(
|
||||
@@ -960,6 +961,61 @@ async function main() {
|
||||
return new Response(Bun.file(import.meta.dir + "/console.html"));
|
||||
}
|
||||
|
||||
// ─── Contractor / entity drill-down page ───
|
||||
// Single-contractor portfolio view across every wired source:
|
||||
// OSHA national, Chicago history, ticker chart, parent link,
|
||||
// federal contracts, debarment, unions, training. Click any
|
||||
// contractor name in a permit Entity Brief to land here.
|
||||
if (url.pathname === "/contractor") {
|
||||
return new Response(Bun.file(import.meta.dir + "/contractor.html"), {
|
||||
headers: { ...cors, "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/intelligence/contractor_profile" && req.method === "POST") {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const b = (await req.json().catch(() => ({}))) as { name?: string };
|
||||
if (!b.name) return err("missing name", 400);
|
||||
// Use the entity-brief library directly — single entity, all sources.
|
||||
const { fetchOshaBrief, fetchTickerBrief, fetchContractorHistory, fetchParentLink, fetchFederalContracts, fetchDebarmentBrief, fetchNlrbBriefReal, fetchIlsosBrief, fetchNewsMentions, fetchDiversityCerts, scoreNewsSentiment, fetchBlsConstructionTrend, normalizeEntityName, entityTicker } = await import("./entity.js");
|
||||
const [osha, stock, history, parent_link, federal, debarment, nlrb, ilsos, news, diversity, macro] = await Promise.all([
|
||||
fetchOshaBrief(b.name),
|
||||
fetchTickerBrief(b.name),
|
||||
fetchContractorHistory(b.name),
|
||||
fetchParentLink(b.name),
|
||||
fetchFederalContracts(b.name),
|
||||
fetchDebarmentBrief(b.name),
|
||||
fetchNlrbBriefReal(b.name),
|
||||
fetchIlsosBrief(b.name),
|
||||
fetchNewsMentions(b.name),
|
||||
fetchDiversityCerts(b.name),
|
||||
fetchBlsConstructionTrend(),
|
||||
]);
|
||||
const news_sentiment = news ? scoreNewsSentiment(news) : null;
|
||||
return ok({
|
||||
key: normalizeEntityName(b.name),
|
||||
display_name: b.name,
|
||||
ticker: entityTicker(b.name),
|
||||
osha,
|
||||
stock,
|
||||
history,
|
||||
parent_link,
|
||||
federal,
|
||||
debarment,
|
||||
nlrb,
|
||||
ilsos,
|
||||
news,
|
||||
news_sentiment,
|
||||
diversity,
|
||||
macro,
|
||||
generated_at: new Date().toISOString(),
|
||||
duration_ms: Date.now() - start,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return err(`contractor_profile: ${e.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Intelligence: Market data — public building permits → staffing demand forecast
|
||||
if (url.pathname === "/intelligence/market" && req.method === "POST") {
|
||||
const start = Date.now();
|
||||
@@ -1158,14 +1214,86 @@ async function main() {
|
||||
// a PROPOSED fill drawn from our 500K worker bench. Surfaces the
|
||||
// meta-index dimension directly: "what past similar fills had in
|
||||
// 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") {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const permitUrl = "https://data.cityofchicago.org/resource/ydr8-5enu.json";
|
||||
// Recent + substantial permits only — skip tiny ones that
|
||||
// don't imply real staffing demand.
|
||||
// Include contact_1 + contact_2 fields so the Entity Brief
|
||||
// panel on each card can populate without a second fetch.
|
||||
// Contacts identify the applicant / contractor by name —
|
||||
// those are the keys we pass to OSHA/ILSOS enrichment.
|
||||
const permits: any[] = await fetch(
|
||||
`${permitUrl}?$select=permit_type,work_type,work_description,reported_cost,street_number,street_direction,street_name,community_area,issue_date&`
|
||||
`${permitUrl}?$select=id,permit_type,work_type,work_description,reported_cost,street_number,street_direction,street_name,community_area,issue_date,contact_1_name,contact_1_type,contact_2_name,contact_2_type&`
|
||||
+ `$where=reported_cost>250000 AND issue_date>'2025-06-01'`
|
||||
+ `&$order=issue_date DESC&$limit=6`
|
||||
).then(r => r.json()).catch(() => []);
|
||||
@@ -1193,6 +1321,11 @@ async function main() {
|
||||
// query path exactly. k=200 to ensure boost fires across
|
||||
// the full memory surface (the embedding-discrimination
|
||||
// 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", {
|
||||
index_name: "workers_500k_v1",
|
||||
filter_dataset: "workers_500k",
|
||||
@@ -1202,6 +1335,7 @@ async function main() {
|
||||
top_k: 5, generate: false,
|
||||
use_playbook_memory: true, playbook_memory_k: 200,
|
||||
}).catch(() => ({ sources: [] as any[] }));
|
||||
const hybridMs = Date.now() - hybridT0;
|
||||
|
||||
// Path 2 — discovered patterns for this role in this city.
|
||||
const patternRes = await api("POST", "/vectors/playbook_memory/patterns", {
|
||||
@@ -1240,14 +1374,72 @@ async function main() {
|
||||
else if (daysToDeadline <= 21) urgency = "soon";
|
||||
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({
|
||||
permit: {
|
||||
id: p.id,
|
||||
cost,
|
||||
work_type: p.work_type || "General construction",
|
||||
description: (p.work_description || "").substring(0, 140),
|
||||
address: `${p.street_number || ""} ${p.street_direction || ""} ${p.street_name || ""}`.trim(),
|
||||
community_area: p.community_area,
|
||||
issue_date: (p.issue_date || "").substring(0, 10),
|
||||
// Contacts — used by /intelligence/permit_entities to
|
||||
// enrich each card with OSHA + ILSOS on expand.
|
||||
contact_1_name: p.contact_1_name || "",
|
||||
contact_1_type: p.contact_1_type || "",
|
||||
contact_2_name: p.contact_2_name || "",
|
||||
contact_2_type: p.contact_2_type || "",
|
||||
},
|
||||
implied_bill_rate: contractBillRate,
|
||||
timeline: {
|
||||
@@ -1260,12 +1452,32 @@ async function main() {
|
||||
role,
|
||||
count,
|
||||
city, state,
|
||||
pool_size: searchRes.sql_matches,
|
||||
pool_size: poolSize,
|
||||
candidates: sources,
|
||||
},
|
||||
discovered_pattern: patternRes.discovered_pattern,
|
||||
pattern_matched: patternRes.matched_playbooks ?? 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1281,6 +1493,58 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Intelligence: per-permit entity brief — OSHA + ILSOS + property
|
||||
// Takes a permit identifier (we look it up from Chicago Socrata) or
|
||||
// raw contact fields directly from the client. Returns an "ETF
|
||||
// basket" shape: property + entities + per-entity risk factors.
|
||||
// OSHA is live-scraped (cached 30d). ILSOS returns a structured
|
||||
// placeholder because apps.ilsos.gov blocks our ASN.
|
||||
if (url.pathname === "/intelligence/permit_entities" && req.method === "POST") {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const b = await req.json().catch(() => ({})) as {
|
||||
permit_id?: string;
|
||||
address?: string;
|
||||
work_type?: string;
|
||||
contact_1_name?: string;
|
||||
contact_1_type?: string;
|
||||
contact_2_name?: string;
|
||||
contact_2_type?: string;
|
||||
fetch_osha?: boolean;
|
||||
fetch_ilsos?: boolean;
|
||||
};
|
||||
// If the caller didn't pass contact fields but did pass a
|
||||
// permit_id, go pull the record from Chicago Socrata.
|
||||
let permit = b;
|
||||
if (b.permit_id && !b.contact_1_name) {
|
||||
const u = `https://data.cityofchicago.org/resource/ydr8-5enu.json?$where=id='${encodeURIComponent(b.permit_id)}'`;
|
||||
const rows = (await fetch(u).then((r) => r.json())) as any[];
|
||||
const p = rows?.[0];
|
||||
if (p) {
|
||||
const addr = [p.street_number, p.street_direction, p.street_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
permit = {
|
||||
permit_id: b.permit_id,
|
||||
address: addr,
|
||||
work_type: p.work_type,
|
||||
contact_1_name: p.contact_1_name,
|
||||
contact_1_type: p.contact_1_type,
|
||||
contact_2_name: p.contact_2_name,
|
||||
contact_2_type: p.contact_2_type,
|
||||
};
|
||||
}
|
||||
}
|
||||
const brief = await buildPermitBrief(permit, {
|
||||
fetchOsha: b.fetch_osha !== false,
|
||||
fetchIlsos: b.fetch_ilsos !== false,
|
||||
});
|
||||
return ok({ ...brief, duration_ms: Date.now() - start });
|
||||
} catch (e: any) {
|
||||
return err(`permit_entities: ${e.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Removed 2026-04-20: /intelligence/learn was a legacy CSV writer
|
||||
// that destructively re-wrote successful_playbooks. /log and
|
||||
// /log_failure replace it cleanly via /vectors/playbook_memory/seed
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// langfuse_bridge — the missing piece called out in project_lost_stack.md
|
||||
// and Phase 40 PRD. Polls Langfuse `/api/public/traces` at interval,
|
||||
// forwards every completed trace to observer `:3800/event` with
|
||||
// `source: "langfuse"`. Observer's existing ring buffer + analyzer
|
||||
// pick it up, so the KB learns from cost/latency/provider deltas per
|
||||
// model — not just from scenario outcomes.
|
||||
//
|
||||
// Loopback: observer persistOp() appends to data/_observer/ops.jsonl
|
||||
// and its aggregator produces pathway_recommendations.jsonl. This
|
||||
// bridge closes the feedback loop between LLM call metadata and the
|
||||
// playbook/KB learning surface.
|
||||
//
|
||||
// State persistence: last-seen trace timestamp written to a JSON file
|
||||
// so restarts don't double-emit. Bounded forward window (50/tick) so
|
||||
// first-run catch-up doesn't hammer the observer.
|
||||
|
||||
const LANGFUSE_URL = process.env.LANGFUSE_URL ?? "http://localhost:3000";
|
||||
const LANGFUSE_PUBLIC = process.env.LANGFUSE_PUBLIC_KEY;
|
||||
const LANGFUSE_SECRET = process.env.LANGFUSE_SECRET_KEY;
|
||||
const OBSERVER_URL = process.env.OBSERVER_URL ?? "http://localhost:3800";
|
||||
const POLL_INTERVAL_MS = Number(process.env.LANGFUSE_POLL_MS ?? 30000);
|
||||
const BATCH_LIMIT = Number(process.env.LANGFUSE_BATCH_LIMIT ?? 50);
|
||||
const STATE_FILE = process.env.LANGFUSE_STATE_FILE
|
||||
?? "/var/lib/lakehouse-guard/langfuse_last_seen.json";
|
||||
|
||||
interface LangfuseTrace {
|
||||
id: string;
|
||||
name?: string;
|
||||
timestamp: string;
|
||||
input?: any;
|
||||
output?: any;
|
||||
latency?: number; // seconds, per Langfuse API
|
||||
totalCost?: number;
|
||||
usage?: { input?: number; output?: number; total?: number };
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
interface State { last_seen_ts?: string }
|
||||
|
||||
function basicAuth(): string {
|
||||
return "Basic " + btoa(`${LANGFUSE_PUBLIC}:${LANGFUSE_SECRET}`);
|
||||
}
|
||||
|
||||
async function loadState(): Promise<State> {
|
||||
try {
|
||||
const f = Bun.file(STATE_FILE);
|
||||
if (!(await f.exists())) return {};
|
||||
return JSON.parse(await f.text()) as State;
|
||||
} catch (e) {
|
||||
console.warn(`[langfuse-bridge] state load failed: ${e}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState(s: State): Promise<void> {
|
||||
try {
|
||||
await Bun.write(STATE_FILE, JSON.stringify(s));
|
||||
} catch (e) {
|
||||
console.warn(`[langfuse-bridge] state save failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTracesSince(cursor?: string): Promise<LangfuseTrace[]> {
|
||||
const url = new URL("/api/public/traces", LANGFUSE_URL);
|
||||
url.searchParams.set("limit", String(BATCH_LIMIT));
|
||||
url.searchParams.set("orderBy", "timestamp.asc");
|
||||
if (cursor) url.searchParams.set("fromTimestamp", cursor);
|
||||
const resp = await fetch(url, {
|
||||
headers: { authorization: basicAuth() },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`langfuse ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
||||
}
|
||||
const body: any = await resp.json();
|
||||
return (body.data ?? []) as LangfuseTrace[];
|
||||
}
|
||||
|
||||
// Shape one Langfuse trace into the ObservedOp the observer expects
|
||||
// (see mcp-server/observer.ts:29). `source: "langfuse"` is the
|
||||
// provenance flag so the analyzer can weight traces differently from
|
||||
// scenario-sourced events.
|
||||
function toObservedOp(t: LangfuseTrace): Record<string, any> {
|
||||
const endpoint = t.metadata?.provider
|
||||
?? t.metadata?.model
|
||||
?? t.name
|
||||
?? "langfuse.trace";
|
||||
const inputSummary = typeof t.input === "string"
|
||||
? t.input.slice(0, 200)
|
||||
: JSON.stringify(t.input ?? {}).slice(0, 200);
|
||||
const outputSummary = typeof t.output === "string"
|
||||
? t.output.slice(0, 200)
|
||||
: JSON.stringify(t.output ?? {}).slice(0, 200);
|
||||
return {
|
||||
timestamp: t.timestamp,
|
||||
endpoint: `langfuse:${endpoint}`,
|
||||
input_summary: inputSummary,
|
||||
success: !t.metadata?.error,
|
||||
duration_ms: Math.round((t.latency ?? 0) * 1000),
|
||||
output_summary: outputSummary,
|
||||
source: "langfuse",
|
||||
sig_hash: t.metadata?.sig_hash,
|
||||
event_kind: t.metadata?.task_class,
|
||||
// Extra fields the observer doesn't schema but the KB aggregator
|
||||
// can still pick up via JSON passthrough.
|
||||
model: t.metadata?.model,
|
||||
provider: t.metadata?.provider,
|
||||
prompt_tokens: t.usage?.input,
|
||||
completion_tokens: t.usage?.output,
|
||||
total_tokens: t.usage?.total,
|
||||
total_cost: t.totalCost,
|
||||
};
|
||||
}
|
||||
|
||||
async function forwardToObserver(op: Record<string, any>): Promise<void> {
|
||||
const resp = await fetch(`${OBSERVER_URL}/event`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(op),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`observer ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
const state = await loadState();
|
||||
let traces: LangfuseTrace[];
|
||||
try {
|
||||
traces = await fetchTracesSince(state.last_seen_ts);
|
||||
} catch (e) {
|
||||
console.warn(`[langfuse-bridge] fetch failed: ${e}`);
|
||||
return;
|
||||
}
|
||||
if (traces.length === 0) {
|
||||
console.log(`[langfuse-bridge] no new traces since ${state.last_seen_ts ?? "start"}`);
|
||||
return;
|
||||
}
|
||||
let last = state.last_seen_ts ?? "";
|
||||
let forwarded = 0;
|
||||
for (const t of traces) {
|
||||
try {
|
||||
await forwardToObserver(toObservedOp(t));
|
||||
forwarded++;
|
||||
if (t.timestamp > last) last = t.timestamp;
|
||||
} catch (e) {
|
||||
console.warn(`[langfuse-bridge] forward ${t.id} failed: ${e}`);
|
||||
// Don't advance cursor on forward failure — retry next tick.
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (last) await saveState({ last_seen_ts: last });
|
||||
console.log(
|
||||
`[langfuse-bridge] forwarded ${forwarded}/${traces.length}, last_seen=${last}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!LANGFUSE_PUBLIC || !LANGFUSE_SECRET) {
|
||||
console.error("LANGFUSE_PUBLIC_KEY + LANGFUSE_SECRET_KEY required");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`[langfuse-bridge] polling ${LANGFUSE_URL} every ${POLL_INTERVAL_MS}ms → ${OBSERVER_URL}/event`,
|
||||
);
|
||||
await tick();
|
||||
setInterval(tick, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(`[langfuse-bridge] fatal: ${e}`);
|
||||
process.exit(1);
|
||||
});
|
||||
+366
-3
@@ -37,7 +37,7 @@ interface ObservedOp {
|
||||
// Phase 24 — optional provenance so error analyzer and playbook
|
||||
// builder can differentiate MCP-layer ops from scenario-sourced
|
||||
// events. Scenarios set source="scenario" + staffer_id + sig_hash.
|
||||
source?: "mcp" | "scenario";
|
||||
source?: "mcp" | "scenario" | "langfuse" | "overseer_correction";
|
||||
staffer_id?: string;
|
||||
sig_hash?: string;
|
||||
event_kind?: string;
|
||||
@@ -47,6 +47,12 @@ interface ObservedOp {
|
||||
count?: number;
|
||||
rescue_attempted?: boolean;
|
||||
rescue_succeeded?: boolean;
|
||||
// Overseer-correction-specific (2026-04-23): lets the analyzer
|
||||
// correlate corrections with the drift that prompted them and with
|
||||
// subsequent outcomes that either validated or invalidated the advice.
|
||||
task_class?: string;
|
||||
correction?: string;
|
||||
applied_at_turn?: number;
|
||||
}
|
||||
|
||||
const recentOps: ObservedOp[] = [];
|
||||
@@ -135,6 +141,270 @@ 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>();
|
||||
|
||||
// ─── Hand-review for scrum/agent candidate responses (2026-04-25) ───
|
||||
//
|
||||
// Observer is OUTSIDE the scrum loop's epistemic scope, so its verdict
|
||||
// can be treated as truth about whether a candidate review is grounded.
|
||||
// Two-tier evaluator:
|
||||
// 1. Try cloud LLM (qwen3-coder:480b) — semantic judgment with
|
||||
// response + source excerpt + grounding stats as context.
|
||||
// 2. On cloud failure (throttle/timeout) → deterministic heuristic
|
||||
// over grounding_pct + total_quotes. Marked source: "heuristic"
|
||||
// so consumers can tell which rung produced the verdict.
|
||||
// Every verdict is persisted to data/_kb/observer_reviews.jsonl.
|
||||
|
||||
const OBSERVER_REVIEWS = "/home/profit/lakehouse/data/_kb/observer_reviews.jsonl";
|
||||
|
||||
interface HandReviewInput {
|
||||
file_path: string;
|
||||
model: string;
|
||||
response: string;
|
||||
source_content: string;
|
||||
grounding_stats: { total: number; grounded: number; groundedPct: number | null };
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
interface HandReviewVerdict {
|
||||
verdict: "accept" | "reject" | "cycle";
|
||||
confidence: number;
|
||||
notes: string;
|
||||
source: "cloud" | "heuristic";
|
||||
}
|
||||
|
||||
async function handReview(input: HandReviewInput): Promise<HandReviewVerdict> {
|
||||
const t0 = Date.now();
|
||||
let verdict: HandReviewVerdict;
|
||||
|
||||
try {
|
||||
verdict = await cloudHandReview(input);
|
||||
} catch (e) {
|
||||
console.error(`[observer/review] cloud failed (${(e as Error).message}); using heuristic`);
|
||||
verdict = heuristicHandReview(input);
|
||||
}
|
||||
|
||||
// Persist regardless of source so we can later compare cloud vs
|
||||
// heuristic verdicts on the same input and tune the heuristic.
|
||||
const row = {
|
||||
ts: new Date().toISOString(),
|
||||
file_path: input.file_path,
|
||||
model: input.model,
|
||||
attempt: input.attempt,
|
||||
response_chars: input.response.length,
|
||||
grounding_stats: input.grounding_stats,
|
||||
verdict: verdict.verdict,
|
||||
confidence: verdict.confidence,
|
||||
notes: verdict.notes,
|
||||
source: verdict.source,
|
||||
duration_ms: Date.now() - t0,
|
||||
};
|
||||
try {
|
||||
const { appendFile } = await import("node:fs/promises");
|
||||
await appendFile(OBSERVER_REVIEWS, JSON.stringify(row) + "\n");
|
||||
} catch { /* best-effort persistence */ }
|
||||
|
||||
return verdict;
|
||||
}
|
||||
|
||||
async function cloudHandReview(input: HandReviewInput): Promise<HandReviewVerdict> {
|
||||
const grounded = input.grounding_stats.grounded;
|
||||
const total = input.grounding_stats.total;
|
||||
const pct = input.grounding_stats.groundedPct;
|
||||
// Truncate to keep the prompt under typical context windows.
|
||||
// 2000 + 4000 = ~6000 chars ≈ 1500 tokens, plus response context.
|
||||
const responseExcerpt = input.response.slice(0, 2000);
|
||||
const sourceExcerpt = input.source_content.slice(0, 4000);
|
||||
|
||||
const prompt = `You are a code-review quality observer. Decide whether the following automated review is grounded in the actual source — not invented, not hallucinated.
|
||||
|
||||
FILE: ${input.file_path}
|
||||
MODEL: ${input.model}
|
||||
ATTEMPT: ${input.attempt}
|
||||
ANCHOR GROUNDING: ${grounded}/${total} backtick-quoted snippets matched the source verbatim${pct !== null ? ` (${pct}%)` : ""}
|
||||
|
||||
REVIEW (first 2000 chars):
|
||||
\`\`\`
|
||||
${responseExcerpt}
|
||||
\`\`\`
|
||||
|
||||
SOURCE EXCERPT (first 4000 chars):
|
||||
\`\`\`
|
||||
${sourceExcerpt}
|
||||
\`\`\`
|
||||
|
||||
Respond ONLY with a JSON object:
|
||||
{
|
||||
"verdict": "accept" | "reject" | "cycle",
|
||||
"confidence": 0-100,
|
||||
"notes": "<1-2 sentences on what makes this grounded or hallucinated>"
|
||||
}
|
||||
|
||||
- accept: review references real symbols/lines in source; findings could be acted on.
|
||||
- reject: review invents APIs, fabricates calls, contradicts source. Do NOT record.
|
||||
- cycle: review is mediocre — partially grounded but wrong shape, try a stronger model.`;
|
||||
|
||||
// Hand-review uses paid OpenRouter so it sidesteps the Ollama Cloud
|
||||
// throttle that drove every prior iter into the heuristic fallback.
|
||||
// Grok 4.1 fast: $0.20 in / $0.50 out per M tokens, 2M ctx. A typical
|
||||
// hand-review (~6K input + 300 output) costs ~$0.0014. Selected via
|
||||
// J directive 2026-04-25 ("best model under $0.72/M").
|
||||
const resp = await fetch(`${LAKEHOUSE}/v1/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "openrouter",
|
||||
model: "x-ai/grok-4.1-fast",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: 300,
|
||||
temperature: 0.0,
|
||||
}),
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`/v1/chat ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
||||
}
|
||||
const j: any = await resp.json();
|
||||
const content = (j?.choices?.[0]?.message?.content ?? "").trim();
|
||||
// Pull JSON object from the response — model may wrap it in prose.
|
||||
const m = content.match(/\{[\s\S]*\}/);
|
||||
if (!m) throw new Error(`no JSON object in response: ${content.slice(0, 100)}`);
|
||||
const parsed = JSON.parse(m[0]);
|
||||
const v = String(parsed.verdict ?? "accept").toLowerCase();
|
||||
return {
|
||||
verdict: (v === "reject" || v === "cycle") ? v as "reject" | "cycle" : "accept",
|
||||
confidence: Number(parsed.confidence ?? 50),
|
||||
notes: String(parsed.notes ?? "").slice(0, 500),
|
||||
source: "cloud",
|
||||
};
|
||||
}
|
||||
|
||||
function heuristicHandReview(input: HandReviewInput): HandReviewVerdict {
|
||||
// Deterministic fallback when cloud is throttled. Conservative:
|
||||
// only flip to reject when the evidence is overwhelming, otherwise
|
||||
// accept (fall-open principle — observer is policy, not blocker).
|
||||
const total = input.grounding_stats.total;
|
||||
const pct = input.grounding_stats.groundedPct;
|
||||
const respLen = input.response.length;
|
||||
|
||||
// Too short to be a real review
|
||||
if (respLen < 1500) {
|
||||
return { verdict: "reject", confidence: 80, notes: `response too short (${respLen} chars)`, source: "heuristic" };
|
||||
}
|
||||
// Below 5 quotes — not enough signal to judge grounding; accept
|
||||
if (total < 5 || pct === null) {
|
||||
return { verdict: "accept", confidence: 50, notes: `insufficient quote signal (${total} quotes); accepting`, source: "heuristic" };
|
||||
}
|
||||
// Very heavy hallucination
|
||||
if (pct < 20) {
|
||||
return { verdict: "reject", confidence: 85, notes: `low grounding (${pct}% of ${total} quotes)`, source: "heuristic" };
|
||||
}
|
||||
// Mediocre — cycle to a stronger model
|
||||
if (pct < 50) {
|
||||
return { verdict: "cycle", confidence: 65, notes: `mediocre grounding (${pct}% of ${total} quotes); try stronger`, source: "heuristic" };
|
||||
}
|
||||
// Good enough
|
||||
return { verdict: "accept", confidence: 75, notes: `grounding ${pct}% of ${total} quotes`, source: "heuristic" };
|
||||
}
|
||||
|
||||
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 ───
|
||||
|
||||
async function analyzeErrors() {
|
||||
@@ -142,6 +412,11 @@ async function analyzeErrors() {
|
||||
const failures = recentOps.filter(op => !op.success);
|
||||
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 =>
|
||||
`[${f.endpoint}] ${f.input_summary}: ${f.error}`
|
||||
).join("\n");
|
||||
@@ -251,6 +526,28 @@ function startHttpListener() {
|
||||
.map(o => ({ ts: o.timestamp, ok: o.success, staffer: o.staffer_id, kind: o.event_kind, role: o.role })),
|
||||
}));
|
||||
}
|
||||
// ─── Hand-review endpoint (2026-04-25) ───
|
||||
// scrum/agent posts a candidate response + source content + grounding
|
||||
// stats. Observer evaluates via cloud LLM (qwen3-coder:480b) with
|
||||
// semantic context and returns {verdict, confidence, notes}. On
|
||||
// cloud throttle, falls back to a deterministic heuristic over the
|
||||
// grounding stats so the loop keeps moving with honest signal.
|
||||
//
|
||||
// This is the policy layer scrum was missing — pre-2026-04-25 the
|
||||
// scrum_master applied a hardcoded grounding-rate threshold inline,
|
||||
// which baked judgment into the wrong layer. Now scrum reports data
|
||||
// (response + source + stats) and observer decides accept/reject/cycle.
|
||||
if (req.method === "POST" && url.pathname === "/review") {
|
||||
return req.json().then((body: any) => handReview(body))
|
||||
.then((verdict) => new Response(JSON.stringify(verdict), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}))
|
||||
.catch((e: Error) =>
|
||||
new Response(JSON.stringify({ verdict: "accept", notes: `observer error: ${e.message}`, source: "heuristic" }), {
|
||||
status: 200, // fall-open shape — scrum keeps moving on observer failure
|
||||
headers: { "content-type": "application/json" },
|
||||
}));
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/event") {
|
||||
return req.json().then((body: any) => {
|
||||
const op: ObservedOp = {
|
||||
@@ -261,7 +558,10 @@ function startHttpListener() {
|
||||
duration_ms: Number(body.duration_ms ?? 0),
|
||||
output_summary: body.output_summary ?? (body.success ? "filled" : (body.error ?? "failed")),
|
||||
error: body.error,
|
||||
source: "scenario",
|
||||
// Respect the client's provenance if set (langfuse bridge
|
||||
// sends source:"langfuse", etc.). Default to "scenario"
|
||||
// to keep legacy Phase 24 callers working.
|
||||
source: body.source ?? "scenario",
|
||||
staffer_id: body.staffer_id,
|
||||
sig_hash: body.sig_hash,
|
||||
event_kind: body.event_kind,
|
||||
@@ -284,6 +584,58 @@ function startHttpListener() {
|
||||
console.error(`[observer] HTTP listener bound to 0.0.0.0:${OBSERVER_PORT}`);
|
||||
}
|
||||
|
||||
// ─── Overseer corrections tailer (2026-04-23) ───
|
||||
|
||||
// The gateway's /v1/respond loop writes T3 overseer corrections to
|
||||
// data/_kb/overseer_corrections.jsonl. Tail it once per cycle and
|
||||
// inject each new row into the same recentOps ring that analyzeErrors
|
||||
// + consolidatePlaybooks read — so a correction that just fired shows
|
||||
// up alongside the outcomes it was meant to repair, and the analyzer
|
||||
// can flag patterns like "three corrections on staffing.fill with the
|
||||
// same advice — underlying problem isn't a drift, it's a data gap".
|
||||
const CORRECTIONS_PATH = process.env.OVERSEER_CORRECTIONS_PATH
|
||||
?? "/home/profit/lakehouse/data/_kb/overseer_corrections.jsonl";
|
||||
let correctionsCursor = 0; // byte offset
|
||||
|
||||
async function tailOverseerCorrections(): Promise<number> {
|
||||
const f = Bun.file(CORRECTIONS_PATH);
|
||||
if (!(await f.exists())) return 0;
|
||||
const size = f.size;
|
||||
if (size <= correctionsCursor) return 0;
|
||||
|
||||
// Read only the suffix since the last cursor; keeps tail work
|
||||
// bounded even as the file grows.
|
||||
const text = await f.slice(correctionsCursor, size).text();
|
||||
correctionsCursor = size;
|
||||
|
||||
let forwarded = 0;
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
let row: any;
|
||||
try { row = JSON.parse(line); } catch { continue; }
|
||||
const op: ObservedOp = {
|
||||
timestamp: row.created_at ?? new Date().toISOString(),
|
||||
endpoint: `overseer:${row.model ?? "gpt-oss:120b"}`,
|
||||
input_summary: `${row.task_class ?? "?"}: ${row.reason ?? "escalation"}`,
|
||||
// Correction itself is neither success nor failure — it's a
|
||||
// mitigation attempt. We mark success=true so analyzeErrors
|
||||
// doesn't count it as a failure, but the preview lets the
|
||||
// analyzer see what was tried.
|
||||
success: true,
|
||||
duration_ms: Number(row.usage?.latency_ms ?? 0),
|
||||
output_summary: String(row.correction ?? "").slice(0, 200),
|
||||
source: "overseer_correction",
|
||||
sig_hash: row.sig_hash,
|
||||
task_class: row.task_class,
|
||||
correction: String(row.correction ?? ""),
|
||||
applied_at_turn: Number(row.applied_at_turn ?? 0),
|
||||
};
|
||||
recordExternalOp(op);
|
||||
forwarded++;
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
// ─── Main loop ───
|
||||
|
||||
async function main() {
|
||||
@@ -306,7 +658,14 @@ async function main() {
|
||||
await Bun.sleep(CYCLE_SECS * 1000);
|
||||
cycle++;
|
||||
|
||||
// Every cycle: analyze errors if any
|
||||
// Every cycle: tail the overseer corrections KB stream, then
|
||||
// analyze errors. Order matters — if an overseer correction just
|
||||
// landed for a sig_hash that previously failed, the analyzer
|
||||
// should see both.
|
||||
const newCorrections = await tailOverseerCorrections();
|
||||
if (newCorrections > 0) {
|
||||
console.error(`[observer] pulled ${newCorrections} new overseer correction(s) into ring`);
|
||||
}
|
||||
await analyzeErrors();
|
||||
|
||||
// Every 5 cycles: consolidate playbooks
|
||||
@@ -315,12 +674,16 @@ async function main() {
|
||||
}
|
||||
|
||||
const scenarioOps = recentOps.filter(o => o.source === "scenario").length;
|
||||
const langfuseOps = recentOps.filter(o => o.source === "langfuse").length;
|
||||
const correctionOps = recentOps.filter(o => o.source === "overseer_correction").length;
|
||||
const stats = {
|
||||
cycle,
|
||||
total_ops: recentOps.length,
|
||||
successes: recentOps.filter(o => o.success).length,
|
||||
failures: recentOps.filter(o => !o.success).length,
|
||||
scenario_ops: scenarioOps,
|
||||
langfuse_ops: langfuseOps,
|
||||
overseer_corrections: correctionOps,
|
||||
};
|
||||
console.error(`[observer] cycle ${cycle}: ${JSON.stringify(stats)}`);
|
||||
}
|
||||
|
||||
+1357
-16
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Lakehouse Gateway
|
||||
After=network.target ollama.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/profit/lakehouse
|
||||
ExecStart=/home/profit/lakehouse/target/release/gateway
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=RUST_LOG=info
|
||||
# Lance S3 support — connects to MinIO via the AWS SDK env vars.
|
||||
# AWS_ALLOW_HTTP required for non-TLS MinIO endpoints.
|
||||
Environment=AWS_ACCESS_KEY_ID=profit
|
||||
Environment=AWS_SECRET_ACCESS_KEY=29IgevhKQjE2WHg088ieI1wP
|
||||
Environment=AWS_ENDPOINT=http://localhost:9000
|
||||
Environment=AWS_ALLOW_HTTP=true
|
||||
Environment=AWS_DEFAULT_REGION=us-east-1
|
||||
# Langfuse keys — shared with lakehouse-langfuse-bridge.service so the
|
||||
# gateway-side emitter (`/v1/chat`, `/v1/respond`) and the observer
|
||||
# ingest path see the same project. Leading `-` makes missing-file a
|
||||
# warn-not-fatal so gateway still starts if Langfuse is torn down.
|
||||
EnvironmentFile=-/etc/lakehouse/langfuse.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env bun
|
||||
// Real-world inference pipeline for Chicago building permits.
|
||||
// Uses the unified matrix retriever (chicago_permits + entity_brief +
|
||||
// sec_tickers + llm_team_runs + distilled_procedural) to enrich a
|
||||
// Grok 4.1 fast analysis. Observer hand-reviews each result.
|
||||
//
|
||||
// First true USE of the matrix architecture on real ingested data —
|
||||
// not the scrum self-improvement loop, the staffing intelligence loop.
|
||||
//
|
||||
// Usage:
|
||||
// bun run scripts/analyze_chicago_contracts.ts [N]
|
||||
// N = number of permits to analyze (default 5)
|
||||
|
||||
const GATEWAY = process.env.LAKEHOUSE_URL ?? "http://localhost:3100";
|
||||
const OBSERVER = process.env.LH_OBSERVER_URL ?? "http://localhost:3800";
|
||||
const RAW_BUCKET = "raw";
|
||||
const MC_ALIAS = "local";
|
||||
const STAGE_DIR = "/tmp/chicago_analyze";
|
||||
const OUTPUT = "/home/profit/lakehouse/data/_kb/contract_analyses.jsonl";
|
||||
|
||||
const CONTRACT_CORPORA = [
|
||||
"chicago_permits_v1",
|
||||
"entity_brief_v1",
|
||||
"sec_tickers_v1",
|
||||
"llm_team_runs_v1",
|
||||
"llm_team_response_cache_v1",
|
||||
"distilled_procedural_v20260423102847",
|
||||
];
|
||||
|
||||
interface Permit {
|
||||
permit_?: string;
|
||||
permit_type?: string;
|
||||
permit_status?: string;
|
||||
work_description?: string;
|
||||
reported_cost?: string | number;
|
||||
contact_1?: any;
|
||||
contact_2?: any;
|
||||
contact_3_name?: string;
|
||||
street_number?: string;
|
||||
street_direction?: string;
|
||||
street_name?: string;
|
||||
suffix?: string;
|
||||
issue_date?: string;
|
||||
community_area?: string;
|
||||
ward?: string;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
interface MatrixHit {
|
||||
source_corpus: string;
|
||||
score: number;
|
||||
doc_id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function log(msg: string) { console.log(`[contract ${new Date().toISOString().slice(11,19)}] ${msg}`); }
|
||||
|
||||
async function fetchPermits(n: number): Promise<Permit[]> {
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.mkdir(STAGE_DIR, { recursive: true });
|
||||
const local = `${STAGE_DIR}/permits.json`;
|
||||
const proc = Bun.spawn(["mc", "cp", "-q", `${MC_ALIAS}/${RAW_BUCKET}/chicago/permits_2026-04-25.json`, local]);
|
||||
await proc.exited;
|
||||
const all: Permit[] = JSON.parse(await Bun.file(local).text());
|
||||
// Pick high-cost permits with named contractors — most interesting for staffing analysis.
|
||||
// Field is `contact_1_name`, not `contact_1`. reported_cost is integer-like string.
|
||||
const meaningful = all.filter(p =>
|
||||
p.reported_cost && Number(p.reported_cost) >= 100000 &&
|
||||
(p.contact_1_name || p.contact_2_name)
|
||||
);
|
||||
log(`raw permits: ${all.length} · meaningful (cost >= $100k + has contractor): ${meaningful.length}`);
|
||||
// Sample evenly across the meaningful set
|
||||
const sampled: Permit[] = [];
|
||||
const stride = Math.max(1, Math.floor(meaningful.length / n));
|
||||
for (let i = 0; i < meaningful.length && sampled.length < n; i += stride) {
|
||||
sampled.push(meaningful[i]);
|
||||
}
|
||||
return sampled;
|
||||
}
|
||||
|
||||
function permitToText(p: Permit): string {
|
||||
const addr = `${p.street_number ?? ""} ${p.street_direction ?? ""} ${p.street_name ?? ""} ${p.suffix ?? ""}`.replace(/\s+/g, " ").trim();
|
||||
const c1 = (p as any).contact_1_name ?? (typeof p.contact_1 === "string" ? p.contact_1 : (p.contact_1?.name ?? ""));
|
||||
const c2 = (p as any).contact_2_name ?? (typeof p.contact_2 === "string" ? p.contact_2 : (p.contact_2?.name ?? ""));
|
||||
return [
|
||||
`Chicago Building Permit ${p.permit_ ?? "?"}`,
|
||||
`Type: ${p.permit_type ?? "?"} · Status: ${p.permit_status ?? "?"}`,
|
||||
`Address: ${addr} · Community ${p.community_area ?? "?"} · Ward ${p.ward ?? "?"}`,
|
||||
`Issued: ${p.issue_date ?? "?"}`,
|
||||
`Reported cost: $${Number(p.reported_cost ?? 0).toLocaleString()}`,
|
||||
`Primary contractor: ${c1 || "unknown"}`,
|
||||
c2 ? `Secondary: ${c2}` : "",
|
||||
`Owner: ${p.contact_3_name ?? "?"}`,
|
||||
`Work description: ${(p.work_description ?? "").slice(0, 800)}`,
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
async function fetchMatrixHits(query: string): Promise<{ hits: MatrixHit[]; by_corpus: Record<string, number>; latency_ms: number }> {
|
||||
const t0 = Date.now();
|
||||
const all: MatrixHit[] = [];
|
||||
const byCorpus: Record<string, number> = {};
|
||||
await Promise.all(CONTRACT_CORPORA.map(async (idx) => {
|
||||
try {
|
||||
const r = await fetch(`${GATEWAY}/vectors/search`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ index_name: idx, query, top_k: 3 }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!r.ok) { byCorpus[idx] = -1; return; }
|
||||
const data: any = await r.json();
|
||||
const results = data.results ?? [];
|
||||
byCorpus[idx] = results.length;
|
||||
for (const h of results) {
|
||||
all.push({
|
||||
source_corpus: idx,
|
||||
score: Number(h.score ?? 0),
|
||||
doc_id: String(h.doc_id ?? "?"),
|
||||
text: String(h.chunk_text ?? "").slice(0, 400),
|
||||
});
|
||||
}
|
||||
} catch { byCorpus[idx] = -1; }
|
||||
}));
|
||||
all.sort((a, b) => b.score - a.score);
|
||||
return { hits: all.slice(0, 10), by_corpus: byCorpus, latency_ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
function buildMatrixPreamble(hits: MatrixHit[]): string {
|
||||
if (hits.length === 0) return "";
|
||||
const lines = [
|
||||
`═══ 📖 MATRIX CONTEXT — ${hits.length} relevant hits across the knowledge base ═══`,
|
||||
"Reference material from prior contractor data, SEC tickers, LLM team analyses, and distilled procedures. Use as evidence; do NOT invent.",
|
||||
"",
|
||||
];
|
||||
for (let i = 0; i < hits.length; i++) {
|
||||
const h = hits[i];
|
||||
lines.push(`[${i + 1}] ${h.source_corpus} (score=${h.score.toFixed(2)}, doc=${h.doc_id}): ${h.text.replace(/\s+/g, " ").trim()}`);
|
||||
}
|
||||
lines.push("═══");
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function chat(model: string, prompt: string): Promise<{ content: string; error?: string }> {
|
||||
try {
|
||||
const r = await fetch(`${GATEWAY}/v1/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "openrouter",
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: 1500,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
signal: AbortSignal.timeout(90000),
|
||||
});
|
||||
if (!r.ok) return { content: "", error: `HTTP ${r.status}: ${(await r.text()).slice(0, 200)}` };
|
||||
const j: any = await r.json();
|
||||
return { content: j.choices?.[0]?.message?.content ?? "" };
|
||||
} catch (e: any) { return { content: "", error: e.message }; }
|
||||
}
|
||||
|
||||
async function observerReview(input: { permit_id: string; model: string; response: string; permit_text: string }): Promise<{ verdict: string; confidence: number; notes: string; source: string }> {
|
||||
try {
|
||||
const r = await fetch(`${OBSERVER}/review`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
file_path: `chicago_permit/${input.permit_id}`,
|
||||
model: input.model,
|
||||
response: input.response,
|
||||
source_content: input.permit_text,
|
||||
grounding_stats: { total: 0, grounded: 0, groundedPct: null },
|
||||
attempt: 1,
|
||||
}),
|
||||
signal: AbortSignal.timeout(90000),
|
||||
});
|
||||
if (!r.ok) return { verdict: "accept", confidence: 50, notes: `observer ${r.status}`, source: "fallthrough" };
|
||||
return await r.json();
|
||||
} catch (e: any) { return { verdict: "accept", confidence: 50, notes: `observer error: ${e.message}`, source: "fallthrough" }; }
|
||||
}
|
||||
|
||||
async function analyzeOne(p: Permit, idx: number, total: number): Promise<any> {
|
||||
const permit_id = p.permit_ ?? `unknown_${idx}`;
|
||||
const t0 = Date.now();
|
||||
log(`══ permit ${idx + 1}/${total} · ${permit_id} · type=${p.permit_type} · cost=$${Number(p.reported_cost ?? 0).toLocaleString()}`);
|
||||
const permitText = permitToText(p);
|
||||
|
||||
// Build matrix query: combine type + work description + contractor name for retrieval anchoring
|
||||
const c1 = (p as any).contact_1_name ?? (typeof p.contact_1 === "string" ? p.contact_1 : (p.contact_1?.name ?? ""));
|
||||
const matrixQuery = `${p.permit_type ?? ""} ${(p.work_description ?? "").slice(0, 300)} ${c1}`;
|
||||
const matrix = await fetchMatrixHits(matrixQuery);
|
||||
const corporaSummary = Object.entries(matrix.by_corpus).map(([k, v]) => `${k.split("_v")[0]}=${v}`).join(" ");
|
||||
log(` 📖 matrix: ${matrix.hits.length} hits in ${matrix.latency_ms}ms · ${corporaSummary}`);
|
||||
|
||||
const preamble = buildMatrixPreamble(matrix.hits);
|
||||
const task = `${preamble}You are a staffing-intelligence analyst reviewing a real Chicago building permit. Using the MATRIX CONTEXT above as evidence, produce a structured analysis:
|
||||
|
||||
PERMIT:
|
||||
${permitText}
|
||||
|
||||
Produce a markdown analysis with:
|
||||
1. **Permit summary** — 2 sentences on what this is
|
||||
2. **Contractor signal** — what we know about the named contractor(s) from matrix context (cite [N] hits). If unknown, say so.
|
||||
3. **Staffing fit** — what trades/headcount/skills this permit implies
|
||||
4. **Risk flags** — anything in matrix context that suggests caution (debarment, prior incidents, low-quality history). If none, say so.
|
||||
5. **Opportunity score** — 0-100 with one-sentence rationale
|
||||
|
||||
Cite matrix hits as [N] inline. If matrix has no relevant hits, say "no matrix evidence" — do NOT invent contractor history.`;
|
||||
|
||||
const resp = await chat("x-ai/grok-4.1-fast", task);
|
||||
if (resp.error) {
|
||||
log(` ✗ chat error: ${resp.error.slice(0, 100)}`);
|
||||
return { permit_id, ok: false, error: resp.error, ts: new Date().toISOString() };
|
||||
}
|
||||
log(` ✓ analysis ${resp.content.length} chars`);
|
||||
|
||||
const verdict = await observerReview({ permit_id, model: "openrouter/x-ai/grok-4.1-fast", response: resp.content, permit_text: permitText });
|
||||
log(` observer: ${verdict.verdict} (conf=${verdict.confidence}, src=${verdict.source})`);
|
||||
|
||||
return {
|
||||
permit_id, ok: true,
|
||||
permit_type: p.permit_type, cost: Number(p.reported_cost ?? 0),
|
||||
contractor: c1, matrix_hits: matrix.hits.length, matrix_corpora: matrix.by_corpus, matrix_ms: matrix.latency_ms,
|
||||
analysis: resp.content,
|
||||
observer_verdict: verdict.verdict, observer_conf: verdict.confidence, observer_notes: verdict.notes, observer_src: verdict.source,
|
||||
duration_ms: Date.now() - t0, ts: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const n = Number(process.argv[2] ?? 5);
|
||||
log(`fetching ${n} permits from raw bucket...`);
|
||||
const permits = await fetchPermits(n);
|
||||
log(`analyzing ${permits.length} permits sequentially...`);
|
||||
|
||||
const fs = await import("node:fs/promises");
|
||||
const { appendFile } = fs;
|
||||
const results: any[] = [];
|
||||
for (let i = 0; i < permits.length; i++) {
|
||||
const r = await analyzeOne(permits[i], i, permits.length);
|
||||
results.push(r);
|
||||
await appendFile(OUTPUT, JSON.stringify(r) + "\n");
|
||||
}
|
||||
|
||||
log(`\n══ SUMMARY ══`);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
const accepted = results.filter(r => r.observer_verdict === "accept").length;
|
||||
const cycled = results.filter(r => r.observer_verdict === "cycle").length;
|
||||
const rejected = results.filter(r => r.observer_verdict === "reject").length;
|
||||
const avgHits = results.reduce((a, r) => a + (r.matrix_hits ?? 0), 0) / Math.max(1, results.length);
|
||||
log(` permits analyzed: ${ok}/${results.length}`);
|
||||
log(` observer: accept=${accepted} cycle=${cycled} reject=${rejected}`);
|
||||
log(` avg matrix hits per permit: ${avgHits.toFixed(1)}`);
|
||||
log(` output: ${OUTPUT}`);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(`FATAL: ${e.message}`); process.exit(1); });
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# One-shot dump of all testing data into the `raw` MinIO bucket.
|
||||
# Persistent test corpus so we don't re-extract every run.
|
||||
#
|
||||
# Layout:
|
||||
# raw/
|
||||
# staffing/ — workers_500k.parquet, resumes.parquet
|
||||
# entities/ — entities.jsonl, sec_company_tickers.json
|
||||
# llm_team/ — *.jsonl extracts from knowledge_base PG tables
|
||||
# chicago/ — permits_<date>.json (last 30 days)
|
||||
# MANIFEST.json — documents what's here + when
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO=/home/profit/lakehouse
|
||||
BUCKET=raw
|
||||
ALIAS=local
|
||||
STAGE=$(mktemp -d /tmp/raw_dump.XXXXX)
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
|
||||
log() { echo "[dump $(date -u +%H:%M:%S)] $*"; }
|
||||
|
||||
log "creating bucket ${ALIAS}/${BUCKET} (idempotent)"
|
||||
mc mb --ignore-existing ${ALIAS}/${BUCKET}
|
||||
|
||||
# ─── 1. STAFFING ───
|
||||
log "staffing/ — workers_500k.parquet (323 MB) + resumes.parquet"
|
||||
mc cp -q ${REPO}/data/datasets/workers_500k.parquet ${ALIAS}/${BUCKET}/staffing/workers_500k.parquet
|
||||
mc cp -q ${REPO}/data/datasets/resumes.parquet ${ALIAS}/${BUCKET}/staffing/resumes.parquet
|
||||
|
||||
# ─── 2. ENTITIES + SEC + GEO ───
|
||||
log "entities/ — contractor entities cache + SEC tickers + svep + tif districts"
|
||||
mc cp -q ${REPO}/data/_entity_cache/entities.jsonl ${ALIAS}/${BUCKET}/entities/entities.jsonl
|
||||
mc cp -q ${REPO}/data/_entity_cache/sec_company_tickers.json ${ALIAS}/${BUCKET}/sec/company_tickers.json
|
||||
mc cp -q ${REPO}/data/_entity_cache/svep_log.json ${ALIAS}/${BUCKET}/entities/svep_log.json
|
||||
mc cp -q ${REPO}/data/_entity_cache/tif_districts.geojson ${ALIAS}/${BUCKET}/chicago/tif_districts.geojson
|
||||
|
||||
# ─── 3. LLM TEAM HISTORY (Postgres → JSONL → S3) ───
|
||||
log "llm_team/ — extracting from knowledge_base PG tables"
|
||||
LLM_TABLES=(team_runs pipeline_runs lab_experiments lab_trials meta_pipelines meta_runs conversations response_cache memory_entries adaptive_runs)
|
||||
for tbl in "${LLM_TABLES[@]}"; do
|
||||
out=${STAGE}/${tbl}.jsonl
|
||||
rows=$(sudo -u postgres psql -d knowledge_base -At -c "SELECT COUNT(*) FROM ${tbl};" 2>/dev/null || echo 0)
|
||||
if [ "$rows" -eq 0 ]; then
|
||||
log " · ${tbl}: 0 rows, skipping"
|
||||
continue
|
||||
fi
|
||||
sudo -u postgres psql -d knowledge_base -At -c "COPY (SELECT row_to_json(t) FROM ${tbl} t) TO STDOUT;" > "$out" 2>/dev/null
|
||||
size=$(du -h "$out" | awk '{print $1}')
|
||||
log " · ${tbl}: ${rows} rows (${size})"
|
||||
mc cp -q "$out" ${ALIAS}/${BUCKET}/llm_team/${tbl}.jsonl
|
||||
done
|
||||
|
||||
# ─── 4. CHICAGO PERMITS (last 30 days, paginated) ───
|
||||
log "chicago/ — pulling last 30 days of permits from data.cityofchicago.org"
|
||||
since=$(date -u -d '30 days ago' +%Y-%m-%d)
|
||||
out=${STAGE}/permits_${DATE}.json
|
||||
url="https://data.cityofchicago.org/resource/ydr8-5enu.json?\$where=issue_date%3E='${since}'&\$limit=10000&\$order=issue_date%20DESC"
|
||||
if curl -sf --max-time 60 "$url" -o "$out"; then
|
||||
count=$(python3 -c "import json; print(len(json.load(open('${out}'))))")
|
||||
size=$(du -h "$out" | awk '{print $1}')
|
||||
log " · permits since ${since}: ${count} records (${size})"
|
||||
mc cp -q "$out" ${ALIAS}/${BUCKET}/chicago/permits_${DATE}.json
|
||||
else
|
||||
log " · WARN: chicago permits fetch failed; skipping"
|
||||
fi
|
||||
|
||||
# ─── 5. MANIFEST ───
|
||||
log "writing MANIFEST.json"
|
||||
manifest=${STAGE}/MANIFEST.json
|
||||
python3 - <<PY
|
||||
import json, subprocess, datetime
|
||||
out = subprocess.check_output(['mc','ls','-r','--json','${ALIAS}/${BUCKET}'], text=True)
|
||||
items = []
|
||||
for line in out.strip().split('\n'):
|
||||
if not line: continue
|
||||
o = json.loads(line)
|
||||
items.append({'key': o.get('key',''), 'size': o.get('size',0)})
|
||||
total_size = sum(i['size'] for i in items)
|
||||
manifest = {
|
||||
'bucket': '${BUCKET}',
|
||||
'created_at': datetime.datetime.utcnow().isoformat() + 'Z',
|
||||
'total_objects': len(items),
|
||||
'total_size_bytes': total_size,
|
||||
'total_size_human': f'{total_size / (1024*1024):.1f} MB',
|
||||
'items': items,
|
||||
}
|
||||
with open('${manifest}','w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
PY
|
||||
mc cp -q "$manifest" ${ALIAS}/${BUCKET}/MANIFEST.json
|
||||
|
||||
log "DONE. Bucket contents:"
|
||||
mc ls -r ${ALIAS}/${BUCKET} | head -30
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user