ms-ai-architect/tests/kb-update/test-taxonomy.test.mjs
Kjell Tore Guttormsen b9f51ea7b3 feat(ms-ai-architect): C3.3 — course_products (live-enumerert) + makeCourseClassifier (TDD) [skip-docs]
Produkt-slug->kategori-mapping for kurs-deteksjon (C3.3 av GODKJENT C3-spec).

- lib/taxonomy.mjs: makeCourseClassifier(tax) — speiler makeClassifier; forste
  in-domain produkt vinner; skill AVLEDES fra category_skill (lagres ALDRI i
  course_products -> kan ikke divergere); ukjent/tom/non-array -> null.
- data/domain-taxonomy.json: ny topp-niva course_products (slug->category) +
  provenance. 15 in-domain live slugs.
- test-taxonomy.test.mjs (+6): atferd (in-domain->{skill,category}, first-wins,
  out-of-domain/tom/non-array->null, manglende map) + REELL data-invariant
  (hver slug-kategori resolver til skill, derivasjon matcher) + azure-openai-live.

ENUMERERING (gotcha #2, live mot /api/v1/modules?products=<slug> server-side):
- DOD (0 treff): azure-ai-foundry, ai-services, azure-ai-services, foundry ->
  Foundry/AI-services-kurs dukker opp under 'azure-openai'.
- Slug-feller bekreftet live: 'fabric' (ikke microsoft-fabric), 'entra' (ikke
  microsoft-entra-id), 'azure-cosmos-db' (ikke cosmos-db), 'azure-cognitive-search'
  (ikke azure-ai-search).
- 'azure-kubernetes-service' live men EKSKLUDERT (ingen AI-kategori -> stoy).
- Inkluderte (15): azure-openai, azure-machine-learning, azure-cognitive-search,
  azure-cosmos-db, fabric, azure-databricks, microsoft-copilot-studio,
  power-automate, power-apps, power-platform, ai-builder, microsoft-365-copilot,
  microsoft-purview, entra, defender-for-cloud.

Avvik fra spec 4.4-eksempel: course_products lagrer slug->category (ikke
slug->{skill,category}); skill avledes — anti-divergens, encapsulated av
makeCourseClassifier (downstream-kontrakt {skill,category} uendret).

Gate 7 C3.3 mott: test gronn, eval --json IDENTISK (refTall/K-counts uendret).
kb-update 263->269, validate PASSED (239/0), null regresjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:48:04 +02:00

234 lines
12 KiB
JavaScript

// tests/kb-update/test-taxonomy.test.mjs
// Unit tests for scripts/kb-update/lib/taxonomy.mjs + data/domain-taxonomy.json.
// The taxonomy is the single source of truth (lag 0) consolidating the four
// previously-divergent taxonomies: poll TARGET_PREFIXES, discover INCLUDE/EXCLUDE,
// category-skill-map.json, and report-changes getFilePriority. These tests pin the
// behaviour the refactored scripts must reproduce, plus the no-divergence invariant.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
loadTaxonomy,
saveTaxonomy,
getSitemapPrefixes,
getCategorySkill,
makeClassifier,
makeCourseClassifier,
makePriorityFn,
applyCategoryReassignments,
} from '../../scripts/kb-update/lib/taxonomy.mjs';
const tax = loadTaxonomy();
// --- (a) sitemap prefixes — poll/discover parity ---
test('sitemap_prefixes — 18 entries with poll/discover parity', () => {
const prefixes = getSitemapPrefixes(tax);
assert.equal(prefixes.length, 18);
// poll superset includes the 6 that discover previously lacked
for (const p of ['microsoftteams_en-us_', 'sharepoint_en-us_', 'microsoft-365_en-us_',
'training_en-us_', 'cloud-computing_en-us_', 'privacy_en-us_']) {
assert.ok(prefixes.includes(p), `missing prefix ${p}`);
}
assert.ok(prefixes.includes('azure_en-us_'));
// dotnet is deliberately excluded (75 sitemaps, 12 matches)
assert.ok(!prefixes.includes('dotnet_en-us_'));
});
// --- (c) category → skill — PHYSICAL DISK is canonical ---
test('category_skill — disk-truth canonical (the 4 formerly stale entries)', () => {
// category-skill-map.json had these as ms-ai-engineering; disk says otherwise.
assert.equal(getCategorySkill(tax, 'monitoring-observability'), 'ms-ai-governance');
assert.equal(getCategorySkill(tax, 'copilot-extensibility'), 'ms-ai-advisor');
assert.equal(getCategorySkill(tax, 'prompt-engineering'), 'ms-ai-advisor');
assert.equal(getCategorySkill(tax, 'performance-scalability'), 'ms-ai-security');
// unchanged ones
assert.equal(getCategorySkill(tax, 'rag-architecture'), 'ms-ai-engineering');
assert.equal(getCategorySkill(tax, 'responsible-ai'), 'ms-ai-governance');
assert.equal(getCategorySkill(tax, 'cost-optimization'), 'ms-ai-security');
// unknown category → null
assert.equal(getCategorySkill(tax, 'does-not-exist'), null);
});
// --- (b) classifyUrl — reproduces discover's classification exactly ---
test('classifyUrl — include rules map to correct {skill, category}', () => {
const classify = makeClassifier(tax);
assert.deepEqual(
classify('https://learn.microsoft.com/azure/ai-foundry/openai/how-to/manage-costs'),
{ skill: 'ms-ai-engineering', category: 'azure-ai-services' }
);
// monitoring → governance (was a divergence; derivation reproduces discover's original)
assert.deepEqual(
classify('https://learn.microsoft.com/azure/azure-monitor/logs/foo'),
{ skill: 'ms-ai-governance', category: 'monitoring-observability' }
);
// copilot-studio → advisor (was a divergence; derivation reproduces discover's original)
assert.deepEqual(
classify('https://learn.microsoft.com/microsoft-copilot-studio/fundamentals-what-is'),
{ skill: 'ms-ai-advisor', category: 'copilot-extensibility' }
);
// alternation pattern
assert.deepEqual(
classify('https://learn.microsoft.com/security/benchmark/azure/foo'),
{ skill: 'ms-ai-security', category: 'ai-security-engineering' }
);
});
test('classifyUrl — exclude wins over include and non-matches return null', () => {
const classify = makeClassifier(tax);
// /samples/ excluded even though /azure/ai-foundry/ would include
assert.equal(classify('https://learn.microsoft.com/azure/ai-foundry/samples/quickstart'), null);
// /training/ excluded
assert.equal(classify('https://learn.microsoft.com/training/modules/intro'), null);
// unrelated path
assert.equal(classify('https://learn.microsoft.com/azure/virtual-machines/overview'), null);
});
test('INVARIANT — every include category resolves and skill === category_skill[category]', () => {
const classify = makeClassifier(tax);
for (const rule of tax.relevance.include) {
const skill = getCategorySkill(tax, rule.category);
assert.ok(skill, `include category ${rule.category} absent from category_skill`);
}
// probe each rule via a synthetic URL and assert no skill/category divergence
const probes = [
['https://learn.microsoft.com/azure/ai-foundry/x', 'azure-ai-services'],
['https://learn.microsoft.com/azure/machine-learning/x', 'mlops-genaiops'],
['https://learn.microsoft.com/azure/search/x', 'rag-architecture'],
['https://learn.microsoft.com/azure/azure-monitor/x', 'monitoring-observability'],
['https://learn.microsoft.com/azure/well-architected/x', 'architecture'],
['https://learn.microsoft.com/microsoft-copilot-studio/x', 'copilot-extensibility'],
['https://learn.microsoft.com/purview/x', 'responsible-ai'],
['https://learn.microsoft.com/agent-framework/x', 'agent-orchestration'],
['https://learn.microsoft.com/azure/cosmos-db/x', 'data-engineering'],
];
for (const [url, expectedCat] of probes) {
const r = classify(url);
assert.ok(r, `no classification for ${url}`);
assert.equal(r.category, expectedCat);
assert.equal(r.skill, getCategorySkill(tax, r.category), `divergence at ${url}`);
}
});
// --- (d) getFilePriority — reproduces report-changes ordering exactly ---
test('getFilePriority — pattern → priority, first match wins', () => {
const prio = makePriorityFn(tax);
assert.equal(prio('skills/ms-ai-security/references/cost-optimization/budget.md'), 'critical');
assert.equal(prio('skills/ms-ai-governance/references/responsible-ai/fairness.md'), 'high');
// 'governance' substring → high
assert.equal(prio('skills/ms-ai-governance/references/norwegian-public-sector-governance/x.md'), 'high');
assert.equal(prio('skills/ms-ai-advisor/references/platforms/copilot-studio.md'), 'medium');
// no pattern → low
assert.equal(prio('skills/ms-ai-engineering/references/data-engineering/cosmos.md'), 'low');
});
test('getFilePriority — order: cost (critical) outranks governance (high)', () => {
const prio = makePriorityFn(tax);
// contains both 'cost' and 'governance' — critical rule is evaluated first
assert.equal(prio('skills/x/references/cost-governance-tradeoffs.md'), 'critical');
});
// --- (e) taxonomy persistence (Sesjon 19, B3 merge-apply) ---
// merge-apply repoints every category owned by the absorbed skill to the
// absorber and must PERSIST that to domain-taxonomy.json. Two new primitives:
// a pure reassign helper + an atomic save mirroring saveDecisions.
test('applyCategoryReassignments — repoints only matching from->to; pure (no mutation)', () => {
const before = JSON.parse(JSON.stringify(tax.category_skill));
const next = applyCategoryReassignments(tax, [
{ category: 'rag-architecture', from: 'ms-ai-engineering', to: 'ms-ai-advisor' }, // matches → repointed
{ category: 'responsible-ai', from: 'ms-ai-NONMATCH', to: 'ms-ai-advisor' }, // current != from → no-op
{ category: 'does-not-exist', from: 'whatever', to: 'ms-ai-advisor' }, // absent category → no-op
]);
assert.equal(next.category_skill['rag-architecture'], 'ms-ai-advisor', 'matching entry repointed');
assert.equal(next.category_skill['responsible-ai'], 'ms-ai-governance', 'non-matching from is a no-op');
assert.equal(next.category_skill['does-not-exist'], undefined, 'absent category stays absent');
// purity: original object + its nested map are untouched, a fresh object is returned
assert.deepEqual(tax.category_skill, before);
assert.notEqual(next, tax);
assert.notEqual(next.category_skill, tax.category_skill);
});
test('saveTaxonomy + loadTaxonomy — atomic round-trip preserves content', () => {
const dir = mkdtempSync(join(tmpdir(), 'tax-save-'));
try {
const next = applyCategoryReassignments(tax, [
{ category: 'rag-architecture', from: 'ms-ai-engineering', to: 'ms-ai-advisor' },
]);
saveTaxonomy(next, dir);
const reloaded = loadTaxonomy(dir);
assert.equal(getCategorySkill(reloaded, 'rag-architecture'), 'ms-ai-advisor', 'reassignment persisted');
assert.equal(getCategorySkill(reloaded, 'responsible-ai'), 'ms-ai-governance', 'untouched entries survive');
assert.equal(reloaded.version, tax.version, 'non-category_skill fields survive the round-trip');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// --- (f) makeCourseClassifier (C3.3) — product-slug → {skill, category} ---
// course_products maps an in-domain Learn product slug → category; the owning
// skill is DERIVED from category_skill (never stored redundantly), mirroring
// makeClassifier's anti-divergence rule. First in-domain product wins; a course
// whose products are all out-of-domain returns null (not a lead).
// Synthetic taxonomy isolates the classifier's behaviour from the real data.
const courseTax = {
category_skill: {
'azure-ai-services': 'ms-ai-engineering',
'platforms': 'ms-ai-advisor',
},
course_products: {
'azure-openai': 'azure-ai-services',
'microsoft-copilot-studio': 'platforms',
'power-automate': 'platforms',
},
};
test('makeCourseClassifier — in-domain slug → derived {skill, category}', () => {
const classify = makeCourseClassifier(courseTax);
assert.deepEqual(classify(['azure-openai']), { skill: 'ms-ai-engineering', category: 'azure-ai-services' });
assert.deepEqual(classify(['microsoft-copilot-studio']), { skill: 'ms-ai-advisor', category: 'platforms' });
});
test('makeCourseClassifier — first in-domain product wins (order matters)', () => {
const classify = makeCourseClassifier(courseTax);
// a leading out-of-domain slug is skipped; first in-domain wins
assert.deepEqual(classify(['some-unknown-product', 'azure-openai']), { skill: 'ms-ai-engineering', category: 'azure-ai-services' });
// when two in-domain products are present, the FIRST in the list wins
assert.deepEqual(classify(['azure-openai', 'power-automate']), { skill: 'ms-ai-engineering', category: 'azure-ai-services' });
assert.deepEqual(classify(['power-automate', 'azure-openai']), { skill: 'ms-ai-advisor', category: 'platforms' });
});
test('makeCourseClassifier — out-of-domain / empty / non-array → null', () => {
const classify = makeCourseClassifier(courseTax);
assert.equal(classify(['totally-unknown', 'another-unknown']), null, 'all out-of-domain → null');
assert.equal(classify([]), null, 'empty products → null');
assert.equal(classify(null), null, 'non-array input → null (defensive)');
assert.equal(classify(undefined), null, 'undefined input → null (defensive)');
});
test('makeCourseClassifier — taxonomy without course_products → always null', () => {
const classify = makeCourseClassifier({ category_skill: {} });
assert.equal(classify(['azure-openai']), null, 'absent course_products map → no lead');
});
// --- (g) REAL course_products invariant (C3.3 data, enumerated from live API) ---
test('INVARIANT — every course_products category resolves to a skill, derivation matches', () => {
assert.ok(tax.course_products, 'course_products section present in domain-taxonomy.json');
assert.ok(Object.keys(tax.course_products).length > 0, 'course_products is non-empty');
const classify = makeCourseClassifier(tax);
for (const [slug, category] of Object.entries(tax.course_products)) {
assert.equal(typeof category, 'string', `${slug} maps to a category string`);
const skill = getCategorySkill(tax, category);
assert.ok(skill, `course_products slug ${slug} → category ${category} absent from category_skill`);
assert.deepEqual(classify([slug]), { skill, category }, `divergence for slug ${slug}`);
}
});
test('makeCourseClassifier — azure-openai (verified-live slug) → engineering/azure-ai-services', () => {
// azure-openai is the empirically-confirmed live slug (spike §: 34 hits).
const classify = makeCourseClassifier(tax);
assert.deepEqual(classify(['azure-openai']), { skill: 'ms-ai-engineering', category: 'azure-ai-services' });
});