diff --git a/scripts/kb-update/lib/classify-ref-type.mjs b/scripts/kb-update/lib/classify-ref-type.mjs new file mode 100644 index 0000000..66f1adb --- /dev/null +++ b/scripts/kb-update/lib/classify-ref-type.mjs @@ -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} + */ +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; +} diff --git a/tests/kb-update/test-classify-ref-type.test.mjs b/tests/kb-update/test-classify-ref-type.test.mjs new file mode 100644 index 0000000..72bfc9d --- /dev/null +++ b/tests/kb-update/test-classify-ref-type.test.mjs @@ -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'); + } +});