ms-ai-architect/docs/c3-course-detection-plan.md
Kjell Tore Guttormsen 215437cb5d docs(ms-ai-architect): C3 §8 besluttet (delegert) — spec GODKJENT, +fetch-robusthetskontrakt +removed full-enum-pin [skip-docs]
Operatør delegerte de 4 åpne beslutningene (mandat: profesjonell, til å stole på). Alle besluttet; (a) fetch fikk binding robusthetskontrakt (response.ok/timeout/429-Retry-After), (c) removed pinnet til full-enum-kjøringer (last_full_enum-kadens) for å unngå falsk-positiv flom. §7-gatekriterier for C3.1/C3.2 utvidet. Neste = C3.1 (TDD).

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

21 KiB
Raw Permalink Blame History

C3 — Kurs-deteksjon via Learn Platform API (spec)

Spec for krav 3 (kurs-deteksjon). Skrevet 2026-06-23 (Sesjon 30) på det verifiserte grunnlaget i c3-course-detection-spike.md. Status: GODKJENT (S31). De fire åpne beslutningene i §8 er delegert til arkitekt av operatør (mandat: «profesjonell plugin, til å stole på») og besluttet — to med skjerpende robusthets-/korrekthetskrav (§8 (a) fetch-kontrakt, §8 (c) full-enum-pin). C3.1 er klarert til oppstart. Stier relative til plugin-rot.


0. Mål og avgrensning

Mål: Oppdage at Microsoft har publisert nye eller endrede treningskurs (modules + learning paths) innenfor de produktene KB-en dekker, slik at operatøren kan vurdere om temaet bør dekkes i kunnskapsbasen.

Ikke-mål (eksplisitt):

  • IKKE auto-ingest av kursinnhold som KB-filer. Et kurs er et signal om at et tema finnes — ikke en doc-side som transformeres. (Spike §29: «oppdage … som bør dekkes», ikke ingest.)
  • IKKE å erstatte den eksisterende sitemap-baserte doc-discovery (discover-new-urls.mjs). C3 er et parallelt, uavhengig spor på en helt annen datakilde (Platform API, ikke sitemaps).
  • IKKE å skrive til KB (skills/) eller til decisions.json fra detektoren. Se §2 (arkitektur-invariant).

Datakilde (besluttet S29): Microsoft Learn Platform API (/api/v1, Entra ID app-only). IKKE legacy /api/catalog/ (anonym, deprekeres «starting June 2026»), IKKE microsoft-learn-MCP (docs-only, ingen dato/produkt-filter). Begrunnelse i spike §6.


1. Verifisert API-grunnlag (kort — full versjon i spike)

Alt under er empirisk verifisert i S29 (spike §14§26):

  • Base: https://learn.microsoft.com/api/v1 · påkrevd query ?api-version=2023-11-01-preview (kun preview finnes).
  • Auth: Entra client-credentials → bearer-token, scope https://learn.microsoft.com/.default. Token-endepunkt er standard Entra v2.0: https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token (tenant-id, client-id, client-secret hentes fra Keychain — se §3). Verifisert: token → HTTP 200 mot /api/v1/modules.
  • Endepunkter (per-type, ikke kombinert): /api/v1/modules , /api/v1/learning-paths (kebab-case). /api/v1/products finnes IKKE (404).
  • Paginering: respons {"value":[…], "nextLink":…} — cursor via nextLink; maxpagesize ≤ 100 (default 30).
  • Felt vi bruker: id (UID — stabil nøkkel), title, updatedAt (ISO 8601), products[].id, url. Learning path: samme + modules[].
  • Filter: flere params = AND; komma-separert i én param = OR. Produkt-filter virker server-side (products=azure-openai → 34 treff = ekte filtrering).

To gotchas spec-en pinner (verifisert empirisk — MÅ håndteres i kode)

  1. updatedAt.gt krever full ISO 8601 datetime. updatedAt.gt=2026-01-01T00:00:00Z → virker. Dato-only updatedAt.gt=2026-01-01fail-closed (0 treff), selv om doc-eksempelet viser dato-only. Currency-cursoren må alltid serialiseres som full ISO med Z.
  2. Produkt-slugs må enumereres fra live data. Doc-eksempelet azure-ai-foundry → 0 treff (død slug). Ekte slugs samles ved å paginere moduler og hente distinct products[].id (siden /api/v1/products ikke finnes). azure-openai (34) bekreftet levende.
  3. Locale: bruk en-us som primær (nb-no faller tilbake til en-us-metadata der oversettelse mangler).

2. Arkitektur-invariant (binding — gjelder C3 uendret)

Eksisterende kode håndhever at deteksjon aldri skriver decisions.json eller skills/ — kun via operatør-gate. Dette er ikke en konvensjon men en testet invariant:

  • discover-new-urls.mjs:12-14 importerer kun loadDecisions, isDecided — aldri saveDecisions. Håndhevet av tests/kb-update/test-discover-invariant.test.mjs (grepper import-linjene, assert.doesNotMatchsaveDecisions/atomic-write/backup).
  • run-detection.mjs kjører kun execFileSync('node', …) på allow-listede steg — kan strukturelt aldri spawne claude (Claude-fri ToS-flate, detection-schedule.mjs:1-9).

C3 følger samme to-stegs-mønster som discover-new-urls:

STEG 1 (Claude-fri, planlagt — detektoren)        STEG 2 (operatør-gated, Claude-i-loop — gaten)
─────────────────────────────────────────        ───────────────────────────────────────────────
detect-courses.mjs                                /architect:kb-update (utvidet seksjon)
  • leser Keychain-creds                            • leser course-detection-report.json
  • token → paginer API (produkt + updatedAt.gt)    • presenterer nye/endrede kurs for operatør
  • diff mot course-registry.json                   • operatør approve/reject
  • SKRIVER:                                         • SKRIVER leads til decisions.json (courses-koll.)
      - course-detection-report.json (kandidater)      via recordCourseLead + saveDecisions
      - course-registry.json (egen diff-state)
  • IMPORTERER ALDRI saveDecisions

course-registry.json er detektorens egen sporings-state (diff-baseline), på samme måte som url-registry.json skrives av poll-sitemaps.mjs. Det er hverken KB eller decisions.json → å skrive det bryter ikke invarianten. (Eksplisitt: invarianten forbyr skriving til skills/ og til gatens decisions.json — ikke detektorens private state.)


3. Hemmelighets-håndtering (Keychain, Claude-fri)

Detektoren leser de tre creds direkte fra macOS Keychain ved kjøretid — aldri fra repo eller env (strengere enn ~/.zshenv-mønster; spike §12).

// lib/keychain.mjs (NY) — fail-soft: returnerer null hvis item mangler.
import { execFileSync } from 'node:child_process';
export function readSecret(service, account = 'ktg') {
  try {
    return execFileSync('security',
      ['find-generic-password', '-s', service, '-a', account, '-w'],
      { encoding: 'utf8' }).trim();
  } catch { return null; } // item mangler / ikke macOS → caller fail-softer
}

Items (account ktg, satt opp S29): learn-platform-client-secret, learn-platform-tenant-id, learn-platform-client-id. Client-secret utløper ~2028-06 (eneste vedlikeholdspunkt).

Fail-soft-kontrakt: mangler én av de tre creds, skal detect-courses.mjs logge årsak, skrive en skipped-rapport og exit 0 — aldri kræsje deteksjons-pipelinen for en bruker som ikke har satt opp Platform API. (Begrunnelse: C3 er opt-in; en bruker uten Entra-app skal ikke få en rød pipeline.)

Merk (literal-IDer): tenant-id/client-id/secret skal ALDRI committes — heller ikke i denne spec-en, test-fixtures eller logger. Tester injiserer falske creds via en stub.


4. Data-design

4.1 data/course-registry.json — detektorens diff-state (NY)

Detektorens private «known-UID»-register. Skrives av detektoren, lest ved neste kjøring. Mønster = url-registry.json (atomisk skriving via registry-io-stil).

{
  "version": 1,
  "created_at": "2026-06-23T12:00:00Z",
  "last_run": "2026-06-23T12:00:00Z",        // FULL ISO — cursor for updatedAt.gt neste kjøring (gotcha #1)
  "last_full_enum": "2026-06-23T12:00:00Z",  // FULL ISO — siste full enumerering (uten updatedAt.gt). Styrer removed-beregning (§4.2) + full-enum-kadens (≥30 d)
  "courses": {
    "<module-or-path-uid>": {
      "type": "module",                  // "module" | "learning-path"
      "title": "...",
      "url": "https://learn.microsoft.com/training/modules/...",
      "products": ["azure-openai"],
      "updated_at": "2026-06-20T08:00:00Z",  // kursets updatedAt sist sett
      "first_seen": "2026-06-23T12:00:00Z",
      "last_seen": "2026-06-23T12:00:00Z"
    }
  }
}
  • Første kjøring (ingen last_run): full paginering uten updatedAt.gt → etablerer baseline, setter last_run = now og last_full_enum = now. Ingen leads emitteres på baseline-kjøringen (alt er «kjent» fra start). Besluttet: §8 (b).
  • Inkrementelle kjøringer (normalfall): updatedAt.gt=<last_run> henter kun endrede; diff klassifiserer new/updated. removed beregnes IKKE her (et updatedAt.gt-filtrert svar utelater alt uendret → naiv removed-diff ville falskt flagge nesten hele registeret; se §4.2). last_run = now.
  • Periodisk full enumerering (når last_full_enum mangler eller er eldre enn 30 d): full paginering uten updatedAt.gt → diff gir new/updated + removed (full API-sett vs. register). Setter last_run = now og last_full_enum = now. Dette er den eneste kjøringstypen som kan oppdage retirerte kurs.

4.2 Diff-semantikk (spike §24)

  • ny UID (i API, ikke i register) → lead kind: "new".
  • endret updatedAt (UID i begge, nyere timestamp) → lead kind: "updated".
  • forsvunnet UID (i register, ikke i API-svar) → kind: "removed". Behandles som informasjonssignal i rapporten, IKKE som ledger-lead (et retirert kurs er ikke et «tema å dekke»). Korrekthets-pin (besluttet §8 (c)): removed beregnes KUN på full-enumereringskjøringer (§4.1) — aldri på inkrementelle updatedAt.gt-kjøringer, der «ikke i svar» er sant for alt uendret og ville gi massiv falsk-positiv flom. Inkrementelle kjøringer setter removed: [].

4.3 data/course-detection-report.json — STEG 1-output (NY)

Analogt med discovery-report.json:

{
  "generated_at": "2026-06-23T12:00:00Z",
  "status": "ok",                  // "ok" | "skipped" (creds mangler) | "error"
  "skipped_reason": null,
  "new": [ { "uid", "title", "url", "products", "suggested_skill", "suggested_category", "updated_at" } ],
  "updated": [ /* samme form */ ],
  "removed": [ { "uid", "title", "url" } ],
  "counts": { "new": 0, "updated": 0, "removed": 0 }
}

4.4 Produkt-slug→skill-mapping — config i domain-taxonomy.json (UTVIDELSE)

Mappingen er config, ikke hardkoding (spike §30). Den hører hjemme i lag-0-taksonomien, som er den bevisst konsoliderte ene sannhetskilden (domain-taxonomy.json-provenance: «Consolidates four previously-divergent taxonomies into one»). Ny topp-nivå-seksjon:

"course_products": {
  "azure-openai":              { "skill": "ms-ai-engineering", "category": "azure-ai-services" },
  "microsoft-copilot-studio":  { "skill": "ms-ai-advisor",     "category": "platforms" },
  "power-automate":            { "skill": "ms-ai-advisor",     "category": "platforms" },
  "ai-builder":                { "skill": "ms-ai-advisor",     "category": "platforms" }
  // … finaliseres i C3.3 fra enumererte live-slugs (gotcha #2)
}

Ny accessor i taxonomy.mjs: makeCourseClassifier(tax)(products[]) => {skill, category} | null (første in-domain produkt vinner; ukjent slug → null = ikke in-domain → ikke lead). Speiler makeClassifier.

4.5 Ledger-utvidelse — courses-kolleksjon i decisions.json (STEG 2)

decisions.json har i dag to parallelle kolleksjoner: decisions (URL-nøklet, Spor A) og actions (skill-nøklet, Spor B). Kurs er en tredje entitetstype (UID-nøklet) → får en tredje kolleksjon, additivt:

{
  "version": 1, "updated_at": null,
  "decisions": { /* url-keyed, uendret */ },
  "actions":   { /* skill-keyed, uendret */ },
  "courses": {                              // NY — uid-keyed
    "<uid>": {
      "kind": "new",                        // "new" | "updated"
      "status": "pending",                  // pending | approved | rejected (dedup-policy A)
      "title": "...", "url": "...", "products": ["azure-openai"],
      "suggested_skill": "ms-ai-engineering",
      "updated_at": "2026-06-20T08:00:00Z",
      "detected_at": "2026-06-23T12:00:00Z",
      "decided_at": null, "note": ""
    }
  }
}

Nye rene helpers i decisions-io.mjs (speiler recordAction/setActionStatus/isActionDecided): recordCourseLead(ledger, uid, lead), setCourseLeadStatus(ledger, uid, status, decided_at), isCourseLeadDecided(ledger, uid). createLedger() får courses: {}; loadDecisions defaulter ledger.courses ??= {} (bakoverkompatibelt med eksisterende ledgere uten feltet).

Hvorfor egen kolleksjon, ikke gjenbruk av decisions (URL-nøklet)? Et godkjent kurs-lead må ALDRI havne i doc-transform-pipelinen (fetch→transform→KB-fil) — det ville være auto-ingest, som er eksplisitt ikke-mål. En egen courses-kolleksjon holder kurs-leads strukturelt utenfor decisions-strømmen som apply-pathen leser. Dedup er per UID (stabil nøkkel; URL kan endres). Alternativ vurdert og forkastet: gjenbruke decisions med en source-diskriminator + skip i apply-path — mer kobling, høyere risiko for utilsiktet ingest.


5. Scheduler-integrasjon (Claude-fri, opt-in)

Nytt steg i den frosne DETECTION_STEPS (detection-schedule.mjs:36-41), gated av et nytt flagg — speiler skillLifecycle-mønsteret:

{ name: 'detect-courses', dir: 'kb-update', script: 'detect-courses.mjs', args: [], courseDetection: true },

run-detection.mjs filtrerer steget bort når config.include_course_detection !== true (samme filter-linje som i dag bruker include_skill_lifecycle).

Ny config-nøkkel include_course_detection i DEFAULT_SCHEDULE_CONFIGdefault false (krever Keychain-creds som ikke alle har; opt-in inni opt-in). Plumbing additivt: coerce() får en boolean-gren, serializeScheduleConfig får linjen, K5 round-trip-testen utvides. (Eier-CLI write-schedule-config.mjs kan senere eksponere et --courses-flagg; ikke nødvendig for MVP — operatør kan sette nøkkelen manuelt i ~/.claude/ms-ai-architect/ms-ai-architect.local.md.)


6. Network-klient (lib/learn-api.mjs, NY)

Claude-fri nettverkslag (skriver ingen filer). Eksisterende HTTP-mønster er node:https.get med retry (sitemap-stream.mjs), men det håndterer ikke bearer-token/JSON/POST. C3-klienten bruker native fetch (innebygd og global i Node 25 → null ny avhengighet, holder zero-dependency-konvensjonen). Besluttet: §8 (a).

Robusthetskontrakt (binding — skiller «funker» fra «profesjonell», testes i C3.1):fetch har to fallgruver som wrapperen MÅ lukke:

  1. fetch kaster ikke på HTTP 4xx/5xx (kun på nettverksfeil) → wrapperen sjekker response.ok eksplisitt og behandler ikke-ok som feil.
  2. fetch har ingen default timeout → hver request får AbortSignal.timeout(<ms>).

Retry-policy: 3 forsøk på 429 + 5xx med backoff som respekterer Retry-After-headeren (Microsofts API rate-limiter; fast backoff ellers). 4xx ≠ 429 (f.eks. 401/403) er ikke retrybart → caster umiddelbart. Fail-closed på vedvarende feil (caster opp så detect-courses fail-softer til error-rapport + exit 0). Token hentes ÉN gang per kjøring (client-credentials-token varer ~1 t) og gjenbrukes på tvers av paginering.

Eksporterte funksjoner (skisse):

export async function getToken({ tenantId, clientId, clientSecret })   // client-credentials → bearer
export async function* paginate(endpoint, params, token)               // yield items via nextLink
// endpoint ∈ {'modules','learning-paths'}; params bygger ?api-version + products + updatedAt.gt
export function buildUpdatedAtGt(iso)   // PIN: full ISO m/ Z (gotcha #1) — kaster på dato-only input

7. Faseplan med verifiserbare kriterier

Hver fase = ÉN økt, TDD (Iron Law: failing test først), gated på kjørbart kriterium før neste. Subagenter committer aldri; validate før commit.

Fase Leveranse Verifiserbart gate-kriterium (kjørbart)
0 Auth-oppsett + API-kartlegging FERDIG (S29). Token → 200 verifisert.
C3.1 lib/keychain.mjs + lib/learn-api.mjs (token, paginer, filter-bygging) node --test tests/kb-update/test-learn-api.test.mjs grønn: token-body-bygging, buildUpdatedAtGt kaster på dato-only + emitterer full ISO, nextLink-paginering, produkt-param. Robusthetskontrakt (§6, besluttet §8 (a)): test at ikke-ok HTTP-status (404/500, stub) kaster (ikke svelges), at 429 retries og respekterer Retry-After, at 4xx≠429 ikke retries, og at timeout (AbortSignal) caster. Claude-fri-invariant-test grønn (ingen claude/anthropic i fila). Manuell live-probe (utenfor CI): henter ≥1 modul med ekte creds.
C3.2 lib/course-diff.mjs (ren) + course-registry.json-schema (m/ last_full_enum) + load/save test-course-diff.test.mjs grønn: new/updated/no-op + tom-baseline. Full-enum-pin (besluttet §8 (c)): test at removed KUN emitteres på full-enum-modus, at inkrementell modus gir removed: [] selv når register-UIDs mangler i (filtrert) svar, og at full-enum trigges når last_full_enum mangler/er >30 d. Round-trip persistens-test. Diff-modulen er ren (invariant-test: ingen write-util-import).
C3.3 course_products i domain-taxonomy.json (slugs enumerert fra live data) + makeCourseClassifier test-taxonomy.test.mjs utvidet grønn: in-domain slug → skill, ukjent → null. Enumererings-kjøring dokumentert i commit (distinct products[].id ∩ in-domain). refTall/K-counts uendret (eval --json).
C3.4 detect-courses.mjs (binder C3.1C3.3) + DETECTION_STEPS-steg + config-nøkkel Invariant-test (test-detect-courses-invariant): ingen saveDecisions-import. Fail-soft-test: creds=null → status:"skipped" + exit 0. Report-shape-test. DETECTION_STEPS-filter-test (include_course_detection:false ⇒ steg droppet). Hele tests/kb-update/*.test.mjs fortsatt grønn (213+).
C3.5 courses-kolleksjon i decisions-io.mjs + /architect:kb-update-gate-seksjon test-decisions-io/test-decisions-actions utvidet grønn: recordCourseLead/setCourseLeadStatus/isCourseLeadDecided + dedup-policy-A per UID + bakoverkompat (ledger uten courses). Eksisterende ledger-tester urørt grønn. Kommando-dry-run viser leads, ingen transform/ingest trigges.
C3.6 SessionStart-surfacing + docs (development.md, CLAUDE.md) + STATE summarizeCourses-enhetstest (one-liner «N nye/M endrede kurs i dekkede produkter»). docs/development.md beskriver C3-workflow. CLAUDE.md kommando-/hook-tabell oppdatert.

8. Besluttede valg (delegert til arkitekt, S31)

Operatøren kunne ikke ta disse selv og delegerte dem med mandatet «profesjonell plugin, til å stole på». Alle fire besluttet. (a) og (c) ble skjerpet utover opprinnelig anbefaling fordi kvalitetsmandatet krever det.

(a) HTTP-klient → fetch MED robusthetskontrakt. BESLUTTET. fetch (innebygd i Node 25, null ny avhengighet, riktig for bearer+JSON+POST). Skjerpelse: kontrakten i §6 er binding — eksplisitt response.ok-sjekk (fetch kaster ikke på 4xx/5xx), AbortSignal.timeout(), retry på 429/5xx som respekterer Retry-After. Uten denne kontrakten ville en 429 feiltolkes som tom respons. Forkastet: rått fetch uten kontrakt, og å speile node:https.get (dekker ikke POST/bearer).

(b) Baseline-kjøring emitterer ingen leads. BESLUTTET. Første kjøring etablerer kun registeret. Begrunnelse: en flom av N hundre «nye» kurs dag 1 lærer operatøren at verktøyet er støy, ikke signal — det motsatte av «til å stole på». Full backlog → fremtidig eksplisitt --backfill/audit-modus (roadmap, ikke MVP). Forkastet for MVP: behandle hele baseline som new-leads.

(c) removed = kun rapport-signal, MED korrekthets-pin. BESLUTTET. Informasjonslinje i rapporten, ikke ledger-lead. Skjerpelse (binding, §4.1/§4.2): removed beregnes KUN på full-enumereringskjøringer — aldri inkrementelt, der en updatedAt.gt-diff ville falskt flagge nesten hele registeret som removed. Dette er en tillitskritisk korrekthetsregel, ikke bare en preferanse. Forkastet: emittere removed som lead (et retirert kurs er ikke et tema å dekke); og naiv removed-diff på inkrementelle kjøringer (falsk-positiv flom).

(d) include_course_detection default false. BESLUTTET. Opt-in. Funksjonen krever Entra-creds nesten ingen har; default true ville gitt hver bruker et rødt/«skipped» steg. Forkastet: default true.


9. Verifiseringskilder

  • Verifisert empirisk (S29): c3-course-detection-spike.md — auth grønn, API-kontrakt, gotchas, live-probe.
  • Verifisert mot kode (S30, denne spec-en): detection-schedule.mjs:22-41 (DEFAULT_SCHEDULE_CONFIG, DETECTION_STEPS, coerce), discover-new-urls.mjs:12-14,100-108 (to-stegs + read-only-invariant + report-form), registry-migrate.mjs:51-57 (reservert course-felt allerede i registry-schema — C3-krok), domain-taxonomy.json (lag-0-konsolidering, category_skill-form), decisions-io.mjs (decisions/actions-kolleksjoner, dedup-policy A).
  • Standard (ikke prosjekt-spesifikt): Entra v2.0 client-credentials token-endepunkt login.microsoftonline.com/<tenant>/oauth2/v2.0/token — konsistent med verifisert auth i spike (tenant-id i Keychain, scope .default).
  • Ikke verifisert (flagget): ekte produkt-slug for Azure AI Foundry (enumereres i C3.3).
  • Løst ved design (S31, §8 (c)): removed-deteksjonens fullstendighet ved server-side updatedAt.gt-filter. Forsvunne UIDs vises ikke i et filtrert svar → removed beregnes utelukkende på periodiske full-enumereringskjøringer (last_full_enum-kadens, §4.1/§4.2), aldri inkrementelt. Inkrementelle kjøringer setter removed: [].