Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a00e9bb438 | ||
|
|
ed57eda1d8 | ||
|
|
c3c9c2174a | ||
|
|
940737daa7 | ||
|
|
d56f08e740 | ||
|
|
ca7375ea2b | ||
|
|
2d9cb128bf | ||
|
|
dd77632d0e | ||
|
|
a50e9586f2 | ||
|
|
6ed48c1a69 | ||
|
|
74ad77211f | ||
|
|
2cac64636c | ||
|
|
6cafa7ec0e | ||
|
|
98db129b8f | ||
|
|
5d93a715c3 | ||
|
|
7b88fb9269 | ||
|
|
47776b07cd | ||
|
|
86123fce4c | ||
|
|
ebd9ab7c77 | ||
|
|
f6af0fd409 | ||
|
|
bfe1ea9d1c | ||
|
|
dc6dd1d30c | ||
|
|
19a65b87e3 | ||
|
|
454da15301 | ||
|
|
00c8408335 | ||
|
|
8aa7ee974f | ||
|
|
bc698eb6da | ||
|
|
ff5de76241 | ||
|
|
3eaac413e6 | ||
|
|
8d02c7f441 | ||
|
|
643dd2d520 | ||
|
|
d77622fc6b | ||
|
|
d11632a6fa | ||
|
|
e7636f202b | ||
|
|
73f242e3e4 | ||
|
|
5bdd159966 | ||
|
|
681f39d5fa | ||
|
|
20a039c379 | ||
|
|
1b433a9308 | ||
|
|
2cf359a646 | ||
|
|
68b6697bcb | ||
|
|
c989253e9b | ||
|
|
1ea802943f | ||
|
|
27b1d27605 | ||
|
|
f753e11157 | ||
|
|
107a68224d | ||
|
|
0844206660 | ||
|
|
340fca2427 | ||
|
|
d9bd4c9bdf | ||
|
|
69919d9d57 | ||
|
|
d1d97a045b | ||
|
|
540a9a27ee | ||
|
|
3a0b37ed93 | ||
|
|
2dbc8dbc83 | ||
|
|
56bf30cfd8 | ||
|
|
52bb216c2d | ||
|
|
7c47734287 | ||
|
|
86f63a083d | ||
|
|
d277efbfd2 | ||
|
|
626f18d491 | ||
|
|
e1ef185868 | ||
|
|
0115a60072 | ||
|
|
54689d523c | ||
|
|
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 |
@@ -0,0 +1,42 @@
|
||||
# Real Archon workflow on the Lakehouse repo, fully via our gateway.
|
||||
# Three Pi nodes, each fires LLM → /v1/chat/completions → OpenRouter,
|
||||
# every call lands a Langfuse trace + observer event.
|
||||
#
|
||||
# Read-only (allowed_tools: [read]). Don't pass --branch / leave
|
||||
# --no-worktree at runtime so Archon doesn't try to create a worktree.
|
||||
name: lakehouse-architect-review
|
||||
description: 'Pi reviews Lakehouse architecture in 3 turns through our gateway.'
|
||||
provider: pi
|
||||
model: openrouter/x-ai/grok-4.1-fast
|
||||
|
||||
nodes:
|
||||
- id: shape
|
||||
prompt: |
|
||||
Read these files and answer in 3 short bullets describing the
|
||||
architectural shape of Lakehouse:
|
||||
- /home/profit/lakehouse/Cargo.toml
|
||||
- /home/profit/lakehouse/lakehouse.toml
|
||||
- /home/profit/lakehouse/docs/MODE_RUNNER_TUNING_PLAN.md
|
||||
Be terse. No preamble.
|
||||
allowed_tools: ["read"]
|
||||
effort: low
|
||||
idle_timeout: 90000
|
||||
|
||||
- id: weakness
|
||||
prompt: |
|
||||
Read /home/profit/lakehouse/crates/gateway/src/v1/mod.rs and
|
||||
identify ONE real weakness or risk. Cite file:line. One paragraph.
|
||||
allowed_tools: ["read"]
|
||||
effort: low
|
||||
idle_timeout: 90000
|
||||
depends_on: [shape]
|
||||
|
||||
- id: improvement
|
||||
prompt: |
|
||||
Based on the prior weakness ($weakness.output), propose ONE
|
||||
surgical improvement (≤6 lines of Rust). Show the patch as
|
||||
`old_string` and `new_string` in markdown code blocks.
|
||||
allowed_tools: []
|
||||
effort: low
|
||||
idle_timeout: 90000
|
||||
depends_on: [weakness]
|
||||
@@ -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
+31
@@ -46,6 +46,7 @@ dependencies = [
|
||||
name = "aibridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -4085,11 +4086,14 @@ dependencies = [
|
||||
"shared",
|
||||
"storaged",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tonic",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
"truth",
|
||||
"validator",
|
||||
"vectord",
|
||||
]
|
||||
|
||||
@@ -4678,6 +4682,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"croner",
|
||||
"csv",
|
||||
"journald",
|
||||
"lopdf",
|
||||
"mysql_async",
|
||||
"object_store",
|
||||
@@ -6895,6 +6900,7 @@ dependencies = [
|
||||
"storaged",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"truth",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -8726,6 +8732,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 +8909,19 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "validator"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arrow 55.2.0",
|
||||
"parquet 55.2.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -8919,6 +8949,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]
|
||||
|
||||
+27
-3
@@ -23,6 +23,7 @@ import { runStaticCheck } from "./checks/static.ts";
|
||||
import { runDynamicCheck } from "./checks/dynamic.ts";
|
||||
import { runInferenceCheck } from "./checks/inference.ts";
|
||||
import { runKbCheck } from "./checks/kb_query.ts";
|
||||
import { runKimiArchitectCheck } from "./checks/kimi_architect.ts";
|
||||
|
||||
const VERDICTS_DIR = "/home/profit/lakehouse/data/_auditor/verdicts";
|
||||
// Playbook for audit findings — one row per block/warn finding from a
|
||||
@@ -56,7 +57,7 @@ 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),
|
||||
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)),
|
||||
]);
|
||||
|
||||
@@ -67,6 +68,29 @@ export async function auditPr(pr: PrSnapshot, opts: AuditOptions = {}): Promise<
|
||||
...kbFindings,
|
||||
];
|
||||
|
||||
// Kimi-architect second-pass review. Off by default; enabled with
|
||||
// LH_AUDITOR_KIMI=1. Sequential (not in the parallel block above)
|
||||
// because it consumes the prior findings as context — Kimi sees what
|
||||
// deepseek already flagged and is asked "what did everyone miss?"
|
||||
// Failure-isolated by design: any error returns a single info-level
|
||||
// skip finding so the existing audit pipeline never blocks on Kimi.
|
||||
if (process.env.LH_AUDITOR_KIMI === "1") {
|
||||
try {
|
||||
const kimiFindings = await runKimiArchitectCheck(diff, allFindings, {
|
||||
pr_number: pr.number,
|
||||
head_sha: pr.head_sha,
|
||||
});
|
||||
allFindings.push(...kimiFindings);
|
||||
} catch (e) {
|
||||
allFindings.push({
|
||||
check: "kimi_architect",
|
||||
severity: "info",
|
||||
summary: `kimi_architect outer error — ${(e as Error).message.slice(0, 160)}`,
|
||||
evidence: [(e as Error).stack?.slice(0, 360) ?? ""],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const duration_ms = Date.now() - t0;
|
||||
const metrics = {
|
||||
audit_duration_ms: duration_ms,
|
||||
@@ -184,7 +208,7 @@ function formatReviewBody(v: Verdict): string {
|
||||
lines.push("");
|
||||
|
||||
// Per-check sections, only if the check produced findings.
|
||||
const checkOrder = ["static", "dynamic", "inference", "kb_query"] as const;
|
||||
const checkOrder = ["static", "dynamic", "inference", "kb_query", "kimi_architect"] as const;
|
||||
for (const check of checkOrder) {
|
||||
const fs = byCheck[check] ?? [];
|
||||
if (fs.length === 0) continue;
|
||||
@@ -217,6 +241,6 @@ function formatReviewBody(v: Verdict): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function stubFinding(check: "dynamic" | "inference", why: string): Finding[] {
|
||||
function stubFinding(check: "dynamic" | "inference" | "kimi_architect", why: string): Finding[] {
|
||||
return [{ check, severity: "info", summary: `${check} check skipped — ${why}`, evidence: [why] }];
|
||||
}
|
||||
|
||||
+363
-118
@@ -14,19 +14,55 @@
|
||||
|
||||
import type { Claim, Finding } from "../types.ts";
|
||||
import { Glob } from "bun";
|
||||
import { readFile } from "node:fs/promises";
|
||||
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";
|
||||
// 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.
|
||||
// Rebuild 2026-04-26: route claim verification through /v1/mode/execute
|
||||
// (task_class=pr_audit) so we get pathway memory + lakehouse_answers_v1
|
||||
// + JSON-shaped framing molded into ONE prompt. The hand-rolled
|
||||
// systemMsg/userMsg path was reinventing the mode runner badly.
|
||||
//
|
||||
// 2026-04-27 update: original default kimi-k2:1t hit a sustained
|
||||
// upstream outage on Ollama Cloud (consistent 500 ISE across hours of
|
||||
// retries — verified with trivial 8-token probes). Swapped default to
|
||||
// deepseek-v3.1:671b which is proven working end-to-end through the
|
||||
// pr_audit mode runner during Phase 5 distillation acceptance testing.
|
||||
// kimi-k2:1t can be re-selected via LH_AUDITOR_REVIEW_MODEL env when
|
||||
// the upstream returns. Tie-breaker stays grok-4.1-fast (different
|
||||
// vendor lineage so consensus + tie-break won't fail-correlate).
|
||||
const MODEL = process.env.LH_AUDITOR_REVIEW_MODEL ?? "deepseek-v3.1:671b";
|
||||
const TIEBREAKER_MODEL = process.env.LH_AUDITOR_TIEBREAKER_MODEL ?? "x-ai/grok-4.1-fast";
|
||||
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 the consensus models' context windows
|
||||
// (deepseek-v3.1 64K, gpt-oss-120b 128K). When the raw PR diff
|
||||
// exceeds this, we truncate and signal it via curationNote — the
|
||||
// pr_audit mode runner's matrix retrieval (lakehouse_answers_v1 +
|
||||
// arch + symbols) supplies the cross-PR context that tree-split
|
||||
// used to synthesize from scratch. Tree-split itself was retired
|
||||
// 2026-04-27 (see commit deleting treeSplitDiff/callCloud/SHARD_*).
|
||||
const MAX_DIFF_CHARS = 40000;
|
||||
const CALL_TIMEOUT_MS = 120_000;
|
||||
// Mode runner can take longer than a raw /v1/chat call because it does
|
||||
// pathway-fingerprint lookup + matrix retrieval + relevance filter
|
||||
// before the LLM call. Budget extra time so we don't trip on a slow
|
||||
// answers-corpus search.
|
||||
const MODE_RUNNER_TIMEOUT_MS = 240_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",
|
||||
@@ -51,9 +87,23 @@ export async function runInferenceCheck(claims: Claim[], diff: string): Promise<
|
||||
}];
|
||||
}
|
||||
|
||||
// 2026-04-27 architecture simplification: dropped the tree-split
|
||||
// scratchpad layer. Rationale: the mode runner's pr_audit pipeline
|
||||
// pulls from lakehouse_answers_v1 (gold-standard prior audits) +
|
||||
// lakehouse_arch_v1 + lakehouse_symbols_v1 via matrix retrieval. That
|
||||
// corpus IS the cross-PR context the tree-split was synthesizing
|
||||
// from scratch on every audit run. With the distillation substrate
|
||||
// shipped (commits 27b1d27..1b433a9), per-shard fact extraction is
|
||||
// redundant — and gpt-oss:120b at 168 calls/audit was the dominant
|
||||
// cost. Now: truncate diff to MAX_DIFF_CHARS, hand straight to the
|
||||
// mode runner, let retrieval supply context. ONE strong-model call
|
||||
// per consensus rep × N=3 reps = 3 calls total per audit.
|
||||
const truncated = diff.length > MAX_DIFF_CHARS
|
||||
? diff.slice(0, MAX_DIFF_CHARS) + `\n...[${diff.length - MAX_DIFF_CHARS} more chars truncated]`
|
||||
? diff.slice(0, MAX_DIFF_CHARS) + `\n...[${diff.length - MAX_DIFF_CHARS} more chars truncated — the pr_audit mode runner has matrix retrieval against lakehouse_answers_v1 + arch + symbols for cross-PR context]`
|
||||
: diff;
|
||||
const curationNote = diff.length > MAX_DIFF_CHARS
|
||||
? ` (truncated ${diff.length}→${MAX_DIFF_CHARS} chars; matrix retrieval supplies cross-PR context)`
|
||||
: "";
|
||||
|
||||
// Build the reviewer prompt in the same shape as run_codereview's
|
||||
// review stage (llm_team_ui.py:10950), adapted for claim verification:
|
||||
@@ -61,133 +111,148 @@ 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.
|
||||
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",
|
||||
"it placeholder / aspirational / unwired?",
|
||||
"",
|
||||
"A claim is BACKED when the diff contains a real code path that delivers",
|
||||
"the claimed behavior. A claim is NOT BACKED when:",
|
||||
" - the claim asserts functionality but the diff only adds types/fields",
|
||||
" with no consumer",
|
||||
" - the claim mentions tests but no test function was added",
|
||||
" - 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",
|
||||
"",
|
||||
"Respond with strict JSON only. No prose before or after. Shape:",
|
||||
"{",
|
||||
' "claim_verdicts": [',
|
||||
' {"claim_idx": 0, "backed": false, "evidence": "short reason"}',
|
||||
" ],",
|
||||
' "unflagged_gaps": [',
|
||||
' {"location": "file:line", "summary": "short description"}',
|
||||
" ]",
|
||||
"}",
|
||||
].join("\n");
|
||||
//
|
||||
// Curation flag is now just a truncation flag — when the diff was
|
||||
// cut, tell the reviewer it didn't see the full picture so it doesn't
|
||||
// confidently mark a claim NOT BACKED based on absence in the
|
||||
// (potentially incomplete) input.
|
||||
const isCurated = curationNote.length > 0;
|
||||
const prNumber = ctx?.pr_number ?? 0;
|
||||
|
||||
const userMsg = [
|
||||
`Ship-claims the author made (numbered 0..N-1):`,
|
||||
verifiable.map((c, i) => ` ${i}. [${c.strength}] "${c.text}" at ${c.location}`).join("\n"),
|
||||
"",
|
||||
`Diff:`,
|
||||
"```",
|
||||
truncated,
|
||||
"```",
|
||||
"",
|
||||
`For each numbered claim above, emit a claim_verdicts entry. For gaps the`,
|
||||
`author DIDN'T claim but that look like placeholder code, emit unflagged_gaps.`,
|
||||
`Strict JSON only, matching the shape described. No prose outside JSON.`,
|
||||
].join("\n");
|
||||
// N=3 consensus — fire the mode runner three times in parallel.
|
||||
// Each /v1/mode/execute call composes pathway memory + answers corpus
|
||||
// + JSON-shaped pr_audit framing internally, so the auditor's only
|
||||
// job here is to vote-aggregate. Wall-clock ~= single call.
|
||||
const primaryRuns = await Promise.all(
|
||||
Array.from({ length: N_CONSENSUS }, () =>
|
||||
runModeRunnerInference(truncated, verifiable, prNumber, isCurated, MODEL)),
|
||||
);
|
||||
|
||||
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 },
|
||||
],
|
||||
// Deterministic classification — temp=0 is greedy-sample, so
|
||||
// identical input yields identical output on the same model
|
||||
// version. This kills the signature creep we observed in the
|
||||
// 9-run empirical test (sig_count 16→27 from cloud phrasing
|
||||
// variance at temp=0.2).
|
||||
//
|
||||
// IMPORTANT: keep think=true. gpt-oss:120b is a reasoning
|
||||
// model; setting think=false caused it to return empty content
|
||||
// on large prompts (observed during Level 1 validation: 13421
|
||||
// tokens used, empty content returned). The reasoning trace is
|
||||
// variable prose, but at temp=0 the FINAL classification is
|
||||
// still deterministic because greedy sampling converges to
|
||||
// the same conclusion from the same starting state.
|
||||
max_tokens: 3000,
|
||||
temperature: 0,
|
||||
think: true,
|
||||
}),
|
||||
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.
|
||||
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[][] = [];
|
||||
// The N=3 consensus calls run via Promise.all — wall-clock is
|
||||
// bounded by the SLOWEST call, not the sum. Pre-2026-04-27 we
|
||||
// summed and reported "Xms total" which double/triple-counted
|
||||
// (Opus self-audit caught it). Use max for accurate wall-clock.
|
||||
let maxLatencyMs = 0;
|
||||
let totalEnrichedChars = 0;
|
||||
let bugFingerprintsSeen = 0;
|
||||
let matrixKeptSeen = 0;
|
||||
for (const run of parsedRuns) {
|
||||
maxLatencyMs = Math.max(maxLatencyMs, run.latency_ms ?? 0);
|
||||
totalEnrichedChars += run.enriched_chars ?? 0;
|
||||
bugFingerprintsSeen = Math.max(bugFingerprintsSeen, run.bug_fingerprints ?? 0);
|
||||
matrixKeptSeen = Math.max(matrixKeptSeen, run.matrix_kept ?? 0);
|
||||
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: `pr_audit mode runner completed (model=${MODEL}, consensus=${parsedRuns.length}/${N_CONSENSUS}, ${maxLatencyMs}ms wall-clock)${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}`,
|
||||
`enrichment: ${bugFingerprintsSeen} bug fingerprints, ${matrixKeptSeen} answers-corpus chunks, prompt avg ${Math.round(totalEnrichedChars / Math.max(parsedRuns.length, 1))} chars`,
|
||||
],
|
||||
});
|
||||
|
||||
for (const v of parsed.claim_verdicts ?? []) {
|
||||
if (v?.backed === false) {
|
||||
const idx = typeof v.claim_idx === "number" ? v.claim_idx : -1;
|
||||
// Indices point at the verifiable[] list we sent the cloud,
|
||||
// not the full claims[] list. Translate back.
|
||||
const claim = verifiable[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
|
||||
// through the same mode runner so framing/enrichment match.
|
||||
const tb = await runModeRunnerInference(truncated, verifiable, prNumber, isCurated, 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";
|
||||
@@ -198,13 +263,49 @@ 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.
|
||||
// Post-2026-04-27: extraction now runs against the truncated diff
|
||||
// (no scratchpad to extract from since tree-split was retired).
|
||||
// Fact extraction is still useful for surfacing entities/symbols
|
||||
// into audit_facts.jsonl even from truncated input.
|
||||
if (isCurated && ctx && process.env.LH_AUDITOR_SKIP_EXTRACT !== "1") {
|
||||
try {
|
||||
await extractAndPersistFacts(truncated, 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
|
||||
@@ -248,6 +349,150 @@ export async function runInferenceCheck(claims: Claim[], diff: string): Promise<
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Single mode-runner call — consensus + tie-breaker dispatch through
|
||||
// here. Returns parsed JSON shape + telemetry from /v1/mode/execute
|
||||
// (latency, enrichment metrics) + any error diagnostic. NEVER throws.
|
||||
// The consensus aggregator handles partial failures by dropping
|
||||
// non-parsed runs from the vote.
|
||||
interface CloudRunResult {
|
||||
parsed: any | null;
|
||||
latency_ms: number;
|
||||
enriched_chars: number;
|
||||
bug_fingerprints: number;
|
||||
matrix_kept: number;
|
||||
error?: string; // "unreachable" | "non_200" | "unparseable"
|
||||
diagnostic?: string; // first 200 chars for debugging
|
||||
model: string;
|
||||
}
|
||||
|
||||
async function runModeRunnerInference(
|
||||
diffOrScratchpad: string,
|
||||
claims: Claim[],
|
||||
prNumber: number,
|
||||
isCurated: boolean,
|
||||
model: string,
|
||||
): Promise<CloudRunResult> {
|
||||
// user_question carries the claim list + the curation note (if any).
|
||||
// pr_audit's framing (mode.rs FRAMING_PR_AUDIT) holds the JSON shape +
|
||||
// strict-output rules so we don't repeat them here.
|
||||
const claimDigest = claims
|
||||
.map((c, i) => ` ${i}. [${c.strength}] "${c.text}" at ${c.location}`)
|
||||
.join("\n");
|
||||
const curationNote = isCurated
|
||||
? "\n\nNOTE: the FILE below is a curated multi-shard scratchpad of the diff, not the raw diff itself. Absence in the scratchpad is NOT evidence of absence in the actual diff. Only mark backed=false on direct contradiction (e.g. scratchpad shows the function is empty / a stub). Skip unflagged_gaps entirely when scratchpad is curated."
|
||||
: "";
|
||||
const userQuestion = [
|
||||
"Verify each ship-claim against the diff (or scratchpad).",
|
||||
"",
|
||||
"Ship-claims (numbered 0..N-1):",
|
||||
claimDigest,
|
||||
curationNote,
|
||||
"",
|
||||
"Every claim above must produce exactly one claim_verdicts entry. Output strict JSON only — no prose outside the JSON object.",
|
||||
].join("\n");
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${GATEWAY}/v1/mode/execute`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
task_class: "pr_audit",
|
||||
file_path: `pr-${prNumber}.diff`,
|
||||
file_content: diffOrScratchpad,
|
||||
user_question: userQuestion,
|
||||
force_model: model,
|
||||
force_temperature: 0,
|
||||
}),
|
||||
signal: AbortSignal.timeout(MODE_RUNNER_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
return {
|
||||
parsed: null, latency_ms: 0, enriched_chars: 0, bug_fingerprints: 0, matrix_kept: 0,
|
||||
error: "unreachable", diagnostic: (e as Error).message.slice(0, 200), model,
|
||||
};
|
||||
}
|
||||
if (!resp.ok) {
|
||||
return {
|
||||
parsed: null, latency_ms: 0, enriched_chars: 0, bug_fingerprints: 0, matrix_kept: 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, latency_ms: 0, enriched_chars: 0, bug_fingerprints: 0, matrix_kept: 0,
|
||||
error: "unparseable", diagnostic: (e as Error).message, model,
|
||||
};
|
||||
}
|
||||
const content: string = typeof body?.response === "string" ? body.response : "";
|
||||
const parsed = extractJson(content);
|
||||
// Number-coerced extractors so a non-numeric upstream value (string,
|
||||
// null, NaN) collapses to 0 instead of poisoning downstream
|
||||
// arithmetic. Caught 2026-04-27 by kimi_architect self-audit —
|
||||
// optional-chaining + ?? only catches null/undefined, not type drift.
|
||||
const num = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
return {
|
||||
parsed,
|
||||
latency_ms: num(body?.latency_ms),
|
||||
enriched_chars: num(body?.enriched_prompt_chars),
|
||||
bug_fingerprints: num(body?.sources?.bug_fingerprints_count),
|
||||
matrix_kept: num(body?.sources?.matrix_chunks_kept),
|
||||
error: parsed ? undefined : "unparseable",
|
||||
diagnostic: parsed ? undefined : content.slice(0, 200),
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Pull out plausible code-symbol names from a summary string.
|
||||
// Matches:
|
||||
// - identifier with backticks: `foo_bar`
|
||||
|
||||
@@ -25,6 +25,7 @@ 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;
|
||||
|
||||
@@ -61,6 +62,14 @@ export async function runKbCheck(claims: Claim[], prFiles: string[] = []): Promi
|
||||
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,
|
||||
@@ -207,6 +216,99 @@ 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
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
// Kimi-architect check — second-pass senior architectural review using
|
||||
// kimi-for-coding (Kimi K2.6) via /v1/chat provider=kimi.
|
||||
//
|
||||
// Runs AFTER the deepseek inference check (N=3 consensus) and the
|
||||
// static/kb_query checks. Reads their findings as context and asks Kimi
|
||||
// "what did everyone else miss?" — complementing the cheap-consensus
|
||||
// voting with a sparse senior pass that catches load-bearing issues
|
||||
// (compile errors, false telemetry, schema bypasses, etc.) which the
|
||||
// voting structure can't see.
|
||||
//
|
||||
// Why Kimi here and not in the inner inference loop:
|
||||
// - Cost: ~3min wall-clock per call vs ~30s for deepseek consensus.
|
||||
// - TOS: api.kimi.com is User-Agent-gated (see crates/gateway/src/v1/
|
||||
// kimi.rs); cost-bounded calls only.
|
||||
// - Value: experiment 2026-04-27 showed 7/7 grounding rate with full
|
||||
// files vs ~50% on truncated input. Best as a sparse complement, not
|
||||
// a replacement.
|
||||
//
|
||||
// Failure-isolated: any Kimi error returns a single info-level Finding
|
||||
// "kimi_architect skipped — <reason>" so the existing audit pipeline
|
||||
// is never blocked by a Kimi outage / TOS revocation / 429.
|
||||
//
|
||||
// Cost cap: if a kimi_verdicts/<pr>-<sha>.json file exists less than 24h
|
||||
// old, return cached findings without calling upstream. New commits
|
||||
// produce new SHAs so this is per-head, not per-day.
|
||||
//
|
||||
// Off by default: caller checks LH_AUDITOR_KIMI=1 before invoking.
|
||||
|
||||
import { readFile, writeFile, mkdir, appendFile, stat, realpath } from "node:fs/promises";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import type { Finding, CheckKind } from "../types.ts";
|
||||
|
||||
const GATEWAY = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
|
||||
const KIMI_VERDICTS_DIR = "/home/profit/lakehouse/data/_auditor/kimi_verdicts";
|
||||
const KIMI_AUDITS_JSONL = "/home/profit/lakehouse/data/_kb/kimi_audits.jsonl";
|
||||
const REPO_ROOT = "/home/profit/lakehouse";
|
||||
// Canonicalize at module load — REPO_ROOT itself may be a symlink in
|
||||
// some environments (e.g. /home/profit is a bind-mount). Computing
|
||||
// once at startup means the per-finding grounding loop can compare
|
||||
// realpath(target) against this stable anchor.
|
||||
const REPO_ROOT_REAL = (() => {
|
||||
try { return realpathSync(REPO_ROOT); }
|
||||
catch { return REPO_ROOT; }
|
||||
})();
|
||||
// 15 min budget. Bun's fetch has an intrinsic ~300s limit that our
|
||||
// AbortController + setTimeout combo could not override; we use curl
|
||||
// via Bun.spawn instead (callKimi below). Curl honors -m for max
|
||||
// transfer time without a hard intrinsic ceiling.
|
||||
const CALL_TIMEOUT_MS = 900_000;
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_DIFF_CHARS = 180_000;
|
||||
const MAX_PRIOR_FINDINGS = 50;
|
||||
// Default provider/model = ollama_cloud/kimi-k2.6. Pre-2026-04-27 we
|
||||
// went direct to api.kimi.com, but Ollama Cloud Pro now exposes the
|
||||
// same model legitimately, so we route there to avoid User-Agent
|
||||
// gating. The api.kimi.com path (provider=kimi) remains wired in the
|
||||
// gateway as a fallback for when Ollama Cloud is upstream-broken.
|
||||
const KIMI_PROVIDER = process.env.LH_AUDITOR_KIMI_PROVIDER ?? "ollama_cloud";
|
||||
const KIMI_MODEL = process.env.LH_AUDITOR_KIMI_MODEL ?? "kimi-k2.6";
|
||||
// Cross-lineage alternation. 2026-04-27 J's call: Opus is too
|
||||
// expensive to auto-fire (~$0.30/audit). Kimi K2.6 via Go-sub is
|
||||
// effectively free; Haiku 4.5 via Zen is ~$0.04. Alternate between
|
||||
// them so we get cross-lineage signal (Moonshot vs Anthropic) on
|
||||
// every PR's audit history without burning the budget.
|
||||
//
|
||||
// Default: Kimi K2.6 on even audits, Haiku 4.5 on odd. Each PR's
|
||||
// audits flip between vendors as new SHAs come in.
|
||||
//
|
||||
// Frontier models (Opus 4.7, GPT-5.5, Gemini 3.1) are NOT in the
|
||||
// auto path. Operator hands distilled findings to a frontier model
|
||||
// manually when high-leverage decisions need it. Removing Opus from
|
||||
// auto-promotion saves ~$1-3/day on the daemon at our cadence.
|
||||
//
|
||||
// Override the alternation entirely with LH_AUDITOR_KIMI_MODEL
|
||||
// (forces one model regardless of audit count); set
|
||||
// LH_AUDITOR_KIMI_ALT_MODEL to the alternate.
|
||||
const ALT_MODEL = process.env.LH_AUDITOR_KIMI_ALT_MODEL ?? "claude-haiku-4-5";
|
||||
const ALT_PROVIDER = process.env.LH_AUDITOR_KIMI_ALT_PROVIDER ?? "opencode";
|
||||
const FORCE_DEFAULT = process.env.LH_AUDITOR_KIMI_MODEL !== undefined && process.env.LH_AUDITOR_KIMI_MODEL !== "";
|
||||
|
||||
function selectModel(diffLen: number, auditIndex: number = 0): { provider: string; model: string; promoted: boolean } {
|
||||
// Operator override — env-pinned model wins.
|
||||
if (FORCE_DEFAULT) {
|
||||
return { provider: KIMI_PROVIDER, model: KIMI_MODEL, promoted: false };
|
||||
}
|
||||
// Alternate Kimi (default, even index) ↔ Haiku (alt, odd index).
|
||||
// diffLen kept in the signature for future "big diff → Haiku
|
||||
// anyway" logic; not used yet so we don't auto-burn on big PRs.
|
||||
void diffLen;
|
||||
if (auditIndex % 2 === 1) {
|
||||
return { provider: ALT_PROVIDER, model: ALT_MODEL, promoted: true };
|
||||
}
|
||||
return { provider: KIMI_PROVIDER, model: KIMI_MODEL, promoted: false };
|
||||
}
|
||||
// Model-aware max_tokens. Different upstream APIs cap at different
|
||||
// limits and reject requests that exceed them:
|
||||
// - Anthropic Opus 4.x: 32K output (with extended-output header)
|
||||
// - Anthropic Haiku 4.5: 8K output
|
||||
// - Kimi K2.6 (reasoning): 128K — needs headroom because
|
||||
// reasoning_content counts against the budget
|
||||
// - Default: 16K, conservative middle ground
|
||||
//
|
||||
// 2026-04-27 BLOCK from Opus self-audit: the prior single-default of
|
||||
// 128K worked silently (Anthropic clamps server-side) but was
|
||||
// technically invalid. Per-model caps make it explicit. Override via
|
||||
// LH_AUDITOR_KIMI_MAX_TOKENS to force a value (also fixes the empty-
|
||||
// env Number("") -> 0 trap by using `||` not `??`).
|
||||
const MAX_TOKENS_OVERRIDE = Number(process.env.LH_AUDITOR_KIMI_MAX_TOKENS) || 0;
|
||||
function maxTokensFor(model: string): number {
|
||||
if (MAX_TOKENS_OVERRIDE > 0) return MAX_TOKENS_OVERRIDE;
|
||||
if (model.startsWith("claude-opus")) return 32_000;
|
||||
if (model.startsWith("claude-haiku") || model.startsWith("claude-sonnet")) return 8_192;
|
||||
if (model.startsWith("kimi-")) return 128_000;
|
||||
if (model.startsWith("gpt-5") || model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4")) return 32_000;
|
||||
return 16_000;
|
||||
}
|
||||
|
||||
export interface KimiArchitectContext {
|
||||
pr_number: number;
|
||||
head_sha: string;
|
||||
}
|
||||
|
||||
interface KimiVerdictFile {
|
||||
pr_number: number;
|
||||
head_sha: string;
|
||||
cached_at: string;
|
||||
model: string;
|
||||
latency_ms: number;
|
||||
finish_reason: string;
|
||||
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
raw_content: string;
|
||||
findings: Finding[];
|
||||
grounding: { total: number; verified: number; rate: number };
|
||||
}
|
||||
|
||||
export async function runKimiArchitectCheck(
|
||||
diff: string,
|
||||
priorFindings: Finding[],
|
||||
ctx: KimiArchitectContext,
|
||||
): Promise<Finding[]> {
|
||||
const cachePath = join(KIMI_VERDICTS_DIR, `${ctx.pr_number}-${ctx.head_sha.slice(0, 12)}.json`);
|
||||
const outageSentinel = `${cachePath}.outage`;
|
||||
const OUTAGE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
// Outage negative-cache — if upstream failed within OUTAGE_TTL_MS,
|
||||
// skip this audit and return immediately. Prevents the daemon from
|
||||
// hammering a downed Kimi/Anthropic upstream every 90s.
|
||||
if (existsSync(outageSentinel)) {
|
||||
try {
|
||||
const s = await stat(outageSentinel);
|
||||
if (Date.now() - s.mtimeMs < OUTAGE_TTL_MS) {
|
||||
const note = JSON.parse(await readFile(outageSentinel, "utf8"));
|
||||
return [skipFinding(`upstream still down (cached ${Math.round((Date.now() - s.mtimeMs) / 1000)}s ago): ${String(note.reason).slice(0, 160)}`)];
|
||||
}
|
||||
} catch { /* malformed sentinel — fall through to fresh call */ }
|
||||
}
|
||||
|
||||
// Cost cap — return cached findings if a verdict for this exact head
|
||||
// SHA was generated within the TTL.
|
||||
const cached = await loadCachedVerdict(cachePath);
|
||||
if (cached) {
|
||||
return cached.findings.length > 0
|
||||
? cached.findings
|
||||
: [{ check: "kimi_architect" as CheckKind, severity: "info", summary: "kimi_architect cached — 0 findings", evidence: [`cache: ${cachePath}`] }];
|
||||
}
|
||||
|
||||
// Alternate model based on how many audits this PR has had — gives
|
||||
// cross-lineage signal (Kimi/Moonshot ↔ Haiku/Anthropic) on every
|
||||
// PR's audit history. Count is derived from existing kimi_verdicts
|
||||
// files for this PR; cheap O(N_PRs) directory read.
|
||||
let auditIndex = 0;
|
||||
try {
|
||||
const dir = "/home/profit/lakehouse/data/_auditor/kimi_verdicts";
|
||||
if (existsSync(dir)) {
|
||||
const all = require("node:fs").readdirSync(dir) as string[];
|
||||
auditIndex = all.filter((f) => f.startsWith(`${ctx.pr_number}-`)).length;
|
||||
}
|
||||
} catch { /* default 0 — Kimi */ }
|
||||
|
||||
const selected = selectModel(diff.length, auditIndex);
|
||||
let response: { content: string; usage: any; finish_reason: string; latency_ms: number };
|
||||
try {
|
||||
response = await callKimi(buildPrompt(diff, priorFindings, ctx), selected.provider, selected.model);
|
||||
} catch (e) {
|
||||
// Negative-cache for 10 min on outage (caught 2026-04-27 by Opus
|
||||
// self-audit): without this, every audit cycle within the 24h
|
||||
// TTL re-calls upstream while it's still down. Use a sentinel
|
||||
// file with mtime check rather than persisting a verdict so the
|
||||
// happy-path cache reader doesn't have to special-case it.
|
||||
const sentinel = `${cachePath}.outage`;
|
||||
try { await writeFile(sentinel, JSON.stringify({ at: new Date().toISOString(), reason: (e as Error).message.slice(0, 200) })); } catch {}
|
||||
return [skipFinding(`kimi call failed (${selected.model}): ${(e as Error).message.slice(0, 200)}`)];
|
||||
}
|
||||
|
||||
const findings = parseFindings(response.content);
|
||||
const grounding = await computeGrounding(findings);
|
||||
|
||||
const verdict: KimiVerdictFile = {
|
||||
pr_number: ctx.pr_number,
|
||||
head_sha: ctx.head_sha,
|
||||
cached_at: new Date().toISOString(),
|
||||
model: selected.model,
|
||||
latency_ms: response.latency_ms,
|
||||
finish_reason: response.finish_reason,
|
||||
usage: {
|
||||
prompt_tokens: response.usage?.prompt_tokens ?? 0,
|
||||
completion_tokens: response.usage?.completion_tokens ?? 0,
|
||||
total_tokens: response.usage?.total_tokens ?? 0,
|
||||
},
|
||||
raw_content: response.content,
|
||||
findings,
|
||||
grounding,
|
||||
};
|
||||
|
||||
// Cache-poisoning guard (caught 2026-04-27 by Opus self-audit):
|
||||
// when parseFindings returns 0 findings (Kimi rambled, prompt too
|
||||
// big, or the markdown shape changed and our regex missed every
|
||||
// block), persisting the empty verdict short-circuits all future
|
||||
// audits in the 24h TTL window with a useless cached "0 findings"
|
||||
// result. Better to leave no cache and re-call upstream next time.
|
||||
// Always append metrics — observability shouldn't depend on whether
|
||||
// findings parsed.
|
||||
await appendMetrics(verdict);
|
||||
if (findings.length > 0) {
|
||||
await persistVerdict(cachePath, verdict);
|
||||
return findings;
|
||||
}
|
||||
return [{
|
||||
check: "kimi_architect" as CheckKind,
|
||||
severity: "info",
|
||||
summary: `kimi_architect produced 0 ranked findings (${response.finish_reason}, ${verdict.usage.completion_tokens} tokens) — not cached`,
|
||||
evidence: [`raw saved (no cache): see kimi_audits.jsonl ${verdict.cached_at}`],
|
||||
}];
|
||||
}
|
||||
|
||||
async function loadCachedVerdict(path: string): Promise<KimiVerdictFile | null> {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const s = await stat(path);
|
||||
if (Date.now() - s.mtimeMs > CACHE_TTL_MS) return null;
|
||||
return JSON.parse(await readFile(path, "utf8")) as KimiVerdictFile;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function buildPrompt(diff: string, priorFindings: Finding[], ctx: KimiArchitectContext): string {
|
||||
const truncatedDiff = diff.length > MAX_DIFF_CHARS
|
||||
? diff.slice(0, MAX_DIFF_CHARS) + `\n\n... [truncated; original diff was ${diff.length} chars]`
|
||||
: diff;
|
||||
|
||||
const priorBlock = priorFindings
|
||||
.filter(f => f.severity !== "info")
|
||||
.slice(0, MAX_PRIOR_FINDINGS)
|
||||
.map(f => `- [${f.check}/${f.severity}] ${f.summary}${f.evidence?.[0] ? ` — ${f.evidence[0].slice(0, 160)}` : ""}`)
|
||||
.join("\n");
|
||||
|
||||
return `You are a senior software architect doing a second-pass review on PR #${ctx.pr_number} (head ${ctx.head_sha.slice(0, 12)}). The team's automated auditor (deepseek-v3.1:671b, N=3 consensus) already produced findings. Your job is NOT to repeat what they found — your job is to catch what their voting structure CAN'T see: compile errors, type-system bypasses, false telemetry, silent determinism leaks, schema-bypass anti-patterns, load-bearing assumptions that look fine line-by-line.
|
||||
|
||||
GROUNDING RULES (non-negotiable):
|
||||
- Cite file:line for EVERY finding. Lines you cite must actually contain what you claim. Confabulating a finding wastes more time than missing one.
|
||||
- If the diff is truncated and you can't verify a claim, say "diff-truncated, can't verify" — DO NOT guess.
|
||||
- Distinguish architectural concerns (no specific line) from concrete bugs (specific line). Don't dress one as the other.
|
||||
|
||||
PRIOR FINDINGS FROM DEEPSEEK CONSENSUS (do not repeat these):
|
||||
${priorBlock || "(none)"}
|
||||
|
||||
OUTPUT FORMAT (markdown):
|
||||
- ## Verdict (one sentence)
|
||||
- ## Findings (5-10 items, each formatted EXACTLY as below)
|
||||
|
||||
For each finding use this exact shape so a parser can lift them:
|
||||
|
||||
### F1: <one-line summary>
|
||||
- **Severity:** block | warn | info
|
||||
- **File:** path/to/file.ext:LINE
|
||||
- **Rationale:** one or two sentences
|
||||
|
||||
THE DIFF:
|
||||
|
||||
${truncatedDiff}
|
||||
`;
|
||||
}
|
||||
|
||||
async function callKimi(prompt: string, provider: string, model: string): Promise<{ content: string; usage: any; finish_reason: string; latency_ms: number }> {
|
||||
const t0 = Date.now();
|
||||
const body = JSON.stringify({
|
||||
provider,
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: maxTokensFor(model),
|
||||
temperature: 0.2,
|
||||
});
|
||||
// curl via Bun.spawn — bypasses Bun fetch's ~300s intrinsic ceiling.
|
||||
// -m sets the max transfer time honored end-to-end. Body is piped via
|
||||
// stdin to avoid argv length limits on big audit prompts (~50K+ tokens).
|
||||
const proc = Bun.spawn({
|
||||
cmd: [
|
||||
"curl", "-sS", "-X", "POST",
|
||||
"-m", String(Math.ceil(CALL_TIMEOUT_MS / 1000)),
|
||||
"-H", "content-type: application/json",
|
||||
"--data-binary", "@-",
|
||||
`${GATEWAY}/v1/chat`,
|
||||
],
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
proc.stdin.write(body);
|
||||
await proc.stdin.end();
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`curl exit ${exitCode}: ${stderr.slice(0, 300)}`);
|
||||
}
|
||||
let j: any;
|
||||
try { j = JSON.parse(stdout); }
|
||||
catch (e) {
|
||||
throw new Error(`bad response (${stdout.length} bytes): ${stdout.slice(0, 300)}`);
|
||||
}
|
||||
if (j.error || !j.choices) {
|
||||
throw new Error(`gateway error: ${JSON.stringify(j).slice(0, 300)}`);
|
||||
}
|
||||
return {
|
||||
content: j.choices?.[0]?.message?.content ?? "",
|
||||
usage: j.usage ?? {},
|
||||
finish_reason: j.choices?.[0]?.finish_reason ?? "unknown",
|
||||
latency_ms: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse Kimi's markdown into Finding[]. Format expected (per buildPrompt):
|
||||
// ### F<N>: <summary>
|
||||
// - **Severity:** block | warn | info
|
||||
// - **File:** path:line
|
||||
// - **Rationale:** ...
|
||||
function parseFindings(content: string): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
const blocks = content.split(/^###\s+F\d+:\s*/m).slice(1);
|
||||
for (const block of blocks) {
|
||||
const summary = (block.split("\n")[0] ?? "").trim();
|
||||
if (!summary) continue;
|
||||
const sev = /\*\*Severity:\*\*\s*(block|warn|info)/i.exec(block)?.[1]?.toLowerCase();
|
||||
const fileLine = /\*\*File:\*\*\s*(\S+)/i.exec(block)?.[1] ?? "unknown";
|
||||
const rationale = /\*\*Rationale:\*\*\s*([\s\S]+?)(?=\n###|\n\*\*|$)/i.exec(block)?.[1]?.trim() ?? "";
|
||||
const severity: Finding["severity"] = sev === "block" ? "block" : sev === "warn" ? "warn" : "info";
|
||||
findings.push({
|
||||
check: "kimi_architect" as CheckKind,
|
||||
severity,
|
||||
summary: summary.slice(0, 240),
|
||||
evidence: [fileLine, rationale.slice(0, 360)].filter(Boolean),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// For each finding's cited file:line, grep the actual file to verify
|
||||
// the line exists. Returns total + verified counts; per-finding metadata
|
||||
// is appended into the evidence array so the reader can see which
|
||||
// citations were verified.
|
||||
async function computeGrounding(findings: Finding[]): Promise<{ total: number; verified: number; rate: number }> {
|
||||
// readFile (async) instead of readFileSync — caught 2026-04-27 by
|
||||
// Kimi's self-audit. Sync I/O in an async fn blocks the event loop
|
||||
// for every cited file; doesn't matter at 10 findings, would matter
|
||||
// at 100+.
|
||||
const checks = await Promise.all(findings.map(async (f) => {
|
||||
const cite = f.evidence[0] ?? "";
|
||||
const m = /^(\S+?):(\d+)/.exec(cite);
|
||||
if (!m) return false;
|
||||
const [, relpath, lineStr] = m;
|
||||
const line = Number(lineStr);
|
||||
if (!line || !relpath) return false;
|
||||
|
||||
// Path-traversal guard, two-layer (caught 2026-04-27 by Kimi
|
||||
// self-audits on dd77632 then 2d9cb12).
|
||||
//
|
||||
// Layer 1 (lexical): resolve() normalizes `..` segments. Refuse
|
||||
// any path that doesn't anchor under REPO_ROOT.
|
||||
//
|
||||
// Layer 2 (symlink): even if the lexical path is anchored, it
|
||||
// could be a symlink whose target escapes. realpath() resolves
|
||||
// symlinks; compare the real path against REPO_ROOT_REAL.
|
||||
//
|
||||
// Both layers exist because attackers might bypass either alone:
|
||||
// raw `../etc/passwd` triggers layer 1; a planted symlink at
|
||||
// ./safe-looking-name → /etc/passwd triggers layer 2.
|
||||
const abs = resolve(REPO_ROOT, relpath);
|
||||
if (!abs.startsWith(REPO_ROOT + "/") && abs !== REPO_ROOT) {
|
||||
f.evidence.push(`[grounding: path escapes repo root, refusing]`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!existsSync(abs)) {
|
||||
f.evidence.push("[grounding: file not found]");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// Symlink-resolution check before any read. realpath() throws
|
||||
// if the file doesn't exist; existsSync above shields the
|
||||
// common case but a TOCTOU race could still error here — the
|
||||
// outer catch handles it.
|
||||
const realPath = await realpath(abs);
|
||||
if (!realPath.startsWith(REPO_ROOT_REAL + "/") && realPath !== REPO_ROOT_REAL) {
|
||||
f.evidence.push(`[grounding: symlink target escapes repo root, refusing]`);
|
||||
return false;
|
||||
}
|
||||
const lines = (await readFile(realPath, "utf8")).split("\n");
|
||||
if (line < 1 || line > lines.length) {
|
||||
f.evidence.push(`[grounding: line ${line} > EOF (${lines.length})]`);
|
||||
return false;
|
||||
}
|
||||
f.evidence.push(`[grounding: verified at ${relpath}:${line}]`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
f.evidence.push(`[grounding: read failed: ${(e as Error).message.slice(0, 80)}]`);
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
const verified = checks.filter(Boolean).length;
|
||||
const total = findings.length;
|
||||
return { total, verified, rate: total === 0 ? 0 : verified / total };
|
||||
}
|
||||
|
||||
async function persistVerdict(path: string, v: KimiVerdictFile): Promise<void> {
|
||||
await mkdir(KIMI_VERDICTS_DIR, { recursive: true });
|
||||
await writeFile(path, JSON.stringify(v, null, 2));
|
||||
}
|
||||
|
||||
async function appendMetrics(v: KimiVerdictFile): Promise<void> {
|
||||
// dirname() instead of join(path, "..") — caught 2026-04-27 by both
|
||||
// Haiku and Opus self-audits. The "/.." idiom resolves correctly
|
||||
// via Node path normalization but is non-idiomatic + breaks if the
|
||||
// path ever has trailing dots.
|
||||
await mkdir(dirname(KIMI_AUDITS_JSONL), { recursive: true });
|
||||
await appendFile(KIMI_AUDITS_JSONL, JSON.stringify({
|
||||
pr_number: v.pr_number,
|
||||
head_sha: v.head_sha,
|
||||
audited_at: v.cached_at,
|
||||
model: v.model,
|
||||
latency_ms: v.latency_ms,
|
||||
finish_reason: v.finish_reason,
|
||||
prompt_tokens: v.usage.prompt_tokens,
|
||||
completion_tokens: v.usage.completion_tokens,
|
||||
findings_total: v.findings.length,
|
||||
findings_block: v.findings.filter(f => f.severity === "block").length,
|
||||
findings_warn: v.findings.filter(f => f.severity === "warn").length,
|
||||
grounding_verified: v.grounding.verified,
|
||||
grounding_rate: Number(v.grounding.rate.toFixed(3)),
|
||||
}) + "\n");
|
||||
}
|
||||
|
||||
function skipFinding(why: string): Finding {
|
||||
return {
|
||||
check: "kimi_architect" as CheckKind,
|
||||
severity: "info",
|
||||
summary: `kimi_architect skipped — ${why}`,
|
||||
evidence: [why],
|
||||
};
|
||||
}
|
||||
+175
-48
@@ -54,49 +54,87 @@ export function runStaticCheck(diff: string): Finding[] {
|
||||
const isAuditorCheckerFile = path.startsWith("auditor/checks/") ||
|
||||
path.startsWith("auditor/fixtures/");
|
||||
|
||||
// Track multi-line backtick-template state across the file. Walks
|
||||
// all post-merge lines (context + added, skipping removed lines)
|
||||
// in order and keeps `inMultilineBacktick` flipping on each
|
||||
// unescaped backtick. Pre-2026-04-26 the per-line walk in
|
||||
// isInsideQuotedString missed `todo!()` matches inside docstring
|
||||
// template literals because the opening backtick lived on a
|
||||
// line above the match. Now we OR the file-level state into the
|
||||
// per-line check.
|
||||
let inMultilineBacktick = false;
|
||||
|
||||
for (let idx = 0; idx < lines.length; idx++) {
|
||||
const line = lines[idx];
|
||||
if (!line.startsWith("+") || line.startsWith("+++")) continue;
|
||||
const added = line.slice(1);
|
||||
|
||||
if (!isAuditorCheckerFile) {
|
||||
for (const { re, why } of BLOCK_PATTERNS) {
|
||||
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;
|
||||
// Diff bookkeeping lines and removed lines don't contribute to
|
||||
// the post-merge file's string state.
|
||||
if (line.startsWith("+++") || line.startsWith("---") ||
|
||||
line.startsWith("@@") || line.startsWith("\\ No newline")) continue;
|
||||
if (line.startsWith("-")) continue;
|
||||
|
||||
const isAdded = line.startsWith("+");
|
||||
// Strip the diff prefix (' ' for context, '+' for added).
|
||||
const body = (isAdded || line.startsWith(" ")) ? line.slice(1) : line;
|
||||
|
||||
// Compute the file-level backtick state ENTERING this line.
|
||||
// The state machine sees pattern matches against the right
|
||||
// context: a line that opens a backtick block has its own
|
||||
// pattern checks evaluated under "inside-backtick" semantics
|
||||
// for the portion AFTER the opening tick. Pre-2026-04-27 the
|
||||
// state was updated AFTER the pattern checks, so the FIRST
|
||||
// pattern on a backtick-opening line slipped through with
|
||||
// stale "outside-backtick" semantics. Caught by Kimi self-audit.
|
||||
const stateAtLineStart = inMultilineBacktick;
|
||||
const stateAtLineEnd = updateBacktickState(body, stateAtLineStart);
|
||||
|
||||
if (isAdded) {
|
||||
const added = body;
|
||||
|
||||
if (!isAuditorCheckerFile) {
|
||||
for (const { re, why } of BLOCK_PATTERNS) {
|
||||
const m = added.match(re);
|
||||
if (m && typeof m.index === "number") {
|
||||
// Skip if EITHER (a) the file was already inside a
|
||||
// multi-line backtick block when this line started, OR
|
||||
// (b) the match sits inside a quoted string literal on
|
||||
// THIS line. The earlier code only checked stateAtLineStart;
|
||||
// now we also check that the match isn't past the
|
||||
// opening backtick of a block that opens on this line.
|
||||
if (stateAtLineStart || isInsideQuotedString(added, m.index)) continue;
|
||||
findings.push({
|
||||
check: "static",
|
||||
severity: "block",
|
||||
summary: `${why} in ${path}`,
|
||||
evidence: [`${path}:+${idx + 1}: ${added.trim().slice(0, 160)}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { re, why } of WARN_COMMENT_PATTERNS) {
|
||||
if (re.test(line)) {
|
||||
findings.push({
|
||||
check: "static",
|
||||
severity: "block",
|
||||
severity: "warn",
|
||||
summary: `${why} in ${path}`,
|
||||
evidence: [`${path}:+${idx + 1}: ${added.trim().slice(0, 160)}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const { re, why } of INFO_HARDCODED_PATTERNS) {
|
||||
if (re.test(added)) {
|
||||
findings.push({
|
||||
check: "static",
|
||||
severity: "info",
|
||||
summary: `${why} in ${path}`,
|
||||
evidence: [`${path}:+${idx + 1}: ${added.trim().slice(0, 160)}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { re, why } of WARN_COMMENT_PATTERNS) {
|
||||
if (re.test(line)) {
|
||||
findings.push({
|
||||
check: "static",
|
||||
severity: "warn",
|
||||
summary: `${why} in ${path}`,
|
||||
evidence: [`${path}:+${idx + 1}: ${added.trim().slice(0, 160)}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const { re, why } of INFO_HARDCODED_PATTERNS) {
|
||||
if (re.test(added)) {
|
||||
findings.push({
|
||||
check: "static",
|
||||
severity: "info",
|
||||
summary: `${why} in ${path}`,
|
||||
evidence: [`${path}:+${idx + 1}: ${added.trim().slice(0, 160)}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Carry the end-of-line state forward to the next iteration.
|
||||
inMultilineBacktick = stateAtLineEnd;
|
||||
}
|
||||
|
||||
// "Field added but never read" heuristic — catches exactly the
|
||||
@@ -105,10 +143,20 @@ export function runStaticCheck(diff: string): Finding[] {
|
||||
// elsewhere might exist). The point is: if NEITHER this diff nor
|
||||
// any other line in the diff reads the field, the PR is shipping
|
||||
// state without a consumer.
|
||||
//
|
||||
// Serde exemption: if the field's parent struct derives Serialize
|
||||
// or Deserialize, the read-site is the macro itself — JSON
|
||||
// round-trips consume every public field. Without this exemption
|
||||
// the check produces false positives on every response/request
|
||||
// struct shipped through `/v1/*`.
|
||||
const addedLines = lines.filter(l => l.startsWith("+") && !l.startsWith("+++"))
|
||||
.map(l => l.slice(1));
|
||||
const newFields = extractNewFields(addedLines);
|
||||
for (const field of newFields) {
|
||||
const newFields = extractNewFieldsWithLine(lines);
|
||||
const seenNames = new Set<string>();
|
||||
for (const { name: field, lineIdx } of newFields) {
|
||||
if (seenNames.has(field)) continue;
|
||||
seenNames.add(field);
|
||||
if (parentStructHasSerdeDerive(lines, lineIdx)) continue;
|
||||
const readPattern = new RegExp(`[\\.:]\\s*${escape(field)}\\b|\\b${escape(field)}\\s*:`);
|
||||
// The definition line itself matches readPattern — filter it out
|
||||
// by requiring at least TWO lines in the diff mention the field
|
||||
@@ -146,26 +194,105 @@ function splitDiffByFile(diff: string): Map<string, string[]> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Extract new `pub name: Type,` fields from added lines. Rust syntax.
|
||||
// Narrowly-scoped: only matches at the start of a trimmed line,
|
||||
// requires `pub ` prefix, ignores `pub fn` / `pub struct` / etc.
|
||||
function extractNewFields(addedLines: string[]): string[] {
|
||||
const fields = new Set<string>();
|
||||
for (const line of addedLines) {
|
||||
const t = line.trim();
|
||||
// pub NAME: Type,
|
||||
// Extract new `pub name: Type,` fields from the per-file diff lines,
|
||||
// keeping each occurrence's line index so the caller can resolve the
|
||||
// parent struct. Same narrow rules as before: starts with `pub `,
|
||||
// excludes `pub fn` / `pub struct` / etc.
|
||||
function extractNewFieldsWithLine(lines: string[]): Array<{ name: string; lineIdx: number }> {
|
||||
const out: Array<{ name: string; lineIdx: number }> = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.startsWith("+") || line.startsWith("+++")) continue;
|
||||
const t = line.slice(1).trim();
|
||||
const m = t.match(/^pub\s+(?!fn\b|struct\b|enum\b|mod\b|use\b|trait\b|impl\b|const\b|static\b|type\b)(\w+)\s*:/);
|
||||
if (m) fields.add(m[1]);
|
||||
if (m) out.push({ name: m[1], lineIdx: i });
|
||||
}
|
||||
return Array.from(fields);
|
||||
return out;
|
||||
}
|
||||
|
||||
// True if the field at `fieldLineIdx` lives inside a struct whose
|
||||
// declaration carries `#[derive(... Serialize|Deserialize ...)]`. We
|
||||
// walk backward through the diff (added + context lines both count —
|
||||
// a struct declaration unchanged by the PR still appears as context)
|
||||
// to find the nearest `pub struct` boundary, then scan a few lines
|
||||
// above it for derive attributes. Conservative bounds:
|
||||
// - 80 lines back to find `struct` (struct definitions can grow large)
|
||||
// - 8 lines above the `struct` keyword for attribute lines
|
||||
// Stops the struct-search early if we hit a `}` at zero indent
|
||||
// (the previous scope) or another `pub struct` (we left ours).
|
||||
function parentStructHasSerdeDerive(lines: string[], fieldLineIdx: number): boolean {
|
||||
// Bounds-check fieldLineIdx (caught 2026-04-27 by Kimi self-audit).
|
||||
// Pre-fix: if fieldLineIdx >= lines.length, the loop ran from a
|
||||
// negative implicit upper bound (fieldLineIdx - 80 could be > 0
|
||||
// even when fieldLineIdx is past EOF) and read undefined slots.
|
||||
// Defensive: bail early on out-of-range input.
|
||||
if (fieldLineIdx < 0 || fieldLineIdx >= lines.length) return false;
|
||||
|
||||
let structLineIdx = -1;
|
||||
for (let i = fieldLineIdx - 1; i >= 0 && i >= fieldLineIdx - 80; i--) {
|
||||
const raw = lines[i];
|
||||
if (typeof raw !== "string" || raw.length === 0) continue;
|
||||
const body = stripDiffPrefix(raw);
|
||||
const trimmed = body.trim();
|
||||
if (/^pub\s+struct\s+\w/.test(trimmed)) {
|
||||
structLineIdx = i;
|
||||
break;
|
||||
}
|
||||
// Closing brace at column 0 means the enclosing scope ended above
|
||||
// the field — we're not actually inside a struct.
|
||||
if (body.startsWith("}")) return false;
|
||||
}
|
||||
if (structLineIdx < 0) return false;
|
||||
|
||||
for (let j = structLineIdx - 1; j >= 0 && j >= structLineIdx - 8; j--) {
|
||||
const raw = lines[j];
|
||||
if (typeof raw !== "string") continue;
|
||||
const trimmed = stripDiffPrefix(raw).trim();
|
||||
if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("///")) continue;
|
||||
if (!trimmed.startsWith("#[")) break;
|
||||
if (/derive\s*\([^)]*\b(Serialize|Deserialize)\b/.test(trimmed)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strip leading +/-/space from a unified-diff line, leaving the raw
|
||||
// source line. Handles the case where the line is shorter than 1 char
|
||||
// (rare but real for empty-context lines).
|
||||
function stripDiffPrefix(line: string): string {
|
||||
if (line.length === 0) return line;
|
||||
const c = line[0];
|
||||
if (c === "+" || c === "-" || c === " ") return line.slice(1);
|
||||
return line;
|
||||
}
|
||||
|
||||
// Walk a single line and toggle the cross-line backtick state on each
|
||||
// unescaped backtick. Single-quote and double-quote runs are line-
|
||||
// bounded in JS/TS/Rust by language rules (string literals don't span
|
||||
// newlines without explicit `\` continuation), so we only track
|
||||
// backticks across lines. Returns the new state for the next line.
|
||||
function updateBacktickState(line: string, inBacktick: boolean): boolean {
|
||||
let state = inBacktick;
|
||||
let inDouble = false;
|
||||
let inSingle = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
const esc = i > 0 && line[i - 1] === "\\";
|
||||
if (esc) continue;
|
||||
// Inside a multi-line backtick template, single/double quotes
|
||||
// don't open new strings — they're literal characters of the
|
||||
// template. Same applies the other way around.
|
||||
if (c === '"' && !inSingle && !state) inDouble = !inDouble;
|
||||
else if (c === "'" && !inDouble && !state) inSingle = !inSingle;
|
||||
else if (c === "`" && !inDouble && !inSingle) state = !state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// 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).
|
||||
// "in quote" state on each unescaped quote. Per-line only — the file-
|
||||
// level walk in runStaticCheck handles multi-line backtick templates
|
||||
// via updateBacktickState.
|
||||
function isInsideQuotedString(line: string, pos: number): boolean {
|
||||
let inDouble = false, inSingle = false, inBacktick = false;
|
||||
for (let i = 0; i < pos; i++) {
|
||||
|
||||
+68
-9
@@ -51,11 +51,20 @@ const WEAK_PATTERNS: RegExp[] = [
|
||||
|
||||
// Empirical claims: runtime measurements / observed outcomes that can't
|
||||
// be verified from a diff (only from the actual run that produced
|
||||
// them). Example: "6/6 iterations complete, 58 cloud calls, 306s
|
||||
// end-to-end" — true, but only the test's own summary.json can
|
||||
// confirm it. Classifying as empirical lets the inference check skip
|
||||
// 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,
|
||||
@@ -66,6 +75,30 @@ const EMPIRICAL_PATTERNS: RegExp[] = [
|
||||
// "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 {
|
||||
@@ -101,7 +134,7 @@ function scanText(text: string, location_prefix: string, commit_sha: string, out
|
||||
// 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 = firstMatch(line, EMPIRICAL_PATTERNS);
|
||||
const empirical = firstUnquotedMatch(line, EMPIRICAL_PATTERNS);
|
||||
if (empirical) {
|
||||
out.push({
|
||||
text: line.trim().slice(0, 200),
|
||||
@@ -111,7 +144,7 @@ function scanText(text: string, location_prefix: string, commit_sha: string, out
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const strong = firstMatch(line, STRONG_PATTERNS);
|
||||
const strong = firstUnquotedMatch(line, STRONG_PATTERNS);
|
||||
if (strong) {
|
||||
out.push({
|
||||
text: line.trim().slice(0, 200),
|
||||
@@ -121,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),
|
||||
@@ -131,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),
|
||||
@@ -143,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;
|
||||
}
|
||||
+63
-3
@@ -24,14 +24,30 @@ const POLL_INTERVAL_MS = 90_000; // 90s — enough budget for audit runs to comp
|
||||
const PAUSE_FILE = "/home/profit/lakehouse/auditor.paused";
|
||||
const STATE_FILE = "/home/profit/lakehouse/data/_auditor/state.json";
|
||||
|
||||
// Per-PR audit cap. Prevents the daemon from running away on a PR
|
||||
// when each push surfaces new findings — operator wants to review
|
||||
// in batch, not have the daemon burn budget while they're away.
|
||||
// Default 3 audits per PR. Override via LH_AUDITOR_MAX_AUDITS_PER_PR.
|
||||
// Set to 0 to disable the cap.
|
||||
//
|
||||
// Reset (after manual review): edit data/_auditor/state.json and
|
||||
// set audit_count_per_pr.<N> = 0 (or delete the key). Daemon picks
|
||||
// up the change on the next cycle without restart.
|
||||
const MAX_AUDITS_PER_PR = Number(process.env.LH_AUDITOR_MAX_AUDITS_PER_PR) || 3;
|
||||
|
||||
interface State {
|
||||
// Map: PR number → last-audited head SHA. Lets us dedupe audits
|
||||
// across restarts (poller can crash/restart without re-auditing
|
||||
// all open PRs from scratch).
|
||||
last_audited: Record<string, string>;
|
||||
// Map: PR number → number of audits run on that PR since last reset.
|
||||
// Daemon halts auditing a PR once this hits MAX_AUDITS_PER_PR.
|
||||
// Operator clears the entry to resume.
|
||||
audit_count_per_pr: Record<string, number>;
|
||||
started_at: string;
|
||||
cycles_total: number;
|
||||
cycles_skipped_paused: number;
|
||||
cycles_skipped_capped: number;
|
||||
audits_run: number;
|
||||
last_cycle_at?: string;
|
||||
}
|
||||
@@ -47,17 +63,21 @@ async function loadState(): Promise<State> {
|
||||
return {
|
||||
last_audited: s.last_audited ?? {},
|
||||
started_at: s.started_at ?? new Date().toISOString(),
|
||||
audit_count_per_pr: s.audit_count_per_pr ?? {},
|
||||
cycles_total: s.cycles_total ?? 0,
|
||||
cycles_skipped_paused: s.cycles_skipped_paused ?? 0,
|
||||
cycles_skipped_capped: s.cycles_skipped_capped ?? 0,
|
||||
audits_run: s.audits_run ?? 0,
|
||||
last_cycle_at: s.last_cycle_at,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
last_audited: {},
|
||||
audit_count_per_pr: {},
|
||||
started_at: new Date().toISOString(),
|
||||
cycles_total: 0,
|
||||
cycles_skipped_paused: 0,
|
||||
cycles_skipped_capped: 0,
|
||||
audits_run: 0,
|
||||
};
|
||||
}
|
||||
@@ -89,12 +109,38 @@ async function runCycle(state: State): Promise<State> {
|
||||
console.log(`[auditor] cycle ${state.cycles_total}: ${prs.length} open PR(s)`);
|
||||
|
||||
for (const pr of prs) {
|
||||
const last = state.last_audited[String(pr.number)];
|
||||
const prKey = String(pr.number);
|
||||
const last = state.last_audited[prKey];
|
||||
if (last === pr.head_sha) {
|
||||
console.log(`[auditor] skip PR #${pr.number} (SHA ${pr.head_sha.slice(0, 8)} already audited)`);
|
||||
continue;
|
||||
}
|
||||
console.log(`[auditor] audit PR #${pr.number} (${pr.head_sha.slice(0, 8)}) — ${pr.title.slice(0, 60)}`);
|
||||
// Per-head-SHA audit cap. Each new push gets MAX_AUDITS_PER_PR
|
||||
// fresh attempts; the counter auto-resets when the head SHA
|
||||
// changes. Operator only intervenes manually if a single SHA
|
||||
// somehow needs MORE than the cap (rare — usually transient
|
||||
// upstream errors clear themselves inside 3 attempts).
|
||||
//
|
||||
// Reset rule: if `last` exists (we've seen this PR before) AND
|
||||
// pr.head_sha != last, that's a new push. Drop the counter.
|
||||
// The dedup branch above already handles same-SHA → skip, so
|
||||
// we only land here when the SHA actually moved.
|
||||
if (last !== undefined && (state.audit_count_per_pr[prKey] ?? 0) > 0) {
|
||||
const prior_count = state.audit_count_per_pr[prKey];
|
||||
console.log(`[auditor] PR #${pr.number} new head ${pr.head_sha.slice(0, 8)} (prior ${last.slice(0, 8)}, was ${prior_count}/${MAX_AUDITS_PER_PR}) — resetting cap counter`);
|
||||
state.audit_count_per_pr[prKey] = 0;
|
||||
}
|
||||
const auditedSoFar = state.audit_count_per_pr[prKey] ?? 0;
|
||||
if (MAX_AUDITS_PER_PR > 0 && auditedSoFar >= MAX_AUDITS_PER_PR) {
|
||||
// This branch only fires now if the SAME head SHA somehow
|
||||
// burned MAX audits (transient upstream errors retried that
|
||||
// many times). Operator can clear state.audit_count_per_pr.<N>
|
||||
// = 0 to force one more attempt; otherwise wait for next push.
|
||||
console.log(`[auditor] skip PR #${pr.number} (same head ${pr.head_sha.slice(0, 8)} burned ${auditedSoFar}/${MAX_AUDITS_PER_PR} — push new code or clear state.json audit_count_per_pr.${prKey})`);
|
||||
state.cycles_skipped_capped += 1;
|
||||
continue;
|
||||
}
|
||||
console.log(`[auditor] audit PR #${pr.number} (${pr.head_sha.slice(0, 8)}) — ${pr.title.slice(0, 60)} [${auditedSoFar + 1}/${MAX_AUDITS_PER_PR}]`);
|
||||
try {
|
||||
// Skip dynamic by default: it mutates live playbook state and
|
||||
// re-runs on every PR update would pollute quickly. Operator
|
||||
@@ -106,8 +152,22 @@ async function runCycle(state: State): Promise<State> {
|
||||
skip_inference: process.env.LH_AUDITOR_SKIP_INFERENCE === "1",
|
||||
});
|
||||
console.log(`[auditor] verdict=${verdict.overall} findings=${verdict.metrics.findings_total} (block=${verdict.metrics.findings_block} warn=${verdict.metrics.findings_warn})`);
|
||||
state.last_audited[String(pr.number)] = pr.head_sha;
|
||||
state.last_audited[prKey] = pr.head_sha;
|
||||
state.audit_count_per_pr[prKey] = auditedSoFar + 1;
|
||||
state.audits_run += 1;
|
||||
if (state.audit_count_per_pr[prKey] >= MAX_AUDITS_PER_PR) {
|
||||
console.log(`[auditor] PR #${pr.number} reached cap (${MAX_AUDITS_PER_PR} audits) — daemon will skip further audits until reset`);
|
||||
}
|
||||
// Persist state immediately after each successful audit so the
|
||||
// increment survives a crash. Pre-2026-04-27 the cycle saved
|
||||
// once at the end (main.ts:140), which lost the count if the
|
||||
// daemon was killed mid-cycle. Fix lifted from kimi_architect's
|
||||
// own audit on this very file. saveState is idempotent + cheap
|
||||
// (one JSON write), so per-audit cost is negligible.
|
||||
try { await saveState(state); }
|
||||
catch (e) {
|
||||
console.error(`[auditor] saveState mid-cycle failed: ${(e as Error).message} — count held in memory`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[auditor] audit failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -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); });
|
||||
@@ -0,0 +1,85 @@
|
||||
// drift_report.ts — comparison of a current run summary vs the
|
||||
// previous run summary on disk. Spec calls this "drift detection";
|
||||
// concretely it answers: did the pipeline behave the same way as
|
||||
// last time, and if not, was the change explained by an input change
|
||||
// or did it appear out of nowhere (silent drift)?
|
||||
//
|
||||
// Severity:
|
||||
// ok — within 20% on every metric, no hash surprises
|
||||
// warn — record-count or category swing > 20%, OR new error class
|
||||
// alert — output_hash differs while input_hash is identical
|
||||
// (deterministic violation — same input → different output)
|
||||
|
||||
import {
|
||||
ValidationResult, requireString, requireIsoTimestamp,
|
||||
} from "./types";
|
||||
import type { StageName } from "./stage_receipt";
|
||||
|
||||
export const DRIFT_REPORT_SCHEMA_VERSION = 2;
|
||||
export const DRIFT_THRESHOLD_PCT = 0.20;
|
||||
|
||||
export type DriftSeverity = "ok" | "warn" | "alert";
|
||||
|
||||
export interface StageDrift {
|
||||
stage: StageName;
|
||||
delta_records_in: number; // current - prior
|
||||
delta_records_out: number;
|
||||
delta_accepted: number;
|
||||
delta_quarantined: number;
|
||||
pct_change_out: number | null; // null when prior had 0 records
|
||||
// null when input_hash isn't materialized into the stage summary —
|
||||
// schema v1 lied and reported `true` here. v2 is honest: callers
|
||||
// that want determinism enforcement must read the full StageReceipt
|
||||
// off disk and compute input_hash equality there.
|
||||
input_hash_match: boolean | null;
|
||||
output_hash_match: boolean;
|
||||
// alert if input_hash matches but output_hash diverges
|
||||
deterministic_violation: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface DriftReport {
|
||||
schema_version: number;
|
||||
run_id: string;
|
||||
prior_run_id: string | null; // null when no prior run on disk
|
||||
generated_at: string;
|
||||
severity: DriftSeverity;
|
||||
stages: StageDrift[];
|
||||
// Top-level swings the human reader should see immediately.
|
||||
flags: string[];
|
||||
}
|
||||
|
||||
export function validateDriftReport(input: unknown): ValidationResult<DriftReport> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return { valid: false, errors: ["expected object"] };
|
||||
}
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== DRIFT_REPORT_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${DRIFT_REPORT_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.run_id, "run_id", errors) && ok;
|
||||
if (r.prior_run_id !== null && typeof r.prior_run_id !== "string") {
|
||||
errors.push("prior_run_id: must be string or null");
|
||||
ok = false;
|
||||
}
|
||||
ok = requireIsoTimestamp(r.generated_at, "generated_at", errors) && ok;
|
||||
if (!["ok", "warn", "alert"].includes(r.severity as string)) {
|
||||
errors.push(`severity: must be ok|warn|alert, got ${JSON.stringify(r.severity)}`);
|
||||
ok = false;
|
||||
}
|
||||
if (!Array.isArray(r.stages)) {
|
||||
errors.push("stages: expected array");
|
||||
ok = false;
|
||||
}
|
||||
if (!Array.isArray(r.flags)) {
|
||||
errors.push("flags: expected array");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as DriftReport };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// EvidenceRecord schema tests.
|
||||
//
|
||||
// Two positive fixtures (one per real-source prototype: distilled_facts
|
||||
// + contract_analyses) and three negative fixtures pinning the
|
||||
// non-negotiable invariants the spec demands:
|
||||
// - every record must trace to a source (provenance)
|
||||
// - schema_version must match — silent v1/v2 drift is the worst kind
|
||||
// - required identity fields (run_id) cannot be missing
|
||||
//
|
||||
// Run with: bun test auditor/schemas/distillation/evidence_record.test.ts
|
||||
|
||||
import { test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { validateEvidenceRecord, EVIDENCE_SCHEMA_VERSION } from "./evidence_record";
|
||||
|
||||
const FIXTURE_DIR = resolve(import.meta.dir, "fixtures");
|
||||
|
||||
function loadFixture(name: string): unknown {
|
||||
return JSON.parse(readFileSync(resolve(FIXTURE_DIR, name), "utf8"));
|
||||
}
|
||||
|
||||
test("EVIDENCE_SCHEMA_VERSION is 1 — bump deliberately, never silently", () => {
|
||||
expect(EVIDENCE_SCHEMA_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
test("positive: distilled_fact materialized record validates", () => {
|
||||
const r = validateEvidenceRecord(loadFixture("evidence_positive_distilled_fact.json"));
|
||||
if (!r.valid) console.error("unexpected errors:", r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
if (r.valid) {
|
||||
expect(r.value.run_id).toBe("cae21289");
|
||||
expect(r.value.model_role).toBe("extractor");
|
||||
expect(r.value.provenance.source_file).toBe("data/_kb/distilled_facts.jsonl");
|
||||
}
|
||||
});
|
||||
|
||||
test("positive: contract_analysis materialized record validates with retrieval + observer fields", () => {
|
||||
const r = validateEvidenceRecord(loadFixture("evidence_positive_contract_analysis.json"));
|
||||
if (!r.valid) console.error("unexpected errors:", r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
if (r.valid) {
|
||||
expect(r.value.observer_verdict).toBe("reject");
|
||||
expect(r.value.observer_confidence).toBe(95);
|
||||
expect(r.value.retrieved_context?.matrix_corpora?.length).toBe(4);
|
||||
expect(r.value.failure_markers).toContain("observer_rejected");
|
||||
}
|
||||
});
|
||||
|
||||
test("negative: missing run_id is rejected with a specific error", () => {
|
||||
const r = validateEvidenceRecord(loadFixture("evidence_negative_no_run_id.json"));
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) {
|
||||
expect(r.errors.some(e => e.includes("run_id"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("negative: schema_version mismatch is rejected (silent v1/v2 drift guard)", () => {
|
||||
const r = validateEvidenceRecord(loadFixture("evidence_negative_bad_schema_version.json"));
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) {
|
||||
expect(r.errors.some(e => e.includes("schema_version"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("negative: bad provenance (non-sha256 sig_hash, non-ISO timestamp) is rejected", () => {
|
||||
const r = validateEvidenceRecord(loadFixture("evidence_negative_bad_provenance.json"));
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) {
|
||||
// Must catch BOTH the sig_hash AND the recorded_at — comprehensive
|
||||
// error reporting is part of the contract.
|
||||
expect(r.errors.some(e => e.includes("sig_hash"))).toBe(true);
|
||||
expect(r.errors.some(e => e.includes("recorded_at"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("negative: non-object input is rejected with clear error", () => {
|
||||
const r = validateEvidenceRecord("not an object");
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) {
|
||||
expect(r.errors[0]).toContain("expected object");
|
||||
}
|
||||
});
|
||||
|
||||
test("negative: human_override with invalid decision is rejected", () => {
|
||||
const fixture = loadFixture("evidence_positive_distilled_fact.json") as Record<string, unknown>;
|
||||
fixture.human_override = {
|
||||
overrider: "test-user",
|
||||
decision: "maybe", // invalid — must be accept|reject|needs_review
|
||||
reason: "test",
|
||||
overridden_at: "2026-04-26T22:30:00.000Z",
|
||||
};
|
||||
const r = validateEvidenceRecord(fixture);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) {
|
||||
expect(r.errors.some(e => e.includes("human_override.decision"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("positive: human_override = null is allowed (explicitly no override)", () => {
|
||||
const fixture = loadFixture("evidence_positive_distilled_fact.json") as Record<string, unknown>;
|
||||
fixture.human_override = null;
|
||||
const r = validateEvidenceRecord(fixture);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("negative: observer_confidence outside [0, 100] is rejected", () => {
|
||||
const fixture = loadFixture("evidence_positive_contract_analysis.json") as Record<string, unknown>;
|
||||
fixture.observer_confidence = 150;
|
||||
const r = validateEvidenceRecord(fixture);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) {
|
||||
expect(r.errors.some(e => e.includes("observer_confidence"))).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// EvidenceRecord — the unified per-execution-trace record that the
|
||||
// Evidence View emits and the Success Scorer reads.
|
||||
//
|
||||
// Derived from now.md spec + reconciliation of two existing prototypes:
|
||||
// - distilled_facts.jsonl / distilled_procedures.jsonl (LLM-extracted
|
||||
// text with run_id + sig_hash + extractor + verifier + embedding)
|
||||
// - contract_analyses.jsonl (observer integration + retrieval
|
||||
// telemetry + cost + duration)
|
||||
//
|
||||
// Required fields are the ones every record MUST have for traceability:
|
||||
// run_id, task_id, timestamp, schema_version, provenance. Everything
|
||||
// else is typed-but-optional because no single source has all of them
|
||||
// — the Evidence View materializes them by JOINing across streams when
|
||||
// the source data is present.
|
||||
//
|
||||
// schema_version starts at 1 and gets bumped on breaking changes.
|
||||
// Validators MUST check schema_version and refuse unknown values so a
|
||||
// future v2 reader doesn't silently accept v1 records (or vice versa).
|
||||
|
||||
import {
|
||||
ValidationResult, Provenance,
|
||||
requireString, requireNumber, requireIsoTimestamp, requireProvenance, requireStringArray,
|
||||
} from "./types";
|
||||
|
||||
export const EVIDENCE_SCHEMA_VERSION = 1;
|
||||
|
||||
export type ModelRole =
|
||||
| "executor" // produced the answer (e.g. scrum reviewer, mode runner LLM call)
|
||||
| "reviewer" // judged an executor output (e.g. observer, hand-review)
|
||||
| "extractor" // pulled structured data from text (e.g. fact_extractor)
|
||||
| "verifier" // confirmed/rejected an extracted claim (verifier in distilled_*)
|
||||
| "categorizer" // assigned a category (categorizer in distilled_*)
|
||||
| "tiebreaker" // resolved a consensus split
|
||||
| "applier" // landed code (scrum_applier)
|
||||
| "embedder" // produced embeddings
|
||||
| "other";
|
||||
|
||||
export interface EvidenceRecord {
|
||||
// ── Identity ──
|
||||
// run_id ties this record to a specific execution. Sources use it
|
||||
// inconsistently (some stream-level, some per-call). The Evidence
|
||||
// View canonicalizes to per-call; if the source is stream-level,
|
||||
// synthesize as `${stream_run_id}:${row_index}`.
|
||||
run_id: string;
|
||||
|
||||
// task_id groups records by logical task (e.g. one PR = one task_id
|
||||
// across multiple per-call runs). Defaults to run_id when no group
|
||||
// exists — never null.
|
||||
task_id: string;
|
||||
|
||||
// ISO 8601 of when the EXECUTION happened, not when this record was
|
||||
// materialized. Use the source row's timestamp; provenance carries
|
||||
// the materialization time separately.
|
||||
timestamp: string;
|
||||
|
||||
schema_version: number;
|
||||
|
||||
// ── Provenance ── (required — no record without source linkage)
|
||||
provenance: Provenance;
|
||||
|
||||
// ── Model attribution (optional) ──
|
||||
model_name?: string; // e.g. "kimi-k2:1t", "gpt-oss:120b"
|
||||
model_provider?: string; // e.g. "ollama_cloud", "openrouter", "ollama"
|
||||
model_role?: ModelRole;
|
||||
|
||||
// ── Content hashes (optional) ──
|
||||
// sha256 of the full input prompt and full output content. Pre-
|
||||
// computed so the Evidence Index can dedup across re-runs of the
|
||||
// same prompt without re-hashing.
|
||||
input_hash?: string;
|
||||
output_hash?: string;
|
||||
|
||||
// ── Repo + execution context ──
|
||||
source_files?: string[]; // files the run touched/read
|
||||
commands_run?: string[]; // shell commands or tool calls fired
|
||||
retrieved_context?: { // what the model saw via retrieval
|
||||
matrix_corpora?: string[];
|
||||
matrix_hits?: number;
|
||||
matrix_chunks_kept?: number;
|
||||
matrix_chunks_dropped?: number;
|
||||
pathway_fingerprints_seen?: number;
|
||||
};
|
||||
|
||||
// ── Observer + scratchpad ──
|
||||
observer_notes?: string[]; // observer.review() free-form notes
|
||||
observer_verdict?: "accept" | "reject" | "cycle" | string;
|
||||
observer_confidence?: number; // 0-100
|
||||
scratchpad_summary?: string; // tree-split scratchpad text or hash ref
|
||||
|
||||
// ── Outcome markers ──
|
||||
// Both arrays exist because a run can have multiple succeeded gates
|
||||
// AND multiple failed gates simultaneously. Empty arrays are valid;
|
||||
// missing arrays are also valid (means "no evidence either way").
|
||||
success_markers?: string[]; // e.g. "cargo_green", "tests_passed", "anchor_grounded"
|
||||
failure_markers?: string[]; // e.g. "warning_count_up", "rationale_mismatch", "consensus_split"
|
||||
|
||||
// ── Validation telemetry ──
|
||||
validation_results?: {
|
||||
grounded_fraction?: number; // mode_compare grounding %
|
||||
schema_valid?: boolean;
|
||||
pathway_replay_succeeded?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// ── Human-in-loop ──
|
||||
human_override?: {
|
||||
overrider: string; // user identifier
|
||||
decision: "accept" | "reject" | "needs_review";
|
||||
reason: string;
|
||||
overridden_at: string; // ISO 8601
|
||||
} | null;
|
||||
|
||||
// ── Performance ──
|
||||
cost_usd?: number;
|
||||
latency_ms?: number;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
|
||||
// ── Free-form text content (the actual run output) ──
|
||||
// Optional because some sources are pure metadata (auto_apply.jsonl)
|
||||
// and have no text payload. Present for distilled_*, contract_analyses,
|
||||
// mode_experiments, scrum_reviews etc.
|
||||
text?: string;
|
||||
|
||||
// ── Domain-specific metadata bucket ──
|
||||
// Source-specific fields that don't earn a top-level slot. e.g.
|
||||
// contract_analyses rows carry `contractor` here; mode_experiments
|
||||
// could carry `corpus_set`. Typed scalar values only — keep this
|
||||
// small or it becomes a junk drawer. Added 2026-04-27 (Kimi audit
|
||||
// flagged `(ev as any).contractor` schema bypass at export_sft.ts:126).
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export function validateEvidenceRecord(input: unknown): ValidationResult<EvidenceRecord> {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return { valid: false, errors: ["expected object, got " + (input === null ? "null" : typeof input)] };
|
||||
}
|
||||
const r = input as Record<string, unknown>;
|
||||
|
||||
// Required
|
||||
let ok = true;
|
||||
ok = requireString(r.run_id, "run_id", errors) && ok;
|
||||
ok = requireString(r.task_id, "task_id", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.timestamp, "timestamp", errors) && ok;
|
||||
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
|
||||
|
||||
if (r.schema_version !== EVIDENCE_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${EVIDENCE_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
|
||||
// Optional but typed-when-present
|
||||
if (r.model_role !== undefined) {
|
||||
const valid: ModelRole[] = ["executor", "reviewer", "extractor", "verifier", "categorizer", "tiebreaker", "applier", "embedder", "other"];
|
||||
if (!valid.includes(r.model_role as ModelRole)) {
|
||||
errors.push(`model_role: must be one of ${valid.join("|")}, got ${JSON.stringify(r.model_role)}`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (r.input_hash !== undefined && !/^[0-9a-f]{64}$/.test(String(r.input_hash))) {
|
||||
errors.push("input_hash: must be hex sha256 when present");
|
||||
ok = false;
|
||||
}
|
||||
if (r.output_hash !== undefined && !/^[0-9a-f]{64}$/.test(String(r.output_hash))) {
|
||||
errors.push("output_hash: must be hex sha256 when present");
|
||||
ok = false;
|
||||
}
|
||||
if (r.source_files !== undefined && !requireStringArray(r.source_files, "source_files", errors)) ok = false;
|
||||
if (r.commands_run !== undefined && !requireStringArray(r.commands_run, "commands_run", errors)) ok = false;
|
||||
if (r.success_markers !== undefined && !requireStringArray(r.success_markers, "success_markers", errors)) ok = false;
|
||||
if (r.failure_markers !== undefined && !requireStringArray(r.failure_markers, "failure_markers", errors)) ok = false;
|
||||
if (r.observer_notes !== undefined && !requireStringArray(r.observer_notes, "observer_notes", errors)) ok = false;
|
||||
|
||||
if (r.observer_confidence !== undefined) {
|
||||
if (!requireNumber(r.observer_confidence, "observer_confidence", errors)) ok = false;
|
||||
else if ((r.observer_confidence as number) < 0 || (r.observer_confidence as number) > 100) {
|
||||
errors.push("observer_confidence: must be in [0, 100]");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (r.human_override !== undefined && r.human_override !== null) {
|
||||
const ho = r.human_override as Record<string, unknown>;
|
||||
if (typeof ho !== "object") {
|
||||
errors.push("human_override: expected object or null");
|
||||
ok = false;
|
||||
} else {
|
||||
ok = requireString(ho.overrider, "human_override.overrider", errors) && ok;
|
||||
ok = requireString(ho.reason, "human_override.reason", errors) && ok;
|
||||
ok = requireIsoTimestamp(ho.overridden_at, "human_override.overridden_at", errors) && ok;
|
||||
if (!["accept", "reject", "needs_review"].includes(ho.decision as string)) {
|
||||
errors.push(`human_override.decision: must be accept|reject|needs_review`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as EvidenceRecord };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"run_id": "cae21289",
|
||||
"task_id": "team_runs:637",
|
||||
"timestamp": "2026-04-23T09:54:40.729599Z",
|
||||
"schema_version": 1,
|
||||
"provenance": {
|
||||
"source_file": "data/_kb/distilled_facts.jsonl",
|
||||
"sig_hash": "not-a-real-sha256",
|
||||
"recorded_at": "yesterday"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"run_id": "cae21289",
|
||||
"task_id": "team_runs:637",
|
||||
"timestamp": "2026-04-23T09:54:40.729599Z",
|
||||
"schema_version": 99,
|
||||
"provenance": {
|
||||
"source_file": "data/_kb/distilled_facts.jsonl",
|
||||
"sig_hash": "21a809e2dc43dfae0000000000000000000000000000000000000000deadbeef",
|
||||
"recorded_at": "2026-04-26T22:30:00.000Z"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"task_id": "team_runs:637",
|
||||
"timestamp": "2026-04-23T09:54:40.729599Z",
|
||||
"schema_version": 1,
|
||||
"provenance": {
|
||||
"source_file": "data/_kb/distilled_facts.jsonl",
|
||||
"sig_hash": "21a809e2dc43dfae0000000000000000000000000000000000000000deadbeef",
|
||||
"recorded_at": "2026-04-26T22:30:00.000Z"
|
||||
},
|
||||
"text": "missing run_id should fail validation"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"run_id": "contract_analysis:101078392:1777250758717",
|
||||
"task_id": "permit:101078392",
|
||||
"timestamp": "2026-04-25T23:45:58.717Z",
|
||||
"schema_version": 1,
|
||||
"provenance": {
|
||||
"source_file": "data/_kb/contract_analyses.jsonl",
|
||||
"line_offset": 0,
|
||||
"sig_hash": "f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1",
|
||||
"recorded_at": "2026-04-26T22:30:00.000Z"
|
||||
},
|
||||
"model_name": "kimi-k2:1t",
|
||||
"model_role": "executor",
|
||||
"model_provider": "ollama_cloud",
|
||||
"retrieved_context": {
|
||||
"matrix_corpora": ["entity_brief_v1", "chicago_permits_v1", "distilled_procedural_v20260423102847", "sec_tickers_v1"],
|
||||
"matrix_hits": 10
|
||||
},
|
||||
"observer_notes": ["contractor history shows 0 prior fills in Chicago downtown zone"],
|
||||
"observer_verdict": "reject",
|
||||
"observer_confidence": 95,
|
||||
"success_markers": ["matrix_hits_above_threshold"],
|
||||
"failure_markers": ["observer_rejected"],
|
||||
"cost_usd": 0.0002,
|
||||
"latency_ms": 25419,
|
||||
"text": "Permit 101078392 contractor ANTHONY FIORE — analysis: insufficient prior performance signal; recommend escalation."
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"run_id": "cae21289",
|
||||
"task_id": "team_runs:637",
|
||||
"timestamp": "2026-04-23T09:54:40.729599Z",
|
||||
"schema_version": 1,
|
||||
"provenance": {
|
||||
"source_file": "data/_kb/distilled_facts.jsonl",
|
||||
"line_offset": 0,
|
||||
"sig_hash": "21a809e2dc43dfae0000000000000000000000000000000000000000deadbeef",
|
||||
"recorded_at": "2026-04-26T22:30:00.000Z"
|
||||
},
|
||||
"model_name": "qwen2.5:latest",
|
||||
"model_role": "extractor",
|
||||
"model_provider": "ollama",
|
||||
"text": "Convergence refers to the system stabilizing into a state of high performance with low variance across iterations.",
|
||||
"validation_results": {
|
||||
"schema_valid": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// ModelLedgerEntry — aggregate per-task-type-per-model performance.
|
||||
// Built by aggregating mode_experiments.jsonl + model_trust.jsonl.
|
||||
// Updated rather than appended — one row per (model_name, task_type)
|
||||
// representing latest aggregates.
|
||||
import {
|
||||
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireStringArray,
|
||||
} from "./types";
|
||||
|
||||
export const MODEL_LEDGER_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface ModelLedgerEntry {
|
||||
schema_version: number;
|
||||
model_name: string;
|
||||
model_provider: string;
|
||||
task_type: string;
|
||||
success_rate: number; // [0, 1]
|
||||
failure_modes: string[]; // top failure mode tags
|
||||
best_partner_model?: string; // pairs well with X (consensus / tie-break)
|
||||
escalation_role?: string; // when this model gets escalated TO (or FROM)
|
||||
cost_usd_p50?: number;
|
||||
latency_ms_p50?: number;
|
||||
latency_ms_p95?: number;
|
||||
context_window?: number;
|
||||
sample_count: number;
|
||||
last_updated: string; // ISO 8601
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export function validateModelLedgerEntry(input: unknown): ValidationResult<ModelLedgerEntry> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== MODEL_LEDGER_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${MODEL_LEDGER_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.model_name, "model_name", errors) && ok;
|
||||
ok = requireString(r.model_provider, "model_provider", errors) && ok;
|
||||
ok = requireString(r.task_type, "task_type", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.last_updated, "last_updated", errors) && ok;
|
||||
ok = requireStringArray(r.failure_modes, "failure_modes", errors) && ok;
|
||||
|
||||
if (!requireNumber(r.success_rate, "success_rate", errors)) ok = false;
|
||||
else if ((r.success_rate as number) < 0 || (r.success_rate as number) > 1) {
|
||||
errors.push("success_rate: must be in [0, 1]"); ok = false;
|
||||
}
|
||||
if (!requireNumber(r.sample_count, "sample_count", errors)) ok = false;
|
||||
else if ((r.sample_count as number) < 1 || !Number.isInteger(r.sample_count)) {
|
||||
errors.push("sample_count: must be positive integer (no aggregate from zero samples)"); ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as ModelLedgerEntry };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Playbook — procedural knowledge extracted from accepted/partially-
|
||||
// accepted runs. Different from pathway_memory's bug_fingerprints (which
|
||||
// are pattern-detectors) — playbooks describe HOW to handle a task type.
|
||||
import {
|
||||
ValidationResult, requireString, requireIsoTimestamp, requireProvenance, requireStringArray,
|
||||
} from "./types";
|
||||
|
||||
export const PLAYBOOK_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface Playbook {
|
||||
schema_version: number;
|
||||
playbook_id: string;
|
||||
task_type: string; // e.g. "scrum_review", "pr_audit", "staffing.fill"
|
||||
problem_pattern: string; // when does this playbook apply?
|
||||
useful_context: string[]; // what to retrieve before running
|
||||
model_routing_path: string[]; // ordered model attempts that worked
|
||||
commands_worked: string[];
|
||||
commands_failed: string[];
|
||||
validation_steps: string[];
|
||||
repo_files_touched: string[];
|
||||
recovery_strategy: string; // what to do when the path fails
|
||||
known_failure_modes: string[];
|
||||
escalation_threshold: string; // when to switch to a stronger model
|
||||
acceptance_criteria: string[]; // how to know it succeeded
|
||||
source_run_ids: string[]; // FK to EvidenceRecord.run_id (provenance — every playbook traces to source)
|
||||
created_at: string;
|
||||
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
|
||||
}
|
||||
|
||||
export function validatePlaybook(input: unknown): ValidationResult<Playbook> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== PLAYBOOK_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${PLAYBOOK_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.playbook_id, "playbook_id", errors) && ok;
|
||||
ok = requireString(r.task_type, "task_type", errors) && ok;
|
||||
ok = requireString(r.problem_pattern, "problem_pattern", errors) && ok;
|
||||
ok = requireString(r.recovery_strategy, "recovery_strategy", errors) && ok;
|
||||
ok = requireString(r.escalation_threshold, "escalation_threshold", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.created_at, "created_at", errors) && ok;
|
||||
ok = requireStringArray(r.useful_context, "useful_context", errors) && ok;
|
||||
ok = requireStringArray(r.model_routing_path, "model_routing_path", errors) && ok;
|
||||
ok = requireStringArray(r.commands_worked, "commands_worked", errors) && ok;
|
||||
ok = requireStringArray(r.commands_failed, "commands_failed", errors) && ok;
|
||||
ok = requireStringArray(r.validation_steps, "validation_steps", errors) && ok;
|
||||
ok = requireStringArray(r.repo_files_touched, "repo_files_touched", errors) && ok;
|
||||
ok = requireStringArray(r.known_failure_modes, "known_failure_modes", errors) && ok;
|
||||
ok = requireStringArray(r.acceptance_criteria, "acceptance_criteria", errors) && ok;
|
||||
ok = requireStringArray(r.source_run_ids, "source_run_ids", errors) && ok;
|
||||
|
||||
if (Array.isArray(r.source_run_ids) && r.source_run_ids.length === 0) {
|
||||
errors.push("source_run_ids: must be non-empty — every playbook traces to source evidence (spec non-negotiable)");
|
||||
ok = false;
|
||||
}
|
||||
if (Array.isArray(r.acceptance_criteria) && r.acceptance_criteria.length === 0) {
|
||||
errors.push("acceptance_criteria: must be non-empty — every playbook needs success criteria (spec non-negotiable)");
|
||||
ok = false;
|
||||
}
|
||||
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as Playbook };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// PreferenceSample — entry in exports/preference/chosen_rejected.jsonl.
|
||||
// Source: real disagreements (audit_discrepancies, scrum ladder retries).
|
||||
// Validator pins: chosen != rejected, both source_run_ids present, reason
|
||||
// is non-empty. No synthesized preferences.
|
||||
import {
|
||||
ValidationResult, requireString, requireIsoTimestamp, requireProvenance,
|
||||
} from "./types";
|
||||
|
||||
export const PREFERENCE_SAMPLE_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface PreferenceSample {
|
||||
schema_version: number;
|
||||
id: string;
|
||||
prompt: string;
|
||||
chosen: string;
|
||||
rejected: string;
|
||||
reason: string; // why chosen > rejected — must be non-empty
|
||||
chosen_run_id: string;
|
||||
rejected_run_id: string;
|
||||
created_at: string;
|
||||
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
|
||||
}
|
||||
|
||||
export function validatePreferenceSample(input: unknown): ValidationResult<PreferenceSample> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== PREFERENCE_SAMPLE_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${PREFERENCE_SAMPLE_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.id, "id", errors) && ok;
|
||||
ok = requireString(r.prompt, "prompt", errors) && ok;
|
||||
ok = requireString(r.chosen, "chosen", errors) && ok;
|
||||
ok = requireString(r.rejected, "rejected", errors) && ok;
|
||||
ok = requireString(r.reason, "reason", errors) && ok;
|
||||
ok = requireString(r.chosen_run_id, "chosen_run_id", errors) && ok;
|
||||
ok = requireString(r.rejected_run_id, "rejected_run_id", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.created_at, "created_at", errors) && ok;
|
||||
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
|
||||
|
||||
// Self-pairing guard.
|
||||
if (r.chosen === r.rejected && typeof r.chosen === "string") {
|
||||
errors.push("chosen and rejected must differ — preference data needs a real disagreement");
|
||||
ok = false;
|
||||
}
|
||||
if (r.chosen_run_id === r.rejected_run_id && typeof r.chosen_run_id === "string") {
|
||||
errors.push("chosen_run_id and rejected_run_id must differ — same run can't disagree with itself");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.reason === "string" && (r.reason as string).trim().length === 0) {
|
||||
errors.push("reason: must be non-whitespace (every preference needs WHY chosen > rejected)");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as PreferenceSample };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// RagSample — entry in exports/rag/playbooks.jsonl. Spec shape exactly,
|
||||
// plus provenance + success_score (so the index can re-rank by quality).
|
||||
import {
|
||||
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireProvenance, requireStringArray,
|
||||
} from "./types";
|
||||
|
||||
export const RAG_SAMPLE_SCHEMA_VERSION = 1;
|
||||
|
||||
// Allowed source_category values. RAG accepts accepted/partial freely;
|
||||
// needs_human_review is opt-in (must be tagged so consumers can filter
|
||||
// it out for SFT).
|
||||
export const RAG_ALLOWED_CATEGORIES = ["accepted", "partially_accepted", "needs_human_review"] as const;
|
||||
export type RagSourceCategory = (typeof RAG_ALLOWED_CATEGORIES)[number];
|
||||
|
||||
export interface RagSample {
|
||||
schema_version: number;
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
source_run_id: string;
|
||||
// Snapshot of the score the source carried at export time. Lets a
|
||||
// consumer see "this was partial" without re-reading scored-runs.
|
||||
success_score: RagSourceCategory;
|
||||
// Same value as success_score by spec (now.md asks for both fields).
|
||||
// Kept distinct so future schemas can diverge them (e.g. an
|
||||
// "is_review_material" flag) without breaking old consumers.
|
||||
source_category: RagSourceCategory;
|
||||
embedding_text: string; // the text to embed (often == content but can be shorter)
|
||||
created_at: string;
|
||||
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
|
||||
}
|
||||
|
||||
export function validateRagSample(input: unknown): ValidationResult<RagSample> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== RAG_SAMPLE_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${RAG_SAMPLE_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.id, "id", errors) && ok;
|
||||
ok = requireString(r.title, "title", errors) && ok;
|
||||
ok = requireString(r.content, "content", errors) && ok;
|
||||
ok = requireString(r.embedding_text, "embedding_text", errors) && ok;
|
||||
ok = requireString(r.source_run_id, "source_run_id", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.created_at, "created_at", errors) && ok;
|
||||
ok = requireStringArray(r.tags, "tags", errors) && ok;
|
||||
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
|
||||
|
||||
if (!RAG_ALLOWED_CATEGORIES.includes(r.success_score as RagSourceCategory)) {
|
||||
errors.push(`success_score: must be one of ${RAG_ALLOWED_CATEGORIES.join("|")} (rejected never enters RAG)`);
|
||||
ok = false;
|
||||
}
|
||||
if (!RAG_ALLOWED_CATEGORIES.includes(r.source_category as RagSourceCategory)) {
|
||||
errors.push(`source_category: must be one of ${RAG_ALLOWED_CATEGORIES.join("|")}`);
|
||||
ok = false;
|
||||
}
|
||||
if (r.success_score !== r.source_category) {
|
||||
errors.push("success_score and source_category must match (mirrored fields per spec)");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.content === "string" && (r.content as string).trim().length === 0) {
|
||||
errors.push("content: must be non-whitespace");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as RagSample };
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// Real-data validation test — proves the EvidenceRecord schema fits
|
||||
// what we ALREADY produce, with the minimum transformation each source
|
||||
// stream requires. Doubles as the stale-extraction probe: if
|
||||
// distilled_facts.jsonl rows can't materialize, we know that stream
|
||||
// has rotted and Phase 2 sources from elsewhere.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Read first N rows from each source jsonl (skip if missing)
|
||||
// 2. Apply minimal transformer: add schema_version + provenance,
|
||||
// synthesize run_id/task_id when source doesn't carry them
|
||||
// 3. Validate each materialized record
|
||||
// 4. Tally pass/fail per source + collect failure reasons
|
||||
//
|
||||
// This file is allowed to skip when source files don't exist (fresh
|
||||
// clone), so it acts as both a CI guard and a real-environment probe.
|
||||
|
||||
import { test, expect } from "bun:test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
validateEvidenceRecord, EVIDENCE_SCHEMA_VERSION, EvidenceRecord, ModelRole,
|
||||
} from "./evidence_record";
|
||||
|
||||
const ROOT = "/home/profit/lakehouse";
|
||||
const SAMPLE_PER_SOURCE = 10;
|
||||
|
||||
interface SourceProbe {
|
||||
source_file: string;
|
||||
transform: (row: any, lineNo: number) => Partial<EvidenceRecord> | null;
|
||||
}
|
||||
|
||||
// Canonical 64-char synthetic sha256 for tests where the source row
|
||||
// lacks one. Pretends the materializer would compute it via
|
||||
// canonicalSha256(orderedKeys(row)) at Phase 2 time. We use a fixed
|
||||
// value here to keep the test deterministic; real materialization
|
||||
// re-hashes per row.
|
||||
const PLACEHOLDER_SHA = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
const RECORDED = "2026-04-26T22:30:00.000Z";
|
||||
|
||||
function provFor(source_file: string, lineNo: number, sigHashRaw?: string): EvidenceRecord["provenance"] {
|
||||
// Pad shorter hashes (distilled_* uses 16-char) to 64 — mimics
|
||||
// canonical recompute.
|
||||
const sig = sigHashRaw && /^[0-9a-f]+$/.test(sigHashRaw)
|
||||
? sigHashRaw.padEnd(64, "0").slice(0, 64)
|
||||
: PLACEHOLDER_SHA;
|
||||
return {
|
||||
source_file: source_file.replace(`${ROOT}/`, ""),
|
||||
line_offset: lineNo,
|
||||
sig_hash: sig,
|
||||
recorded_at: RECORDED,
|
||||
};
|
||||
}
|
||||
|
||||
const PROBES: SourceProbe[] = [
|
||||
{
|
||||
source_file: `${ROOT}/data/_kb/distilled_facts.jsonl`,
|
||||
transform: (row: any, lineNo: number) => ({
|
||||
run_id: String(row.run_id ?? `distilled_facts:${lineNo}`),
|
||||
task_id: String(row.source_label ?? `distilled_facts:${lineNo}`),
|
||||
timestamp: row.created_at,
|
||||
schema_version: EVIDENCE_SCHEMA_VERSION,
|
||||
provenance: provFor(`${ROOT}/data/_kb/distilled_facts.jsonl`, lineNo, row.sig_hash),
|
||||
model_name: row.extractor,
|
||||
model_role: "extractor" as ModelRole,
|
||||
model_provider: "ollama",
|
||||
text: row.text,
|
||||
}),
|
||||
},
|
||||
{
|
||||
source_file: `${ROOT}/data/_kb/distilled_procedures.jsonl`,
|
||||
transform: (row: any, lineNo: number) => ({
|
||||
run_id: String(row.run_id ?? `distilled_procedures:${lineNo}`),
|
||||
task_id: String(row.source_label ?? `distilled_procedures:${lineNo}`),
|
||||
timestamp: row.created_at,
|
||||
schema_version: EVIDENCE_SCHEMA_VERSION,
|
||||
provenance: provFor(`${ROOT}/data/_kb/distilled_procedures.jsonl`, lineNo, row.sig_hash),
|
||||
model_name: row.extractor,
|
||||
model_role: "extractor" as ModelRole,
|
||||
model_provider: "ollama",
|
||||
text: row.text,
|
||||
}),
|
||||
},
|
||||
{
|
||||
source_file: `${ROOT}/data/_kb/contract_analyses.jsonl`,
|
||||
transform: (row: any, lineNo: number) => ({
|
||||
run_id: `contract_analysis:${row.permit_id}:${new Date(row.ts).getTime()}`,
|
||||
task_id: `permit:${row.permit_id}`,
|
||||
timestamp: row.ts,
|
||||
schema_version: EVIDENCE_SCHEMA_VERSION,
|
||||
provenance: provFor(`${ROOT}/data/_kb/contract_analyses.jsonl`, lineNo),
|
||||
model_role: "executor" as ModelRole,
|
||||
retrieved_context: {
|
||||
matrix_corpora: Object.keys(row.matrix_corpora ?? {}),
|
||||
matrix_hits: row.matrix_hits,
|
||||
},
|
||||
observer_notes: row.observer_notes ? [row.observer_notes].flat() : undefined,
|
||||
observer_verdict: row.observer_verdict,
|
||||
observer_confidence: row.observer_conf,
|
||||
success_markers: row.ok ? ["matrix_hits_above_threshold"] : undefined,
|
||||
failure_markers: !row.ok || row.observer_verdict === "reject" ? ["observer_rejected"] : undefined,
|
||||
cost_usd: typeof row.cost === "number" ? row.cost / 1_000_000 : undefined,
|
||||
latency_ms: row.duration_ms,
|
||||
text: row.analysis,
|
||||
}),
|
||||
},
|
||||
{
|
||||
source_file: `${ROOT}/data/_kb/mode_experiments.jsonl`,
|
||||
transform: (row: any, lineNo: number) => ({
|
||||
run_id: `mode_exec:${new Date(row.ts).getTime()}:${row.file_path ?? "?"}`,
|
||||
task_id: row.task_class,
|
||||
timestamp: row.ts,
|
||||
schema_version: EVIDENCE_SCHEMA_VERSION,
|
||||
provenance: provFor(`${ROOT}/data/_kb/mode_experiments.jsonl`, lineNo),
|
||||
model_name: row.model,
|
||||
model_role: "executor" as ModelRole,
|
||||
model_provider: row.model?.includes("/") ? "openrouter" : "ollama_cloud",
|
||||
retrieved_context: {
|
||||
matrix_corpora: row.sources?.matrix_corpus,
|
||||
matrix_chunks_kept: row.sources?.matrix_chunks_kept,
|
||||
matrix_chunks_dropped: row.sources?.matrix_chunks_dropped,
|
||||
pathway_fingerprints_seen: row.sources?.bug_fingerprints_count,
|
||||
},
|
||||
latency_ms: row.latency_ms,
|
||||
text: row.response,
|
||||
source_files: row.file_path ? [row.file_path] : undefined,
|
||||
}),
|
||||
},
|
||||
{
|
||||
source_file: `${ROOT}/data/_kb/scrum_reviews.jsonl`,
|
||||
transform: (row: any, lineNo: number) => ({
|
||||
run_id: `scrum:${new Date(row.reviewed_at).getTime()}:${row.file}`,
|
||||
task_id: `scrum_review:${row.file}`,
|
||||
timestamp: row.reviewed_at,
|
||||
schema_version: EVIDENCE_SCHEMA_VERSION,
|
||||
provenance: provFor(`${ROOT}/data/_kb/scrum_reviews.jsonl`, lineNo),
|
||||
model_name: row.accepted_model,
|
||||
model_role: "executor" as ModelRole,
|
||||
source_files: [row.file],
|
||||
success_markers: row.accepted_on_attempt ? [`accepted_on_attempt_${row.accepted_on_attempt}`] : undefined,
|
||||
text: row.suggestions_preview,
|
||||
}),
|
||||
},
|
||||
{
|
||||
source_file: `${ROOT}/data/_kb/observer_escalations.jsonl`,
|
||||
transform: (row: any, lineNo: number) => ({
|
||||
run_id: `obs_esc:${new Date(row.ts).getTime()}:${row.sig_hash}`,
|
||||
task_id: `observer_escalation:${row.cluster_endpoint ?? "?"}`,
|
||||
timestamp: row.ts,
|
||||
schema_version: EVIDENCE_SCHEMA_VERSION,
|
||||
provenance: provFor(`${ROOT}/data/_kb/observer_escalations.jsonl`, lineNo, row.sig_hash),
|
||||
model_role: "reviewer" as ModelRole,
|
||||
prompt_tokens: row.prompt_tokens,
|
||||
completion_tokens: row.completion_tokens,
|
||||
text: row.analysis,
|
||||
}),
|
||||
},
|
||||
{
|
||||
source_file: `${ROOT}/data/_kb/audit_facts.jsonl`,
|
||||
transform: (row: any, lineNo: number) => ({
|
||||
run_id: `audit_facts:${row.head_sha}:${lineNo}`,
|
||||
task_id: `pr:${row.pr_number}`,
|
||||
timestamp: row.extracted_at,
|
||||
schema_version: EVIDENCE_SCHEMA_VERSION,
|
||||
provenance: provFor(`${ROOT}/data/_kb/audit_facts.jsonl`, lineNo),
|
||||
model_name: row.extractor,
|
||||
model_role: "extractor" as ModelRole,
|
||||
// facts/entities/relationships go into text as a JSON dump for now;
|
||||
// structured handling lives in Phase 2 where we map to specific
|
||||
// EvidenceRecord substructures.
|
||||
text: JSON.stringify({
|
||||
facts: row.facts?.length ?? 0,
|
||||
entities: row.entities?.length ?? 0,
|
||||
relationships: row.relationships?.length ?? 0,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
interface ProbeResult {
|
||||
source_file: string;
|
||||
rows_attempted: number;
|
||||
rows_present: boolean;
|
||||
passed: number;
|
||||
failed: number;
|
||||
failure_reasons: string[]; // unique error strings, top 5
|
||||
}
|
||||
|
||||
const RESULTS: ProbeResult[] = [];
|
||||
|
||||
for (const probe of PROBES) {
|
||||
const sourceLabel = probe.source_file.replace(`${ROOT}/`, "");
|
||||
|
||||
test(`real-data: ${sourceLabel}`, () => {
|
||||
const result: ProbeResult = {
|
||||
source_file: sourceLabel,
|
||||
rows_attempted: 0,
|
||||
rows_present: false,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
failure_reasons: [],
|
||||
};
|
||||
|
||||
if (!existsSync(probe.source_file)) {
|
||||
RESULTS.push(result);
|
||||
// Skip silently — fresh clones won't have these files
|
||||
return;
|
||||
}
|
||||
|
||||
result.rows_present = true;
|
||||
const lines = readFileSync(probe.source_file, "utf8").split("\n").filter(Boolean).slice(0, SAMPLE_PER_SOURCE);
|
||||
const reasons = new Set<string>();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
result.rows_attempted++;
|
||||
let row: unknown;
|
||||
try { row = JSON.parse(lines[i]); }
|
||||
catch { continue; }
|
||||
|
||||
const transformed = probe.transform(row, i);
|
||||
if (!transformed) continue;
|
||||
|
||||
const v = validateEvidenceRecord(transformed);
|
||||
if (v.valid) result.passed++;
|
||||
else {
|
||||
result.failed++;
|
||||
for (const e of v.errors) reasons.add(e);
|
||||
}
|
||||
}
|
||||
result.failure_reasons = Array.from(reasons).slice(0, 5);
|
||||
RESULTS.push(result);
|
||||
|
||||
// Test passes as long as we attempted something and got a result.
|
||||
// Per-source pass/fail counts are reported in the markdown writeup.
|
||||
expect(result.rows_attempted).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("real-data: emit markdown report", () => {
|
||||
const md: string[] = [];
|
||||
md.push("# Real-data validation report");
|
||||
md.push("");
|
||||
md.push("Schema = EvidenceRecord v" + EVIDENCE_SCHEMA_VERSION + ". Sample = first " + SAMPLE_PER_SOURCE + " rows per source.");
|
||||
md.push("");
|
||||
md.push("| Source | Present | Rows | Pass | Fail | Pass% |");
|
||||
md.push("|---|---|---|---|---|---|");
|
||||
for (const r of RESULTS) {
|
||||
const pct = r.rows_attempted > 0 ? Math.round(100 * r.passed / r.rows_attempted) + "%" : "—";
|
||||
md.push(`| ${r.source_file} | ${r.rows_present ? "✓" : "—"} | ${r.rows_attempted} | ${r.passed} | ${r.failed} | ${pct} |`);
|
||||
}
|
||||
md.push("");
|
||||
let hasFailures = false;
|
||||
for (const r of RESULTS) {
|
||||
if (r.failed > 0) {
|
||||
hasFailures = true;
|
||||
md.push(`## Failures in ${r.source_file}`);
|
||||
for (const reason of r.failure_reasons) md.push(`- \`${reason}\``);
|
||||
md.push("");
|
||||
}
|
||||
}
|
||||
if (!hasFailures) {
|
||||
md.push("**No failures across all probed sources.** Every materialized record validates against EvidenceRecord v1.");
|
||||
md.push("");
|
||||
}
|
||||
// Stale extraction probe: explicit pass/fail
|
||||
const distilledFacts = RESULTS.find(r => r.source_file.endsWith("distilled_facts.jsonl"));
|
||||
const distilledProc = RESULTS.find(r => r.source_file.endsWith("distilled_procedures.jsonl"));
|
||||
md.push("## Stale-extraction probe");
|
||||
md.push("");
|
||||
if (distilledFacts && distilledFacts.rows_present && distilledFacts.passed > 0) {
|
||||
md.push(`- **distilled_facts.jsonl:** ${distilledFacts.passed}/${distilledFacts.rows_attempted} materialize cleanly. Stream is alive at the schema level.`);
|
||||
} else if (distilledFacts && !distilledFacts.rows_present) {
|
||||
md.push(`- **distilled_facts.jsonl:** missing — stale or never produced. Phase 2 sources from live streams instead.`);
|
||||
} else {
|
||||
md.push(`- **distilled_facts.jsonl:** present but materialization failures; treat as suspect, prefer mode_experiments + scrum_reviews.`);
|
||||
}
|
||||
if (distilledProc && distilledProc.rows_present && distilledProc.passed > 0) {
|
||||
md.push(`- **distilled_procedures.jsonl:** ${distilledProc.passed}/${distilledProc.rows_attempted} materialize cleanly.`);
|
||||
}
|
||||
md.push("");
|
||||
|
||||
// Write the markdown to a stable path and stdout
|
||||
const out = md.join("\n");
|
||||
Bun.write(`${ROOT}/data/_kb/realdata_validation_report.md`, out);
|
||||
console.log("\n" + out);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// Receipt — per-pipeline-stage record with everything needed to
|
||||
// reproduce the run. Spec non-negotiable: substantive receipts, not
|
||||
// "ran successfully". Every field below has a deterministic source so
|
||||
// the receipt schema validator catches "I forgot to fill it in" the
|
||||
// same way it catches type errors.
|
||||
import {
|
||||
ValidationResult, requireString, requireNumber, requireIsoTimestamp,
|
||||
} from "./types";
|
||||
|
||||
export const RECEIPT_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface FileReference {
|
||||
path: string; // relative to repo root
|
||||
sha256: string; // hex
|
||||
bytes?: number; // optional but recommended
|
||||
}
|
||||
|
||||
export interface Receipt {
|
||||
schema_version: number;
|
||||
command: string; // shell-line or script identifier
|
||||
git_sha: string; // 40-char hex (full SHA1)
|
||||
git_branch?: string;
|
||||
git_dirty?: boolean; // true if working tree had uncommitted changes
|
||||
started_at: string; // ISO 8601
|
||||
ended_at: string; // ISO 8601
|
||||
duration_ms: number;
|
||||
input_files: FileReference[];
|
||||
output_files: FileReference[];
|
||||
record_counts: {
|
||||
in: number;
|
||||
out: number;
|
||||
[key: string]: number; // per-stage extras (filtered, dropped, etc.)
|
||||
};
|
||||
validation_pass: boolean; // explicit — never inferred
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function validateFileRef(v: unknown, field: string, errors: string[]): boolean {
|
||||
if (typeof v !== "object" || v === null) {
|
||||
errors.push(`${field}: expected object`);
|
||||
return false;
|
||||
}
|
||||
const f = v as Record<string, unknown>;
|
||||
let ok = true;
|
||||
ok = requireString(f.path, `${field}.path`, errors) && ok;
|
||||
if (typeof f.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(f.sha256)) {
|
||||
errors.push(`${field}.sha256: must be hex sha256`);
|
||||
ok = false;
|
||||
}
|
||||
if (f.bytes !== undefined && typeof f.bytes !== "number") {
|
||||
errors.push(`${field}.bytes: expected number when present`);
|
||||
ok = false;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
export function validateReceipt(input: unknown): ValidationResult<Receipt> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return { valid: false, errors: ["expected object"] };
|
||||
}
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== RECEIPT_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${RECEIPT_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.command, "command", errors) && ok;
|
||||
if (typeof r.git_sha !== "string" || !/^[0-9a-f]{40}$/.test(r.git_sha as string)) {
|
||||
errors.push("git_sha: must be 40-char hex");
|
||||
ok = false;
|
||||
}
|
||||
ok = requireIsoTimestamp(r.started_at, "started_at", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.ended_at, "ended_at", errors) && ok;
|
||||
ok = requireNumber(r.duration_ms, "duration_ms", errors) && ok;
|
||||
if (typeof r.validation_pass !== "boolean") {
|
||||
errors.push("validation_pass: must be boolean (explicit, never inferred)");
|
||||
ok = false;
|
||||
}
|
||||
if (!Array.isArray(r.input_files)) {
|
||||
errors.push("input_files: expected array");
|
||||
ok = false;
|
||||
} else {
|
||||
for (let i = 0; i < r.input_files.length; i++) {
|
||||
if (!validateFileRef(r.input_files[i], `input_files[${i}]`, errors)) ok = false;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(r.output_files)) {
|
||||
errors.push("output_files: expected array");
|
||||
ok = false;
|
||||
} else {
|
||||
for (let i = 0; i < r.output_files.length; i++) {
|
||||
if (!validateFileRef(r.output_files[i], `output_files[${i}]`, errors)) ok = false;
|
||||
}
|
||||
}
|
||||
if (typeof r.record_counts !== "object" || r.record_counts === null) {
|
||||
errors.push("record_counts: expected object");
|
||||
ok = false;
|
||||
} else {
|
||||
const rc = r.record_counts as Record<string, unknown>;
|
||||
if (typeof rc.in !== "number") { errors.push("record_counts.in: expected number"); ok = false; }
|
||||
if (typeof rc.out !== "number") { errors.push("record_counts.out: expected number"); ok = false; }
|
||||
}
|
||||
if (!Array.isArray(r.errors)) { errors.push("errors: expected array"); ok = false; }
|
||||
if (!Array.isArray(r.warnings)) { errors.push("warnings: expected array"); ok = false; }
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as Receipt };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// run_summary.ts — aggregates StageReceipt rows for one run_id.
|
||||
// Spec field set: total records processed, total accepted/rejected/
|
||||
// quarantined, dataset sizes, validation status, overall hash of run.
|
||||
|
||||
import {
|
||||
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireSha256,
|
||||
} from "./types";
|
||||
import type { StageName } from "./stage_receipt";
|
||||
|
||||
export const RUN_SUMMARY_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface RunStageSummary {
|
||||
stage: StageName;
|
||||
records_in: number;
|
||||
records_out: number;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
quarantined: number;
|
||||
skipped: number;
|
||||
passed: boolean;
|
||||
duration_ms: number;
|
||||
output_hash: string;
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
schema_version: number;
|
||||
run_id: string;
|
||||
started_at: string; // earliest stage timestamp
|
||||
ended_at: string; // latest stage timestamp + duration
|
||||
git_commit: string;
|
||||
stages: RunStageSummary[];
|
||||
// Aggregates across stages
|
||||
total_records_in: number;
|
||||
total_records_out: number;
|
||||
total_accepted: number;
|
||||
total_rejected: number;
|
||||
total_quarantined: number;
|
||||
total_skipped: number;
|
||||
// Dataset sizes — final outputs of each export stage
|
||||
rag_records: number;
|
||||
sft_records: number;
|
||||
preference_pairs: number;
|
||||
// Pipeline-wide pass = AND of every stage validation.passed
|
||||
overall_passed: boolean;
|
||||
// Run-wide hash: sha256 over each stage's output hash, sorted by stage name.
|
||||
// Detects ANY change in any stage output across runs.
|
||||
run_hash: string;
|
||||
total_duration_ms: number;
|
||||
}
|
||||
|
||||
export function validateRunSummary(input: unknown): ValidationResult<RunSummary> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return { valid: false, errors: ["expected object"] };
|
||||
}
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== RUN_SUMMARY_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${RUN_SUMMARY_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.run_id, "run_id", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.started_at, "started_at", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.ended_at, "ended_at", errors) && ok;
|
||||
if (typeof r.git_commit !== "string" || !/^[0-9a-f]{40}$/.test(r.git_commit as string)) {
|
||||
errors.push("git_commit: must be 40-char hex");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.overall_passed !== "boolean") {
|
||||
errors.push("overall_passed: must be boolean");
|
||||
ok = false;
|
||||
}
|
||||
ok = requireSha256(r.run_hash, "run_hash", errors) && ok;
|
||||
for (const k of ["total_records_in", "total_records_out", "total_accepted", "total_rejected",
|
||||
"total_quarantined", "total_skipped", "rag_records", "sft_records",
|
||||
"preference_pairs", "total_duration_ms"]) {
|
||||
if (typeof (r as any)[k] !== "number") {
|
||||
errors.push(`${k}: expected number`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(r.stages)) {
|
||||
errors.push("stages: expected array");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as RunSummary };
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Combined schema tests for ScoredRun, Receipt, Playbook,
|
||||
// ScratchpadSummary, ModelLedgerEntry, RagSample, SftSample,
|
||||
// PreferenceSample. EvidenceRecord lives in its own file because it's
|
||||
// the foundational schema and warrants the JSON-fixture round-trip
|
||||
// pattern; the rest use inline fixture makers since they're simpler.
|
||||
//
|
||||
// Each schema: 1 positive fixture + 4-5 negative cases pinning the
|
||||
// non-negotiable invariants from now.md.
|
||||
//
|
||||
// Run: bun test auditor/schemas/distillation/schemas.test.ts
|
||||
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
import { validateScoredRun, SCORED_RUN_SCHEMA_VERSION } from "./scored_run";
|
||||
import { validateReceipt, RECEIPT_SCHEMA_VERSION } from "./receipt";
|
||||
import { validatePlaybook, PLAYBOOK_SCHEMA_VERSION } from "./playbook";
|
||||
import { validateScratchpadSummary, SCRATCHPAD_SCHEMA_VERSION } from "./scratchpad_summary";
|
||||
import { validateModelLedgerEntry, MODEL_LEDGER_SCHEMA_VERSION } from "./model_ledger";
|
||||
import { validateRagSample, RAG_SAMPLE_SCHEMA_VERSION } from "./rag_sample";
|
||||
import { validateSftSample, SFT_SAMPLE_SCHEMA_VERSION } from "./sft_sample";
|
||||
import { validatePreferenceSample, PREFERENCE_SAMPLE_SCHEMA_VERSION } from "./preference_sample";
|
||||
|
||||
const NOW = "2026-04-26T22:30:00.000Z";
|
||||
const SHA = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const GIT_SHA = "f753e11157eef753e11157eef753e11157eef753";
|
||||
|
||||
const PROVENANCE = {
|
||||
source_file: "data/_kb/scored_runs.jsonl",
|
||||
line_offset: 0,
|
||||
sig_hash: SHA,
|
||||
recorded_at: NOW,
|
||||
};
|
||||
|
||||
// ─── ScoredRun ───────────────────────────────────────────────────────
|
||||
|
||||
const SCORED_RUN_OK = {
|
||||
schema_version: SCORED_RUN_SCHEMA_VERSION,
|
||||
evidence_run_id: "run-abc",
|
||||
evidence_task_id: "task-abc",
|
||||
category: "accepted",
|
||||
reasons: ["cargo_green=true", "anchor_grounding=0.95"],
|
||||
scored_at: NOW,
|
||||
scorer_version: "v1.0.0",
|
||||
sub_scores: { cargo_green: true, anchor_grounding: 0.95 },
|
||||
provenance: PROVENANCE,
|
||||
};
|
||||
|
||||
test("ScoredRun: positive validates", () => {
|
||||
const r = validateScoredRun(SCORED_RUN_OK);
|
||||
if (!r.valid) console.error(r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("ScoredRun: empty reasons rejected (every score needs a reason)", () => {
|
||||
const r = validateScoredRun({ ...SCORED_RUN_OK, reasons: [] });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("reasons"))).toBe(true);
|
||||
});
|
||||
|
||||
test("ScoredRun: invalid category rejected", () => {
|
||||
const r = validateScoredRun({ ...SCORED_RUN_OK, category: "maybe_ok" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("category"))).toBe(true);
|
||||
});
|
||||
|
||||
test("ScoredRun: anchor_grounding > 1 rejected (must be in [0, 1])", () => {
|
||||
const r = validateScoredRun({ ...SCORED_RUN_OK, sub_scores: { ...SCORED_RUN_OK.sub_scores, anchor_grounding: 1.5 } });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("anchor_grounding"))).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Receipt ─────────────────────────────────────────────────────────
|
||||
|
||||
const RECEIPT_OK = {
|
||||
schema_version: RECEIPT_SCHEMA_VERSION,
|
||||
command: "bun run scripts/build_evidence_index.ts",
|
||||
git_sha: GIT_SHA,
|
||||
git_branch: "scrum/auto-apply-19814",
|
||||
git_dirty: false,
|
||||
started_at: NOW,
|
||||
ended_at: NOW,
|
||||
duration_ms: 1234,
|
||||
input_files: [{ path: "data/_kb/scrum_reviews.jsonl", sha256: SHA, bytes: 448000 }],
|
||||
output_files: [{ path: "data/evidence/2026/04/26/run.jsonl", sha256: SHA }],
|
||||
record_counts: { in: 100, out: 95, filtered: 5 },
|
||||
validation_pass: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
test("Receipt: positive validates", () => {
|
||||
const r = validateReceipt(RECEIPT_OK);
|
||||
if (!r.valid) console.error(r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("Receipt: bad git_sha rejected (must be 40-char hex)", () => {
|
||||
const r = validateReceipt({ ...RECEIPT_OK, git_sha: "abc123" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("git_sha"))).toBe(true);
|
||||
});
|
||||
|
||||
test("Receipt: validation_pass must be boolean (never inferred)", () => {
|
||||
const r = validateReceipt({ ...RECEIPT_OK, validation_pass: "yes" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("validation_pass"))).toBe(true);
|
||||
});
|
||||
|
||||
test("Receipt: file refs without proper sha256 rejected", () => {
|
||||
const r = validateReceipt({ ...RECEIPT_OK, output_files: [{ path: "x", sha256: "short" }] });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("sha256"))).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Playbook ────────────────────────────────────────────────────────
|
||||
|
||||
const PLAYBOOK_OK = {
|
||||
schema_version: PLAYBOOK_SCHEMA_VERSION,
|
||||
playbook_id: "pb-scrum-review-001",
|
||||
task_type: "scrum_review",
|
||||
problem_pattern: "Cargo workspace warning escalation after applier patch",
|
||||
useful_context: ["pathway memory bug fingerprints for the file area"],
|
||||
model_routing_path: ["x-ai/grok-4.1-fast"],
|
||||
commands_worked: ["cargo check --workspace"],
|
||||
commands_failed: [],
|
||||
validation_steps: ["warning count must not increase"],
|
||||
repo_files_touched: ["crates/queryd/src/service.rs"],
|
||||
recovery_strategy: "git checkout -- file when cargo red",
|
||||
known_failure_modes: ["unused import noise"],
|
||||
escalation_threshold: "use kimi-k2:1t when isolation mode rejects 2 attempts",
|
||||
acceptance_criteria: ["cargo green", "warning count stable", "rationale-diff aligned"],
|
||||
source_run_ids: ["run-xyz", "run-abc"],
|
||||
created_at: NOW,
|
||||
provenance: PROVENANCE,
|
||||
};
|
||||
|
||||
test("Playbook: positive validates", () => {
|
||||
const r = validatePlaybook(PLAYBOOK_OK);
|
||||
if (!r.valid) console.error(r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("Playbook: empty source_run_ids rejected (every playbook traces to source — spec)", () => {
|
||||
const r = validatePlaybook({ ...PLAYBOOK_OK, source_run_ids: [] });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("source_run_ids"))).toBe(true);
|
||||
});
|
||||
|
||||
test("Playbook: empty acceptance_criteria rejected (every playbook needs success criteria — spec)", () => {
|
||||
const r = validatePlaybook({ ...PLAYBOOK_OK, acceptance_criteria: [] });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("acceptance_criteria"))).toBe(true);
|
||||
});
|
||||
|
||||
// ─── ScratchpadSummary ───────────────────────────────────────────────
|
||||
|
||||
const SCRATCHPAD_OK = {
|
||||
schema_version: SCRATCHPAD_SCHEMA_VERSION,
|
||||
run_id: "run-abc",
|
||||
current_objective: "verify pr_audit mode end-to-end",
|
||||
completed_steps: ["restart gateway"],
|
||||
failed_steps: ["cloud chat returned 500"],
|
||||
pending_steps: ["swap default model"],
|
||||
important_paths: ["auditor/checks/inference.ts"],
|
||||
decisions: ["defer kimi-k2 swap until upstream returns"],
|
||||
unresolved_questions: ["does deepseek match kimi quality?"],
|
||||
validation_status: "partial",
|
||||
next_command: "bun run auditor/audit_one.ts 11",
|
||||
source_scratchpad_hash: SHA,
|
||||
summarized_at: NOW,
|
||||
provenance: PROVENANCE,
|
||||
};
|
||||
|
||||
test("ScratchpadSummary: positive validates", () => {
|
||||
const r = validateScratchpadSummary(SCRATCHPAD_OK);
|
||||
if (!r.valid) console.error(r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("ScratchpadSummary: invalid validation_status rejected", () => {
|
||||
const r = validateScratchpadSummary({ ...SCRATCHPAD_OK, validation_status: "tbd" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("validation_status"))).toBe(true);
|
||||
});
|
||||
|
||||
test("ScratchpadSummary: short scratchpad_hash rejected", () => {
|
||||
const r = validateScratchpadSummary({ ...SCRATCHPAD_OK, source_scratchpad_hash: "short" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("source_scratchpad_hash"))).toBe(true);
|
||||
});
|
||||
|
||||
// ─── ModelLedgerEntry ────────────────────────────────────────────────
|
||||
|
||||
const LEDGER_OK = {
|
||||
schema_version: MODEL_LEDGER_SCHEMA_VERSION,
|
||||
model_name: "kimi-k2:1t",
|
||||
model_provider: "ollama_cloud",
|
||||
task_type: "pr_audit",
|
||||
success_rate: 0.85,
|
||||
failure_modes: ["upstream_500", "context_truncation"],
|
||||
best_partner_model: "x-ai/grok-4.1-fast",
|
||||
escalation_role: "primary",
|
||||
cost_usd_p50: 0.0002,
|
||||
latency_ms_p50: 50000,
|
||||
latency_ms_p95: 90000,
|
||||
context_window: 200000,
|
||||
sample_count: 47,
|
||||
last_updated: NOW,
|
||||
};
|
||||
|
||||
test("ModelLedgerEntry: positive validates", () => {
|
||||
const r = validateModelLedgerEntry(LEDGER_OK);
|
||||
if (!r.valid) console.error(r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("ModelLedgerEntry: success_rate > 1 rejected", () => {
|
||||
const r = validateModelLedgerEntry({ ...LEDGER_OK, success_rate: 1.5 });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("success_rate"))).toBe(true);
|
||||
});
|
||||
|
||||
test("ModelLedgerEntry: zero sample_count rejected (no aggregate from zero)", () => {
|
||||
const r = validateModelLedgerEntry({ ...LEDGER_OK, sample_count: 0 });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("sample_count"))).toBe(true);
|
||||
});
|
||||
|
||||
// ─── RagSample ───────────────────────────────────────────────────────
|
||||
|
||||
const RAG_OK = {
|
||||
schema_version: RAG_SAMPLE_SCHEMA_VERSION,
|
||||
id: "rag-pb-001",
|
||||
title: "Scrum applier rationale-diff alignment",
|
||||
content: "When the applier emits a patch with rationale claiming X but the diff shows Y, the rationale-token alignment gate catches it...",
|
||||
tags: ["scrum_review", "applier"],
|
||||
source_run_id: "run-xyz",
|
||||
success_score: "accepted",
|
||||
source_category: "accepted",
|
||||
embedding_text: "applier rationale-diff alignment guard scrum",
|
||||
created_at: NOW,
|
||||
provenance: PROVENANCE,
|
||||
};
|
||||
|
||||
test("RagSample: positive validates", () => {
|
||||
const r = validateRagSample(RAG_OK);
|
||||
if (!r.valid) console.error(r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("RagSample: success_score=rejected forbidden (RAG never takes rejected)", () => {
|
||||
const r = validateRagSample({ ...RAG_OK, success_score: "rejected", source_category: "rejected" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("success_score"))).toBe(true);
|
||||
});
|
||||
|
||||
test("RagSample: success_score and source_category must match", () => {
|
||||
const r = validateRagSample({ ...RAG_OK, success_score: "accepted", source_category: "partially_accepted" });
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
test("RagSample: whitespace-only content rejected", () => {
|
||||
const r = validateRagSample({ ...RAG_OK, content: " \n " });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("content"))).toBe(true);
|
||||
});
|
||||
|
||||
// ─── SftSample (the strict one) ──────────────────────────────────────
|
||||
|
||||
const SFT_OK = {
|
||||
schema_version: SFT_SAMPLE_SCHEMA_VERSION,
|
||||
id: "sft-pr11-001",
|
||||
instruction: "Audit this PR diff against ship-claims.",
|
||||
context: "claims: 3 strong, 2 moderate",
|
||||
response: "{\"claim_verdicts\": [...]}",
|
||||
source_run_id: "run-pr11",
|
||||
quality_score: "accepted",
|
||||
created_at: NOW,
|
||||
provenance: PROVENANCE,
|
||||
};
|
||||
|
||||
test("SftSample: positive validates", () => {
|
||||
const r = validateSftSample(SFT_OK);
|
||||
if (!r.valid) console.error(r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("SftSample: quality_score=partially_accepted ACCEPTED (--include-partial path)", () => {
|
||||
// Phase 4 update: partial allowed at schema layer; CLI gate decides.
|
||||
const r = validateSftSample({ ...SFT_OK, quality_score: "partially_accepted" });
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("SftSample: quality_score=rejected REJECTED (spec non-negotiable, no leak)", () => {
|
||||
const r = validateSftSample({ ...SFT_OK, quality_score: "rejected" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("quality_score"))).toBe(true);
|
||||
});
|
||||
|
||||
test("SftSample: quality_score=needs_human_review REJECTED (no leak)", () => {
|
||||
const r = validateSftSample({ ...SFT_OK, quality_score: "needs_human_review" });
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
test("SftSample: missing context rejected (must be string, even if empty)", () => {
|
||||
const fixture: Record<string, unknown> = { ...SFT_OK };
|
||||
delete fixture.context;
|
||||
const r = validateSftSample(fixture);
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("context"))).toBe(true);
|
||||
});
|
||||
|
||||
test("SftSample: empty-string context allowed", () => {
|
||||
const r = validateSftSample({ ...SFT_OK, context: "" });
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("SftSample: empty response rejected (no empty pairs)", () => {
|
||||
const r = validateSftSample({ ...SFT_OK, response: "" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("response"))).toBe(true);
|
||||
});
|
||||
|
||||
test("SftSample: whitespace-only instruction rejected", () => {
|
||||
const r = validateSftSample({ ...SFT_OK, instruction: " \t\n " });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("instruction"))).toBe(true);
|
||||
});
|
||||
|
||||
// ─── PreferenceSample ────────────────────────────────────────────────
|
||||
|
||||
const PREF_OK = {
|
||||
schema_version: PREFERENCE_SAMPLE_SCHEMA_VERSION,
|
||||
id: "pref-task-x-001",
|
||||
prompt: "Verify claim: 'all 3 services running on matrix-test'",
|
||||
chosen: "{\"backed\": true, \"evidence\": \"systemctl status confirms 3 active\"}",
|
||||
rejected: "{\"backed\": true, \"evidence\": \"the README says so\"}",
|
||||
reason: "chosen cites runtime evidence, rejected cites doc claim only",
|
||||
chosen_run_id: "run-A",
|
||||
rejected_run_id: "run-B",
|
||||
created_at: NOW,
|
||||
provenance: PROVENANCE,
|
||||
};
|
||||
|
||||
test("PreferenceSample: positive validates", () => {
|
||||
const r = validatePreferenceSample(PREF_OK);
|
||||
if (!r.valid) console.error(r.errors);
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("PreferenceSample: chosen == rejected rejected (no self-pairing)", () => {
|
||||
const r = validatePreferenceSample({ ...PREF_OK, chosen: "x", rejected: "x" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("chosen and rejected"))).toBe(true);
|
||||
});
|
||||
|
||||
test("PreferenceSample: chosen_run_id == rejected_run_id rejected (no self-disagreement)", () => {
|
||||
const r = validatePreferenceSample({ ...PREF_OK, chosen_run_id: "run-A", rejected_run_id: "run-A" });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("chosen_run_id"))).toBe(true);
|
||||
});
|
||||
|
||||
test("PreferenceSample: empty reason rejected (every preference needs WHY)", () => {
|
||||
const r = validatePreferenceSample({ ...PREF_OK, reason: " " });
|
||||
expect(r.valid).toBe(false);
|
||||
if (!r.valid) expect(r.errors.some(e => e.includes("reason"))).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// ScoredRun — output of the deterministic Success Scorer (Phase 3).
|
||||
// Spec mandates 4 categories with explicit reasons; we add scorer
|
||||
// versioning so a future scorer change is detectable in historical data.
|
||||
import {
|
||||
ValidationResult, requireString, requireIsoTimestamp, requireProvenance, requireStringArray, requireNumber,
|
||||
} from "./types";
|
||||
|
||||
export const SCORED_RUN_SCHEMA_VERSION = 1;
|
||||
export const SCORE_CATEGORIES = ["accepted", "partially_accepted", "rejected", "needs_human_review"] as const;
|
||||
export type ScoreCategory = (typeof SCORE_CATEGORIES)[number];
|
||||
|
||||
export interface ScoredRun {
|
||||
schema_version: number;
|
||||
evidence_run_id: string; // FK to EvidenceRecord.run_id
|
||||
evidence_task_id: string; // FK to EvidenceRecord.task_id
|
||||
category: ScoreCategory;
|
||||
reasons: string[]; // human-readable, e.g. ["cargo_green=true", "anchor_grounding<0.7"]
|
||||
scored_at: string; // ISO 8601
|
||||
scorer_version: string; // e.g. "v1.0.0" — bumped on scorer code change
|
||||
// Sub-scores that the scorer collapsed into the category. Persisted
|
||||
// so a downstream UI can show "why" without re-running the scorer.
|
||||
sub_scores?: {
|
||||
cargo_green?: boolean;
|
||||
anchor_grounding?: number;
|
||||
schema_valid?: boolean;
|
||||
pathway_replay_succeeded?: boolean;
|
||||
observer_verdict?: "accept" | "reject" | "cycle";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
provenance: {
|
||||
source_file: string;
|
||||
line_offset?: number;
|
||||
sig_hash: string;
|
||||
recorded_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function validateScoredRun(input: unknown): ValidationResult<ScoredRun> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return { valid: false, errors: ["expected object"] };
|
||||
}
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
if (r.schema_version !== SCORED_RUN_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${SCORED_RUN_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.evidence_run_id, "evidence_run_id", errors) && ok;
|
||||
ok = requireString(r.evidence_task_id, "evidence_task_id", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.scored_at, "scored_at", errors) && ok;
|
||||
ok = requireString(r.scorer_version, "scorer_version", errors) && ok;
|
||||
ok = requireStringArray(r.reasons, "reasons", errors) && ok;
|
||||
if (Array.isArray(r.reasons) && r.reasons.length === 0) {
|
||||
errors.push("reasons: must be non-empty (every score must have at least one reason)");
|
||||
ok = false;
|
||||
}
|
||||
if (!SCORE_CATEGORIES.includes(r.category as ScoreCategory)) {
|
||||
errors.push(`category: must be one of ${SCORE_CATEGORIES.join("|")}, got ${JSON.stringify(r.category)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
|
||||
|
||||
if (r.sub_scores !== undefined) {
|
||||
if (typeof r.sub_scores !== "object" || r.sub_scores === null) {
|
||||
errors.push("sub_scores: expected object when present");
|
||||
ok = false;
|
||||
} else {
|
||||
const ss = r.sub_scores as Record<string, unknown>;
|
||||
if (ss.anchor_grounding !== undefined) {
|
||||
if (!requireNumber(ss.anchor_grounding, "sub_scores.anchor_grounding", errors)) ok = false;
|
||||
else if ((ss.anchor_grounding as number) < 0 || (ss.anchor_grounding as number) > 1) {
|
||||
errors.push("sub_scores.anchor_grounding: must be in [0, 1]");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (ss.observer_verdict !== undefined && !["accept", "reject", "cycle"].includes(ss.observer_verdict as string)) {
|
||||
errors.push("sub_scores.observer_verdict: must be accept|reject|cycle");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as ScoredRun };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// ScratchpadSummary — structured normalization of a tree-split or
|
||||
// long-running scratchpad. Distinct from EvidenceRecord because a
|
||||
// scratchpad accumulates across many calls; this schema captures the
|
||||
// state at a checkpoint moment.
|
||||
import {
|
||||
ValidationResult, requireString, requireIsoTimestamp, requireProvenance, requireStringArray,
|
||||
} from "./types";
|
||||
|
||||
export const SCRATCHPAD_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface ScratchpadSummary {
|
||||
schema_version: number;
|
||||
run_id: string;
|
||||
current_objective: string;
|
||||
completed_steps: string[];
|
||||
failed_steps: string[];
|
||||
pending_steps: string[];
|
||||
important_paths: string[]; // file paths the scratchpad references
|
||||
decisions: string[]; // architectural/scope decisions made
|
||||
unresolved_questions: string[];
|
||||
validation_status: "pass" | "fail" | "partial" | "pending";
|
||||
next_command?: string; // recommendation for next action
|
||||
source_scratchpad_hash: string; // sha256 of the full source scratchpad text — diff detection
|
||||
summarized_at: string; // ISO 8601
|
||||
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
|
||||
}
|
||||
|
||||
const STATUS = ["pass", "fail", "partial", "pending"];
|
||||
|
||||
export function validateScratchpadSummary(input: unknown): ValidationResult<ScratchpadSummary> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== SCRATCHPAD_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${SCRATCHPAD_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.run_id, "run_id", errors) && ok;
|
||||
ok = requireString(r.current_objective, "current_objective", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.summarized_at, "summarized_at", errors) && ok;
|
||||
if (typeof r.source_scratchpad_hash !== "string" || !/^[0-9a-f]{64}$/.test(r.source_scratchpad_hash as string)) {
|
||||
errors.push("source_scratchpad_hash: must be hex sha256");
|
||||
ok = false;
|
||||
}
|
||||
ok = requireStringArray(r.completed_steps, "completed_steps", errors) && ok;
|
||||
ok = requireStringArray(r.failed_steps, "failed_steps", errors) && ok;
|
||||
ok = requireStringArray(r.pending_steps, "pending_steps", errors) && ok;
|
||||
ok = requireStringArray(r.important_paths, "important_paths", errors) && ok;
|
||||
ok = requireStringArray(r.decisions, "decisions", errors) && ok;
|
||||
ok = requireStringArray(r.unresolved_questions, "unresolved_questions", errors) && ok;
|
||||
if (!STATUS.includes(r.validation_status as string)) {
|
||||
errors.push(`validation_status: must be one of ${STATUS.join("|")}`);
|
||||
ok = false;
|
||||
}
|
||||
if (r.next_command !== undefined && typeof r.next_command !== "string") {
|
||||
errors.push("next_command: expected string when present");
|
||||
ok = false;
|
||||
}
|
||||
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as ScratchpadSummary };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SftSample — entry in exports/sft/instruction_response.jsonl. Spec
|
||||
// non-negotiable: ONLY accepted runs, never partial/rejected/needs_human.
|
||||
// Validator enforces that invariant — exporters can't bypass.
|
||||
import {
|
||||
ValidationResult, requireString, requireIsoTimestamp, requireProvenance, requireNumber,
|
||||
} from "./types";
|
||||
|
||||
export const SFT_SAMPLE_SCHEMA_VERSION = 1;
|
||||
|
||||
// SFT default: only `accepted` ships. With --include-partial CLI flag,
|
||||
// `partially_accepted` becomes legal. `rejected` and `needs_human_review`
|
||||
// NEVER ship to SFT — that's the contamination firewall.
|
||||
export const SFT_QUALITY_SCORES = ["accepted", "partially_accepted"] as const;
|
||||
export type SftQualityScore = (typeof SFT_QUALITY_SCORES)[number];
|
||||
|
||||
export interface SftSample {
|
||||
schema_version: number;
|
||||
id: string;
|
||||
instruction: string; // the prompt / user message
|
||||
context: string; // retrieved context that was visible (empty string allowed; null/undefined not)
|
||||
response: string; // the model output that was accepted
|
||||
source_run_id: string;
|
||||
quality_score: SftQualityScore;
|
||||
created_at: string;
|
||||
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
|
||||
}
|
||||
|
||||
export function validateSftSample(input: unknown): ValidationResult<SftSample> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== SFT_SAMPLE_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${SFT_SAMPLE_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.id, "id", errors) && ok;
|
||||
ok = requireString(r.instruction, "instruction", errors) && ok;
|
||||
ok = requireString(r.response, "response", errors) && ok;
|
||||
ok = requireString(r.source_run_id, "source_run_id", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.created_at, "created_at", errors) && ok;
|
||||
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
|
||||
|
||||
// Empty pair guard.
|
||||
if (typeof r.instruction === "string" && (r.instruction as string).trim().length === 0) {
|
||||
errors.push("instruction: must be non-whitespace (no empty pairs)");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.response === "string" && (r.response as string).trim().length === 0) {
|
||||
errors.push("response: must be non-whitespace (no empty pairs)");
|
||||
ok = false;
|
||||
}
|
||||
// Context is required-string but empty is allowed (some SFT samples
|
||||
// are pure instruction→response with no retrieval context).
|
||||
if (typeof r.context !== "string") {
|
||||
errors.push("context: expected string (use empty string for no-context samples)");
|
||||
ok = false;
|
||||
}
|
||||
// The non-negotiable: SFT samples MUST have quality_score in
|
||||
// SFT_QUALITY_SCORES. Anything else is a leak.
|
||||
if (!SFT_QUALITY_SCORES.includes(r.quality_score as SftQualityScore)) {
|
||||
errors.push(`quality_score: must be one of ${SFT_QUALITY_SCORES.join("|")} (no rejected/needs_human leak into SFT — spec non-negotiable). Got ${JSON.stringify(r.quality_score)}`);
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as SftSample };
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// stage_receipt.ts — forensic-grade per-stage receipt.
|
||||
//
|
||||
// Distinct from auditor/schemas/distillation/receipt.ts (Phase 1):
|
||||
// - Phase 1 Receipt is per-script invocation, format inherited from
|
||||
// the early auditor wiring
|
||||
// - StageReceipt (THIS file) matches the now.md Phase 5 spec exactly
|
||||
// and is the canonical artifact for pipeline observability
|
||||
//
|
||||
// Every pipeline stage (collect, score, export-rag, export-sft,
|
||||
// export-preference, future extract-playbooks/index) emits ONE
|
||||
// StageReceipt per run. Receipts are joined by `run_id` (shared
|
||||
// across all stages of a single `run-all` invocation) so a future
|
||||
// query can aggregate across the whole pipeline.
|
||||
|
||||
import {
|
||||
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireSha256,
|
||||
requireStringArray,
|
||||
} from "./types";
|
||||
|
||||
export const STAGE_RECEIPT_SCHEMA_VERSION = 1;
|
||||
|
||||
export const STAGE_NAMES = [
|
||||
"collect", // build_evidence_index — materialize source jsonls → EvidenceRecord
|
||||
"score", // score_runs — EvidenceRecord → ScoredRun
|
||||
"export-rag", // exports/rag/playbooks.jsonl
|
||||
"export-sft", // exports/sft/instruction_response.jsonl
|
||||
"export-preference",// exports/preference/chosen_rejected.jsonl
|
||||
// Reserved for future stages — accept them in the schema so a stage
|
||||
// can be added without bumping schema_version.
|
||||
"extract-playbooks",
|
||||
"index",
|
||||
] as const;
|
||||
export type StageName = (typeof STAGE_NAMES)[number];
|
||||
|
||||
export interface StageFileRef {
|
||||
path: string; // relative to repo root
|
||||
sha256: string; // 64-char hex
|
||||
bytes?: number;
|
||||
record_count?: number; // line count for jsonl, when meaningful
|
||||
}
|
||||
|
||||
export interface StageIO {
|
||||
files: StageFileRef[];
|
||||
record_count: number;
|
||||
hash: string; // 64-char hex — aggregate over all file hashes (sorted)
|
||||
}
|
||||
|
||||
export interface StageStats {
|
||||
accepted: number; // rows that ended up in the stage's output
|
||||
rejected: number; // explicit category=rejected (Score), invalid pairs (Preference), etc.
|
||||
quarantined: number; // routed to exports/quarantine/* with structured reason
|
||||
skipped: number; // parse failures, schema violations at write time
|
||||
}
|
||||
|
||||
export interface StageValidation {
|
||||
passed: boolean; // explicit boolean — never inferred (spec non-negotiable)
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface StageReceipt {
|
||||
schema_version: number;
|
||||
run_id: string; // shared across all stages of one pipeline run
|
||||
stage: StageName;
|
||||
timestamp: string; // ISO 8601 — stage start
|
||||
git_commit: string; // 40-char hex
|
||||
inputs: StageIO;
|
||||
outputs: StageIO;
|
||||
stats: StageStats;
|
||||
validation: StageValidation;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
function validateStageIO(v: unknown, field: string, errors: string[]): boolean {
|
||||
if (typeof v !== "object" || v === null) {
|
||||
errors.push(`${field}: expected object`);
|
||||
return false;
|
||||
}
|
||||
const io = v as Record<string, unknown>;
|
||||
let ok = true;
|
||||
if (!Array.isArray(io.files)) {
|
||||
errors.push(`${field}.files: expected array`);
|
||||
ok = false;
|
||||
} else {
|
||||
for (let i = 0; i < io.files.length; i++) {
|
||||
const f = io.files[i] as Record<string, unknown>;
|
||||
if (typeof f !== "object" || f === null) {
|
||||
errors.push(`${field}.files[${i}]: expected object`);
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
ok = requireString(f.path, `${field}.files[${i}].path`, errors) && ok;
|
||||
ok = requireSha256(f.sha256, `${field}.files[${i}].sha256`, errors) && ok;
|
||||
if (f.bytes !== undefined && typeof f.bytes !== "number") {
|
||||
errors.push(`${field}.files[${i}].bytes: expected number when present`);
|
||||
ok = false;
|
||||
}
|
||||
if (f.record_count !== undefined && typeof f.record_count !== "number") {
|
||||
errors.push(`${field}.files[${i}].record_count: expected number when present`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
ok = requireNumber(io.record_count, `${field}.record_count`, errors) && ok;
|
||||
ok = requireSha256(io.hash, `${field}.hash`, errors) && ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
export function validateStageReceipt(input: unknown): ValidationResult<StageReceipt> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return { valid: false, errors: ["expected object"] };
|
||||
}
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== STAGE_RECEIPT_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${STAGE_RECEIPT_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.run_id, "run_id", errors) && ok;
|
||||
if (typeof r.run_id === "string" && r.run_id.length < 8) {
|
||||
errors.push("run_id: too short — expect uuid-like");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.stage !== "string" || !STAGE_NAMES.includes(r.stage as StageName)) {
|
||||
errors.push(`stage: must be one of ${STAGE_NAMES.join("|")}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireIsoTimestamp(r.timestamp, "timestamp", errors) && ok;
|
||||
if (typeof r.git_commit !== "string" || !/^[0-9a-f]{40}$/.test(r.git_commit as string)) {
|
||||
errors.push("git_commit: must be 40-char hex");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.duration_ms !== "number") {
|
||||
errors.push("duration_ms: expected number");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.inputs !== "object" || r.inputs === null) {
|
||||
errors.push("inputs: expected object");
|
||||
ok = false;
|
||||
} else {
|
||||
ok = validateStageIO(r.inputs, "inputs", errors) && ok;
|
||||
}
|
||||
if (typeof r.outputs !== "object" || r.outputs === null) {
|
||||
errors.push("outputs: expected object");
|
||||
ok = false;
|
||||
} else {
|
||||
ok = validateStageIO(r.outputs, "outputs", errors) && ok;
|
||||
}
|
||||
if (typeof r.stats !== "object" || r.stats === null) {
|
||||
errors.push("stats: expected object");
|
||||
ok = false;
|
||||
} else {
|
||||
const s = r.stats as Record<string, unknown>;
|
||||
for (const k of ["accepted", "rejected", "quarantined", "skipped"]) {
|
||||
if (typeof s[k] !== "number") { errors.push(`stats.${k}: expected number`); ok = false; }
|
||||
}
|
||||
}
|
||||
if (typeof r.validation !== "object" || r.validation === null) {
|
||||
errors.push("validation: expected object");
|
||||
ok = false;
|
||||
} else {
|
||||
const v = r.validation as Record<string, unknown>;
|
||||
if (typeof v.passed !== "boolean") {
|
||||
errors.push("validation.passed: must be boolean (explicit, never inferred)");
|
||||
ok = false;
|
||||
}
|
||||
if (!Array.isArray(v.errors)) { errors.push("validation.errors: expected array"); ok = false; }
|
||||
if (!Array.isArray(v.warnings)) { errors.push("validation.warnings: expected array"); ok = false; }
|
||||
if (Array.isArray(v.errors)) ok = requireStringArray(v.errors, "validation.errors", errors) && ok;
|
||||
if (Array.isArray(v.warnings)) ok = requireStringArray(v.warnings, "validation.warnings", errors) && ok;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as StageReceipt };
|
||||
}
|
||||
|
||||
// Compute the canonical aggregate hash over a list of file refs.
|
||||
// Sorted by path so order-of-iteration doesn't drift the hash.
|
||||
// Each entry contributes "<path>|<sha256>|<record_count>" so two
|
||||
// files with identical content but different paths produce distinct
|
||||
// digests (real difference = real hash difference).
|
||||
export async function aggregateIoHash(files: StageFileRef[]): Promise<string> {
|
||||
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
||||
const parts = sorted.map(f => `${f.path}|${f.sha256}|${f.record_count ?? 0}`);
|
||||
const h = new Bun.CryptoHasher("sha256");
|
||||
h.update(parts.join("\n"));
|
||||
return h.digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Shared types for distillation schemas. Hand-rolled validators (no Zod
|
||||
// dependency) — bun:test runs them; runtime cost is one tiny function
|
||||
// per record. Pattern: each schema exports `validate(x): ValidationResult`
|
||||
// returning `{valid: true, value}` or `{valid: false, errors}`.
|
||||
//
|
||||
// Why hand-rolled: the auditor + scrum + observer pipelines emit JSONL
|
||||
// rows in shapes that already work; we want to ENFORCE those shapes
|
||||
// without adding a 100KB dependency or rewriting producers. The
|
||||
// validators codify what we already produce.
|
||||
//
|
||||
// Naming: schemas live as nouns (`EvidenceRecord`), validators as
|
||||
// `validate<Noun>`. Each schema file exports both the type and the
|
||||
// validator.
|
||||
|
||||
export interface Provenance {
|
||||
// Path to the JSONL or other source where this row came from. Always
|
||||
// relative to /home/profit/lakehouse so receipts are reproducible
|
||||
// across deploys with the same repo layout.
|
||||
source_file: string;
|
||||
|
||||
// Optional byte offset / line number into the source file. Lets a
|
||||
// future "open the source row" UI jump directly to the line. Some
|
||||
// sources (single-row JSON files like _playbook_lessons/*.json) don't
|
||||
// need this.
|
||||
line_offset?: number;
|
||||
|
||||
// SHA-256 of the canonical JSON of the source row (sorted keys, no
|
||||
// whitespace). This is the dedup key — running distillation twice on
|
||||
// the same source produces identical sig_hash, so duplicates are
|
||||
// detectable without full row comparison.
|
||||
sig_hash: string;
|
||||
|
||||
// ISO 8601 of when this provenance link was recorded — usually the
|
||||
// moment the unified Evidence Index ran. Distinct from the source
|
||||
// row's own timestamp, which lives on the EvidenceRecord itself.
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
// Returned by every schema validator. The shape is `{valid: true, value}`
|
||||
// for success (so callers can use `value` with the right type narrowed)
|
||||
// or `{valid: false, errors}` for failure (so callers can surface
|
||||
// every error at once, not just the first).
|
||||
export type ValidationResult<T> =
|
||||
| { valid: true; value: T }
|
||||
| { valid: false; errors: string[] };
|
||||
|
||||
// Standard helpers used by every schema. Centralized so naming +
|
||||
// error message format stay consistent across schemas.
|
||||
|
||||
export function requireString(v: unknown, field: string, errors: string[]): v is string {
|
||||
if (typeof v !== "string") {
|
||||
errors.push(`${field}: expected string, got ${typeof v}`);
|
||||
return false;
|
||||
}
|
||||
if (v.length === 0) {
|
||||
errors.push(`${field}: must be non-empty`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function requireNumber(v: unknown, field: string, errors: string[]): v is number {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||||
errors.push(`${field}: expected finite number, got ${typeof v}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function requireIsoTimestamp(v: unknown, field: string, errors: string[]): v is string {
|
||||
if (!requireString(v, field, errors)) return false;
|
||||
// Permissive ISO 8601: YYYY-MM-DDTHH:MM:SS(.fraction)?(Z|±HH:MM)?
|
||||
const re = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
|
||||
if (!re.test(v as string)) {
|
||||
errors.push(`${field}: not a valid ISO 8601 timestamp: ${(v as string).slice(0, 60)}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function requireSha256(v: unknown, field: string, errors: string[]): v is string {
|
||||
if (!requireString(v, field, errors)) return false;
|
||||
if (!/^[0-9a-f]{64}$/.test(v as string)) {
|
||||
errors.push(`${field}: not a valid hex sha256: ${(v as string).slice(0, 80)}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function requireProvenance(v: unknown, field: string, errors: string[]): v is Provenance {
|
||||
if (typeof v !== "object" || v === null) {
|
||||
errors.push(`${field}: expected object, got ${v === null ? "null" : typeof v}`);
|
||||
return false;
|
||||
}
|
||||
const p = v as Record<string, unknown>;
|
||||
let ok = true;
|
||||
ok = requireString(p.source_file, `${field}.source_file`, errors) && ok;
|
||||
ok = requireSha256(p.sig_hash, `${field}.sig_hash`, errors) && ok;
|
||||
ok = requireIsoTimestamp(p.recorded_at, `${field}.recorded_at`, errors) && ok;
|
||||
if (p.line_offset !== undefined && typeof p.line_offset !== "number") {
|
||||
errors.push(`${field}.line_offset: expected number when present`);
|
||||
ok = false;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
export function requireStringArray(v: unknown, field: string, errors: string[]): v is string[] {
|
||||
if (!Array.isArray(v)) {
|
||||
errors.push(`${field}: expected array, got ${typeof v}`);
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
if (typeof v[i] !== "string") {
|
||||
errors.push(`${field}[${i}]: expected string, got ${typeof v[i]}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compute the canonical sha256 used for sig_hash. Sorts keys so the
|
||||
// hash is stable regardless of producer's serialization order. Uses
|
||||
// Bun.CryptoHasher (sync, fast) rather than node:crypto — matches the
|
||||
// rest of the auditor.
|
||||
export async function canonicalSha256(obj: unknown): Promise<string> {
|
||||
const ordered = orderKeys(obj);
|
||||
const json = JSON.stringify(ordered);
|
||||
const hasher = new Bun.CryptoHasher("sha256");
|
||||
hasher.update(json);
|
||||
return hasher.digest("hex");
|
||||
}
|
||||
|
||||
function orderKeys(v: unknown): unknown {
|
||||
if (v === null || typeof v !== "object") return v;
|
||||
if (Array.isArray(v)) return v.map(orderKeys);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(v as object).sort()) {
|
||||
out[k] = orderKeys((v as Record<string, unknown>)[k]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
// if something can't be verified from a check, it goes into `evidence`
|
||||
// so the verdict is inspectable, not a black box.
|
||||
|
||||
export type CheckKind = "static" | "dynamic" | "inference" | "kb_query";
|
||||
export type CheckKind = "static" | "dynamic" | "inference" | "kb_query" | "kimi_architect";
|
||||
|
||||
export type Severity = "info" | "warn" | "block";
|
||||
|
||||
|
||||
+17
-7
@@ -13,10 +13,17 @@ import { readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Gap, Proposal } from "./types.ts";
|
||||
|
||||
const SIDECAR_URL = process.env.LH_SIDECAR_URL ?? "http://localhost:3200";
|
||||
// Phase 44 migration (2026-04-27): bot/propose.ts now flows through
|
||||
// the gateway's /v1/chat instead of hitting the sidecar's /generate
|
||||
// directly. /v1/usage tracks the call, Langfuse traces it, observer
|
||||
// sees it. Gateway owns the routing.
|
||||
//
|
||||
// 2026-04-28: gpt-oss:120b → deepseek-v3.2 via Ollama Pro. Newer
|
||||
// DeepSeek revision, faster, still on the same OLLAMA_CLOUD_KEY.
|
||||
const GATEWAY_URL = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
|
||||
const REPO_ROOT = "/home/profit/lakehouse";
|
||||
const PRD_PATH = `${REPO_ROOT}/docs/PRD.md`;
|
||||
const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "gpt-oss:120b";
|
||||
const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "deepseek-v3.2";
|
||||
const MAX_TOKENS = 6000;
|
||||
|
||||
export async function findGaps(): Promise<Gap[]> {
|
||||
@@ -72,13 +79,16 @@ export async function generateProposal(gap: Gap, historySummary: string = ""): P
|
||||
sections.push("Propose a small change that addresses this gap. Respond with the JSON object only.");
|
||||
const userPrompt = sections.join("\n");
|
||||
|
||||
const r = await fetch(`${SIDECAR_URL}/generate`, {
|
||||
const r = await fetch(`${GATEWAY_URL}/v1/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: CLOUD_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: userPrompt,
|
||||
provider: "ollama_cloud",
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: 0.2,
|
||||
max_tokens: MAX_TOKENS,
|
||||
think: false,
|
||||
@@ -86,10 +96,10 @@ export async function generateProposal(gap: Gap, historySummary: string = ""): P
|
||||
signal: AbortSignal.timeout(180000), // cloud T3 can be slow — 3 min
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`sidecar ${r.status}: ${await r.text()}`);
|
||||
throw new Error(`gateway /v1/chat ${r.status}: ${await r.text()}`);
|
||||
}
|
||||
const j = await r.json() as any;
|
||||
const raw: string = j.text ?? j.response ?? "";
|
||||
const raw: string = j?.choices?.[0]?.message?.content ?? "";
|
||||
const usage = j.usage ?? {};
|
||||
const tokens = (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0);
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Mode router config — task_class → mode mapping
|
||||
#
|
||||
# `preferred_mode` is the first choice for a task class; `fallback_modes`
|
||||
# get tried in order if the preferred one isn't available (LLM Team can
|
||||
# return Unknown mode for some, OR the matrix has stronger signal for a
|
||||
# fallback). `default_model` seeds the mode runner's model field if the
|
||||
# caller doesn't override.
|
||||
#
|
||||
# Modes are dispatched against LLM Team UI (localhost:5000/api/run) for
|
||||
# now; future Rust-native runners will short-circuit before the proxy.
|
||||
# See crates/gateway/src/v1/mode.rs for the dispatch path.
|
||||
|
||||
[[task_class]]
|
||||
name = "scrum_review"
|
||||
# 2026-04-26 pass5 variance test (5 reps × 4 conditions, grok-4.1-fast,
|
||||
# pathway_memory.rs): composed corpus LOST 5/5 vs isolation (Δ −1.8
|
||||
# grounded findings, p=0.031). See docs/MODE_RUNNER_TUNING_PLAN.md.
|
||||
# Default is now isolation — bug fingerprints + adversarial framing +
|
||||
# file content carries strong models without matrix noise. The
|
||||
# `codereview_lakehouse` matrix path remains available via force_mode
|
||||
# (auto-downgrades to isolation on strong models — see the
|
||||
# is_strong_model gate in crates/gateway/src/v1/mode.rs).
|
||||
preferred_mode = "codereview_isolation"
|
||||
fallback_modes = ["codereview_lakehouse", "codereview", "consensus", "ladder"]
|
||||
default_model = "qwen3-coder:480b"
|
||||
# Corpora kept defined so experimental modes (codereview_matrix_only,
|
||||
# pass2/pass5 sweeps) and weak-model rescue rungs can still pull them.
|
||||
# scrum_findings_v1 is built but EXCLUDED — bake-off showed 24% OOB
|
||||
# line citations from cross-file drift, only safe with same-file gating.
|
||||
matrix_corpus = ["lakehouse_arch_v1", "lakehouse_symbols_v1"]
|
||||
|
||||
[[task_class]]
|
||||
name = "contract_analysis"
|
||||
preferred_mode = "deep_analysis"
|
||||
fallback_modes = ["research", "extract"]
|
||||
default_model = "kimi-k2:1t"
|
||||
matrix_corpus = "chicago_permits_v1"
|
||||
|
||||
[[task_class]]
|
||||
name = "staffing_inference"
|
||||
# Staffing-domain native enrichment runner — Pass 4 (2026-04-26).
|
||||
# Same composer architecture as codereview_lakehouse but with staffing
|
||||
# framing + workers corpus. Validates that the modes-as-prompt-molders
|
||||
# pattern generalizes beyond code review.
|
||||
preferred_mode = "staffing_inference_lakehouse"
|
||||
fallback_modes = ["ladder", "consensus", "pipeline"]
|
||||
# 2026-04-28: gpt-oss-120b:free → kimi-k2.6 via Ollama Pro. Coding-
|
||||
# specialized, faster than gpt-oss, on the same OLLAMA_CLOUD_KEY so
|
||||
# no extra provider hop.
|
||||
default_model = "kimi-k2.6"
|
||||
matrix_corpus = "workers_500k_v8"
|
||||
|
||||
[[task_class]]
|
||||
name = "fact_extract"
|
||||
preferred_mode = "extract"
|
||||
fallback_modes = ["distill"]
|
||||
default_model = "qwen2.5"
|
||||
matrix_corpus = "kb_team_runs_v1"
|
||||
|
||||
[[task_class]]
|
||||
name = "doc_drift_check"
|
||||
preferred_mode = "drift"
|
||||
fallback_modes = ["validator"]
|
||||
# 2026-04-28: gpt-oss:120b → gemini-3-flash-preview via Ollama Pro.
|
||||
# Speed leader on factual checking, same OLLAMA_CLOUD_KEY.
|
||||
default_model = "gemini-3-flash-preview"
|
||||
matrix_corpus = "distilled_factual_v20260423095819"
|
||||
|
||||
[[task_class]]
|
||||
name = "pr_audit"
|
||||
# Auditor's claim-vs-diff verification mode (2026-04-26 rebuild).
|
||||
# Replaces the auditor's hand-rolled inference check with the mode-runner
|
||||
# composer: pathway memory (PR-level patterns) + lakehouse_answers_v1
|
||||
# corpus (prior accepted reviews + observer escalations) + adversarial
|
||||
# JSON-shaped framing. Default model is paid Ollama Cloud kimi-k2:1t for
|
||||
# strong claim-grounding; tie-breaker via auditor-side env override.
|
||||
preferred_mode = "pr_audit"
|
||||
fallback_modes = ["consensus", "ladder"]
|
||||
# kimi-k2:1t broken upstream 2026-04-27 (Ollama Cloud 500 ISE, multi-hour
|
||||
# sustained outage verified by repeated probes). deepseek-v3.1:671b is
|
||||
# the drop-in substitute — proven working end-to-end through pr_audit
|
||||
# during Phase 5 distillation acceptance testing.
|
||||
default_model = "deepseek-v3.1:671b"
|
||||
matrix_corpus = "lakehouse_answers_v1"
|
||||
|
||||
# Fallback when task_class isn't in the table — useful for ad-hoc calls
|
||||
# during development that don't yet have a mapped mode.
|
||||
[default]
|
||||
preferred_mode = "pipeline"
|
||||
fallback_modes = ["consensus", "ladder"]
|
||||
default_model = "qwen3.5:latest"
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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 = "deepseek-v3.2"
|
||||
# Cloud-tier Ollama (Pro plan as of 2026-04-28). Key resolved from
|
||||
# OLLAMA_CLOUD_KEY at gateway boot; Pro tier upgraded the account so
|
||||
# rate limits + model access widen without a key change. Model-prefix
|
||||
# routing: "cloud/<model>" auto-routes here. 39-model fleet now
|
||||
# includes deepseek-v3.2, deepseek-v4-{flash,pro}, gemini-3-flash-
|
||||
# preview, glm-{5,5.1}, kimi-k2.6, qwen3-coder-next.
|
||||
# 2026-04-28: default upgraded gpt-oss:120b → deepseek-v3.2 (newest
|
||||
# DeepSeek revision; kimi-k2:1t still upstream-broken with HTTP 500).
|
||||
|
||||
[[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 = "x-ai/grok-4.1-fast"
|
||||
# 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.
|
||||
|
||||
[[provider]]
|
||||
name = "opencode"
|
||||
base_url = "https://opencode.ai/zen/v1"
|
||||
# Unified endpoint — covers BOTH Zen (pay-per-token Anthropic/OpenAI/
|
||||
# Gemini frontier) AND Go (flat-sub Kimi/GLM/DeepSeek/Qwen/Minimax).
|
||||
# Upstream bills per-model: Zen models hit Zen balance, Go models hit
|
||||
# Go subscription cap. /zen/go/v1 is the Go-only sub-path (rejects
|
||||
# Zen models), kept for reference but not used by this provider.
|
||||
auth = "bearer"
|
||||
auth_env = "OPENCODE_API_KEY"
|
||||
default_model = "claude-opus-4-7"
|
||||
# OpenCode (Zen + GO unified endpoint). One sk-* key reaches Claude
|
||||
# Opus 4.7, GPT-5.5-pro, Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM,
|
||||
# Qwen, plus 4 free-tier models. OpenAI-compatible Chat Completions
|
||||
# at /v1/chat/completions. Model-prefix routing: "opencode/<name>"
|
||||
# auto-routes here, prefix stripped before upstream call.
|
||||
# Key file: /etc/lakehouse/opencode.env (loaded via systemd EnvironmentFile).
|
||||
# Model catalog: curl -H "Authorization: Bearer ..." https://opencode.ai/zen/v1/models
|
||||
# Note: /zen/go/v1 is the GO-only sub-path (Kimi/GLM/DeepSeek tier);
|
||||
# /zen/v1 covers everything including Anthropic (which /zen/go/v1 rejects).
|
||||
|
||||
[[provider]]
|
||||
name = "kimi"
|
||||
base_url = "https://api.kimi.com/coding/v1"
|
||||
auth = "bearer"
|
||||
auth_env = "KIMI_API_KEY"
|
||||
default_model = "kimi-for-coding"
|
||||
# Direct Kimi For Coding provider. `api.kimi.com` is a SEPARATE account
|
||||
# system from `api.moonshot.ai` and `api.moonshot.cn` — keys are NOT
|
||||
# interchangeable. Used when Ollama Cloud's `kimi-k2:1t` is upstream-
|
||||
# broken and OpenRouter's `moonshotai/kimi-k2.6` is rate-limited.
|
||||
# Model id: `kimi-for-coding` (kimi-k2.6 underneath).
|
||||
# Key file: /etc/lakehouse/kimi.env (loaded via systemd EnvironmentFile).
|
||||
# Model-prefix routing: "kimi/<model>" auto-routes here, prefix stripped.
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -3,10 +3,26 @@ use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// HTTP client for the Python AI sidecar.
|
||||
///
|
||||
/// `generate()` has two transport modes:
|
||||
/// - When `gateway_url` is None (default), it posts to
|
||||
/// `${base_url}/generate` (sidecar direct).
|
||||
/// - When `gateway_url` is `Some(url)`, it posts to
|
||||
/// `${url}/v1/chat` with `provider="ollama"` so the call appears
|
||||
/// in `/v1/usage` and Langfuse traces.
|
||||
///
|
||||
/// `embed()`, `rerank()`, and admin methods always go direct to the
|
||||
/// sidecar — no `/v1` equivalent yet, no point round-tripping.
|
||||
///
|
||||
/// Phase 44 part 2 (2026-04-27): the gateway URL is wired in by
|
||||
/// callers that want observability (vectord modules); it's left
|
||||
/// unset by callers that ARE the gateway internals (avoids self-loops
|
||||
/// + redundant hops).
|
||||
#[derive(Clone)]
|
||||
pub struct AiClient {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
gateway_url: Option<String>,
|
||||
}
|
||||
|
||||
// -- Request/Response types --
|
||||
@@ -86,9 +102,22 @@ impl AiClient {
|
||||
Self {
|
||||
client,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
gateway_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as `new`, but every `generate()` is routed through
|
||||
/// `${gateway_url}/v1/chat` (provider=ollama) for observability.
|
||||
/// Use this for callers OUTSIDE the gateway. Inside the gateway
|
||||
/// itself, prefer `new()` — calling /v1/chat from /v1/chat works
|
||||
/// (no infinite loop, ollama_arm doesn't use AiClient) but adds
|
||||
/// a wasted localhost hop.
|
||||
pub fn new_with_gateway(base_url: &str, gateway_url: &str) -> Self {
|
||||
let mut c = Self::new(base_url);
|
||||
c.gateway_url = Some(gateway_url.trim_end_matches('/').to_string());
|
||||
c
|
||||
}
|
||||
|
||||
pub async fn health(&self) -> Result<serde_json::Value, String> {
|
||||
let resp = self.client
|
||||
.get(format!("{}/health", self.base_url))
|
||||
@@ -114,6 +143,13 @@ impl AiClient {
|
||||
}
|
||||
|
||||
pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
|
||||
if let Some(gw) = self.gateway_url.as_deref() {
|
||||
return self.generate_via_gateway(gw, req).await;
|
||||
}
|
||||
// Direct-sidecar legacy path. Used by gateway internals (so
|
||||
// ollama_arm can call sidecar without a self-loop) and by
|
||||
// any consumer that wants raw transport without /v1/usage
|
||||
// accounting.
|
||||
let resp = self.client
|
||||
.post(format!("{}/generate", self.base_url))
|
||||
.json(&req)
|
||||
@@ -128,6 +164,59 @@ impl AiClient {
|
||||
resp.json().await.map_err(|e| format!("generate parse error: {e}"))
|
||||
}
|
||||
|
||||
/// Phase 44 part 2: route generate() through the gateway's
|
||||
/// /v1/chat with provider="ollama" so the call lands in
|
||||
/// /v1/usage + Langfuse. Translates between the sidecar
|
||||
/// GenerateRequest/Response shape and the OpenAI-compat
|
||||
/// chat shape on the wire.
|
||||
async fn generate_via_gateway(&self, gateway_url: &str, req: GenerateRequest) -> Result<GenerateResponse, String> {
|
||||
let mut messages = Vec::with_capacity(2);
|
||||
if let Some(sys) = &req.system {
|
||||
messages.push(serde_json::json!({"role": "system", "content": sys}));
|
||||
}
|
||||
messages.push(serde_json::json!({"role": "user", "content": req.prompt}));
|
||||
let mut body = serde_json::json!({
|
||||
"messages": messages,
|
||||
"provider": "ollama",
|
||||
});
|
||||
if let Some(m) = &req.model { body["model"] = serde_json::json!(m); }
|
||||
if let Some(t) = req.temperature { body["temperature"] = serde_json::json!(t); }
|
||||
if let Some(mt) = req.max_tokens { body["max_tokens"] = serde_json::json!(mt); }
|
||||
if let Some(th) = req.think { body["think"] = serde_json::json!(th); }
|
||||
|
||||
let resp = self.client
|
||||
.post(format!("{}/v1/chat", gateway_url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("/v1/chat request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("/v1/chat error: {text}"));
|
||||
}
|
||||
let parsed: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("/v1/chat parse error: {e}"))?;
|
||||
|
||||
let text = parsed
|
||||
.pointer("/choices/0/message/content")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let model = parsed.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_else(|| req.model.as_deref().unwrap_or(""))
|
||||
.to_string();
|
||||
let prompt_tokens = parsed.pointer("/usage/prompt_tokens").and_then(|v| v.as_u64());
|
||||
let completion_tokens = parsed.pointer("/usage/completion_tokens").and_then(|v| v.as_u64());
|
||||
|
||||
Ok(GenerateResponse {
|
||||
text,
|
||||
model,
|
||||
tokens_evaluated: prompt_tokens,
|
||||
tokens_generated: completion_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn rerank(&self, req: RerankRequest) -> Result<RerankResponse, String> {
|
||||
let resp = self.client
|
||||
.post(format!("{}/rerank", self.base_url))
|
||||
|
||||
@@ -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,20 @@ 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;
|
||||
// u32::try_from saturates at u32::MAX instead of silently
|
||||
// truncating bits when tokens_evaluated/_generated comes back
|
||||
// as a u64 > 4 billion. Caught 2026-04-27 by Opus self-audit.
|
||||
prompt_tokens = prompt_tokens.saturating_add(u32::try_from(resp.tokens_evaluated.unwrap_or(0)).unwrap_or(u32::MAX));
|
||||
completion_tokens = completion_tokens.saturating_add(u32::try_from(resp.tokens_generated.unwrap_or(0)).unwrap_or(u32::MAX));
|
||||
if !resp.text.trim().is_empty() {
|
||||
combined = resp.text;
|
||||
break;
|
||||
@@ -188,9 +208,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 +216,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(u32::try_from(resp.tokens_evaluated.unwrap_or(0)).unwrap_or(u32::MAX));
|
||||
completion_tokens = completion_tokens.saturating_add(u32::try_from(resp.tokens_generated.unwrap_or(0)).unwrap_or(u32::MAX));
|
||||
combined.push_str(&resp.text);
|
||||
continuations += 1;
|
||||
}
|
||||
@@ -219,6 +242,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,8 @@ aibridge = { path = "../aibridge" }
|
||||
ingestd = { path = "../ingestd" }
|
||||
vectord = { path = "../vectord" }
|
||||
journald = { path = "../journald" }
|
||||
truth = { path = "../truth" }
|
||||
validator = { path = "../validator" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -29,3 +31,4 @@ tracing-opentelemetry = { workspace = true }
|
||||
arrow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
toml = { 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
+161
-10
@@ -1,6 +1,7 @@
|
||||
mod access;
|
||||
mod access_service;
|
||||
mod auth;
|
||||
mod execution_loop;
|
||||
mod observability;
|
||||
mod tools;
|
||||
mod v1;
|
||||
@@ -67,14 +68,62 @@ 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 {
|
||||
tracing::warn!("workspace rebuild: {e}");
|
||||
}
|
||||
|
||||
// AI sidecar client
|
||||
let ai_client = aibridge::client::AiClient::new(&config.sidecar.url);
|
||||
// AI sidecar clients — Phase 44 part 3 (2026-04-27).
|
||||
//
|
||||
// Two flavors of the same client:
|
||||
// - `ai_client_direct` posts directly to ${sidecar}/generate. Used
|
||||
// inside the gateway by V1State + the legacy /ai proxy. These
|
||||
// call sites are themselves the implementation of /v1/chat
|
||||
// (or its sidecar shim), so routing them through /v1/chat
|
||||
// would self-loop.
|
||||
// - `ai_client_observable` posts via ${gateway}/v1/chat with
|
||||
// provider="ollama". Used by vectord modules (autotune agent,
|
||||
// /vectors service) so their LLM calls land in /v1/usage and
|
||||
// Langfuse traces. Adds one localhost HTTP hop per call (~ms);
|
||||
// accepted for the observability gain.
|
||||
//
|
||||
// The gateway can call its own /v1/chat over localhost during
|
||||
// boot's transient period because we don't fire any LLM calls
|
||||
// until the listener is up — the observable client is just
|
||||
// configured here, not exercised.
|
||||
let ai_client_direct = aibridge::client::AiClient::new(&config.sidecar.url);
|
||||
let gateway_self_url = format!("http://{}:{}", config.gateway.host, config.gateway.port);
|
||||
let ai_client_observable = aibridge::client::AiClient::new_with_gateway(
|
||||
&config.sidecar.url,
|
||||
&gateway_self_url,
|
||||
);
|
||||
// Backwards-compat alias for the (many) existing references in this file.
|
||||
// Defaults to direct so the existing wiring (V1State, /ai proxy)
|
||||
// keeps its non-self-loop transport. New vectord wiring below
|
||||
// explicitly uses ai_client_observable.
|
||||
let ai_client = ai_client_direct.clone();
|
||||
|
||||
// Vector service components — built before the router because both the
|
||||
// /vectors service AND ingestd need the agent handle to enqueue triggers.
|
||||
@@ -92,6 +141,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.
|
||||
@@ -106,7 +161,9 @@ async fn main() {
|
||||
agent_cfg,
|
||||
vectord::agent::AgentDeps {
|
||||
store: store.clone(),
|
||||
ai_client: ai_client.clone(),
|
||||
// Observable: autotune agent's LLM calls go through
|
||||
// /v1/chat for /v1/usage + Langfuse visibility.
|
||||
ai_client: ai_client_observable.clone(),
|
||||
catalog: registry.clone(),
|
||||
index_registry: index_reg.clone(),
|
||||
hnsw_store: hnsw.clone(),
|
||||
@@ -153,10 +210,17 @@ 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(),
|
||||
ai_client: ai_client.clone(),
|
||||
// Observable: /vectors service's LLM calls (RAG, summary,
|
||||
// playbook synthesis, etc.) flow through /v1/chat.
|
||||
ai_client: ai_client_observable.clone(),
|
||||
job_tracker: vectord::jobs::JobTracker::new(),
|
||||
index_registry: index_reg.clone(),
|
||||
hnsw_store: hnsw,
|
||||
@@ -172,17 +236,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 +270,86 @@ 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
|
||||
},
|
||||
kimi_key: {
|
||||
// Direct Kimi For Coding (api.kimi.com) — bypasses the
|
||||
// broken-upstream kimi-k2:1t and OpenRouter rate caps.
|
||||
// Key from /etc/lakehouse/kimi.env (KIMI_API_KEY=sk-kimi-…).
|
||||
let k = v1::kimi::resolve_kimi_key();
|
||||
if k.is_some() {
|
||||
tracing::info!("v1: Kimi key loaded — /v1/chat provider=kimi enabled (model=kimi-for-coding)");
|
||||
} else {
|
||||
tracing::debug!("v1: no Kimi key — provider=kimi will 503");
|
||||
}
|
||||
k
|
||||
},
|
||||
opencode_key: {
|
||||
// OpenCode GO multi-vendor gateway — Claude Opus 4.7,
|
||||
// GPT-5.5-pro, Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM,
|
||||
// Qwen + free-tier. Key from /etc/lakehouse/opencode.env.
|
||||
let k = v1::opencode::resolve_opencode_key();
|
||||
if k.is_some() {
|
||||
tracing::info!("v1: OpenCode key loaded — /v1/chat provider=opencode enabled (40 models)");
|
||||
} else {
|
||||
tracing::debug!("v1: no OpenCode key — provider=opencode will 503");
|
||||
}
|
||||
k
|
||||
},
|
||||
validate_workers: {
|
||||
// Load workers_500k.parquet snapshot for /v1/validate.
|
||||
// Path overridable via LH_WORKERS_PARQUET env. Missing
|
||||
// file is non-fatal — validators run schema/PII checks
|
||||
// unaffected; only worker-existence checks fail clean.
|
||||
let path_str = std::env::var("LH_WORKERS_PARQUET")
|
||||
.unwrap_or_else(|_| "/home/profit/lakehouse/data/datasets/workers_500k.parquet".into());
|
||||
let path = std::path::Path::new(&path_str);
|
||||
if path.exists() {
|
||||
match validator::staffing::parquet_lookup::load_workers_parquet(path) {
|
||||
Ok(lookup) => {
|
||||
tracing::info!("v1: workers parquet loaded from {} — /v1/validate worker-existence checks enabled", path_str);
|
||||
lookup
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("v1: workers parquet at {} unreadable ({e}) — /v1/validate worker-existence checks will fail Consistency", path_str);
|
||||
std::sync::Arc::new(validator::InMemoryWorkerLookup::new())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("v1: workers parquet at {} not found — /v1/validate worker-existence checks will fail Consistency", path_str);
|
||||
std::sync::Arc::new(validator::InMemoryWorkerLookup::new())
|
||||
}
|
||||
},
|
||||
// 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 +364,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.text());
|
||||
} 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.text() });
|
||||
}
|
||||
}
|
||||
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.text().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::new_text("assistant", 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.text() }],
|
||||
});
|
||||
}
|
||||
|
||||
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.text().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::new_text("assistant", 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! /v1/iterate — the Phase 43 PRD's "generate → validate → correct → retry" loop.
|
||||
//!
|
||||
//! Closes the "0→85% with iteration" thesis structurally. A caller
|
||||
//! posts a prompt + artifact kind + validation context; the gateway:
|
||||
//! 1. Generates a JSON artifact via /v1/chat (any provider/model)
|
||||
//! 2. Extracts the JSON object from the model output
|
||||
//! 3. Validates via /v1/validate (FillValidator / EmailValidator /
|
||||
//! PlaybookValidator with the shared WorkerLookup)
|
||||
//! 4. On ValidationError, appends the error to the prompt and
|
||||
//! retries up to `max_iterations` (default 3)
|
||||
//! 5. Returns the accepted artifact + Report on success, OR the
|
||||
//! attempt history + final error on max-iter exhaustion
|
||||
//!
|
||||
//! Internal calls go via HTTP loopback to localhost:gateway_port so
|
||||
//! the same /v1/usage tracking and Langfuse traces apply. A small
|
||||
//! latency cost (~1-3ms per loopback hop) for clean separation of
|
||||
//! concerns and observability.
|
||||
//!
|
||||
//! 2026-04-27 Phase 43 v3 part 3: this endpoint makes the iteration
|
||||
//! loop a first-class lakehouse capability rather than a per-caller
|
||||
//! re-implementation. Staffing executors, agent loops, and future
|
||||
//! validators all reach the same code path.
|
||||
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_MAX_ITERATIONS: u32 = 3;
|
||||
const LOOPBACK_TIMEOUT_SECS: u64 = 240;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct IterateRequest {
|
||||
/// "fill" | "email" | "playbook" — picks which validator runs.
|
||||
pub kind: String,
|
||||
/// The prompt to seed generation. Validation errors from prior
|
||||
/// attempts are appended on retry.
|
||||
pub prompt: String,
|
||||
/// Provider/model passed through to /v1/chat. e.g. "ollama_cloud"
|
||||
/// + "kimi-k2.6", or "opencode" + "claude-haiku-4-5".
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
/// Optional system prompt — sent to /v1/chat as the system message.
|
||||
#[serde(default)]
|
||||
pub system: Option<String>,
|
||||
/// Validation context (target_count, city, state, role, client_id
|
||||
/// for fills; candidate_id for emails). Forwarded to /v1/validate.
|
||||
#[serde(default)]
|
||||
pub context: Option<serde_json::Value>,
|
||||
/// Cap on iteration count. Defaults to 3 per the Phase 43 PRD.
|
||||
#[serde(default)]
|
||||
pub max_iterations: Option<u32>,
|
||||
/// Forwarded to /v1/chat. Defaults to 0.2 if unset.
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f64>,
|
||||
/// Forwarded to /v1/chat. Defaults to 4096 if unset.
|
||||
#[serde(default)]
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IterateAttempt {
|
||||
pub iteration: u32,
|
||||
pub raw: String,
|
||||
pub status: AttemptStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AttemptStatus {
|
||||
/// Model output didn't contain extractable JSON.
|
||||
NoJson,
|
||||
/// JSON extracted but failed validation; carries the error.
|
||||
ValidationFailed { error: serde_json::Value },
|
||||
/// Validation passed (last attempt's terminal status).
|
||||
Accepted,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IterateResponse {
|
||||
pub artifact: serde_json::Value,
|
||||
pub validation: serde_json::Value,
|
||||
pub iterations: u32,
|
||||
pub history: Vec<IterateAttempt>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IterateFailure {
|
||||
pub error: String,
|
||||
pub iterations: u32,
|
||||
pub history: Vec<IterateAttempt>,
|
||||
}
|
||||
|
||||
pub async fn iterate(
|
||||
State(state): State<super::V1State>,
|
||||
Json(req): Json<IterateRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let max_iter = req.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS).max(1);
|
||||
let temperature = req.temperature.unwrap_or(0.2);
|
||||
let max_tokens = req.max_tokens.unwrap_or(4096);
|
||||
let mut history: Vec<IterateAttempt> = Vec::with_capacity(max_iter as usize);
|
||||
let mut current_prompt = req.prompt.clone();
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(LOOPBACK_TIMEOUT_SECS))
|
||||
.build() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("client build: {e}")).into_response(),
|
||||
};
|
||||
// Self-loopback to the gateway port. Carries gateway internal
|
||||
// calls through /v1/chat + /v1/validate so /v1/usage tracks them.
|
||||
let gateway = "http://127.0.0.1:3100";
|
||||
|
||||
for iteration in 0..max_iter {
|
||||
// ── Generate ──
|
||||
let mut messages = Vec::with_capacity(2);
|
||||
if let Some(sys) = &req.system {
|
||||
messages.push(serde_json::json!({"role": "system", "content": sys}));
|
||||
}
|
||||
messages.push(serde_json::json!({"role": "user", "content": current_prompt}));
|
||||
let chat_body = serde_json::json!({
|
||||
"messages": messages,
|
||||
"provider": req.provider,
|
||||
"model": req.model,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
});
|
||||
let raw = match call_chat(&client, gateway, &chat_body).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("/v1/chat hop failed at iter {iteration}: {e}")).into_response(),
|
||||
};
|
||||
|
||||
// ── Extract JSON ──
|
||||
let artifact = match extract_json(&raw) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
history.push(IterateAttempt {
|
||||
iteration,
|
||||
raw: raw.chars().take(2000).collect(),
|
||||
status: AttemptStatus::NoJson,
|
||||
});
|
||||
current_prompt = format!(
|
||||
"{}\n\nYour previous attempt did not contain a JSON object. Reply with ONLY a valid JSON object matching the requested artifact shape.",
|
||||
req.prompt,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Validate ──
|
||||
let validate_body = serde_json::json!({
|
||||
"kind": req.kind,
|
||||
"artifact": artifact,
|
||||
"context": req.context.clone().unwrap_or(serde_json::Value::Null),
|
||||
});
|
||||
match call_validate(&client, gateway, &validate_body).await {
|
||||
Ok(report) => {
|
||||
history.push(IterateAttempt {
|
||||
iteration,
|
||||
raw: raw.chars().take(2000).collect(),
|
||||
status: AttemptStatus::Accepted,
|
||||
});
|
||||
return (StatusCode::OK, Json(IterateResponse {
|
||||
artifact,
|
||||
validation: report,
|
||||
iterations: iteration + 1,
|
||||
history,
|
||||
})).into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
let err_summary = err.to_string();
|
||||
history.push(IterateAttempt {
|
||||
iteration,
|
||||
raw: raw.chars().take(2000).collect(),
|
||||
status: AttemptStatus::ValidationFailed {
|
||||
error: serde_json::to_value(&err_summary).unwrap_or(serde_json::Value::Null),
|
||||
},
|
||||
});
|
||||
// Append validation feedback to prompt for next iter.
|
||||
// The model sees concrete failure mode + retries with
|
||||
// corrective context. This is the "observer correction"
|
||||
// in Phase 43 PRD shape, simplified — the validator
|
||||
// itself IS the observer for now.
|
||||
current_prompt = format!(
|
||||
"{}\n\nPrior attempt failed validation:\n{}\n\nFix the specific issue above and respond with a corrected JSON object.",
|
||||
req.prompt, err_summary,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(IterateFailure {
|
||||
error: format!("max iterations reached ({max_iter}) without passing validation"),
|
||||
iterations: max_iter,
|
||||
history,
|
||||
})).into_response()
|
||||
}
|
||||
|
||||
async fn call_chat(client: &reqwest::Client, gateway: &str, body: &serde_json::Value) -> Result<String, String> {
|
||||
let resp = client.post(format!("{gateway}/v1/chat"))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("chat hop: {e}"))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("chat {}: {}", status, body.chars().take(300).collect::<String>()));
|
||||
}
|
||||
let parsed: serde_json::Value = resp.json().await.map_err(|e| format!("chat parse: {e}"))?;
|
||||
Ok(parsed.pointer("/choices/0/message/content")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_validate(client: &reqwest::Client, gateway: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let resp = client.post(format!("{gateway}/v1/validate"))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("validate hop: {e}"))?;
|
||||
let status = resp.status();
|
||||
let parsed: serde_json::Value = resp.json().await.map_err(|e| format!("validate parse: {e}"))?;
|
||||
if status.is_success() {
|
||||
Ok(parsed)
|
||||
} else {
|
||||
// The /v1/validate endpoint returns a ValidationError JSON
|
||||
// on 422; surface its structure verbatim so the prompt-
|
||||
// appending step gets specific failure detail.
|
||||
Err(serde_json::to_string(&parsed).unwrap_or_else(|_| format!("validation {} (unparseable body)", status)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the first JSON object from a model's output. Handles
|
||||
/// fenced code blocks (```json ... ```), bare braces, and stray
|
||||
/// prose around the JSON. Returns None on no extractable object.
|
||||
fn extract_json(raw: &str) -> Option<serde_json::Value> {
|
||||
// Try fenced first.
|
||||
let candidates: Vec<String> = {
|
||||
let mut out = vec![];
|
||||
let mut s = raw;
|
||||
while let Some(start) = s.find("```") {
|
||||
let after = &s[start + 3..];
|
||||
// Skip optional language tag (json, etc.)
|
||||
let body_start = after.find('\n').map(|n| n + 1).unwrap_or(0);
|
||||
let body = &after[body_start..];
|
||||
if let Some(end) = body.find("```") {
|
||||
out.push(body[..end].trim().to_string());
|
||||
s = &body[end + 3..];
|
||||
} else { break; }
|
||||
}
|
||||
out
|
||||
};
|
||||
for c in &candidates {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(c) {
|
||||
if v.is_object() { return Some(v); }
|
||||
}
|
||||
}
|
||||
// Fall back to outermost {...} balance.
|
||||
let bytes = raw.as_bytes();
|
||||
let mut depth = 0i32;
|
||||
let mut start: Option<usize> = None;
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
match b {
|
||||
b'{' => { if start.is_none() { start = Some(i); } depth += 1; }
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
if let Some(s) = start {
|
||||
let slice = &raw[s..=i];
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(slice) {
|
||||
if v.is_object() { return Some(v); }
|
||||
}
|
||||
start = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_json_from_fenced_block() {
|
||||
let raw = "Here's my answer:\n```json\n{\"fills\": [{\"candidate_id\": \"W-1\"}]}\n```\nDone.";
|
||||
let v = extract_json(raw).unwrap();
|
||||
assert!(v.get("fills").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_json_from_bare_braces() {
|
||||
let raw = "Here you go: {\"fills\": [{\"candidate_id\": \"W-2\"}]}";
|
||||
let v = extract_json(raw).unwrap();
|
||||
assert!(v.get("fills").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_json_returns_none_on_no_object() {
|
||||
assert!(extract_json("just prose, no json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_json_picks_first_balanced() {
|
||||
let raw = "{\"a\":1} then {\"b\":2}";
|
||||
let v = extract_json(raw).unwrap();
|
||||
assert_eq!(v.get("a").and_then(|v| v.as_i64()), Some(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Kimi For Coding adapter — direct provider for `kimi-for-coding`
|
||||
//! (kimi-k2.6 underneath). Used when Ollama Cloud's `kimi-k2:1t` is
|
||||
//! returning sustained 5xx (broken upstream) and OpenRouter's
|
||||
//! `moonshotai/kimi-k2.6` is rate-limited.
|
||||
//!
|
||||
//! Endpoint per `kimi.com/code/docs` and `moonshotai.github.io/kimi-cli`:
|
||||
//! base_url: https://api.kimi.com/coding/v1
|
||||
//! model id: kimi-for-coding
|
||||
//! auth: Bearer sk-kimi-…
|
||||
//! protocol: OpenAI Chat Completions compatible
|
||||
//!
|
||||
//! IMPORTANT: `api.kimi.com` is a separate account system from
|
||||
//! `api.moonshot.ai` and `api.moonshot.cn`. Keys are NOT interchangeable.
|
||||
//! This adapter is for `sk-kimi-*` keys provisioned via the Kimi
|
||||
//! membership console only.
|
||||
//!
|
||||
//! Key sourcing priority:
|
||||
//! 1. Env var `KIMI_API_KEY` (loaded from /etc/lakehouse/kimi.env via
|
||||
//! systemd EnvironmentFile=)
|
||||
//! 2. /etc/lakehouse/kimi.env directly (rescue path if env not loaded)
|
||||
//!
|
||||
//! First hit wins. Resolved once at gateway startup, stored on
|
||||
//! `V1State.kimi_key`.
|
||||
|
||||
use std::time::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
|
||||
|
||||
const KIMI_BASE_URL: &str = "https://api.kimi.com/coding/v1";
|
||||
// Default 600s — kimi-for-coding is a reasoning model; on large
|
||||
// code-audit prompts (~50KB+ input + 8K output) it routinely needs
|
||||
// 3-8 min to think + emit. Override with KIMI_TIMEOUT_SECS env var.
|
||||
const KIMI_TIMEOUT_SECS_DEFAULT: u64 = 600;
|
||||
|
||||
fn kimi_timeout_secs() -> u64 {
|
||||
std::env::var("KIMI_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.unwrap_or(KIMI_TIMEOUT_SECS_DEFAULT)
|
||||
}
|
||||
|
||||
pub fn resolve_kimi_key() -> Option<String> {
|
||||
if let Ok(k) = std::env::var("KIMI_API_KEY") {
|
||||
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
||||
}
|
||||
if let Ok(raw) = std::fs::read_to_string("/etc/lakehouse/kimi.env") {
|
||||
for line in raw.lines() {
|
||||
if let Some(rest) = line.strip_prefix("KIMI_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 "kimi/" namespace prefix if the caller used it so the
|
||||
// upstream API sees the bare model id (e.g. "kimi-for-coding").
|
||||
let model = req.model.strip_prefix("kimi/").unwrap_or(&req.model).to_string();
|
||||
|
||||
// Flatten content to a plain String. api.kimi.com is text-only on
|
||||
// the coding endpoint; the OpenAI multimodal array shape
|
||||
// ([{type:"text",text:"..."},{type:"image_url",...}]) returns 400.
|
||||
// Message::text() concats text-parts and drops non-text. Caught
|
||||
// 2026-04-27 by Kimi's self-audit (kimi.rs:137 — content as raw
|
||||
// serde_json::Value risked upstream rejection).
|
||||
let body = KimiChatBody {
|
||||
model: model.clone(),
|
||||
messages: req.messages.iter().map(|m| KimiMessage {
|
||||
role: m.role.clone(),
|
||||
content: serde_json::Value::String(m.text()),
|
||||
}).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(kimi_timeout_secs()))
|
||||
.build()
|
||||
.map_err(|e| format!("build client: {e}"))?;
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let resp = client
|
||||
.post(format!("{}/chat/completions", KIMI_BASE_URL))
|
||||
.bearer_auth(key)
|
||||
// api.kimi.com gates this endpoint by User-Agent — only sanctioned
|
||||
// coding agents (Claude Code, Kimi CLI, Roo Code, Kilo Code) get
|
||||
// through. Generic clients receive 403 access_terminated_error.
|
||||
// J accepted the TOS risk on 2026-04-27; revisit if Moonshot
|
||||
// tightens enforcement.
|
||||
.header("User-Agent", "claude-code/1.0.0")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("api.kimi.com unreachable: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_else(|_| "?".into());
|
||||
return Err(format!("api.kimi.com {}: {}", status, body));
|
||||
}
|
||||
|
||||
let parsed: KimiChatResponse = resp.json().await
|
||||
.map_err(|e| format!("invalid kimi response: {e}"))?;
|
||||
|
||||
let latency_ms = t0.elapsed().as_millis();
|
||||
let choice = parsed.choices.into_iter().next()
|
||||
.ok_or_else(|| "kimi 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.text().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 = "kimi",
|
||||
model = %model,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
latency_ms = latency_ms as u64,
|
||||
"kimi 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: serde_json::Value::String(text) },
|
||||
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
|
||||
}],
|
||||
usage: UsageBlock {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens: prompt_tokens + completion_tokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// -- Kimi wire shapes (OpenAI-compatible) --
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct KimiChatBody {
|
||||
model: String,
|
||||
messages: Vec<KimiMessage>,
|
||||
max_tokens: u32,
|
||||
temperature: f64,
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct KimiMessage { role: String, content: serde_json::Value }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KimiChatResponse {
|
||||
choices: Vec<KimiChoice>,
|
||||
#[serde(default)]
|
||||
usage: Option<KimiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KimiChoice {
|
||||
message: KimiMessageResp,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KimiMessageResp { content: String }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KimiUsage { prompt_tokens: u32, completion_tokens: u32 }
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_kimi_key_does_not_panic() {
|
||||
let _ = resolve_kimi_key();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_body_serializes_to_openai_shape() {
|
||||
let body = KimiChatBody {
|
||||
model: "kimi-for-coding".into(),
|
||||
messages: vec![
|
||||
KimiMessage { 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\":\"kimi-for-coding\""));
|
||||
assert!(json.contains("\"messages\""));
|
||||
assert!(json.contains("\"max_tokens\":800"));
|
||||
assert!(json.contains("\"stream\":false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_prefix_strip() {
|
||||
let cases = [
|
||||
("kimi/kimi-for-coding", "kimi-for-coding"),
|
||||
("kimi-for-coding", "kimi-for-coding"),
|
||||
("kimi/kimi-k2.6", "kimi-k2.6"),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let out = input.strip_prefix("kimi/").unwrap_or(input);
|
||||
assert_eq!(out, expected, "{input} should become {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+421
-14
@@ -13,7 +13,17 @@
|
||||
|
||||
pub mod ollama;
|
||||
pub mod ollama_cloud;
|
||||
pub mod openrouter;
|
||||
pub mod gemini;
|
||||
pub mod claude;
|
||||
pub mod kimi;
|
||||
pub mod opencode;
|
||||
pub mod validate;
|
||||
pub mod iterate;
|
||||
pub mod langfuse_trace;
|
||||
pub mod mode;
|
||||
pub mod respond;
|
||||
pub mod truth;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -34,6 +44,41 @@ 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>,
|
||||
/// Kimi For Coding (api.kimi.com) bearer token — direct provider
|
||||
/// for `kimi-for-coding`. Used when Ollama Cloud's `kimi-k2:1t` is
|
||||
/// upstream-broken. Loaded at startup via `kimi::resolve_kimi_key()`
|
||||
/// from `KIMI_API_KEY` env or `/etc/lakehouse/kimi.env`. None =
|
||||
/// provider="kimi" calls 503.
|
||||
pub kimi_key: Option<String>,
|
||||
/// OpenCode GO (opencode.ai) bearer token — multi-vendor curated
|
||||
/// gateway. One sk-* key reaches Claude Opus 4.7, GPT-5.5-pro,
|
||||
/// Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM, Qwen + free-tier.
|
||||
/// Loaded at startup via `opencode::resolve_opencode_key()` from
|
||||
/// `OPENCODE_API_KEY` env or `/etc/lakehouse/opencode.env`. None =
|
||||
/// provider="opencode" calls 503.
|
||||
pub opencode_key: Option<String>,
|
||||
/// Shared WorkerLookup loaded once at startup from
|
||||
/// workers_500k.parquet (path: LH_WORKERS_PARQUET env, default
|
||||
/// data/datasets/workers_500k.parquet). Used by /v1/validate to
|
||||
/// run FillValidator/EmailValidator with worker-existence checks.
|
||||
/// Falls back to an empty InMemoryWorkerLookup if the file is
|
||||
/// missing — validators still run schema/PII checks but every
|
||||
/// worker-existence check fails (Consistency error), which is
|
||||
/// the correct behavior when the roster isn't configured.
|
||||
pub validate_workers: std::sync::Arc<dyn validator::WorkerLookup>,
|
||||
/// 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,25 +91,88 @@ 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))
|
||||
// Canonical OpenAI path alias — lets any client built on the
|
||||
// openai SDK (pi-ai, langchain-js, etc.) treat the gateway as
|
||||
// a drop-in middleware via OPENAI_BASE_URL=http://gw/v1 alone.
|
||||
// Same handler as /chat; same OpenAI-compatible request shape.
|
||||
.route("/chat/completions", post(chat))
|
||||
.route("/respond", post(respond::respond))
|
||||
.route("/usage", get(usage))
|
||||
.route("/sessions", get(sessions))
|
||||
.route("/context", get(truth::context))
|
||||
.route("/mode", post(mode::route))
|
||||
.route("/mode/list", get(mode::list))
|
||||
.route("/mode/execute", post(mode::execute))
|
||||
.route("/validate", post(validate::validate))
|
||||
.route("/iterate", post(iterate::iterate))
|
||||
.route("/health", get(health))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// -- Shared types (OpenAI-compatible) --
|
||||
|
||||
/// OpenAI-compatible message. `content` accepts either a plain string or
|
||||
/// an array of content parts (the modern multimodal shape:
|
||||
/// `[{type:"text", text:"..."}, {type:"image_url", ...}]`). We store as
|
||||
/// `serde_json::Value` to preserve client shape on forward; downstream
|
||||
/// providers can take it verbatim. `Message::text()` flattens for
|
||||
/// places that need a plain string (Ollama prompt assembly, char
|
||||
/// counts, the assistant's own response synthesis).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Message {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub content: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
impl Message {
|
||||
/// Construct a plain text message — the common shape for callers
|
||||
/// that don't need multimodal content. Wraps the body in
|
||||
/// `serde_json::Value::String` so downstream serializers see the
|
||||
/// canonical OpenAI shape.
|
||||
pub fn new_text(role: impl Into<String>, body: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: role.into(),
|
||||
content: serde_json::Value::String(body.into()),
|
||||
}
|
||||
}
|
||||
/// Flatten content to a plain string. Strings pass through; content-
|
||||
/// part arrays concatenate the `text` fields with newlines and skip
|
||||
/// non-text parts (images etc.) — Phase 38/39 callers are text-only,
|
||||
/// real multimodal forwarding is queued.
|
||||
pub fn text(&self) -> String {
|
||||
match &self.content {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Array(parts) => {
|
||||
let mut out = String::new();
|
||||
for p in parts {
|
||||
if let Some(t) = p.get("text").and_then(|v| v.as_str()) {
|
||||
if !out.is_empty() { out.push('\n'); }
|
||||
out.push_str(t);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
@@ -120,6 +228,137 @@ 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());
|
||||
}
|
||||
if let Some(rest) = req.model.strip_prefix("kimi/") {
|
||||
return ("kimi".to_string(), rest.to_string());
|
||||
}
|
||||
if let Some(rest) = req.model.strip_prefix("opencode/") {
|
||||
return ("opencode".to_string(), rest.to_string());
|
||||
}
|
||||
// Bare `vendor/model` shape (e.g. `x-ai/grok-4.1-fast`,
|
||||
// `moonshotai/kimi-k2`, `openai/gpt-oss-120b:free`) → OpenRouter.
|
||||
// This makes the gateway a drop-in OpenAI-compatible middleware:
|
||||
// clients using the official `openai` SDK only set OPENAI_BASE_URL
|
||||
// + a model name and get correct upstream routing without needing
|
||||
// our custom `provider` field. Ollama models in J's stack use
|
||||
// `model:tag` form with NO slash (`qwen3.5:latest`, `kimi-k2:1t`),
|
||||
// so a slash here unambiguously means "namespaced provider/model".
|
||||
if req.model.contains('/') {
|
||||
return ("openrouter".to_string(), req.model.clone());
|
||||
}
|
||||
// Vendor-bare model names (no slash, no colon) — `gpt-4o-mini`,
|
||||
// `claude-3-5-sonnet-20241022`, etc. Tools like pi-ai validate
|
||||
// models against an OpenAI-style catalog (no namespace prefix),
|
||||
// so they send the bare name. Map to OpenRouter's namespaced form
|
||||
// by inferring the vendor from the leading token. Falls through to
|
||||
// ollama if no pattern matches — preserves existing behavior.
|
||||
if !req.model.contains(':') && !req.model.contains('/') {
|
||||
let m = req.model.as_str();
|
||||
if m.starts_with("gpt-") || m.starts_with("o1-") || m.starts_with("o3-") || m.starts_with("o4-") || m == "o1" || m == "o3" || m == "o4-mini" {
|
||||
return ("openrouter".to_string(), format!("openai/{}", m));
|
||||
}
|
||||
if m.starts_with("claude-") {
|
||||
return ("openrouter".to_string(), format!("anthropic/{}", m));
|
||||
}
|
||||
if m.starts_with("grok-") {
|
||||
return ("openrouter".to_string(), format!("x-ai/{}", m));
|
||||
}
|
||||
}
|
||||
("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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_prefix_infers_and_strips() {
|
||||
let r = mk_req(None, "kimi/kimi-for-coding");
|
||||
assert_eq!(resolve_provider(&r), ("kimi".into(), "kimi-for-coding".into()));
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
State(state): State<V1State>,
|
||||
Json(req): Json<ChatRequest>,
|
||||
@@ -131,27 +370,111 @@ 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())
|
||||
}
|
||||
"kimi" => {
|
||||
// Direct Kimi For Coding provider — bypasses Ollama Cloud's
|
||||
// upstream-broken kimi-k2:1t and OpenRouter's rate-limited
|
||||
// moonshotai/kimi-k2.6. Uses sk-kimi-* keys from the Kimi
|
||||
// membership console.
|
||||
let key = state.kimi_key.as_deref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"KIMI_API_KEY not configured".to_string(),
|
||||
))?;
|
||||
let r = kimi::chat(key, &*req_for_adapter)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("kimi: {e}")))?;
|
||||
(r, "kimi".to_string())
|
||||
}
|
||||
"opencode" => {
|
||||
// OpenCode GO multi-vendor gateway — Claude Opus 4.7,
|
||||
// GPT-5.5-pro, Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM,
|
||||
// Qwen, free-tier. OpenAI-compat at opencode.ai/zen/go/v1.
|
||||
let key = state.opencode_key.as_deref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"OPENCODE_API_KEY not configured".to_string(),
|
||||
))?;
|
||||
let r = opencode::chat(key, &*req_for_adapter)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("opencode: {e}")))?;
|
||||
(r, "opencode".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, kimi, opencode"),
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -165,10 +488,10 @@ async fn chat(
|
||||
// untouched.
|
||||
if let Some(lf) = &state.langfuse {
|
||||
let output = resp.choices.first()
|
||||
.map(|c| c.message.content.clone())
|
||||
.map(|c| c.message.text())
|
||||
.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 +506,59 @@ async fn chat(
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 40 part 2 — fire-and-forget /event to observer at :3800.
|
||||
// Same ring-buffer that scrum + scenario events land in, so any
|
||||
// tool-routed-through-our-gateway (Pi, Archon, openai SDK clients)
|
||||
// shows up alongside scrum_master events for KB consolidation +
|
||||
// pathway-memory + bug-fingerprint compounding. Best-effort:
|
||||
// observer being down doesn't block the chat response.
|
||||
{
|
||||
let provider = used_provider.clone();
|
||||
let model = resp.model.clone();
|
||||
let prompt_tokens = resp.usage.prompt_tokens;
|
||||
let completion_tokens = resp.usage.completion_tokens;
|
||||
let success = true;
|
||||
tokio::spawn(async move {
|
||||
let body = serde_json::json!({
|
||||
"endpoint": "/v1/chat",
|
||||
"source": "v1.chat",
|
||||
"event_kind": "chat_completion",
|
||||
"input_summary": format!(
|
||||
"{} {} prompt={}t",
|
||||
provider, model, prompt_tokens
|
||||
),
|
||||
"output_summary": format!(
|
||||
"completion={}t {}ms",
|
||||
completion_tokens, latency_ms
|
||||
),
|
||||
"success": success,
|
||||
"duration_ms": latency_ms,
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
let _ = client
|
||||
.post("http://localhost:3800/event")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
// 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))
|
||||
@@ -199,6 +569,43 @@ async fn usage(State(state): State<V1State>) -> impl IntoResponse {
|
||||
Json(snapshot)
|
||||
}
|
||||
|
||||
/// Production operational health endpoint.
|
||||
///
|
||||
/// `/v1/health` reports per-subsystem status as a JSON object so an
|
||||
/// operator (or the lakehouse-auditor service, or a load balancer)
|
||||
/// can verify the gateway is fully booted, has its provider keys
|
||||
/// loaded, the worker roster is hot, and Langfuse is reachable.
|
||||
/// Returns 200 always — fields are observed-state, not pass/fail
|
||||
/// gates. A monitoring tool should evaluate the booleans + counts
|
||||
/// against its own thresholds.
|
||||
async fn health(State(state): State<V1State>) -> impl IntoResponse {
|
||||
// Honest worker count via WorkerLookup::len. Production switchover
|
||||
// verification: after swapping workers_500k.parquet → real Chicago
|
||||
// data and restarting, this number should match the row count of
|
||||
// the new file. 0 means the file was missing / unreadable / had a
|
||||
// schema mismatch and the gateway booted with the empty fallback.
|
||||
let workers_count = state.validate_workers.len();
|
||||
let providers_configured = serde_json::json!({
|
||||
"ollama_cloud": state.ollama_cloud_key.is_some(),
|
||||
"openrouter": state.openrouter_key.is_some(),
|
||||
"kimi": state.kimi_key.is_some(),
|
||||
"opencode": state.opencode_key.is_some(),
|
||||
"gemini": state.gemini_key.is_some(),
|
||||
"claude": state.claude_key.is_some(),
|
||||
});
|
||||
let langfuse_configured = state.langfuse.is_some();
|
||||
let usage_snapshot = state.usage.read().await.clone();
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"workers_count": workers_count,
|
||||
"workers_loaded": workers_count > 0,
|
||||
"providers_configured": providers_configured,
|
||||
"langfuse_configured": langfuse_configured,
|
||||
"usage_total_requests": usage_snapshot.requests,
|
||||
"usage_by_provider": usage_snapshot.by_provider.keys().collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
// Phase 38 is stateless — no session persistence yet. Return an empty
|
||||
// list in OpenAI-ish shape so clients that probe this endpoint don't
|
||||
// 404. Real session state lands in Phase 41 with the profile-system
|
||||
@@ -230,7 +637,7 @@ mod tests {
|
||||
assert_eq!(r.model, "qwen3.5:latest");
|
||||
assert_eq!(r.messages.len(), 2);
|
||||
assert_eq!(r.messages[0].role, "system");
|
||||
assert_eq!(r.messages[1].content, "Hi");
|
||||
assert_eq!(r.messages[1].text(), "Hi");
|
||||
assert_eq!(r.temperature, Some(0.2));
|
||||
assert_eq!(r.max_tokens, Some(100));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -60,10 +60,7 @@ pub async fn chat(client: &AiClient, req: &ChatRequest) -> Result<ChatResponse,
|
||||
model: resp.model,
|
||||
choices: vec![Choice {
|
||||
index: 0,
|
||||
message: Message {
|
||||
role: "assistant".into(),
|
||||
content: resp.text,
|
||||
},
|
||||
message: Message::new_text("assistant", resp.text),
|
||||
finish_reason: "stop".into(),
|
||||
}],
|
||||
usage: UsageBlock {
|
||||
@@ -89,13 +86,14 @@ fn flatten_messages(messages: &[Message]) -> (String, String) {
|
||||
let mut system = String::new();
|
||||
let mut prompt = String::new();
|
||||
for m in messages {
|
||||
let body = m.text();
|
||||
if m.role == "system" {
|
||||
if !system.is_empty() { system.push('\n'); }
|
||||
system.push_str(&m.content);
|
||||
system.push_str(&body);
|
||||
} else {
|
||||
prompt.push_str(&m.role);
|
||||
prompt.push_str(": ");
|
||||
prompt.push_str(&m.content);
|
||||
prompt.push_str(&body);
|
||||
prompt.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
@@ -104,7 +102,7 @@ fn flatten_messages(messages: &[Message]) -> (String, String) {
|
||||
}
|
||||
|
||||
fn estimate_prompt_tokens(messages: &[Message]) -> u32 {
|
||||
let chars: usize = messages.iter().map(|m| m.content.chars().count()).sum();
|
||||
let chars: usize = messages.iter().map(|m| m.text().chars().count()).sum();
|
||||
((chars + 3) / 4) as u32
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ pub async fn chat(
|
||||
let text = parsed.response.unwrap_or_default();
|
||||
|
||||
let prompt_tokens = parsed.prompt_eval_count.unwrap_or_else(|| {
|
||||
let chars: usize = req.messages.iter().map(|m| m.content.chars().count()).sum();
|
||||
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
|
||||
((chars + 3) / 4) as u32
|
||||
});
|
||||
let completion_tokens = parsed.eval_count.unwrap_or_else(|| {
|
||||
@@ -112,7 +112,7 @@ pub async fn chat(
|
||||
model: parsed.model.unwrap_or_else(|| req.model.clone()),
|
||||
choices: vec![Choice {
|
||||
index: 0,
|
||||
message: Message { role: "assistant".into(), content: text },
|
||||
message: Message::new_text("assistant", text),
|
||||
finish_reason: "stop".into(),
|
||||
}],
|
||||
usage: UsageBlock {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! OpenCode GO adapter — multi-vendor curated gateway via opencode.ai/zen/go.
|
||||
//!
|
||||
//! One sk-* key reaches Claude Opus 4.7, GPT-5.5-pro, Gemini 3.1-pro,
|
||||
//! Kimi K2.6, DeepSeek, GLM, Qwen, plus 4 free-tier models.
|
||||
//! OpenAI-compatible Chat Completions; auth via Bearer.
|
||||
//!
|
||||
//! Why a separate adapter (vs reusing openrouter.rs):
|
||||
//! - Different account, different key, different base_url
|
||||
//! - No HTTP-Referer / X-Title headers (those are OpenRouter-specific)
|
||||
//! - Future-proof for any opencode-only request shaping
|
||||
//!
|
||||
//! Key sourcing priority:
|
||||
//! 1. Env var `OPENCODE_API_KEY` (loaded from /etc/lakehouse/opencode.env
|
||||
//! via systemd EnvironmentFile=)
|
||||
//! 2. /etc/lakehouse/opencode.env directly (rescue path if env missing)
|
||||
//!
|
||||
//! Resolved once at gateway startup, stored on `V1State.opencode_key`.
|
||||
//! Model-prefix routing: "opencode/<model>" auto-routes here, prefix
|
||||
//! stripped before upstream call.
|
||||
|
||||
use std::time::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
|
||||
|
||||
// /zen/v1 is the unified OpenCode endpoint that covers BOTH the
|
||||
// Zen pay-per-token tier (Claude/GPT/Gemini frontier) AND the Go
|
||||
// subscription tier (Kimi/GLM/DeepSeek/Qwen/Minimax/mimo). When the
|
||||
// caller has both, opencode bills per-model: Zen models charge Zen
|
||||
// balance, Go models charge against the Go subscription cap.
|
||||
//
|
||||
// /zen/go/v1 exists as a Go-only sub-path (rejects Zen models with
|
||||
// "Model not supported"); we use the unified /zen/v1 since the same
|
||||
// key works for both with correct billing routing upstream.
|
||||
const OPENCODE_BASE_URL: &str = "https://opencode.ai/zen/v1";
|
||||
// 600s default — opencode upstream models include reasoning-heavy
|
||||
// variants (Claude Opus, Kimi K2.6, GLM-5.1) that legitimately take
|
||||
// 3-5 min on big audit prompts. Override via OPENCODE_TIMEOUT_SECS.
|
||||
const OPENCODE_TIMEOUT_SECS_DEFAULT: u64 = 600;
|
||||
|
||||
fn opencode_timeout_secs() -> u64 {
|
||||
std::env::var("OPENCODE_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.unwrap_or(OPENCODE_TIMEOUT_SECS_DEFAULT)
|
||||
}
|
||||
|
||||
pub fn resolve_opencode_key() -> Option<String> {
|
||||
if let Ok(k) = std::env::var("OPENCODE_API_KEY") {
|
||||
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
||||
}
|
||||
if let Ok(raw) = std::fs::read_to_string("/etc/lakehouse/opencode.env") {
|
||||
for line in raw.lines() {
|
||||
if let Some(rest) = line.strip_prefix("OPENCODE_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 "opencode/" namespace prefix so the upstream sees the
|
||||
// bare model id (e.g. "claude-opus-4-7", "kimi-k2.6").
|
||||
let model = req.model.strip_prefix("opencode/").unwrap_or(&req.model).to_string();
|
||||
|
||||
// Anthropic models on opencode reject `temperature` with a 400
|
||||
// "temperature is deprecated for this model" error. Strip the
|
||||
// field for claude-* and the new gpt-5.x reasoning lineages
|
||||
// (Anthropic/OpenAI's reasoning models all moved away from temp).
|
||||
// Other models keep the caller's value or default to 0.3.
|
||||
let drop_temp = model.starts_with("claude-")
|
||||
|| model.starts_with("gpt-5")
|
||||
|| model.starts_with("o1")
|
||||
|| model.starts_with("o3")
|
||||
|| model.starts_with("o4");
|
||||
let body = OCChatBody {
|
||||
model: model.clone(),
|
||||
messages: req.messages.iter().map(|m| OCMessage {
|
||||
role: m.role.clone(),
|
||||
content: m.content.clone(),
|
||||
}).collect(),
|
||||
// filter(|&n| n > 0) catches Some(0) — same trap that bit the
|
||||
// Kimi adapter when callers passed empty-env-parsed-to-0.
|
||||
max_tokens: req.max_tokens.filter(|&n| n > 0).unwrap_or(800),
|
||||
temperature: if drop_temp { None } else { Some(req.temperature.unwrap_or(0.3)) },
|
||||
stream: false,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(opencode_timeout_secs()))
|
||||
.build()
|
||||
.map_err(|e| format!("build client: {e}"))?;
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let resp = client
|
||||
.post(format!("{}/chat/completions", OPENCODE_BASE_URL))
|
||||
.bearer_auth(key)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("opencode.ai unreachable: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_else(|_| "?".into());
|
||||
return Err(format!("opencode.ai {}: {}", status, body));
|
||||
}
|
||||
|
||||
let parsed: OCChatResponse = resp.json().await
|
||||
.map_err(|e| format!("invalid opencode response: {e}"))?;
|
||||
|
||||
let latency_ms = t0.elapsed().as_millis();
|
||||
let choice = parsed.choices.into_iter().next()
|
||||
.ok_or_else(|| "opencode 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.text().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 = "opencode",
|
||||
model = %model,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
latency_ms = latency_ms as u64,
|
||||
"opencode 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: serde_json::Value::String(text) },
|
||||
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
|
||||
}],
|
||||
usage: UsageBlock {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens: prompt_tokens + completion_tokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// -- OpenCode wire shapes (OpenAI-compatible) --
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OCChatBody {
|
||||
model: String,
|
||||
messages: Vec<OCMessage>,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f64>,
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OCMessage { role: String, content: serde_json::Value }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OCChatResponse {
|
||||
choices: Vec<OCChoice>,
|
||||
#[serde(default)]
|
||||
usage: Option<OCUsage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OCChoice {
|
||||
message: OCMessageResp,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OCMessageResp { content: String }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OCUsage { prompt_tokens: u32, completion_tokens: u32 }
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_opencode_key_does_not_panic() {
|
||||
let _ = resolve_opencode_key();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_prefix_strip() {
|
||||
let cases = [
|
||||
("opencode/claude-opus-4-7", "claude-opus-4-7"),
|
||||
("opencode/kimi-k2.6", "kimi-k2.6"),
|
||||
("claude-opus-4-7", "claude-opus-4-7"),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let out = input.strip_prefix("opencode/").unwrap_or(input);
|
||||
assert_eq!(out, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_tokens_filters_zero() {
|
||||
// The trap: empty env -> Number("") -> 0 -> Some(0). Adapter
|
||||
// must not pass 0 upstream; should fall to 800.
|
||||
let some_zero: Option<u32> = Some(0);
|
||||
let result = some_zero.filter(|&n| n > 0).unwrap_or(800);
|
||||
assert_eq!(result, 800);
|
||||
let some_real: Option<u32> = Some(4096);
|
||||
assert_eq!(some_real.filter(|&n| n > 0).unwrap_or(800), 4096);
|
||||
let none_val: Option<u32> = None;
|
||||
assert_eq!(none_val.filter(|&n| n > 0).unwrap_or(800), 800);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! 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(),
|
||||
// Pass content through verbatim — preserves OpenAI's multimodal
|
||||
// content-parts shape (`[{type:"text",text:"..."}, ...]`) so the
|
||||
// upstream provider sees exactly what the client sent.
|
||||
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.text().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: serde_json::Value::String(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: serde_json::Value }
|
||||
|
||||
#[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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! /v1/validate — gateway-side artifact validation endpoint.
|
||||
//!
|
||||
//! Phase 43 v3 part 2: makes the validator crate network-callable.
|
||||
//! Any caller (scrum loop, test harness, future agent) can POST a
|
||||
//! generated artifact and get back a Report (success) or
|
||||
//! ValidationError (failure with structured field/reason).
|
||||
//!
|
||||
//! Request shape:
|
||||
//! POST /v1/validate
|
||||
//! {
|
||||
//! "kind": "fill" | "email" | "playbook",
|
||||
//! "artifact": { ... },
|
||||
//! "context": { ... } // optional — folded into artifact._context
|
||||
//! }
|
||||
//!
|
||||
//! Response on success: 200 + Report JSON
|
||||
//! Response on failure: 422 + ValidationError JSON
|
||||
//! Response on bad request: 400 + plain-text error
|
||||
//!
|
||||
//! The shared WorkerLookup is loaded once at gateway startup from
|
||||
//! workers_500k.parquet (path configurable via LH_WORKERS_PARQUET
|
||||
//! env, defaults to data/datasets/workers_500k.parquet). Falls back
|
||||
//! to an empty InMemoryWorkerLookup if the file is missing — the
|
||||
//! validators will still run schema/length/PII checks but worker-
|
||||
//! existence checks will all fail (Consistency error), which is the
|
||||
//! correct behavior when the roster isn't configured.
|
||||
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use serde::Deserialize;
|
||||
use validator::{
|
||||
Artifact, Validator, ValidationError,
|
||||
staffing::{
|
||||
fill::FillValidator,
|
||||
email::EmailValidator,
|
||||
playbook::PlaybookValidator,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ValidateRequest {
|
||||
/// `"fill" | "email" | "playbook"` — picks which validator runs.
|
||||
pub kind: String,
|
||||
/// The artifact JSON (free-form; shape depends on `kind`).
|
||||
pub artifact: serde_json::Value,
|
||||
/// Optional context bag — merged into `artifact._context` so the
|
||||
/// validator can read fields like `target_count`, `city`,
|
||||
/// `client_id`, `candidate_id` without callers having to embed
|
||||
/// `_context` in the artifact themselves.
|
||||
#[serde(default)]
|
||||
pub context: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn validate(
|
||||
State(state): State<super::V1State>,
|
||||
Json(req): Json<ValidateRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// Merge context into artifact under `_context` so validators can
|
||||
// pull contract metadata uniformly.
|
||||
let mut artifact_value = req.artifact;
|
||||
if let Some(ctx) = req.context {
|
||||
if let Some(obj) = artifact_value.as_object_mut() {
|
||||
obj.insert("_context".to_string(), ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch.
|
||||
let workers = state.validate_workers.clone();
|
||||
let result: Result<validator::Report, ValidationError> = match req.kind.as_str() {
|
||||
"fill" => FillValidator::new(workers).validate(&Artifact::FillProposal(artifact_value)),
|
||||
"email" => EmailValidator::new(workers).validate(&Artifact::EmailDraft(artifact_value)),
|
||||
"playbook" => PlaybookValidator.validate(&Artifact::Playbook(artifact_value)),
|
||||
other => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("unknown kind '{other}' — expected fill | email | playbook"),
|
||||
).into_response(),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(report) => (StatusCode::OK, Json(report)).into_response(),
|
||||
Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, Json(e)).into_response(),
|
||||
}
|
||||
}
|
||||
@@ -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,15 @@
|
||||
[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 }
|
||||
# Parquet loader for ParquetWorkerLookup (Phase 43 v3 — production
|
||||
# WorkerLookup backed by workers_500k.parquet snapshot).
|
||||
arrow = { workspace = true }
|
||||
parquet = { 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,181 @@
|
||||
//! 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;
|
||||
}
|
||||
|
||||
// ─── Worker lookup (Phase 43 v2) ────────────────────────────────────────
|
||||
//
|
||||
// Validators that cross-check artifacts against the worker roster
|
||||
// (FillValidator, EmailValidator) take an `Arc<dyn WorkerLookup>` at
|
||||
// construction. Keeping the trait sync + in-memory mirrors the
|
||||
// lakehouse pattern of "load truth into memory, validate against
|
||||
// snapshot, refresh periodically" rather than per-call DB hits.
|
||||
//
|
||||
// Production impl: wrap a parquet snapshot loaded from
|
||||
// `data/datasets/workers_500k.parquet` (or its safe view counterpart
|
||||
// once Track A.B lands). Tests use `InMemoryWorkerLookup`.
|
||||
|
||||
/// One worker row from the staffing roster — the fields validators
|
||||
/// actually read. Anything not on this struct (resume_text, scores,
|
||||
/// communications) is intentionally hidden from the validator path.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerRecord {
|
||||
pub candidate_id: String,
|
||||
pub name: String,
|
||||
/// Free-form. Validators check for `"active"` (any other value
|
||||
/// fails the status check). Common values from existing data:
|
||||
/// "active", "inactive", "placed", "blacklisted".
|
||||
pub status: String,
|
||||
pub city: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub role: Option<String>,
|
||||
/// Client ids this worker has been blacklisted from. Populated
|
||||
/// from joining a blacklist table; empty when not provided.
|
||||
#[serde(default)]
|
||||
pub blacklisted_clients: Vec<String>,
|
||||
}
|
||||
|
||||
/// Worker lookup contract. Sync by design — implementations should
|
||||
/// hold an in-memory snapshot, not perform per-call I/O.
|
||||
pub trait WorkerLookup: Send + Sync {
|
||||
fn find(&self, candidate_id: &str) -> Option<WorkerRecord>;
|
||||
/// Number of workers in the snapshot. Default 0 for impls that
|
||||
/// genuinely don't know (e.g. a future SQL-backed lookup that
|
||||
/// counts on demand). InMemoryWorkerLookup overrides with the
|
||||
/// HashMap size; ParquetWorkerLookup constructs an
|
||||
/// InMemoryWorkerLookup so it inherits the override. Used by
|
||||
/// /v1/health to report data-load status during production
|
||||
/// switchover (the Chicago dataset replaces synthetic test data;
|
||||
/// the health endpoint is how operators verify the new file
|
||||
/// loaded correctly without restart-and-pray).
|
||||
fn len(&self) -> usize { 0 }
|
||||
}
|
||||
|
||||
/// HashMap-backed lookup. Used by validator unit tests + as a
|
||||
/// reasonable bootstrap impl for production once the parquet loader
|
||||
/// fills it on startup.
|
||||
pub struct InMemoryWorkerLookup {
|
||||
rows: std::collections::HashMap<String, WorkerRecord>,
|
||||
}
|
||||
|
||||
impl InMemoryWorkerLookup {
|
||||
pub fn new() -> Self {
|
||||
Self { rows: Default::default() }
|
||||
}
|
||||
pub fn from_records(records: Vec<WorkerRecord>) -> Self {
|
||||
let mut rows = std::collections::HashMap::with_capacity(records.len());
|
||||
for r in records {
|
||||
rows.insert(r.candidate_id.clone(), r);
|
||||
}
|
||||
Self { rows }
|
||||
}
|
||||
pub fn insert(&mut self, record: WorkerRecord) {
|
||||
self.rows.insert(record.candidate_id.clone(), record);
|
||||
}
|
||||
pub fn len(&self) -> usize { self.rows.len() }
|
||||
pub fn is_empty(&self) -> bool { self.rows.is_empty() }
|
||||
}
|
||||
|
||||
impl Default for InMemoryWorkerLookup {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl WorkerLookup for InMemoryWorkerLookup {
|
||||
fn find(&self, candidate_id: &str) -> Option<WorkerRecord> {
|
||||
self.rows.get(candidate_id).cloned()
|
||||
}
|
||||
fn len(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user