From b9f51ea7b346bcffe29fd440a58edd07c9d76507 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 13:48:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(ms-ai-architect):=20C3.3=20=E2=80=94=20cou?= =?UTF-8?q?rse=5Fproducts=20(live-enumerert)=20+=20makeCourseClassifier=20?= =?UTF-8?q?(TDD)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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) --- scripts/kb-update/data/domain-taxonomy.json | 20 +++++- scripts/kb-update/lib/taxonomy.mjs | 25 ++++++++ tests/kb-update/test-taxonomy.test.mjs | 67 +++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) 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' }); +});