feat(ms-ai-architect): Sesjon 5 - transformasjonslag (lag 4, doc→KB-fil)

Lag 4 destillerer et hentet Microsoft Learn-dokument til en KB-referansefil. Speiler mønsteret fra decisions-io/verify-out: ren-funksjon-lib + tynn LLM-integrasjon + TDD. lib/transform.mjs SKRIVER ALDRI — returnerer content/verdict/sti; skriving skjer kun via operatør-gate etter lag 5.

Nøkkelfunn/-fiks: gammel prompt-template.md la kilder i en bunn-seksjon, men build-registry/kb-headers.parseSourceHeader skanner kun øverste 500 bytes → authority_source-dekning = 0 %. Lag 4 legger **Source:** i HEADER-blokka — forutsetningen for at lag 3 (fremtidig) kan backfille authority_source og lag-5 regel 3 fyrer.

- buildKbHeader({title,status,category,source,lastUpdated}): Status+Source obligatoriske (kaster ved mangel). - validateKbFile(content) → {valid,missing[]}; bruker parseSourceHeader (én sannhetskilde med build-registry). - buildChange(...): eksplisitt lag-4→lag-5-bro, mater classifyChange. - resolveTargetPath(tax,category,filename): ruter via taksonomi getCategorySkill; null=ukjent→gate.

Prompt: scripts/kb-update/transform-prompt.md (doc→KB; husformat fra prompt-template.md, status-påstander eksplisitte for lag-5-gaten). Wiret i commands/kb-update.md §3.d (ny godkjent URL→fetch→destillér→header→validate→buildChange→lag5→resolveTargetPath→gate→atomisk) + §4.d (oppdateringer passerer validateKbFile). eval.mjs eksporterer nå evalSkill for kriterium-testen.

Kriterium møtt: «regenerer 1 fil → eval-score ≥ baseline» (test-transform-criterion.test.mjs). TDD: 12 lib-tester (inkl. import-invariant: ingen write-utils) + 2 kriterium FØR kode. Tester: validate 239 · kb-update 111 (+16) · kb-eval 15 (+2) · kb-integrity 115/115.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REiKFhP4w6xGXXqWKpPCJJ
This commit is contained in:
Kjell Tore Guttormsen 2026-06-19 22:31:26 +02:00
commit 48884b67a6
6 changed files with 468 additions and 3 deletions

View file

@ -156,7 +156,7 @@ function scanK9Hints(body) {
return { timeSensitiveTokenHits: tokens.length, sample: [...new Set(tokens)].slice(0, 12) };
}
function evalSkill(skillName) {
export function evalSkill(skillName) {
const skillDir = join(SKILLS_DIR, skillName);
const skillMd = join(skillDir, 'SKILL.md');
if (!existsSync(skillMd)) return null;

View file

@ -0,0 +1,116 @@
// transform.mjs — Lag 4: TRANSFORMASJON (doc → KB-fil). Pure helper library that
// turns an approved, fetched Microsoft Learn document into a KB reference file in
// the plugin's house format. It is the deterministic spine the LLM destillation
// (commands/kb-update.md, transform-prompt.md) hangs on:
//
// buildKbHeader — the mandatory header block (Status + Source obligatory)
// validateKbFile — enforce the mandatory-field contract on any candidate file
// buildChange — construct the lag-5 `change` object (the lag-4 → lag-5 seam)
// resolveTargetPath — route the file to its owning skill via the taxonomy (lag 0)
//
// ARCHITECTURE INVARIANT (spec §83): this lib NEVER writes. It returns content,
// verdicts and paths; applying a file is the operator gate's job, after lag 5
// (verify-out) has cleared it. Mirrors decisions-io / verify-out: pure functions,
// no write-util imports. The only dependency is kb-headers (read-only parser) so
// validateKbFile uses the SAME source detection as build-registry — one source of
// truth for "what counts as a Source header" (the top-500-byte region).
import { parseSourceHeader } from './kb-headers.mjs';
import { getCategorySkill } from './taxonomy.mjs';
// Mandatory header fields. Status is obligatory per spec ("status-felt obligatorisk");
// Source is what lets lag 3 backfill authority_source and lag-5 rule 3 eventually fire.
const REQUIRED_META = ['title', 'status', 'category', 'source', 'lastUpdated'];
/**
* Build the deterministic KB-file header block. Status and Source live IN the
* header (top 500 bytes) so kb-headers.parseSourceHeader and the "Last updated:"
* scanner can see them the old bottom-of-file source section was invisible to
* those scanners, which is why authority_source coverage was 0%.
*
* @param {{title: string, status: string, category: string, source: string,
* lastUpdated: string}} meta
* @returns {string} the header block, ending with the `---` rule + trailing newline
* @throws if any mandatory field is missing/empty
*/
export function buildKbHeader(meta) {
const m = meta ?? {};
const missing = REQUIRED_META.filter((k) => {
const v = m[k];
return v === undefined || v === null || String(v).trim() === '';
});
if (missing.length) {
throw new Error(`buildKbHeader: missing mandatory field(s): ${missing.join(', ')}`);
}
return (
`# ${m.title}\n\n` +
`**Last updated:** ${m.lastUpdated}\n` +
`**Status:** ${m.status}\n` +
`**Category:** ${m.category}\n` +
`**Source:** ${m.source}\n\n` +
`---\n`
);
}
const RE_TITLE = /^#\s+\S/m;
const RE_LAST_UPDATED = /\*\*Last updated:\*\*\s*[\d-]+/i;
const RE_STATUS = /\*\*Status:\*\*\s*\S/i;
/**
* Validate that a candidate KB file carries the mandatory contract: a title, a
* "Last updated:" header (so it is never dateless never the 'unverified' bucket),
* a Status field, and a Source header in the scannable region. Used by the gate
* before a transformed file may be written.
*
* @param {string} content full file content
* @returns {{valid: boolean, missing: string[]}}
*/
export function validateKbFile(content) {
const s = String(content ?? '');
const missing = [];
if (!RE_TITLE.test(s)) missing.push('title');
if (!RE_LAST_UPDATED.test(s)) missing.push('last_updated');
if (!RE_STATUS.test(s)) missing.push('status');
if (parseSourceHeader(s) === null) missing.push('source');
return { valid: missing.length === 0, missing };
}
/**
* Construct the lag-5 `change` object from a transformation result the explicit,
* documented seam between lag 4 and lag 5. Decouples lag 4's field names from
* verify-out's, and supplies the safe defaults lag 5 expects (no refutations yet;
* authority_source null until lag 3 backfills it).
*
* @param {{field: string, oldValue?: string, newValue: string, sourceUrl?: string,
* authoritySource?: string|null, refutations?: Array}} t
* @returns {{field: string, old_value: string, new_value: string,
* source_url: string|null, authority_source: string|null, refutations: Array}}
*/
export function buildChange(t) {
const o = t ?? {};
return {
field: o.field ?? '',
old_value: o.oldValue ?? '',
new_value: o.newValue ?? '',
source_url: o.sourceUrl ?? null,
authority_source: o.authoritySource ?? null,
refutations: Array.isArray(o.refutations) ? o.refutations : [],
};
}
/**
* Resolve where a transformed file belongs, routing via the taxonomy's canonical
* category skill map (physical-disk truth this is what replaced
* category-skill-map.json). Returns null for an unknown category so the caller
* must gate rather than silently misroute.
*
* @param {object} tax loaded domain taxonomy
* @param {string} category
* @param {string} filename kebab-case .md filename
* @returns {string|null} repo-relative path, or null if the category is unknown
*/
export function resolveTargetPath(tax, category, filename) {
const skill = getCategorySkill(tax, category);
if (!skill) return null;
return `skills/${skill}/references/${category}/${filename}`;
}

View file

@ -0,0 +1,96 @@
# Doc → KB-fil destillasjonsprompt (lag 4)
Brukes av `commands/kb-update.md` (apply-flyten) for å destillere et **hentet** Microsoft
Learn-dokument til en KB-referansefil i pluginens husformat. Pares med
`scripts/kb-update/lib/transform.mjs`: den deterministiske header-byggeren
(`buildKbHeader`), kontrakt-valideringen (`validateKbFile`) og lag-5-broen
(`buildChange`). Skiller seg fra `scripts/skill-gen/prompt-template.md` (som
genererer fra MCP-research, fritt) ved at denne **destillerer én utpekt kilde** og
**forankrer** innholdet mot den.
## Hvorfor Source ligger i headeren
`build-registry`/`lib/kb-headers.mjs` skanner kun de **øverste 500 bytene** etter
`**Source:**`. Den gamle malen la kilder i en bunn-seksjon → usynlig for skanneren →
`authority_source`-dekning = 0 %. Lag 4 legger `**Source:**` i header-blokka. Det er
det som lar lag 3 (verifisering-INN, fremtidig) backfille `authority_source` og lag-5
regel 3 (autoritets-mismatch) faktisk fyre.
## Variabler (caller injiserer)
- `{{TITLE}}` — engelsk tittel (h1)
- `{{STATUS}}` — release-stage fra kilden: `GA` | `Public Preview` | `Private Preview` | `Announced` | `Deprecated` | `Retired`
- `{{CATEGORY}}` — taksonomi-kategorinøkkel (f.eks. `rag-architecture`) — bestemmer eierskill via `resolveTargetPath`
- `{{SOURCE_URL}}` — den **utpekte autoritetskilden** (kanonisk Learn-URL dokumentet ble hentet fra)
- `{{LAST_UPDATED}}``YYYY-MM` (dagens måned, caller-injisert)
- `{{FETCHED_DOC}}` — den hentede markdownen fra `microsoft_docs_fetch`
- `{{EXISTING_CONTEXT}}` — sammendrag av overlappende eksisterende ref-filer (unngå duplisering — K4)
## Header (deterministisk — IKKE la LLM-en skrive den)
Header-blokka bygges av `buildKbHeader({title, status, category, source, lastUpdated})`,
ikke av modellen. Den ser slik ut og MÅ stå først:
```markdown
# {{TITLE}}
**Last updated:** {{LAST_UPDATED}}
**Status:** {{STATUS}}
**Category:** {{CATEGORY}}
**Source:** {{SOURCE_URL}}
---
```
Modellen skriver **kun brødteksten under `---`**. Etterpå kjører
`validateKbFile(header + body)` og MÅ returnere `valid: true` (title + Last updated +
Status + Source til stede) før noe gates videre.
## Prompt (brødtekst)
```
Du er Cosmo Skyberg, senior Microsoft AI Solution Architect. Destillér kildedokumentet
under til en kunnskapsreferanse på norsk (engelske tekniske termer beholdes) for et
Claude Code-plugin som hjelper norsk offentlig sektor med Microsoft AI.
KILDE (utpekt autoritet — forankre alt mot denne, ikke mot generell kunnskap):
{{SOURCE_URL}}
KILDEDOKUMENT:
{{FETCHED_DOC}}
Skriv KUN brødteksten (under header-`---`). Seksjoner (alle påkrevd):
1. Introduksjon (2-3 avsnitt) — hva, hvorfor, plassering i MS-økosystemet
2. Kjernekomponenter / nøkkelegenskaper — tabeller for sammenligning
3. Arkitekturmønstre — 2-3 mønstre, når hvert passer, fordeler/ulemper
4. Beslutningsveiledning — «Velg X når …», vanlige feil, røde flagg
5. Integrasjon med Microsoft-stakken
6. Offentlig sektor (Norge) — GDPR, Schrems II, AI Act, datasuverenitet
7. Kostnad og lisensiering — prismodell på oversiktsnivå
8. For arkitekten (Cosmo) — 5-8 nøkkelspørsmål, fallgruver
9. Kilder og verifisering — pek på {{SOURCE_URL}} + evt. relaterte, konfidensnivå per seksjon
REGLER (strenge):
1. Forankring: hver faktapåstand skal kunne spores til kildedokumentet. Fyll ALDRI hull
med gjetninger — skriv «(uverifisert)» eller utelat.
2. STATUS-PÅSTANDER EKSPLISITT: enhver GA/preview/versjon/pris-/SLA-påstand skal stå som
en tydelig, isolerbar setning. Disse blir ALLTID operatør-gated av lag 5 (spec §21) —
ikke skjul dem i prosa, og ikke flat ut en nyansert status («delvis GA, resten preview»)
til en enkel «GA».
3. Norsk prosa, engelske tekniske termer. Tabeller over løpende tekst der det passer.
4. Balansert (fordeler OG ulemper), konkret over vagt, 200-400 linjer.
5. Ingen duplisering av: {{EXISTING_CONTEXT}}
Ingen innledning eller avslutningskommentar — kun markdown-brødteksten.
```
## Etter destillasjon (caller, gated)
1. `content = buildKbHeader({...}) + body`
2. `validateKbFile(content)` → må være `valid: true` (ellers: be modellen fikse manglende felt).
3. For hver status-/load-bearing-påstand: `buildChange({field, oldValue, newValue, sourceUrl, authoritySource})`
`classifyChange(...)` (lag 5). `flagged` → operatør avgjør; status-påstander er alltid `flagged`.
4. `resolveTargetPath(tax, category, filename)` → eierskill-sti (null = ukjent kategori → gate, ikke skriv).
5. Først etter operatør-gate: atomisk skriving (`lib/atomic-write.mjs` + `lib/backup.mjs`). Transform-libben skriver aldri selv.
```