ms-ai-architect/docs/c3-course-detection-plan.md
Kjell Tore Guttormsen 7d9430b7a4 docs(ms-ai-architect): C3-spec — kurs-deteksjon via Learn Platform API (TIL GODKJENNING) [skip-docs]
Forankret i verifisert API-grunnlag (spike, S29) + lest eksisterende arkitektur:
to-stegs som discover-new-urls (detektor skriver rapport+course-registry.json,
ALDRI decisions.json; gaten skriver leads), ny UID-nøklet courses-kolleksjon i
ledger, produkt-slug->skill i domain-taxonomy.json, DETECTION_STEPS-steg gated av
include_course_detection. Faseplan C3.1-C3.6 m/ kjørbart gate-kriterium per fase.
4 åpne beslutninger flagget m/ anbefaling (§8). STATE -> operatør-review neste.

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

243 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](c3-course-detection-spike.md). **Status: TIL GODKJENNING.** Ingen detektor-kode skrives før operatør har godkjent denne spec-en. 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-01`**fail-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.doesNotMatch``saveDecisions`/`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).
```js
// 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).
```jsonc
{
"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)
"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`. Ingen leads emitteres på baseline-kjøringen (alt er «kjent» fra start). _Operatør-beslutning: se §8 åpne punkt (b)._
- **Senere kjøringer:** `updatedAt.gt=<last_run>` henter kun endrede; diff klassifiserer.
### 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»). _Se §8 åpne punkt (c)._
### 4.3 `data/course-detection-report.json` — STEG 1-output (NY)
Analogt med `discovery-report.json`:
```jsonc
{
"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:
```jsonc
"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:
```jsonc
{
"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:
```js
{ 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_CONFIG`**default `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) med en eksplisitt retry/timeout-wrapper som speiler `fetchWithRetry`-semantikken (3 forsøk, lineær backoff, **fail-closed** på vedvarende nettverksfeil — caster opp så `detect-courses` kan fail-softe til en `error`-rapport + exit 0). _Se §8 åpne punkt (a): node:https vs fetch._
Eksporterte funksjoner (skisse):
```js
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. **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 + load/save | `test-course-diff.test.mjs` grønn: new/updated/removed/no-op + tom-baseline. 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. Åpne beslutninger (til operatør i spec-review)
Disse er gjort med en anbefaling i spec-en, men operatøren bør bekrefte:
**(a) HTTP-klient: native `fetch` vs `node:https`.** Anbefaling: `fetch` (innebygd i Node 25, null ny avhengighet, renere for bearer+JSON+POST). Avvik fra eksisterende `node:https`-mønster, men begrunnet. Alternativ: speile `sitemap-stream.mjs` nøyaktig.
**(b) Baseline-kjøring emitterer ingen leads.** Anbefaling: første kjøring etablerer kun `course-registry.json` (alt «kjent»), ingen leads — ellers drukner operatøren i N hundre eksisterende kurs på dag 1. Alternativ: behandle alt på baseline som `new`-leads (full backlog).
**(c) `removed` (retirerte kurs) = kun rapport-signal, ikke ledger-lead.** Anbefaling: informasjonslinje i rapporten (et retirert kurs er ikke et tema å dekke). Alternativ: emittér som lead så operatøren kan vurdere å fjerne tilhørende KB-dekning.
**(d) Default for `include_course_detection`.** Anbefaling: `false` (opt-in, krever creds). Bekreft.
---
## 9. Verifiseringskilder
- **Verifisert empirisk (S29):** [`c3-course-detection-spike.md`](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); `removed`-deteksjonens fullstendighet ved server-side `updatedAt.gt`-filter (forsvunne UIDs vises ikke i et filtrert svar — krever periodisk full enumerering for å fange retirering; håndteres i C3.2/C3.4-design).