feat(ms-ai-architect): erstatt per-felt-cap med totalbudsjett og aapne det frosne filsettet [skip-docs]

To maalte defekter i lastesiden for brukerkontekst (underlag: designnotatets
paragraf 3.1-3.3, maalt ved aa kjoere den ekte mekanismen):

1. DEFAULT_MAX_VALUE_LEN = 160 kappet hvert felt uavhengig av hvor mye av
   budsjettet som sto ubrukt. Maalt: et 300-tegns svar tapte 47 prosent, et
   2000-tegns svar 92 prosent - ogsaa free-context.md, gjennom sin egen
   kodevei. Erstattet av DEFAULT_MAX_TOTAL_LEN = 4000: et totalbudsjett fordelt
   max-min-rettferdig over feltene som overlever linje-cappen.

   Hvorfor 4000: det er noeyaktig hva den gamle formen alt tillot i verste fall
   (cap 25 linjer x 160 tegn), saa endringen er kostnadsnoeytral mot dagens tak
   og strengt bedre under det. Fordi budsjettet deles kun over de hoeyst 25
   feltene som faktisk skrives ut, faar intet felt mindre enn 4000/25 = 160:
   den gamle cappen er blitt gulvet. Begrunnelsen staar der konstanten
   defineres.

   Per-felt-cap var feil FORM, ikke bare feil tall: den kan ikke uttrykke at ett
   langt, viktig svar overlever fordi fem korte ikke trengte sin andel.

2. readOrgFiles itererte den frosne ORG_FILES-lista uten readdir, saa en fil
   brukeren la til i org/ naadde aldri modellen. Begge sider er endret - hooken
   leser katalogen, og buildOrgSummary ignorerer ikke lenger filnavn utenfor
   ORG_FILES - ellers ville endringen vaert en no-op som ser ut som en fiks.
   Bare de fem kanoniske teller fortsatt mot onboarding-fullfoerthet.

Ny ren, delt hjelper orderOrgFiles(names) gir begge sider samme deterministiske
rekkefoelge: ORG_FILES foerst, saa oevrige .md sortert, free-context sist.

Verifisert paa den ekte hooken mot en fixture-org-katalog, med kontrollmaaling
mot HEAD: den syvende fila var usynlig foer og gir 43 tegn naa; en 1198-tegns
fri kontekst ble kappet til 160 foer og overlever hel naa.

TDD: 10 nye tester i tests/kb-update/test-user-data.test.mjs, alle roede foerst
(7 assertion-feil mot gammel kode, 3 paa manglende eksport). Suite 1062/1062.

[skip-docs]: ren intern mekanikk - ingen ny kommando, agent, skill eller hook,
og ingen endring i utoverrettet flate.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-26 10:15:05 +02:00
commit a7098e0fdf
3 changed files with 216 additions and 33 deletions

View file

@ -24,6 +24,7 @@ import {
resolveOrgDir,
resolveConfigPath,
buildOrgSummary,
orderOrgFiles,
} from '../../scripts/kb-update/lib/user-data.mjs';
// ===========================================================================
@ -126,9 +127,9 @@ test('buildOrgSummary — deterministic order follows ORG_FILES regardless of in
);
});
test('buildOrgSummary — unknown filenames are ignored (only ORG_FILES contribute)', () => {
const s = buildOrgSummary({ 'random.md': '## Hemmelig\nverdi' });
assert.equal(s, '');
test('buildOrgSummary — non-.md keys and non-string values contribute nothing', () => {
assert.equal(buildOrgSummary({ 'notater.txt': '## Hemmelig\nverdi' }), '');
assert.equal(buildOrgSummary({ 'ekstra.md': 12345 }), '');
});
test('buildOrgSummary — skips empty sections (header with no body)', () => {
@ -148,7 +149,7 @@ test('buildOrgSummary — caps a long free-text value with an ellipsis (budget g
const long = 'x'.repeat(500);
const s = buildOrgSummary(
{ 'business-references.md': `## Referansearkitektur\n${long}\n` },
{ maxValueLen: 80 },
{ maxTotalLen: 80 },
);
const line = s.split('\n').find((l) => l.includes('Referansearkitektur'));
assert.ok(line, 'value line present');
@ -235,7 +236,7 @@ test('buildOrgSummary — free-context appears AFTER the structured fields', ()
});
test('buildOrgSummary — long free-context is capped with an ellipsis (budget guard)', () => {
const s = buildOrgSummary({ 'free-context.md': 'y'.repeat(500) }, { maxValueLen: 80 });
const s = buildOrgSummary({ 'free-context.md': 'y'.repeat(500) }, { maxTotalLen: 80 });
const line = s.split('\n').find((l) => l.startsWith('Fri kontekst:'));
assert.ok(line, 'free-context line present');
assert.match(line, /…$/, 'truncation marker appended');
@ -263,3 +264,92 @@ test('buildOrgSummary — realistic full onboarding keeps free-context visible (
const s = buildOrgSummary(all); // default cap 25
assert.match(s, /Fri kontekst: Viktig tilleggskontekst/, 'free-context visible within default budget');
});
// ===========================================================================
// (D) Reduksjon — the total value budget replaces the per-field cap, and the
// file set is no longer frozen to ORG_FILES (measured 2026-08-26: a 160-
// char per-field cap threw away 47 % of a 300-char answer and 92 % of a
// 2000-char one while most of the budget stood unspent, and a seventh file
// in org/ never reached the model at all).
// ===========================================================================
test('buildOrgSummary — a long field survives whole while the total budget is unspent', () => {
const long = 'x'.repeat(1500);
const s = buildOrgSummary({
'organization-profile.md': '## Sektor\noffentlig',
'free-context.md': long,
});
assert.ok(s.includes(long), 'a single long answer must survive uncut when the budget allows it');
});
test('buildOrgSummary — unspent budget is redistributed, so every field beats the old 160 cap', () => {
const all = {};
for (const f of ORG_FILES) {
all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\n${'x'.repeat(1000)}`).join('\n\n');
}
const s = buildOrgSummary(all); // 20 fields, default budget 4000 => 200 each
const values = s.split('\n').map((l) => l.slice(l.indexOf(': ') + 2));
for (const v of values) {
assert.ok(v.length >= 200, `fair share of 4000 over 20 fields is 200, got ${v.length}`);
}
});
test('buildOrgSummary — the total value budget is enforced across all fields', () => {
const all = {};
for (const f of ORG_FILES) {
all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\n${'x'.repeat(5000)}`).join('\n\n');
}
const s = buildOrgSummary(all);
const total = s
.split('\n')
.reduce((sum, l) => sum + l.slice(l.indexOf(': ') + 2).length, 0);
assert.ok(total <= 4000, `total value chars must stay within the 4000 budget, got ${total}`);
});
test('buildOrgSummary — a short field is never padded away from its own length', () => {
const s = buildOrgSummary({
'organization-profile.md': '## Sektor\noffentlig',
'technology-stack.md': `## Stack\n${'x'.repeat(9000)}`,
});
assert.match(s, /^Sektor: offentlig$/m, 'a short field is kept whole, never trimmed to its share');
});
test('buildOrgSummary — maxTotalLen is overridable', () => {
const s = buildOrgSummary({ 'organization-profile.md': `## Sektor\n${'x'.repeat(500)}` }, { maxTotalLen: 50 });
const value = s.slice(s.indexOf(': ') + 2);
assert.equal(value.length, 50, `explicit budget must bind, got ${value.length}`);
});
test('buildOrgSummary — an extra .md file beyond ORG_FILES contributes its sections', () => {
const s = buildOrgSummary({ 'kundeportefolje.md': '## Kunder\nStore offentlige etater' });
assert.match(s, /Kunder: Store offentlige etater/);
});
test('buildOrgSummary — extra files come after ORG_FILES, before free-context, sorted by name', () => {
const s = buildOrgSummary({
'zeta.md': '## Zeta\nz',
'alfa.md': '## Alfa\na',
'organization-profile.md': '## Sektor\noffentlig',
'free-context.md': 'fritekst',
});
assert.deepEqual(
s.split('\n').map((l) => l.slice(0, l.indexOf(':'))),
['Sektor', 'Alfa', 'Zeta', 'Fri kontekst'],
);
});
test('orderOrgFiles — ORG_FILES first in canonical order, then extra .md sorted by name', () => {
assert.deepEqual(
orderOrgFiles(['zeta.md', 'technology-stack.md', 'alfa.md', 'organization-profile.md']),
['organization-profile.md', 'technology-stack.md', 'alfa.md', 'zeta.md'],
);
});
test('orderOrgFiles — free-context is excluded (callers append it last, labelled)', () => {
assert.deepEqual(orderOrgFiles([FREE_CONTEXT_FILE, 'alfa.md']), ['alfa.md']);
});
test('orderOrgFiles — non-.md entries and garbage input are dropped', () => {
assert.deepEqual(orderOrgFiles(['notater.txt', '.DS_Store', 'alfa.md']), ['alfa.md']);
assert.deepEqual(orderOrgFiles(null), []);
});