From e0d2d05dbb60013a6933416648786487ad74d400 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 14:38:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(ms-ai-architect):=20C3.5=20=E2=80=94=20cou?= =?UTF-8?q?rses-kolleksjon=20i=20decisions-io.mjs=20(UID-n=C3=B8klet,=20Sp?= =?UTF-8?q?or=20C)=20+=20kb-update=20=C2=A73c=20kurs-gate=20(TDD)=20[skip-?= =?UTF-8?q?docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tredje ledger-kolleksjon (courses), additivt ved siden av decisions (URL, Spor A) og actions (skill, Spor B). De tre rene helperne speiler recordAction-trioen 1:1: - recordCourseLead(led, uid, lead) (ren, UID-nøklet) - setCourseLeadStatus(led, uid, status, at) (ren transisjon, kaster på ukjent UID) - isCourseLeadDecided(led, uid) (dedup policy A: any status) createLedger()->courses:{}; loadDecisions backfiller courses ??= {} (bakoverkompat). Strukturell ingen-ingest-invariant: apply-pathene leser ALDRI courses (apply-skill-op.mjs->kun actions, discover-new-urls.mjs->kun decisions), så et godkjent kurs-lead trigger aldri fetch/transform/KB-skriving. commands/kb-update.md §3c: operatør-gate som leser course-detection-report.json, dedup'er via isCourseLeadDecided, skriver godkjente leads via recordCourseLead (--dry-run viser leads uten å skrive). Tester (+16): test-decisions-courses.test.mjs. Eksisterende test-decisions-io + test-decisions-actions URØRT grønne (ikke-regresjons-bevis, samme mønster som Spor B). kb-update 291->307 · validate 239/0 · kb-eval 100/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/kb-update.md | 37 +++ scripts/kb-update/lib/decisions-io.mjs | 92 ++++++- .../kb-update/test-decisions-courses.test.mjs | 231 ++++++++++++++++++ 3 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 tests/kb-update/test-decisions-courses.test.mjs diff --git a/commands/kb-update.md b/commands/kb-update.md index 28a4595..f526820 100644 --- a/commands/kb-update.md +++ b/commands/kb-update.md @@ -135,6 +135,43 @@ d. **For `approved`:** registrér URLen i `url-registry` (gated) så den fanges e. **Invariant:** `discover-new-urls.mjs` (deteksjon) skriver **aldri** ledgeren — den kun leser. Bare denne gaten skriver. Verifisert av `tests/kb-update/test-discover-invariant.test.mjs`. +### 3c. Kurs-deteksjon-gate — nye/endrede kurs via decision-ledger (courses-kolleksjon, Spor C / C3) + +Operatør-gaten for kurs-sporet. **Et kurs-lead er et *signal* om at et tema finnes — aldri en doc-side som ingestes.** Å godkjenne et lead **henter/transformerer/skriver ingen KB-fil** (det ville vært auto-ingest, et eksplisitt ikke-mål) — det registrerer kun operatørens beslutning i den UID-nøklede `courses`-kolleksjonen. Dette er ENESTE skrivevei for kurs-beslutninger. + +a. **Sjekk rapporten:** `data/course-detection-report.json`. Produsert av den Claude-frie detektoren (`detect-courses.mjs`) — enten schedulert (Tier 1/2) eller manuelt: `node scripts/kb-update/detect-courses.mjs`. Mangler fila, eller `status:"skipped"` (Keychain-creds mangler — C3 er opt-in inni opt-in): **hopp over denne gaten stille**. `status:"error"`: rapportér kort og hopp over. + +b. **Les leads:** `new[]` + `updated[]` (hver `{uid, title, url, products, suggested_skill, suggested_category, updated_at}`). `removed[]` er **kun et informasjonssignal** — vis det som en notis, aldri som en beslutning (et retirert kurs er ikke et tema å dekke; spec §4.2). + +c. **Dedup mot ledgeren (policy A):** utelat leads operatøren allerede har tatt stilling til. Bruk `isCourseLeadDecided` (UID er den stabile nøkkelen — URL kan endres): + +``` +import { loadDecisions, isCourseLeadDecided } from './scripts/kb-update/lib/decisions-io.mjs'; +const led = loadDecisions(); +const fresh = [...report.new, ...report.updated].filter((l) => !isCourseLeadDecided(led, l.uid)); +``` + +d. **Presenter `fresh`** for operatør, gruppert per `suggested_skill` (vis `title`, `url`, `products`, `kind` new/updated). For hvert lead (eller batch): **approve** (relevant — verdt å dekke i KB-en), **reject** (ikke relevant — ikke vis igjen), eller **utsett** (`pending` — ikke avgjort). + +Hvis `--dry-run`: stopp etter presentasjonen — **ikke skriv ledgeren**. + +e. **Skriv beslutningene til ledgeren — ENESTE skrivevei.** Bruk `recordCourseLead` (ikke håndskriv JSON): + +``` +import { loadDecisions, recordCourseLead, saveDecisions } from './scripts/kb-update/lib/decisions-io.mjs'; +let led = loadDecisions(); +led = recordCourseLead(led, uid, { + kind, status: 'approved', title, url, products, suggested_skill, suggested_category, + updated_at, detected_at, decided_at: '', note, +}); +// ...én recordCourseLead per beslutning... +saveDecisions(led); +``` + +`decided_at` settes til dagens dato (caller-injisert — `recordCourseLead` er ren). `decisions.json` er tracket i git. + +f. **Ingen ingest — strukturell invariant.** Et godkjent kurs-lead blir værende i `courses`-kolleksjonen og leses av **ingen** av apply-pathene: `apply-skill-op.mjs` leser kun `actions`, `discover-new-urls.mjs` kun `decisions`. En kurs-beslutning trigger derfor **aldri** fetch/transform/KB-skriving. Skal et kurs' tema faktisk dekkes med en KB-side, er det en **separat, bevisst** doc-discovery-handling gjennom §3b (finn doc-URLen) — ikke en automatisk konsekvens av å godkjenne kurset. Et godkjent kurs surfaces ved sesjonsstart (C3.6: «N nye / M endrede kurs i dekkede produkter») som en påminnelse til operatøren. + ### 4. Per-fil oppdatering (etter brukerens `y`) For hver fil i valgte prioriteter: diff --git a/scripts/kb-update/lib/decisions-io.mjs b/scripts/kb-update/lib/decisions-io.mjs index 4488878..eea6915 100644 --- a/scripts/kb-update/lib/decisions-io.mjs +++ b/scripts/kb-update/lib/decisions-io.mjs @@ -18,22 +18,29 @@ const DEFAULT_DATA_DIR = join(__dirname, '..', 'data'); /** * Empty ledger scaffold. * - * Two parallel collections, both written ONLY through the operator gate: + * Three parallel collections, all written ONLY through the operator gate: * - decisions — URL-keyed (Spor A): which Microsoft Learn page belongs where. * - actions — skill-keyed (Spor B / B3): skill-lifecycle ops (merge_skills, - * sanitize_skill, retire_skill, create_skill). Additive: version - * stays 1, and a pre-action ledger on disk normalizes cleanly - * (loadDecisions backfills the missing actions key). - * @returns {{version: number, updated_at: string|null, decisions: object, actions: object}} + * sanitize_skill, retire_skill, create_skill). + * - courses — UID-keyed (Spor C / C3): training-course leads (new | updated) + * the operator may want covered in the KB. A SEPARATE collection + * on purpose — an approved course lead must never reach the + * doc-transform/ingest pipeline (auto-ingest is a non-goal), so + * the apply-path (which reads only decisions/actions) never sees + * it. Dedup is per stable UID, not URL (spec §4.5). + * Additive: version stays 1, and a ledger written before any of the actions or + * courses layers existed normalizes cleanly (loadDecisions backfills the missing + * keys). + * @returns {{version: number, updated_at: string|null, decisions: object, actions: object, courses: object}} */ export function createLedger() { - return { version: 1, updated_at: null, decisions: {}, actions: {} }; + return { version: 1, updated_at: null, decisions: {}, actions: {}, courses: {} }; } /** * Load the decision ledger from disk. Backward-compatible: a ledger written - * before the action layer existed (no `actions` key) is normalized to `{}` so - * callers never have to guard against undefined. + * before the action or course layers existed (no `actions` / `courses` key) is + * normalized to `{}` so callers never have to guard against undefined. * @param {string} [dataDir] — defaults to ../data/ relative to lib/ * @returns {object} parsed ledger or empty scaffold */ @@ -42,6 +49,7 @@ export function loadDecisions(dataDir = DEFAULT_DATA_DIR) { if (!existsSync(path)) return createLedger(); const led = JSON.parse(readFileSync(path, 'utf8')); if (!led.actions) led.actions = {}; + if (!led.courses) led.courses = {}; return led; } @@ -184,3 +192,71 @@ export function setActionStatus(ledger, key, status, decided_at = null) { }, }; } + +// =========================================================================== +// Course-lead layer (Spor C / C3) — additive, UID-keyed, mirrors the action +// helpers. detect-courses.mjs (Claude-free detection) produces a report; the +// operator gate records APPROVED/REJECTED leads here. A course lead is a SIGNAL +// that a topic exists, never a doc to ingest — so it lives in its own `courses` +// collection that the apply-path (apply-skill-op.mjs / discover-new-urls.mjs) +// never reads. Dedup is per stable UID (spec §4.5: the course `id`, not its URL, +// is the durable key). +// =========================================================================== + +/** + * Has the operator already decided on this course lead? Policy A: any entry + * (pending | approved | rejected) counts — mirrors isDecided / isActionDecided. + * The gate filters detection-report candidates through this so a course the + * operator has touched never resurfaces. + * @param {object} ledger + * @param {string} uid — the course's stable UID (module / learning-path id) + * @returns {boolean} + */ +export function isCourseLeadDecided(ledger, uid) { + return Boolean(ledger.courses && ledger.courses[uid]); +} + +/** + * Record a course lead. Pure — returns a new ledger, does not mutate. Keyed by + * the course's stable UID. Leaves the URL-keyed decisions and skill-keyed + * actions maps untouched. + * @param {object} ledger + * @param {string} uid — the course's stable UID + * @param {{kind: string, status: string, decided_at?: string|null, title?: string, + * url?: string, products?: string[], suggested_skill?: string|null, + * suggested_category?: string, updated_at?: string, detected_at?: string, + * note?: string}} lead + * @returns {object} new ledger + */ +export function recordCourseLead(ledger, uid, lead) { + return { + ...ledger, + updated_at: lead.decided_at ?? ledger.updated_at, + courses: { ...(ledger.courses ?? {}), [uid]: { ...lead } }, + }; +} + +/** + * Transition a recorded course lead to a new status. Pure — returns a new + * ledger, does not mutate. Every other field (title, url, products, + * suggested_skill, …) is preserved. Mirrors setActionStatus; lets the gate flip + * a pending lead approved/rejected without rebuilding the entry. + * @param {object} ledger + * @param {string} uid — the course's stable UID + * @param {string} status — new status (pending | approved | rejected) + * @param {string|null} [decided_at] — when given, advances the entry date + updated_at + * @returns {object} new ledger + * @throws if no course lead exists under `uid` + */ +export function setCourseLeadStatus(ledger, uid, status, decided_at = null) { + const existing = ledger.courses?.[uid]; + if (!existing) throw new Error(`setCourseLeadStatus: no course recorded under uid "${uid}"`); + return { + ...ledger, + updated_at: decided_at ?? ledger.updated_at, + courses: { + ...ledger.courses, + [uid]: { ...existing, status, decided_at: decided_at ?? existing.decided_at }, + }, + }; +} diff --git a/tests/kb-update/test-decisions-courses.test.mjs b/tests/kb-update/test-decisions-courses.test.mjs new file mode 100644 index 0000000..28ca649 --- /dev/null +++ b/tests/kb-update/test-decisions-courses.test.mjs @@ -0,0 +1,231 @@ +// tests/kb-update/test-decisions-courses.test.mjs +// Spor C / C3.5 (Sesjon 36): the ADDITIVE course-lead layer on the lag-2 +// decision ledger. Courses are a THIRD entity type (UID-keyed) — the URL-keyed +// `decisions` map (Spor A) and the skill-keyed `actions` map (Spor B) are both +// untouched. Their contracts stay covered by test-decisions-io.test.mjs and +// test-decisions-actions.test.mjs, which remain green UNMODIFIED, proving this +// extension is non-regressive. Here we cover only the new `courses` collection: +// recordCourseLead (pure), isCourseLeadDecided (dedup policy A per UID), +// setCourseLeadStatus (pure transition), and backward-compatible normalization +// of pre-course ledgers. +// +// WHY a separate collection (spec §4.5): an approved course lead must NEVER +// reach the doc-transform/ingest pipeline (auto-ingest is an explicit non-goal). +// Keeping leads UID-keyed in `courses` holds them structurally out of the +// `decisions` stream the apply-path reads — the apply-path (apply-skill-op.mjs, +// discover-new-urls.mjs) reads only `actions`/`decisions`, never `courses`. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createLedger, + loadDecisions, + saveDecisions, + recordDecision, + isDecided, + recordAction, + isActionDecided, + actionKey, + recordCourseLead, + isCourseLeadDecided, + setCourseLeadStatus, +} from '../../scripts/kb-update/lib/decisions-io.mjs'; + +function withTmp(fn) { + const dir = mkdtempSync(join(tmpdir(), 'dec-crs-test-')); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const UID = 'learn.wwl.introduction-to-azure-openai'; +const LEAD = { + kind: 'new', // "new" | "updated" + status: 'pending', // pending | approved | rejected (dedup policy A) + title: 'Introduction to Azure OpenAI Service', + url: 'https://learn.microsoft.com/training/modules/intro-azure-openai/', + products: ['azure-openai'], + suggested_skill: 'ms-ai-engineering', + suggested_category: 'azure-ai-services', + updated_at: '2026-06-20T08:00:00Z', + detected_at: '2026-06-23T12:00:00Z', + decided_at: null, + note: '', +}; + +// --- scaffold now carries an empty courses collection (additive) --- + +test('createLedger — additive courses:{} alongside untouched decisions:{} + actions:{}', () => { + const led = createLedger(); + assert.equal(led.version, 1, 'version stays 1 — no migration needed'); + assert.deepEqual(led.decisions, {}, 'URL-keyed map unchanged'); + assert.deepEqual(led.actions, {}, 'skill-op map unchanged'); + assert.deepEqual(led.courses, {}, 'new UID-keyed course-lead collection present and empty'); +}); + +// --- isCourseLeadDecided — dedup gate (policy A: any status counts) --- + +test('isCourseLeadDecided — false when absent, true for ANY recorded status (policy A)', () => { + let led = createLedger(); + assert.equal(isCourseLeadDecided(led, UID), false, 'absent UID is undecided'); + led = recordCourseLead(led, UID, { ...LEAD, status: 'rejected', decided_at: '2026-06-23' }); + assert.equal(isCourseLeadDecided(led, UID), true, 'rejected lead still dedups (policy A)'); +}); + +test('isCourseLeadDecided — true for pending and approved alike (policy A)', () => { + let led = createLedger(); + led = recordCourseLead(led, 'uid-pending', { ...LEAD, status: 'pending', decided_at: '2026-06-23' }); + led = recordCourseLead(led, 'uid-approved', { ...LEAD, status: 'approved', decided_at: '2026-06-23' }); + assert.equal(isCourseLeadDecided(led, 'uid-pending'), true, 'pending dedups (policy A)'); + assert.equal(isCourseLeadDecided(led, 'uid-approved'), true, 'approved dedups (policy A)'); +}); + +// --- recordCourseLead — pure, mirrors recordAction --- + +test('recordCourseLead — records under UID, returns new ledger, does not mutate input', () => { + const led = createLedger(); + const next = recordCourseLead(led, UID, LEAD); + assert.deepEqual(led.courses, {}, 'input untouched'); + assert.notEqual(next, led); + assert.equal(next.courses[UID].kind, 'new'); + assert.equal(next.courses[UID].status, 'pending'); + assert.equal(next.courses[UID].suggested_skill, 'ms-ai-engineering'); + assert.deepEqual(next.courses[UID].products, ['azure-openai']); +}); + +test('recordCourseLead — advances updated_at to the decision date', () => { + const next = recordCourseLead(createLedger(), UID, { + ...LEAD, + status: 'approved', + decided_at: '2026-06-23', + }); + assert.equal(next.updated_at, '2026-06-23'); +}); + +test('recordCourseLead — a pending lead (decided_at null) leaves updated_at unchanged', () => { + const next = recordCourseLead(createLedger(), UID, LEAD); // decided_at: null + assert.equal(next.updated_at, null, 'no decision date -> ledger updated_at untouched'); +}); + +test('recordCourseLead — keyed by stable UID, not URL (dedup survives a URL change)', () => { + let led = recordCourseLead(createLedger(), UID, LEAD); + // Same course re-detected later with a changed URL but the SAME UID. + led = recordCourseLead(led, UID, { ...LEAD, url: 'https://learn.microsoft.com/training/modules/renamed/' }); + assert.equal(Object.keys(led.courses).length, 1, 'same UID dedups to one entry despite URL change'); + assert.equal(isCourseLeadDecided(led, UID), true); +}); + +test('recordCourseLead — does NOT touch the URL-keyed decisions or skill-keyed actions maps', () => { + let led = recordDecision(createLedger(), 'https://learn.microsoft.com/x', { + status: 'approved', + decided_at: '2026-06-19', + }); + led = recordAction(led, { + operation_type: 'sanitize_skill', + status: 'approved', + decided_at: '2026-06-20', + targets: { skill: 'ms-ai-governance' }, + }); + const next = recordCourseLead(led, UID, LEAD); + assert.equal(isDecided(next, 'https://learn.microsoft.com/x'), true, 'URL decision survives'); + assert.equal(isActionDecided(next, actionKey({ operation_type: 'sanitize_skill', targets: { skill: 'ms-ai-governance' } })), true, 'action survives'); + assert.equal(isCourseLeadDecided(next, UID), true, 'course lead recorded alongside'); +}); + +// --- ACCEPTANCE CRITERION (gate dedup): a decided course is NOT re-proposed --- +// Mirrors the discovery dedup-diff acceptance test (test-decisions-io.test.mjs): +// the gate filters report candidates through isCourseLeadDecided, so a course +// the operator has already touched (any status) never resurfaces. + +test('dedup-diff — a decided course UID is surfaced in round 1 but filtered in round 2', () => { + const filterUndecidedCourses = (ledger, candidates) => + candidates.filter((c) => !isCourseLeadDecided(ledger, c.uid)); + + // Round 1: empty ledger — the course is a fresh candidate. + let led = createLedger(); + const round1 = filterUndecidedCourses(led, [{ uid: UID }]); + assert.deepEqual(round1.map((c) => c.uid), [UID], 'round 1 surfaces the new course'); + + // Operator rejects it through the gate — the ONLY write path. + led = recordCourseLead(led, UID, { ...LEAD, status: 'rejected', decided_at: '2026-06-23' }); + + // Round 2: same detection output, the decided course must be filtered out. + const round2 = filterUndecidedCourses(led, [{ uid: UID }]); + assert.deepEqual(round2, [], 'round 2 must NOT re-propose the decided course'); +}); + +// --- setCourseLeadStatus — pure status transition (mirrors setActionStatus) --- + +test('setCourseLeadStatus — transitions status, preserves every other field, is pure', () => { + const led = recordCourseLead(createLedger(), UID, { ...LEAD, status: 'pending' }); + const next = setCourseLeadStatus(led, UID, 'approved', '2026-06-24'); + + assert.equal(led.courses[UID].status, 'pending', 'input ledger untouched (pure)'); + assert.notEqual(next, led); + assert.equal(next.courses[UID].status, 'approved'); + assert.equal(next.courses[UID].title, LEAD.title, 'title preserved'); + assert.deepEqual(next.courses[UID].products, LEAD.products, 'products preserved'); + assert.equal(next.courses[UID].suggested_skill, LEAD.suggested_skill, 'suggested_skill preserved'); +}); + +test('setCourseLeadStatus — advances entry decided_at and ledger updated_at', () => { + const led = recordCourseLead(createLedger(), UID, { ...LEAD, status: 'pending' }); + const next = setCourseLeadStatus(led, UID, 'approved', '2026-06-24'); + assert.equal(next.courses[UID].decided_at, '2026-06-24', 'entry date advances'); + assert.equal(next.updated_at, '2026-06-24'); +}); + +test('setCourseLeadStatus — without a date keeps the entry decided_at and ledger updated_at', () => { + let led = recordCourseLead(createLedger(), UID, { ...LEAD, status: 'pending', decided_at: '2026-06-23' }); + const next = setCourseLeadStatus(led, UID, 'approved'); + assert.equal(next.courses[UID].decided_at, '2026-06-23', 'no date -> keep original'); +}); + +test('setCourseLeadStatus — throws on an unknown UID (cannot transition a non-entry)', () => { + const led = recordCourseLead(createLedger(), UID, LEAD); + assert.throws(() => setCourseLeadStatus(led, 'uid-ghost', 'approved'), /no course|ghost/i); +}); + +test('setCourseLeadStatus — leaves decisions, actions, and sibling courses untouched', () => { + let led = recordDecision(createLedger(), 'https://learn.microsoft.com/x', { + status: 'approved', + decided_at: '2026-06-19', + }); + led = recordCourseLead(led, UID, { ...LEAD, status: 'pending' }); + led = recordCourseLead(led, 'uid-sibling', { ...LEAD, status: 'pending' }); + const next = setCourseLeadStatus(led, UID, 'approved', '2026-06-24'); + + assert.equal(isDecided(next, 'https://learn.microsoft.com/x'), true, 'URL decision survives'); + assert.equal(next.courses['uid-sibling'].status, 'pending', 'sibling course untouched'); +}); + +// --- backward compatibility: a pre-course ledger on disk loads cleanly --- + +test('loadDecisions — normalizes a pre-course (no courses key) ledger to courses:{}', () => { + withTmp((dir) => { + // Simulate an on-disk ledger written before the course layer existed. + writeFileSync( + join(dir, 'decisions.json'), + JSON.stringify({ version: 1, updated_at: null, decisions: {}, actions: {} }, null, 2), + ); + const led = loadDecisions(dir); + assert.deepEqual(led.courses, {}, 'missing courses key is backfilled, not undefined'); + // and recording a course lead on the loaded ledger works + const next = recordCourseLead(led, UID, LEAD); + assert.equal(isCourseLeadDecided(next, UID), true); + }); +}); + +test('saveDecisions + loadDecisions — courses round-trip through disk', () => { + withTmp((dir) => { + const led = recordCourseLead(createLedger(), UID, LEAD); + saveDecisions(led, dir); + const back = loadDecisions(dir); + assert.deepEqual(back, led); + }); +});