diff --git a/scripts/kb-update/data/domain-taxonomy.json b/scripts/kb-update/data/domain-taxonomy.json index da0700b..c8673a6 100644 --- a/scripts/kb-update/data/domain-taxonomy.json +++ b/scripts/kb-update/data/domain-taxonomy.json @@ -4,7 +4,8 @@ "provenance": { "category_skill": "PHYSICAL DISK is canonical (skills//references//). category-skill-map.json drifted on 4 entries (copilot-extensibility, monitoring-observability, performance-scalability, prompt-engineering) and is no longer consumed by code.", "sitemap_prefixes": "poll-sitemaps superset (18). discover-new-urls previously had only 12 — now reads this list for poll parity.", - "notes": "dotnet_en-us_ deliberately excluded: 75 sitemaps, only ~12 matches — not worth weekly polling." + "notes": "dotnet_en-us_ deliberately excluded: 75 sitemaps, only ~12 matches — not worth weekly polling.", + "course_products": "Product slugs enumerated LIVE from the Learn Platform API (C3.3, server-side /api/v1/modules?products= probe). Doc slug 'azure-ai-foundry' is DEAD (0 hits) → Foundry/AI-services courses surface under 'azure-openai'. Slug gotchas confirmed live: 'fabric' (not 'microsoft-fabric'), 'entra' (not 'microsoft-entra-id'), 'azure-cosmos-db' (not 'cosmos-db'), 'azure-cognitive-search' (not 'azure-ai-search'). 'azure-kubernetes-service' is live but excluded (no AI category → noise). Maps slug->category; the owning skill is DERIVED from category_skill via makeCourseClassifier (never stored, cannot diverge)." }, "sitemap_prefixes": [ "azure_en-us_", @@ -49,6 +50,23 @@ "responsible-ai": "ms-ai-governance", "security-scoring": "ms-ai-security" }, + "course_products": { + "azure-openai": "azure-ai-services", + "azure-machine-learning": "mlops-genaiops", + "azure-cognitive-search": "rag-architecture", + "azure-cosmos-db": "data-engineering", + "fabric": "data-engineering", + "azure-databricks": "data-engineering", + "microsoft-copilot-studio": "platforms", + "power-automate": "platforms", + "power-apps": "platforms", + "power-platform": "platforms", + "ai-builder": "platforms", + "microsoft-365-copilot": "copilot-extensibility", + "microsoft-purview": "responsible-ai", + "entra": "ai-security-engineering", + "defender-for-cloud": "ai-security-engineering" + }, "relevance": { "include": [ { "pattern": "/azure/ai-foundry/", "category": "azure-ai-services" }, diff --git a/scripts/kb-update/lib/taxonomy.mjs b/scripts/kb-update/lib/taxonomy.mjs index 9c92c2d..9f9aeac 100644 --- a/scripts/kb-update/lib/taxonomy.mjs +++ b/scripts/kb-update/lib/taxonomy.mjs @@ -120,6 +120,31 @@ export function makeClassifier(tax) { }; } +/** + * Build a course classifier for the Learn Platform API course-detection spore + * (C3). course_products maps an in-domain product slug → category; the owning + * skill is DERIVED from category_skill (never stored redundantly), exactly as + * makeClassifier derives skill for URL classification — so the slug→skill path + * cannot diverge from the canonical category ownership. The FIRST in-domain + * product in the list wins; a course whose products are all out-of-domain (no + * slug in course_products) returns null and is therefore not a lead. + * @param {object} tax + * @returns {(products: string[]) => ({skill: string|null, category: string}|null)} + */ +export function makeCourseClassifier(tax) { + const map = tax.course_products ?? {}; + return function classifyCourse(products) { + if (!Array.isArray(products)) return null; + for (const slug of products) { + const category = map[slug]; + if (category) { + return { skill: tax.category_skill[category] ?? null, category }; + } + } + return null; + }; +} + /** * Build a file-priority function mirroring report-changes getFilePriority: * ordered regex rules over the lowercased path; first match wins; else default. diff --git a/tests/kb-update/test-taxonomy.test.mjs b/tests/kb-update/test-taxonomy.test.mjs index 69fdcd5..ee42ddb 100644 --- a/tests/kb-update/test-taxonomy.test.mjs +++ b/tests/kb-update/test-taxonomy.test.mjs @@ -16,6 +16,7 @@ import { getSitemapPrefixes, getCategorySkill, makeClassifier, + makeCourseClassifier, makePriorityFn, applyCategoryReassignments, } from '../../scripts/kb-update/lib/taxonomy.mjs'; @@ -165,3 +166,69 @@ test('saveTaxonomy + loadTaxonomy — atomic round-trip preserves content', () = 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' }); +});