feat(ms-ai-architect): Spor 1 — ref-type-klassifiserer (pure core, MS-sitering-proxy + edge-flagg, TDD) [skip-docs]

This commit is contained in:
Kjell Tore Guttormsen 2026-07-03 00:52:45 +02:00
commit 6b8e1b3767
2 changed files with 170 additions and 0 deletions

View file

@ -0,0 +1,65 @@
// classify-ref-type.mjs — Spor 1 Step 2: pure ref-type classifier.
//
// First-pass proxy: a file citing ≥1 learn.microsoft.com URL is a `reference`.
// Both false-positive directions are flagged for adjudication, never guessed:
// - zero-MS files get a path-heuristic candidate type + reviewFlag:true
// - methodology/template-shaped paths that DO cite MS get reviewFlag:true
// Pure core: no disk, no clock, no randomness; classifyCorpus output is
// relpath-keyed and key-sorted so repeated runs are byte-identical.
import { extractUrls } from './url-normalize.mjs';
// Mirrors transform.mjs:33 (not exported there; transform.mjs is out of this
// step's touch scope). The Port-1 contract's closed type set.
export const VALID_TYPES = ['reference', 'template', 'methodology', 'regulatory'];
// Path shapes that indicate a non-reference artifact. Matched against the full
// relpath, case-insensitive: directory names or filename stems.
const RE_TEMPLATE_PATH = /template|-mal(?:er)?[-./]|sjekkliste|checklist/i;
const RE_METHODOLOGY_PATH = /methodolog|metodikk|framework|rammeverk|playbook|runbook/i;
const RE_REGULATORY_PATH = /eu-ai-act|ai-act|gdpr|eur-lex|regulator|forordning|schrems/i;
// Content signal for regulatory texts: cites the EU law corpus directly.
const RE_REGULATORY_CONTENT = /eur-lex\.europa\.eu|celex/i;
/** Candidate type from path (and regulatory content signal). Pure heuristic. */
function candidateType(path, content) {
if (RE_REGULATORY_PATH.test(path) || RE_REGULATORY_CONTENT.test(content)) return 'regulatory';
if (RE_TEMPLATE_PATH.test(path)) return 'template';
if (RE_METHODOLOGY_PATH.test(path)) return 'methodology';
return 'reference';
}
/**
* Classify one file. Pure.
* @param {{path: string, content: string}} file
* @returns {{type: string, signal: string, mscite: boolean, reviewFlag: boolean}}
*/
export function classifyRefType({ path, content }) {
const mscite = extractUrls(content).length > 0;
const candidate = candidateType(path ?? '', content ?? '');
if (mscite && candidate === 'reference') {
return { type: 'reference', signal: 'ms-citation', mscite: true, reviewFlag: false };
}
if (mscite) {
// Shaped like a non-reference but cites MS — conflicting signals, adjudicate.
return { type: candidate, signal: 'ms-citation+path-conflict', mscite: true, reviewFlag: true };
}
// Zero-MS: never silently a reference — candidate from the path heuristic.
return { type: candidate, signal: 'path-heuristic', mscite: false, reviewFlag: true };
}
/**
* Classify a corpus. Pure, deterministic: relpath-keyed, key-sorted manifest.
* @param {Array<{path: string, content: string}>} entries
* @returns {Record<string, {type: string, signal: string, mscite: boolean, reviewFlag: boolean}>}
*/
export function classifyCorpus(entries) {
const manifest = {};
const sorted = [...(entries ?? [])].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
for (const entry of sorted) {
manifest[entry.path] = classifyRefType(entry);
}
return manifest;
}

View file

@ -0,0 +1,105 @@
// tests/kb-update/test-classify-ref-type.test.mjs
// Spor 1 Step 2 — the pure ref-type classifier: MS-citation is the first-pass proxy
// for `reference`, but zero-MS files and methodology/template-shaped paths that DO
// cite MS are never silently classified — they carry reviewFlag:true for the Step-4
// edge-set adjudication. Pure core, no disk, deterministic output.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
classifyRefType,
classifyCorpus,
VALID_TYPES,
} from '../../scripts/kb-update/lib/classify-ref-type.mjs';
const MS_CITING =
'# Azure AI Search\n\n**Category:** rag\n\n---\n\n## A\n\n' +
'Se https://learn.microsoft.com/azure/search/hybrid-search-overview for detaljer.\n';
const ZERO_MS =
'# Vurderingsnotat\n\n**Category:** governance\n\n---\n\n## A\n\n' +
'Ren intern metodetekst uten eksterne kilder.\n';
const METHODOLOGY_SHAPED_MS_CITING =
'# Vurderingsmetodikk\n\n**Category:** governance\n\n---\n\n## A\n\n' +
'Rammeverk-steg. Se https://learn.microsoft.com/azure/well-architected/ for bakgrunn.\n';
const REGULATORY_ZERO_MS =
'# EU AI Act Art. 6\n\n**Category:** governance\n\n---\n\n## A\n\n' +
'Forordningsteksten: https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R1689\n';
test('classifyRefType — MS-citing file → reference, mscite:true, no review flag', () => {
const r = classifyRefType({ path: 'skills/ms-ai-engineering/references/rag/azure-ai-search.md', content: MS_CITING });
assert.equal(r.type, 'reference');
assert.equal(r.mscite, true);
assert.equal(r.reviewFlag, false);
assert.equal(r.signal, 'ms-citation');
});
test('classifyRefType — zero-MS file is NOT silently reference: reviewFlag:true + path-heuristic candidate', () => {
const r = classifyRefType({ path: 'skills/ms-ai-governance/references/governance/vurderingsnotat.md', content: ZERO_MS });
assert.equal(r.mscite, false);
assert.equal(r.reviewFlag, true);
assert.ok(VALID_TYPES.includes(r.type));
});
test('classifyRefType — methodology-shaped path that cites MS → reviewFlag:true (opposite false-positive direction)', () => {
const r = classifyRefType({
path: 'skills/ms-ai-governance/references/methodology/vurderingsmetodikk.md',
content: METHODOLOGY_SHAPED_MS_CITING,
});
assert.equal(r.mscite, true);
assert.equal(r.reviewFlag, true);
assert.ok(VALID_TYPES.includes(r.type));
});
test('classifyRefType — template-shaped path that cites MS → reviewFlag:true', () => {
const r = classifyRefType({
path: 'skills/ms-ai-governance/references/templates/dpia-template.md',
content: MS_CITING,
});
assert.equal(r.reviewFlag, true);
assert.ok(VALID_TYPES.includes(r.type));
});
test('classifyRefType — regulatory text citing eur-lex (zero-MS) → reviewFlag:true, regulatory candidate', () => {
const r = classifyRefType({
path: 'skills/ms-ai-governance/references/eu-ai-act/article-6.md',
content: REGULATORY_ZERO_MS,
});
assert.equal(r.mscite, false);
assert.equal(r.reviewFlag, true);
assert.equal(r.type, 'regulatory');
});
test('classifyRefType — every emitted type is in VALID_TYPES', () => {
const fixtures = [
{ path: 'skills/x/references/a.md', content: MS_CITING },
{ path: 'skills/x/references/templates/b.md', content: ZERO_MS },
{ path: 'skills/x/references/methodology/c.md', content: METHODOLOGY_SHAPED_MS_CITING },
{ path: 'skills/x/references/eu/d.md', content: REGULATORY_ZERO_MS },
];
for (const f of fixtures) {
const r = classifyRefType(f);
assert.ok(VALID_TYPES.includes(r.type), `${r.type} not in VALID_TYPES`);
}
});
test('classifyCorpus — relpath-keyed, key-sorted, deterministic across runs', () => {
const entries = [
{ path: 'skills/z/references/z.md', content: MS_CITING },
{ path: 'skills/a/references/a.md', content: ZERO_MS },
{ path: 'skills/m/references/m.md', content: METHODOLOGY_SHAPED_MS_CITING },
];
const m1 = classifyCorpus(entries);
const m2 = classifyCorpus([...entries].reverse());
assert.deepEqual(m1, m2);
assert.equal(JSON.stringify(m1), JSON.stringify(m2));
assert.deepEqual(Object.keys(m1), [...Object.keys(m1)].sort());
assert.equal(Object.keys(m1).length, 3);
for (const entry of Object.values(m1)) {
assert.ok(VALID_TYPES.includes(entry.type));
assert.equal(typeof entry.reviewFlag, 'boolean');
assert.equal(typeof entry.mscite, 'boolean');
}
});