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>
This commit is contained in:
parent
792986820b
commit
7d9430b7a4
1 changed files with 243 additions and 0 deletions
243
docs/c3-course-detection-plan.md
Normal file
243
docs/c3-course-detection-plan.md
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
# 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` på `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.1–C3.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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue