fact_extractor: project context + fixed verifier-verdict parser
lakehouse/auditor 8 warnings — see review

Two bundled changes. Both came out of J's observation that the
verifier was defaulting to UNVERIFIABLE on domain-specific facts
because it had no idea what Lakehouse was, which project's code it
was reading, or what framework the types belonged to.

1. Project context preamble. Added docs/AUDITOR_CONTEXT.md — a <400-
   word concise description of the project (crates, services,
   architecture phases, the auditor's role itself). fact_extractor
   reads it once, caches it, prepends it to the extract prompt as a
   "PROJECT CONTEXT (for grounding; do NOT extract from this)"
   section. Both extractor and verifier now see this context, so
   statements like "aggregate<T> returns Map<string, AggregateRow>"
   get grounded as "this is a TypeScript function in the Lakehouse
   auditor subsystem" and the verifier can reason about plausibility
   instead of guessing.

2. Verifier-verdict parser fix. Gemma2's output format varies between
   "**Verdict:** CORRECT" and just "* **CORRECT**" inline (observed
   variance across runs). The old regex required "Verdict:" as a
   label and missed the second format — causing all verdicts to
   stay UNCHECKED. Replaced with a two-pass approach: find each
   fact section start ("**N.**" or "N."), slice to the next section,
   scan the slice for the first CORRECT|INCORRECT|UNVERIFIABLE
   token. Handles both formats plus unfenced fallback.

Verified: 4-fact test extraction went from 0/4 verdicts scored
(pre-fix) to 2/4 CORRECT + 2/4 UNVERIFIABLE (post-fix). The 2
UNVERIFIABLE cases are domain-specific code behavior the verifier
legitimately can't confirm without reading source — correct stance,
not a parser miss.

No new consensus modes yet. J suggested adding codereview or
validator as a second pass; holding until we see whether context
injection alone gives sufficient signal lift.
This commit is contained in:
profit
2026-04-23 00:26:01 -05:00
parent 2a97fd7237
commit 56dbfb7d03
2 changed files with 123 additions and 14 deletions
+54 -14
View File
@@ -23,6 +23,23 @@ 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;
@@ -77,6 +94,16 @@ export async function extractFacts(source: string): Promise<ExtractedFacts> {
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`, {
@@ -84,7 +111,7 @@ export async function extractFacts(source: string): Promise<ExtractedFacts> {
headers: { "content-type": "application/json" },
body: JSON.stringify({
mode: "extract",
prompt: source,
prompt,
extractor: EXTRACTOR,
verifier: VERIFIER,
source: "prompt",
@@ -189,25 +216,38 @@ export async function extractFacts(source: string): Promise<ExtractedFacts> {
}
// Parse verifier's free-form output into a per-fact verdict array.
// The verifier output typically looks like:
// **1.** The claim...
// * **Verdict:** CORRECT
// **2.** ...
// **Verdict:** UNVERIFIABLE
// Using matchAll to iterate — returns a verdict array of length
// numFacts; unmatched positions stay UNCHECKED.
// 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");
const re = /(?:\*\*|#+\s*)?(\d+)[.):]\s[\s\S]*?\bVerdict\s*:\s*\*?\*?\s*(CORRECT|INCORRECT|UNVERIFIABLE)/gi;
for (const m of verifierText.matchAll(re)) {
const idx = Number(m[1]) - 1;
if (idx >= 0 && idx < numFacts) {
out[idx] = m[2].toUpperCase() as "CORRECT" | "INCORRECT" | "UNVERIFIABLE";
}
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;
}