feat(ms-ai-architect): generator-anti-regresjon Fase 1c — composeKbFile + TOC-gate
Nye ref-filer fodes best-practice-konforme sa neste KB-update ikke reverserer TOC-arbeidet (write-path-regresjon). MA fore 1b. - buildToc(body): deterministisk ## Innhold fra ##-seksjoner, GitHub-slugs, fence-aware, idempotent (lister aldri seg selv) - composeKbFile(meta, body): generatoren — header + (store filer >100 linjer) TOC + brodtekst; erstatter bar buildKbHeader+body-konkatenering - validateKbFile: stor fil uten TOC -> missing:['toc'] (write-path-gaten) - Terskel 100 speiler eval.mjs N4_LARGE_FILE_LINES; bundet atferdsmessig via test som importerer ekte checkN4 (anti-regresjons-orakel), ikke cross-import - Wiret composeKbFile inn i transform-prompt.md + kb-update.md (create + in-place) TDD: +11 transform-tester. kb-update 349/349, kb-eval 154/154 (503 totalt). Fikset pre-eksisterende skjor assertion i criterion-testen (stale 'Azure AI Foundry' -> 'AI Foundry'; produktet omdopt) — var rod for 1c.
This commit is contained in:
parent
89eccf8881
commit
2240f1efdd
5 changed files with 215 additions and 19 deletions
|
|
@ -22,6 +22,43 @@ import { getCategorySkill } from './taxonomy.mjs';
|
|||
// Source is what lets lag 3 backfill authority_source and lag-5 rule 3 eventually fire.
|
||||
const REQUIRED_META = ['title', 'status', 'category', 'source', 'lastUpdated'];
|
||||
|
||||
// A reference file longer than this many lines should carry a table of contents
|
||||
// (best-practices §Structure). MIRRORS eval.mjs N4_LARGE_FILE_LINES — kept in sync
|
||||
// behaviourally by test-transform's checkN4 parity assertion, NOT by importing across
|
||||
// the kb-update ↔ kb-eval boundary (transform.mjs's only dependency stays kb-headers).
|
||||
const TOC_MIN_LINES = 100;
|
||||
|
||||
/** Line count counted exactly as checkN4 does (trailing blank lines ignored). */
|
||||
function lineCount(content) {
|
||||
return String(content ?? '').replace(/\n+$/, '').split('\n').length;
|
||||
}
|
||||
|
||||
/** Is this file large enough to require a TOC? (mirror of checkN4's "large" filter) */
|
||||
function isLargeContent(content) {
|
||||
return lineCount(content) > TOC_MIN_LINES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the content already carry a TOC? Mirror of eval.mjs hasToc (internal there):
|
||||
* an explicit TOC heading, OR a cluster (≥3) of in-page anchor links near the top.
|
||||
*/
|
||||
function hasToc(content) {
|
||||
const s = String(content ?? '');
|
||||
if (/^#{1,3}\s*(table of contents|contents|innhold(?:sfortegnelse)?|toc)\b/im.test(s)) return true;
|
||||
const head = s.split('\n').slice(0, 40).join('\n');
|
||||
return (head.match(/\]\(#/g) || []).length >= 3;
|
||||
}
|
||||
|
||||
/** GitHub-style heading slug: lowercase, drop punctuation (keep letters/digits/_/-),
|
||||
* spaces → hyphens. Unicode-aware so Norwegian æ/ø/å survive in anchors. */
|
||||
function slugify(heading) {
|
||||
return String(heading ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
|
||||
.trim()
|
||||
.replace(/\s/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:"
|
||||
|
|
@ -52,6 +89,56 @@ export function buildKbHeader(meta) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic `## Innhold` table of contents from a body's level-2 (`##`)
|
||||
* section headings — the plugin house style (matches the existing TOC'd files and the
|
||||
* heading eval.mjs hasToc recognises). The LLM should NOT hand-author anchor slugs;
|
||||
* this generates GitHub-compatible ones. Headings inside ``` code fences are ignored,
|
||||
* and the `## Innhold` heading never lists itself (so re-running is idempotent).
|
||||
*
|
||||
* @param {string} body — the file body (under the header `---`)
|
||||
* @returns {string} a `## Innhold` block ending in a newline, or '' if there are no
|
||||
* `##` sections to list
|
||||
*/
|
||||
export function buildToc(body) {
|
||||
const lines = String(body ?? '').split('\n');
|
||||
const titles = [];
|
||||
let inFence = false;
|
||||
for (const line of lines) {
|
||||
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
|
||||
if (inFence) continue;
|
||||
const m = /^##\s+(.+?)\s*$/.exec(line); // level-2 only (not # title, not ### sub)
|
||||
if (!m) continue;
|
||||
const title = m[1].trim();
|
||||
if (/^innhold(?:sfortegnelse)?$/i.test(title)) continue; // never list the TOC itself
|
||||
titles.push(title);
|
||||
}
|
||||
if (titles.length === 0) return '';
|
||||
const items = titles.map((t) => `- [${t}](#${slugify(t)})`).join('\n');
|
||||
return `## Innhold\n\n${items}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a complete KB file from header meta + body: the deterministic generator the
|
||||
* write-path uses (replaces the bare `buildKbHeader(meta) + body` concatenation). A
|
||||
* large file (>100 lines, per N4) is born with a `## Innhold` TOC inserted after the
|
||||
* header so regeneration never strips the TOC back out (the Fase 1c regression guard).
|
||||
* Idempotent: a body that already carries a TOC is not double-TOC'd.
|
||||
*
|
||||
* @param {object} meta — same shape as buildKbHeader
|
||||
* @param {string} body — the distilled body (under the header `---`)
|
||||
* @returns {string} the full file content
|
||||
*/
|
||||
export function composeKbFile(meta, body) {
|
||||
const header = buildKbHeader(meta);
|
||||
const b = String(body ?? '');
|
||||
const full = header + b;
|
||||
if (!isLargeContent(full) || hasToc(full)) return full;
|
||||
const toc = buildToc(b);
|
||||
if (!toc) return full;
|
||||
return `${header}\n${toc}\n${b}`;
|
||||
}
|
||||
|
||||
const RE_TITLE = /^#\s+\S/m;
|
||||
const RE_LAST_UPDATED = /\*\*Last updated:\*\*\s*[\d-]+/i;
|
||||
const RE_STATUS = /\*\*Status:\*\*\s*\S/i;
|
||||
|
|
@ -59,8 +146,10 @@ 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.
|
||||
* a Status field, a Source header in the scannable region, and — for a large file
|
||||
* (>100 lines, per N4) — a table of contents. The TOC clause is the write-path
|
||||
* regression gate: a regenerated large file that lost its TOC fails here and the
|
||||
* caller re-prompts, so KB-update can never silently strip a TOC back out.
|
||||
*
|
||||
* @param {string} content — full file content
|
||||
* @returns {{valid: boolean, missing: string[]}}
|
||||
|
|
@ -72,6 +161,7 @@ export function validateKbFile(content) {
|
|||
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');
|
||||
if (isLargeContent(s) && !hasToc(s)) missing.push('toc');
|
||||
return { valid: missing.length === 0, missing };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,9 +42,12 @@ ikke av modellen. Den ser slik ut og MÅ stå først:
|
|||
---
|
||||
```
|
||||
|
||||
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.
|
||||
Modellen skriver **kun brødteksten under `---`**. `composeKbFile({...}, body)` setter så
|
||||
sammen hele fila — header + (for **store filer >100 linjer**) en deterministisk
|
||||
`## Innhold`-TOC fra brødtekstens `##`-seksjoner + brødteksten. Etterpå kjører
|
||||
`validateKbFile(content)` og MÅ returnere `valid: true` (title + Last updated + Status +
|
||||
Source til stede; **for store filer også TOC**) før noe gates videre. TOC-en fødes inn så
|
||||
en senere regenerering ikke stripper den (write-path-regresjon — Fase 1c).
|
||||
|
||||
## Prompt (brødtekst)
|
||||
|
||||
|
|
@ -87,8 +90,8 @@ 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).
|
||||
1. `content = composeKbFile({title, status, category, source, lastUpdated}, body)` — header + (store filer) `## Innhold`-TOC + brødtekst. Erstatter den bare `buildKbHeader(...) + body`-konkateneringen.
|
||||
2. `validateKbFile(content)` → må være `valid: true` (title + Last updated + Status + Source; **store filer også TOC**) (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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue