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:
parent
94a5d1bb14
commit
48884b67a6
6 changed files with 468 additions and 3 deletions
|
|
@ -83,7 +83,14 @@ saveDecisions(led);
|
|||
|
||||
`decided_at` settes til dagens dato (caller-injisert — `recordDecision` er ren). `decisions.json` er tracket i git (overlever, til forskjell fra de genererte rapportene).
|
||||
|
||||
d. **For `approved`:** registrér URLen i `url-registry` (gated) så den fanges av polling heretter — via `lib/registry-io.mjs` `saveRegistry`. Bruk `suggested_skill`/`suggested_category` fra kandidaten. Deretter hentes innholdet og en KB-fil skrives i transformasjonslaget (lag 4, Sesjon 5) — for nå holder det å registrere + flagge for henting.
|
||||
d. **For `approved`:** registrér URLen i `url-registry` (gated) så den fanges av polling heretter — via `lib/registry-io.mjs` `saveRegistry`. Bruk `suggested_skill`/`suggested_category` fra kandidaten. Deretter kjør **transformasjonslaget (lag 4)** for å lage KB-fila:
|
||||
1. `microsoft_docs_fetch` på den godkjente URLen → kildedokument.
|
||||
2. Destillér via `scripts/kb-update/transform-prompt.md` (doc→KB-fil; status-påstander holdes eksplisitte). Multi-agent parallell fan-out foreslås i produksjon (roadmap §71), speilet på `generate-skills`-mønsteret.
|
||||
3. `content = buildKbHeader({title, status, category, source: <godkjent URL>, lastUpdated: <YYYY-MM>}) + body` (`lib/transform.mjs`). **`Status` + `Source` er obligatoriske** — `Source` i header-blokka (øverste 500 bytes) er det som lar lag 3 backfille `authority_source`.
|
||||
4. `validateKbFile(content)` MÅ være `valid: true` før noe gates videre (ellers be modellen fylle manglende felt).
|
||||
5. For hver status-/load-bearing-påstand: `buildChange({...})` → **lag 5** `classifyChange(...)` (samme gate som §4 c2). Status-påstander er alltid `flagged`.
|
||||
6. `resolveTargetPath(tax, category, filename)` → eierskill-sti via taksonomien (`null` = ukjent kategori → flagg for operatør, ikke skriv).
|
||||
7. Først etter operatør-gate: atomisk skriving (`lib/atomic-write.mjs` + `lib/backup.mjs`). **`transform.mjs` skriver aldri selv** — verifisert av `tests/kb-update/test-transform.test.mjs` (import-invariant). Kriterium verifisert av `tests/kb-eval/test-transform-criterion.test.mjs` (regenerer 1 fil → eval ≥ baseline).
|
||||
|
||||
e. **Invariant:** `discover-new-urls.mjs` (deteksjon) skriver **aldri** ledgeren — den kun leser. Bare denne gaten skriver. Verifisert av `tests/kb-update/test-discover-invariant.test.mjs`.
|
||||
|
||||
|
|
@ -99,7 +106,7 @@ c2. **Verifisering-ut (lag 5) — FØR du skriver.** Hver kandidat-endring fra (
|
|||
- **`auto-applied`** → trygt å ta inn i (d) (benign, ikke-status, ingen motbevis, autoritets-match).
|
||||
- **Adversarial motbevis-panel (LLM-runtime):** for status-/load-bearing-påstander, kjør et lite panel som *prøver å motbevise* `new_value` mot den utpekte `authority_source`, og mat resultatene inn som `refutations[]` (`[{refuted, reason}]`). Multi-agent foreslås når lag 4/5 kjøres i produksjon (roadmap §71).
|
||||
- **Invariant:** `verify-out.mjs` skriver aldri — den returnerer kun en verdict. Selve skrivingen skjer i (d), gated. Verifisert av `tests/kb-update/test-verify-out.test.mjs`.
|
||||
d. **Oppdater fila:** `Edit` med endringer som er `auto-applied` eller eksplisitt godkjent av operatør i (c2). Behold "For Cosmo"-seksjonen og overordnet struktur. Oppdater `Last updated: YYYY-MM-DD`-header til dagens dato
|
||||
d. **Oppdater fila:** `Edit` med endringer som er `auto-applied` eller eksplisitt godkjent av operatør i (c2). Behold "For Cosmo"-seksjonen og overordnet struktur. Oppdater `Last updated: YYYY-MM-DD`-header til dagens dato. **Lag-4-kontrakt:** kjør `validateKbFile(<ny fil-innhold>)` (`lib/transform.mjs`) før skriving — den skal være `valid: true`. Mangler fila et `**Source:**`-header (pre-lag-4-filer gjør det; dekning er 0 %), legg det til i header-blokka med den utpekte autoritets-URLen for hovedkilden, slik at lag 3 senere kan backfille `authority_source`.
|
||||
e. **Committ:** `git add <fil>` + `git commit -m "chore(ms-ai-architect): refresh KB $(basename <fil>) [skip-docs]"` med mindre `--single-commit` ble gitt
|
||||
|
||||
### 5. Single-commit modus
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
116
scripts/kb-update/lib/transform.mjs
Normal file
116
scripts/kb-update/lib/transform.mjs
Normal 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}`;
|
||||
}
|
||||
96
scripts/kb-update/transform-prompt.md
Normal file
96
scripts/kb-update/transform-prompt.md
Normal 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.
|
||||
```
|
||||
90
tests/kb-eval/test-transform-criterion.test.mjs
Normal file
90
tests/kb-eval/test-transform-criterion.test.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// tests/kb-eval/test-transform-criterion.test.mjs
|
||||
// Sesjon 5 ACCEPTANCE CRITERION (roadmap rad 5): "Regenerer 1 fil → eval-score ≥ baseline".
|
||||
//
|
||||
// Two halves, both runnable, no LLM:
|
||||
// (1) The live deterministic eval for a target skill is ≥ the recorded baseline
|
||||
// — the literal criterion, a regression guard over eval-baseline.json.
|
||||
// (2) Regenerating one REAL reference file through the lag-4 pipeline (real body +
|
||||
// a fresh buildKbHeader) yields a file that is valid, dated, source-anchored,
|
||||
// routed back to the same skill dir, and preserves the body verbatim — so
|
||||
// writing it in place is score-preserving (same path → same ref count → same
|
||||
// deterministic eval). It is in fact strictly BETTER: the pre-lag-4 file has
|
||||
// no **Source:** header (the 0%-coverage failure mode), the regenerated one does.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { evalSkill } from '../../scripts/kb-eval/eval.mjs';
|
||||
import { buildKbHeader, validateKbFile, resolveTargetPath } from '../../scripts/kb-update/lib/transform.mjs';
|
||||
import { loadTaxonomy } from '../../scripts/kb-update/lib/taxonomy.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||
|
||||
const baseline = JSON.parse(
|
||||
readFileSync(join(PLUGIN_ROOT, 'scripts/kb-eval/data/eval-baseline.json'), 'utf8'),
|
||||
);
|
||||
const TARGET = 'ms-ai-infrastructure';
|
||||
const base = baseline.skills.find((s) => s.name === TARGET);
|
||||
|
||||
// --- (1) live deterministic eval ≥ baseline -----------------------------------
|
||||
|
||||
test(`live deterministic eval ≥ baseline for ${TARGET}`, () => {
|
||||
const live = evalSkill(TARGET);
|
||||
const dl = live.deterministic;
|
||||
const db = base.deterministic;
|
||||
// ref count not reduced
|
||||
assert.ok(live.refFilesActual >= base.refFilesActual,
|
||||
`refFilesActual regressed: ${live.refFilesActual} < ${base.refFilesActual}`);
|
||||
// no pass→fail regression on any deterministic check (true≥true ok, false≥true fails)
|
||||
assert.ok(dl.K2_descriptionFormat.pass >= db.K2_descriptionFormat.pass, 'K2 regressed');
|
||||
assert.ok(dl.K3_bodyLength.pass >= db.K3_bodyLength.pass, 'K3 regressed');
|
||||
assert.ok(dl.K5_progressiveDisclosure.pass >= db.K5_progressiveDisclosure.pass, 'K5 regressed');
|
||||
assert.ok(dl.K6_routingTable.pass >= db.K6_routingTable.pass, 'K6 regressed');
|
||||
assert.ok(dl.refCountConsistency.consistent >= db.refCountConsistency.consistent, 'refConsistency regressed');
|
||||
});
|
||||
|
||||
// --- (2) regenerating one real file is score-preserving (and Source-improving) --
|
||||
|
||||
// Split a KB file into (header, body) at the first `---` rule line.
|
||||
function splitBody(content) {
|
||||
const m = content.match(/\n---\n/);
|
||||
return m ? content.slice(m.index + m[0].length) : content;
|
||||
}
|
||||
|
||||
test('regenerate 1 real file → valid, dated, source-anchored, body preserved, same path', () => {
|
||||
const tax = loadTaxonomy();
|
||||
const refRel = base.judgeInputs.refFileSample[0]; // a real repo-relative path
|
||||
const original = readFileSync(join(PLUGIN_ROOT, refRel), 'utf8');
|
||||
const body = splitBody(original);
|
||||
|
||||
// The pre-lag-4 file has no **Source:** header — the 0%-coverage failure mode.
|
||||
assert.ok(validateKbFile(original).missing.includes('source'),
|
||||
'expected the pre-lag-4 file to lack a header Source');
|
||||
|
||||
// category key + filename from the real path: skills/<skill>/references/<category>/<file>
|
||||
const parts = refRel.split('/');
|
||||
const category = parts[3];
|
||||
const filename = basename(refRel);
|
||||
|
||||
const regenerated = buildKbHeader({
|
||||
title: 'AI Foundry Disaster Recovery Planning',
|
||||
status: 'GA',
|
||||
category,
|
||||
source: 'https://learn.microsoft.com/azure/ai-foundry/concepts/disaster-recovery',
|
||||
lastUpdated: '2026-06',
|
||||
}) + body;
|
||||
|
||||
// Strictly better than the original: now valid (Source present), still dated.
|
||||
const v = validateKbFile(regenerated);
|
||||
assert.equal(v.valid, true, `regenerated file invalid: ${v.missing}`);
|
||||
|
||||
// Body preserved verbatim — transformation does not drop content.
|
||||
assert.ok(regenerated.includes(body), 'body not preserved');
|
||||
assert.ok(regenerated.includes('Azure AI Foundry'), 'expected body content missing');
|
||||
|
||||
// Routes back to the SAME skill/category dir → in-place → count-preserving → eval-score preserved.
|
||||
assert.equal(resolveTargetPath(tax, category, filename), refRel);
|
||||
});
|
||||
156
tests/kb-update/test-transform.test.mjs
Normal file
156
tests/kb-update/test-transform.test.mjs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// tests/kb-update/test-transform.test.mjs
|
||||
// TDD for lag 4 — TRANSFORMASJON (doc → KB-fil). lib/transform.mjs is a pure
|
||||
// helper library (mirrors decisions-io / verify-out): it builds the deterministic
|
||||
// KB-file header, validates the mandatory-field contract, routes the file to its
|
||||
// owning skill via the taxonomy (lag 0), and constructs the lag-5 `change` object.
|
||||
// It NEVER writes — applying the file is the operator gate's job (commands/kb-update.md).
|
||||
//
|
||||
// Why Source lives in the HEADER block: build-registry / kb-headers.parseSourceHeader
|
||||
// scan only the top 500 bytes, so a source buried in a bottom section (as the old
|
||||
// prompt-template did) is invisible → the 0% authority_source coverage. Putting
|
||||
// **Source:** in the header is what lets lag 3 backfill authority_source and lag-5
|
||||
// rule 3 (authority mismatch) eventually fire.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
buildKbHeader,
|
||||
validateKbFile,
|
||||
buildChange,
|
||||
resolveTargetPath,
|
||||
} from '../../scripts/kb-update/lib/transform.mjs';
|
||||
import { parseSourceHeader } from '../../scripts/kb-update/lib/kb-headers.mjs';
|
||||
import { classifyChange } from '../../scripts/kb-update/lib/verify-out.mjs';
|
||||
import { loadTaxonomy } from '../../scripts/kb-update/lib/taxonomy.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const META = {
|
||||
title: 'Azure AI Search - Hybrid Retrieval',
|
||||
status: 'GA',
|
||||
category: 'rag-architecture',
|
||||
source: 'https://learn.microsoft.com/azure/search/hybrid-search-overview',
|
||||
lastUpdated: '2026-06',
|
||||
};
|
||||
|
||||
// --- buildKbHeader: mandatory fields, parseable by the real header scanners ----
|
||||
|
||||
test('buildKbHeader emits title, Last updated, Status, Source, Category in the header block', () => {
|
||||
const h = buildKbHeader(META);
|
||||
assert.match(h, /^# Azure AI Search - Hybrid Retrieval/m);
|
||||
assert.match(h, /\*\*Last updated:\*\*\s*2026-06/);
|
||||
assert.match(h, /\*\*Status:\*\*\s*GA/);
|
||||
assert.match(h, /\*\*Category:\*\*\s*rag-architecture/);
|
||||
assert.match(h, /\*\*Source:\*\*\s*https:\/\/learn\.microsoft\.com/);
|
||||
});
|
||||
|
||||
test('buildKbHeader Source is within the top-500-byte region kb-headers scans', () => {
|
||||
const h = buildKbHeader(META);
|
||||
// The whole point: parseSourceHeader (top 500 bytes) must find it.
|
||||
assert.equal(parseSourceHeader(h), META.source);
|
||||
});
|
||||
|
||||
test('buildKbHeader throws when a mandatory field is missing (status-felt obligatorisk)', () => {
|
||||
assert.throws(() => buildKbHeader({ ...META, status: undefined }), /status/i);
|
||||
assert.throws(() => buildKbHeader({ ...META, source: '' }), /source/i);
|
||||
assert.throws(() => buildKbHeader({ ...META, title: null }), /title/i);
|
||||
assert.throws(() => buildKbHeader({ ...META, lastUpdated: undefined }), /last.?updated/i);
|
||||
});
|
||||
|
||||
// --- validateKbFile: catches files missing the mandatory contract -------------
|
||||
|
||||
test('validateKbFile accepts a header-conformant file', () => {
|
||||
const file = buildKbHeader(META) + '\n## Introduksjon\n\nNoe innhold.\n';
|
||||
const r = validateKbFile(file);
|
||||
assert.equal(r.valid, true);
|
||||
assert.deepEqual(r.missing, []);
|
||||
});
|
||||
|
||||
test('validateKbFile reports each missing mandatory field', () => {
|
||||
// No Status, no Source, no Last updated, no title.
|
||||
const r = validateKbFile('Bare brødtekst uten header.\n');
|
||||
assert.equal(r.valid, false);
|
||||
for (const f of ['title', 'last_updated', 'status', 'source']) {
|
||||
assert.ok(r.missing.includes(f), `expected '${f}' in missing: ${r.missing}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('validateKbFile flags a file with body-only source (the 0%-coverage failure mode)', () => {
|
||||
// Source in a bottom section, NOT the header → invisible to top-500-byte scan.
|
||||
const file =
|
||||
'# Tittel\n\n**Last updated:** 2026-06\n**Status:** Preview\n\n---\n\n' +
|
||||
'Mye innhold ...\n'.repeat(40) +
|
||||
'\n## Kilder\n\n**Source:** https://learn.microsoft.com/x\n';
|
||||
const r = validateKbFile(file);
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.missing.includes('source'));
|
||||
});
|
||||
|
||||
// --- buildChange: the lag-4 → lag-5 contract ----------------------------------
|
||||
|
||||
test('buildChange produces a classifyChange-compatible object with safe defaults', () => {
|
||||
const c = buildChange({
|
||||
field: 'status',
|
||||
oldValue: 'Preview',
|
||||
newValue: 'GA',
|
||||
sourceUrl: META.source,
|
||||
});
|
||||
assert.equal(c.field, 'status');
|
||||
assert.equal(c.old_value, 'Preview');
|
||||
assert.equal(c.new_value, 'GA');
|
||||
assert.equal(c.source_url, META.source);
|
||||
assert.equal(c.authority_source, null); // pre-lag-3 default
|
||||
assert.deepEqual(c.refutations, []);
|
||||
});
|
||||
|
||||
test('buildChange output flows through lag 5 — a status change is flagged, never auto-applied', () => {
|
||||
const c = buildChange({ field: 'status', oldValue: 'Preview', newValue: 'GA', sourceUrl: META.source });
|
||||
const verdict = classifyChange(c);
|
||||
assert.equal(verdict.verdict, 'flagged');
|
||||
assert.equal(verdict.status_claim, true);
|
||||
});
|
||||
|
||||
test('buildChange output flows through lag 5 — a non-status change can auto-apply', () => {
|
||||
const c = buildChange({
|
||||
field: 'description',
|
||||
oldValue: 'En kort beskrivelse.',
|
||||
newValue: 'En litt lengre, oppdatert beskrivelse.',
|
||||
sourceUrl: META.source,
|
||||
});
|
||||
assert.equal(classifyChange(c).verdict, 'auto-applied');
|
||||
});
|
||||
|
||||
// --- resolveTargetPath: taxonomy-driven routing (replaces category-skill-map) --
|
||||
|
||||
test('resolveTargetPath routes via the taxonomy category_skill (disk-truth)', () => {
|
||||
const tax = loadTaxonomy();
|
||||
assert.equal(
|
||||
resolveTargetPath(tax, 'rag-architecture', 'hybrid-retrieval.md'),
|
||||
'skills/ms-ai-engineering/references/rag-architecture/hybrid-retrieval.md',
|
||||
);
|
||||
// a former divergence — taxonomy says advisor, not engineering
|
||||
assert.equal(
|
||||
resolveTargetPath(tax, 'prompt-engineering', 'few-shot.md'),
|
||||
'skills/ms-ai-advisor/references/prompt-engineering/few-shot.md',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveTargetPath returns null for an unknown category (caller must gate)', () => {
|
||||
const tax = loadTaxonomy();
|
||||
assert.equal(resolveTargetPath(tax, 'does-not-exist', 'x.md'), null);
|
||||
});
|
||||
|
||||
// --- ARCHITECTURE INVARIANT: transform.mjs is a pure lib, imports NO write-utils
|
||||
|
||||
test('transform.mjs imports NO write-utils (pure transformer, never writes)', () => {
|
||||
const src = readFileSync(join(__dirname, '../../scripts/kb-update/lib/transform.mjs'), 'utf8');
|
||||
const importLines = src.split('\n').filter((l) => /^\s*import\b/.test(l)).join('\n');
|
||||
assert.doesNotMatch(importLines, /\bsaveDecisions\b/);
|
||||
assert.doesNotMatch(importLines, /\bsaveRegistry\b/);
|
||||
assert.doesNotMatch(importLines, /\bsaveReport\b/);
|
||||
assert.doesNotMatch(importLines, /from\s+'[^']*atomic-write[^']*'/);
|
||||
assert.doesNotMatch(importLines, /from\s+'[^']*\/backup\.mjs'/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue