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>
173 lines
6.8 KiB
JavaScript
173 lines
6.8 KiB
JavaScript
// taxonomy.mjs — Loader + accessors for data/domain-taxonomy.json (lag 0).
|
|
// Zero dependencies. The taxonomy is the single source of truth for in-domain
|
|
// classification: which sitemaps to poll, which URLs are relevant, which
|
|
// skill/category owns content, and update priority. poll-sitemaps,
|
|
// discover-new-urls and report-changes READ this instead of embedding copies.
|
|
|
|
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DEFAULT_DATA_DIR = join(__dirname, '..', 'data');
|
|
|
|
/**
|
|
* Load the domain taxonomy from disk.
|
|
* @param {string} [dataDir] — defaults to ../data/ relative to lib/
|
|
* @returns {object} parsed taxonomy
|
|
*/
|
|
export function loadTaxonomy(dataDir = DEFAULT_DATA_DIR) {
|
|
const path = join(dataDir, 'domain-taxonomy.json');
|
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
}
|
|
|
|
/**
|
|
* Save the domain taxonomy atomically (write to .tmp, then rename) — mirrors
|
|
* saveDecisions. The taxonomy is lag-0 truth and is normally read-only; the ONE
|
|
* writer is the gated merge-apply path (Sesjon 19), which repoints category
|
|
* ownership from an absorbed skill to its absorber. Detection scripts must NOT
|
|
* import this.
|
|
* @param {object} tax — taxonomy object to persist
|
|
* @param {string} [dataDir]
|
|
*/
|
|
export function saveTaxonomy(tax, dataDir = DEFAULT_DATA_DIR) {
|
|
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
|
|
const path = join(dataDir, 'domain-taxonomy.json');
|
|
const tmp = path + '.tmp';
|
|
writeFileSync(tmp, JSON.stringify(tax, null, 2) + '\n', 'utf8');
|
|
renameSync(tmp, path);
|
|
}
|
|
|
|
/**
|
|
* Repoint category ownership for a skill merge. Pure — returns a new taxonomy,
|
|
* mutates nothing. For each { category, from, to }, the category's owner is
|
|
* changed to `to` ONLY when it currently equals `from` (defensive: a category
|
|
* already repointed, or absent, is a no-op — never clobbered). All other fields
|
|
* survive untouched.
|
|
* @param {object} tax
|
|
* @param {Array<{category: string, from: string, to: string}>} reassignments
|
|
* @returns {object} new taxonomy
|
|
*/
|
|
export function applyCategoryReassignments(tax, reassignments) {
|
|
const next = { ...tax.category_skill };
|
|
for (const { category, from, to } of reassignments) {
|
|
if (next[category] === from) next[category] = to;
|
|
}
|
|
return { ...tax, category_skill: next };
|
|
}
|
|
|
|
/**
|
|
* Add category ownership for a NEW skill (create_skill, Sesjon 20). Pure —
|
|
* returns a new taxonomy, mutates nothing. For each { category, skill }, the
|
|
* category is assigned to `skill` ONLY when it is currently UNOWNED (absent):
|
|
* an existing owner is never clobbered (a create must not steal a sibling's
|
|
* category — that would be a merge, a different gated op). Additive counterpart
|
|
* to applyCategoryReassignments: reassign repoints an owned category, add claims
|
|
* an unowned one. The same gated apply-path is the only writer.
|
|
* @param {object} tax
|
|
* @param {Array<{category: string, skill: string}>} additions
|
|
* @returns {object} new taxonomy
|
|
*/
|
|
export function applyCategoryAdditions(tax, additions) {
|
|
const next = { ...tax.category_skill };
|
|
for (const { category, skill } of additions) {
|
|
if (next[category] === undefined) next[category] = skill;
|
|
}
|
|
return { ...tax, category_skill: next };
|
|
}
|
|
|
|
/**
|
|
* Target sitemap child-name prefixes to scan/poll.
|
|
* @param {object} tax
|
|
* @returns {string[]}
|
|
*/
|
|
export function getSitemapPrefixes(tax) {
|
|
return tax.sitemap_prefixes;
|
|
}
|
|
|
|
/**
|
|
* Canonical owning skill for a KB category (physical-disk truth).
|
|
* @param {object} tax
|
|
* @param {string} category
|
|
* @returns {string|null} skill directory name, or null if unknown
|
|
*/
|
|
export function getCategorySkill(tax, category) {
|
|
return tax.category_skill[category] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Build a URL classifier mirroring discover-new-urls semantics:
|
|
* exclude wins; first include match → { skill, category } with skill DERIVED
|
|
* from the canonical category_skill map (no embedded skill assignments).
|
|
* @param {object} tax
|
|
* @returns {(url: string) => ({skill: string|null, category: string}|null)}
|
|
*/
|
|
export function makeClassifier(tax) {
|
|
const include = tax.relevance.include.map((r) => ({
|
|
re: new RegExp(r.pattern),
|
|
category: r.category,
|
|
}));
|
|
const exclude = tax.relevance.exclude.map((p) => new RegExp(p));
|
|
|
|
return function classifyUrl(url) {
|
|
if (exclude.some((re) => re.test(url))) return null;
|
|
for (const rule of include) {
|
|
if (rule.re.test(url)) {
|
|
return { skill: tax.category_skill[rule.category] ?? null, category: rule.category };
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* A file with NO "Last updated:" header (hasDate=false) cannot have its currency
|
|
* verified, so it lands in the dedicated `dateless` tier ('unverified') instead
|
|
* of inheriting a path-based priority — otherwise dateless files flood the
|
|
* critical bucket and drown the genuinely-stale critical files (carry-forward B).
|
|
* hasDate defaults to true so existing single-arg callers keep path-based scoring.
|
|
* @param {object} tax
|
|
* @returns {(filePath: string, hasDate?: boolean) => string}
|
|
*/
|
|
export function makePriorityFn(tax) {
|
|
const rules = tax.priority.rules.map((r) => ({ re: new RegExp(r.match), priority: r.priority }));
|
|
const fallback = tax.priority.default;
|
|
const datelessTier = tax.priority.dateless ?? fallback;
|
|
|
|
return function getFilePriority(filePath, hasDate = true) {
|
|
if (!hasDate) return datelessTier;
|
|
const lower = filePath.toLowerCase();
|
|
for (const r of rules) {
|
|
if (r.re.test(lower)) return r.priority;
|
|
}
|
|
return fallback;
|
|
};
|
|
}
|