ms-ai-architect/scripts/kb-eval/eval.mjs
Kjell Tore Guttormsen 215772df87 feat(ms-ai-architect): Sesjon 1 - skill eval-baseline (rubrikk K1-K9) + ai-foundry probe
Read-only eval-verktoy scripts/kb-eval/eval.mjs (determ. K2/K3/K5/K6 + ref-tall) + operator-gated LLM-judge (judge-prompt.md -> data/judge-results.json) flettet til data/eval-baseline.json. 13 enhetstester (tests/kb-eval/), 42 kb-update-tester gronne. Baseline: K5 3/5 fail, K9 4/5 fail, K4 2/5 fail, ref-tall 2/5 fail; 2 konkrete bugs flagget (security 6x5-vekting-motstrid, governance broken ref-path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REiKFhP4w6xGXXqWKpPCJJ
2026-06-19 20:26:56 +02:00

257 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// eval.mjs — Skill quality baseline (rubrikk K1K9) for ms-ai-architect.
//
// READ-ONLY by default. Computes the deterministic criteria (K2/K3/K5/K6 +
// reference-count consistency) for every skill and extracts the inputs an LLM
// judge needs for the semantic criteria (K1/K4/K7/K8/K9). The baseline file is
// written ONLY with --write (operator gate), per the redesign spec.
//
// Usage:
// node scripts/kb-eval/eval.mjs # human-readable summary
// node scripts/kb-eval/eval.mjs --json # machine output (pure JSON to stdout)
// node scripts/kb-eval/eval.mjs --write # also persist data/eval-baseline.json
//
// Zero dependencies. Reuses kb-update/lib/atomic-write.mjs for the gated write.
import { readdirSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { atomicWriteJson } from '../kb-update/lib/atomic-write.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const SKILLS_DIR = join(PLUGIN_ROOT, 'skills');
const OUT_DIR = join(__dirname, 'data');
const OUT_FILE = join(OUT_DIR, 'eval-baseline.json');
// Thresholds — see spec "Rubrikk K1K9".
const K3_MAX_BODY_LINES = 500;
const K5_MIN_NAMED_RATIO = 0.2;
/** Recursively list .md files under dir. */
function listMarkdown(dir) {
const out = [];
if (!existsSync(dir)) return out;
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...listMarkdown(p));
else if (e.isFile() && e.name.endsWith('.md')) out.push(p);
}
return out;
}
/** Count .md files per immediate subfolder of references/. */
function refCountsPerFolder(refDir) {
const counts = {};
if (!existsSync(refDir)) return counts;
for (const e of readdirSync(refDir, { withFileTypes: true })) {
if (e.isDirectory()) counts[e.name] = listMarkdown(join(refDir, e.name)).length;
}
return counts;
}
/** Split YAML frontmatter from body. */
export function splitFrontmatter(content) {
if (!content.startsWith('---')) return { frontmatter: '', body: content };
const close = content.indexOf('\n---', 3);
if (close === -1) return { frontmatter: '', body: content };
const bodyStart = content.indexOf('\n', close + 1);
return {
frontmatter: content.slice(0, bodyStart === -1 ? content.length : bodyStart + 1),
body: bodyStart === -1 ? '' : content.slice(bodyStart + 1),
};
}
/** Extract the (possibly folded) description value from frontmatter. */
export function extractDescription(frontmatter) {
const lines = frontmatter.split('\n');
const out = [];
let collecting = false;
for (const line of lines) {
if (/^description:/.test(line)) {
collecting = true;
out.push(line.replace(/^description:\s*>?-?\s*/, ''));
continue;
}
if (collecting) {
if (/^[A-Za-z0-9_-]+:\s/.test(line) || line.trim() === '---' || line.trim() === '') {
if (line.trim() === '') continue; // folded blocks keep going across blanks
break;
}
out.push(line.trim());
}
}
return out.join(' ').replace(/\s+/g, ' ').trim();
}
/** K2 — description format: third-person/use-when OR >=3 quoted user phrases. */
export function checkK2(description) {
const quoted = (description.match(/"[^"]+"/g) || []).length;
const useWhenForm = /should be used when|use this skill|triggers on/i.test(description);
const imperativeStart = /^(use|bruk|run|kjør)\b/i.test(description);
const pass = useWhenForm || quoted >= 3;
return { quotedPhrases: quoted, useWhenForm, imperativeStart, pass };
}
/** K3 — SKILL.md body length. */
export function checkK3(body) {
const lines = body.replace(/\n+$/, '').split('\n').length;
return { bodyLines: lines, pass: lines <= K3_MAX_BODY_LINES };
}
/** K5/K6 — progressive disclosure: named .md links vs folder-only references. */
export function checkK5K6(body, totalRefFiles) {
const named = [...new Set((body.match(/references\/[A-Za-z0-9_\-/]+\.md/g) || []))];
// folder references like `references/foo/` not immediately followed by a filename
const folderRefs = [...new Set((body.match(/references\/[A-Za-z0-9_-]+\/(?![A-Za-z0-9_-]+\.md)/g) || []))];
const namedRatio = totalRefFiles > 0 ? named.length / totalRefFiles : 0;
return {
K5: {
namedFileLinks: named.length,
folderRefs: folderRefs.length,
totalRefFiles,
namedRatio: Number(namedRatio.toFixed(4)),
pass: namedRatio >= K5_MIN_NAMED_RATIO,
sampleNamed: named.slice(0, 5),
},
K6: {
// routing table = at least one named start file the model can Read directly
namedStartFiles: named.length,
pass: named.length >= 1,
},
};
}
/** Reference-count consistency: cited per-folder counts vs actual on disk. */
export function checkRefConsistency(body, actualPerFolder) {
const mismatches = [];
const seen = {};
// prose form: references/<folder>/ (N files) and `references/<folder>/` (N files)
const proseRe = /references\/([a-z0-9-]+)\/`?\s*\((\d+)\s*files?\)/gi;
// table form: `references/<folder>/` | N
const tableRe = /`references\/([a-z0-9-]+)\/`\s*\|\s*(\d+)/gi;
for (const re of [proseRe, tableRe]) {
let m;
while ((m = re.exec(body)) !== null) {
const folder = m[1];
const cited = Number(m[2]);
seen[folder] = seen[folder] || new Set();
seen[folder].add(cited);
const actual = actualPerFolder[folder];
if (actual !== undefined && cited !== actual) {
mismatches.push({ folder, cited, actual });
}
}
}
// also flag folders cited with two different numbers
for (const [folder, set] of Object.entries(seen)) {
if (set.size > 1) mismatches.push({ folder, citedMultiple: [...set], actual: actualPerFolder[folder] ?? null });
}
return { consistent: mismatches.length === 0, mismatches };
}
/** Light deterministic pre-scan for K9 (time-sensitive tokens in body). */
function scanK9Hints(body) {
const tokens = body.match(/\b(GA|preview|deprecat\w*|retir\w*|20\d\d|v\d+(?:\.\d+)?)\b/gi) || [];
return { timeSensitiveTokenHits: tokens.length, sample: [...new Set(tokens)].slice(0, 12) };
}
function evalSkill(skillName) {
const skillDir = join(SKILLS_DIR, skillName);
const skillMd = join(skillDir, 'SKILL.md');
if (!existsSync(skillMd)) return null;
const content = readFileSync(skillMd, 'utf8');
const { frontmatter, body } = splitFrontmatter(content);
const description = extractDescription(frontmatter);
const refDir = join(skillDir, 'references');
const refFiles = listMarkdown(refDir);
const perFolder = refCountsPerFolder(refDir);
const K2 = checkK2(description);
const K3 = checkK3(body);
const { K5, K6 } = checkK5K6(body, refFiles.length);
const refConsistency = checkRefConsistency(body, perFolder);
const K9hints = scanK9Hints(body);
return {
name: skillName,
skillMd: relative(PLUGIN_ROOT, skillMd),
refFilesActual: refFiles.length,
refCountsPerFolder: perFolder,
deterministic: {
K2_descriptionFormat: K2,
K3_bodyLength: K3,
K5_progressiveDisclosure: K5,
K6_routingTable: K6,
refCountConsistency: refConsistency,
K9_timeSensitiveHints: K9hints,
},
// Pointers for the LLM-judge pass (K1/K4/K7/K8/K9). The judge reads the files
// directly; we only carry the description + sampled ref files here.
judgeInputs: {
description,
bodyLines: K3.bodyLines,
refFileSample: refFiles.slice(0, 8).map((f) => relative(PLUGIN_ROOT, f)),
},
// Filled in by the operator-gated LLM-judge merge step. Null = not yet judged.
judge: null,
};
}
function main() {
const args = process.argv.slice(2);
const jsonOut = args.includes('--json');
const doWrite = args.includes('--write');
const skillNames = readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory() && existsSync(join(SKILLS_DIR, e.name, 'SKILL.md')))
.map((e) => e.name)
.sort();
const skills = skillNames.map(evalSkill).filter(Boolean);
// Merge operator-gated LLM-judge results (K1/K4/K7/K8/K9) if present.
const judgeFile = join(OUT_DIR, 'judge-results.json');
if (existsSync(judgeFile)) {
const jr = JSON.parse(readFileSync(judgeFile, 'utf8'));
for (const s of skills) if (jr[s.name]) s.judge = jr[s.name];
}
const report = {
rubric: 'K1-K9',
note: 'Deterministic: K2,K3,K5,K6,refCountConsistency. LLM-judge (operator-gated): K1,K4,K7,K8,K9.',
skills,
};
if (doWrite) {
mkdirSync(OUT_DIR, { recursive: true });
atomicWriteJson(OUT_FILE, report);
}
if (jsonOut) {
process.stdout.write(JSON.stringify(report) + '\n');
return;
}
// Human-readable summary
console.log(`\nKB skill eval — ${skills.length} skills (deterministic pass)\n`);
for (const s of skills) {
const d = s.deterministic;
const flag = (b) => (b ? 'PASS' : 'FAIL');
console.log(`${s.name} (${s.refFilesActual} ref-filer)`);
console.log(` K2 description : ${flag(d.K2_descriptionFormat.pass)} (quoted=${d.K2_descriptionFormat.quotedPhrases}, useWhen=${d.K2_descriptionFormat.useWhenForm})`);
console.log(` K3 body ≤500 : ${flag(d.K3_bodyLength.pass)} (${d.K3_bodyLength.bodyLines} linjer)`);
console.log(` K5 prog.disc. : ${flag(d.K5_progressiveDisclosure.pass)} (navngitte=${d.K5_progressiveDisclosure.namedFileLinks}, mapper=${d.K5_progressiveDisclosure.folderRefs}, ratio=${d.K5_progressiveDisclosure.namedRatio})`);
console.log(` K6 routing-tab : ${flag(d.K6_routingTable.pass)} (navngitte startfiler=${d.K6_routingTable.namedStartFiles})`);
const rc = d.refCountConsistency;
console.log(` ref-tall : ${flag(rc.consistent)}${rc.consistent ? '' : ' — ' + JSON.stringify(rc.mismatches)}`);
console.log(` K9 hints : ${d.K9_timeSensitiveHints.timeSensitiveTokenHits} tid-sensitive tokens i body`);
console.log('');
}
console.log(`(LLM-judge K1/K4/K7/K8/K9 kjøres som operatør-gated subagent-steg; baseline skrives med --write.)\n`);
}
// Run only when invoked directly (not when imported by tests).
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main();
}