diff --git a/docs/c3-course-detection-plan.md b/docs/c3-course-detection-plan.md index 0d04501..0f0dacd 100644 --- a/docs/c3-course-detection-plan.md +++ b/docs/c3-course-detection-plan.md @@ -1,6 +1,6 @@ # 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._ +_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: 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._ --- @@ -95,7 +95,8 @@ Detektorens private «known-UID»-register. Skrives av detektoren, lest ved nest { "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_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": { "": { "type": "module", // "module" | "learning-path" @@ -110,13 +111,14 @@ Detektorens private «known-UID»-register. Skrives av detektoren, lest ved nest } ``` -- **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=` henter kun endrede; diff klassifiserer. +- **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=` 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»). _Se §8 åpne punkt (c)._ +- **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`: @@ -193,7 +195,13 @@ Ny config-nøkkel `include_course_detection` i `DEFAULT_SCHEDULE_CONFIG` — **d ## 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._ +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):** rå `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()`. + +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): ```js @@ -212,8 +220,8 @@ Hver fase = ÉN økt, TDD (Iron Law: failing test først), gated på kjørbart k | 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.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.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. | @@ -221,17 +229,17 @@ Hver fase = ÉN økt, TDD (Iron Law: failing test først), gated på kjørbart k --- -## 8. Åpne beslutninger (til operatør i spec-review) +## 8. Besluttede valg (delegert til arkitekt, S31) -Disse er gjort med en anbefaling i spec-en, men operatøren bør bekrefte: +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: 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. +**(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.** 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). +**(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` (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. +**(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) Default for `include_course_detection`.** Anbefaling: `false` (opt-in, krever creds). Bekreft. +**(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`._ --- @@ -240,4 +248,5 @@ Disse er gjort med en anbefaling i spec-en, men operatøren bør bekrefte: - **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//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). +- **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: []`.