feat(ms-ai-architect): renderer batch C (econ + docs 8) + structural test asserts [skip-docs]

Sesjon 5 av v1.10.0-løpet (8 av 17 renderers wrapped med renderPageShell).
Nå alle 17 renderers bruker felles grunnskjelett (page__eyebrow + h1 + verdict).

Renderers wrapped:
- C.1 renderCost: eyebrow=KOSTNAD, key-stats utvidet med DOMINERENDE-komponent
- C.2 renderLicense: eyebrow=LISENS, scenario-card-grid per kandidat-lisens,
  TOPP-LISENS key-stat
- C.3 renderMigrate: eyebrow=MIGRASJON, E2 mat-ladder erstatter aiact-timeline,
  E4 cycle-ribbon ved aktiv fase
- C.4 renderAdr: eyebrow=ADR, D4 critique-card per beslutningsseksjon, ADR-status
  → verdict-pille (accepted/proposed/rejected/deprecated)
- C.5 renderSummary: eyebrow=SAMMENDRAG, E8 read-more for lange rationale
- C.6 renderPoc: eyebrow=POC, E2 mat-ladder + B5 traffic-light per success-kriterie,
  pocVerdict styrer verdict-pille
- C.7 renderUtredning: eyebrow=UTREDNING, A4 screen-tabs (Bakgrunn/Funn/Konklusjon/
  Anbefaling) + E8 read-more på lange seksjoner
- C.8 renderCompare: eyebrow=SAMMENLIGN, D1 scenario-cards-grid per kandidat,
  parseComparison.winner styrer vinner-pille + VINNER key-stat

Parser-utvidelser (R15 forward-compat — eksisterende fixtures uendret):
- parsePhasedPlan: phases[].status (planned/active/done), currentPhaseIndex,
  pocVerdict (kun ved POC-Verdict-linje)
- parseComparison: optional winner-felt fra "## Vinner: <id>"-linje

Topic 2 strategi A i handlePasteImport: sentralisert _consumer-tildeling
(result.data._consumer ||= cmd.id), respekterer parser-spesifikk verdi
(parseMatrixRisk → 'ros').

Fixture-updates: migrate/poc med Status: per fase + POC-Verdict, compare med
"## Vinner:"-linje.

Test-asserts (tests/test-playground-v3.sh +18 PASS, totalt 201/201):
- 25e SC8 per-renderer for batch C (8 renderers)
- 25f Step 12 must_contain (mat-ladder, screen-tabs, _consumer)
- 25g Felles grunnskjelett: alle 17 renderers bruker renderPageShell
- 25h Tier 3-bruk: kanban i conformity/review, mat-ladder i migrate/poc
- 25i Onboarding field-distribution (4 strukturerte, 14 fritekst)

Verifisert: 201/201 statiske, 70/70 parser-fixtures, 7/7 migrations PASS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-04 07:52:52 +02:00
commit fc48d01f1e
5 changed files with 521 additions and 58 deletions

View file

@ -2944,7 +2944,8 @@
name: phaseMatch[1].trim(),
milestones: [],
success_criteria: [],
duration_weeks: null
duration_weeks: null,
status: null
};
bucket = null;
continue;
@ -2961,6 +2962,17 @@
current.duration_weeks = parseInt(durMatch[1], 10);
continue;
}
const statusMatch = /^Status\s*:\s*([\wæøåA-Za-z-]+)/i.exec(trimmed);
if (statusMatch) {
// Normaliser til en av: planned | active | done.
const raw = statusMatch[1].toLowerCase();
let s = null;
if (/^(done|ferdig|fullf[øo]rt|complete[d]?)$/.test(raw)) s = 'done';
else if (/^(active|aktiv|p[åa]g[åa]ende|igang|in[-_]?progress|current|n[åa])$/.test(raw)) s = 'active';
else if (/^(planned|planlagt|kommende|future|fremtid)$/.test(raw)) s = 'planned';
current.status = s || raw;
continue;
}
if (/^Milep[æa]ler\s*:?\s*$/i.test(trimmed)) { bucket = 'milestones'; continue; }
if (/^Suksesskriterier\s*:?\s*$/i.test(trimmed)) { bucket = 'success_criteria'; continue; }
const bulletMatch = /^[-*]\s+(.+)$/.exec(trimmed);
@ -2970,6 +2982,32 @@
}
if (current) phases.push(current);
// Utled currentPhaseIndex: første 'active' ELLER første ikke-'done'.
// R15: -1 hvis ingen faser har status (forward-compat — eksisterende fixtures uberørt).
let currentPhaseIndex = -1;
const anyStatus = phases.some(function (p) { return p.status; });
if (anyStatus) {
for (let i = 0; i < phases.length; i++) {
if (phases[i].status === 'active') { currentPhaseIndex = i; break; }
}
if (currentPhaseIndex < 0) {
for (let i = 0; i < phases.length; i++) {
if (phases[i].status !== 'done') { currentPhaseIndex = i; break; }
}
}
}
// POC-verdict (kun for poc-consumer): "## POC-Verdict: GO|BETINGET|BLOKK"
// R15: undefined for migrate-consumer (uberørt felt).
let pocVerdict;
const pvMatch = /^##\s*POC[- ]?Verdict\s*:\s*([A-Za-zØøÆæÅå -]+)$/im.exec(md);
if (pvMatch) {
const tag = pvMatch[1].toLowerCase().trim();
if (/^(go-?with-?conditions|betinget|conditions?|conditional)$/.test(tag)) pocVerdict = 'go-with-conditions';
else if (/^(block|blokk|blokkert|stop)$/.test(tag)) pocVerdict = 'block';
else if (/^(go|godkjent|ok|pass)$/.test(tag)) pocVerdict = 'go';
}
const risksTbl = parseTable(md, /##\s*Risiko/i);
const risks = risksTbl ? risksTbl.rows.map(function (row) {
const risikoKey = risksTbl.headers[0];
@ -2985,7 +3023,9 @@
}) : [];
if (!phases.length) return { ok: false, errors: [{ section: 'phases', reason: 'Ingen faser funnet (### Fase N)' }] };
return { ok: true, data: { phases: phases, risks: risks } };
const out = { phases: phases, risks: risks, currentPhaseIndex: currentPhaseIndex };
if (pocVerdict) out.pocVerdict = pocVerdict;
return { ok: true, data: out };
}
function parseMarkdown(md) {
@ -3052,7 +3092,12 @@
winner: winnerKey ? (row[winnerKey] || '') : ''
};
});
return { ok: true, data: { subjects: subjects, rows: rows } };
// R15: optional winner-felt fra "## Vinner: <id>"-linje. Brukes av
// renderCompare for verdict-pill og scenario-card highlight.
const out = { subjects: subjects, rows: rows };
const winMatch = /^##\s*Vinner\s*:\s*(.+?)\s*$/im.exec(md) || /^Winner\s*:\s*(.+?)\s*$/im.exec(md);
if (winMatch) out.winner = winMatch[1].trim();
return { ok: true, data: out };
}
// ---- PARSERS routing-objekt ----
@ -3769,7 +3814,27 @@
const tcoHtml = tcoRows
? '<table class="report-table"><thead><tr>' + tcoHeader + '</tr></thead><tbody>' + tcoRows + '</tbody></table>'
: '';
slot.innerHTML = distHtml + breakdownHtml + tcoHtml;
const body = distHtml + breakdownHtml + tcoHtml;
// Utvid cost-distribution-keyStats med DOMINERENDE (top-komponent i breakdown).
const breakdown = data.monthly_breakdown || [];
const dominant = breakdown.reduce(function (acc, m) {
return (m && Number(m.cost) > Number(acc && acc.cost || 0)) ? m : acc;
}, null);
const baseStats = inferKeyStats(data, 'cost-distribution');
const stats = data.keyStats || (dominant
? baseStats.concat([{
label: 'DOMINERENDE',
value: String(dominant.component || '').slice(0, 28),
hint: formatNok(dominant.cost) + '/mnd'
}])
: baseStats);
slot.innerHTML = renderPageShell({
eyebrow: 'KOSTNAD',
title: data.title || 'Kostnadsestimat',
lede: data.lede || 'Distribusjon P10/P50/P90 i NOK med månedlig fordeling og TCO over 3 år.',
verdict: data.verdict || inferVerdict(data, 'cost-distribution'),
keyStats: stats
}, body);
}
function renderLicense(data, slot) {
@ -3796,8 +3861,50 @@
cells +
'</div>';
}).join('');
slot.innerHTML = '<div class="capability-matrix" style="grid-template-columns: 220px repeat(' + licenses.length + ', 1fr);">' +
const matrixHtml = '<div class="capability-matrix" style="grid-template-columns: 220px repeat(' + licenses.length + ', 1fr);">' +
headHtml + rowsHtml + '</div>';
// D1 scenario-card-grid per lisens: hver lisens som card med dekning-stat.
const isAvail = function (cap) { return /^avail|tilgjengelig/i.test((cap && cap.status) || ''); };
const isMiss = function (cap) { return /^miss/i.test((cap && cap.status) || ''); };
const totalCaps = capabilityNames.length;
const licScores = licenses.map(function (l) {
const caps = l.capabilities || [];
const avail = caps.filter(isAvail).length;
const miss = caps.filter(isMiss).length;
const ratio = totalCaps ? (avail / totalCaps) : 0;
const status = ratio >= 0.8 ? 'met' : ratio >= 0.4 ? 'partial' : 'missing';
return { name: l.name, avail: avail, miss: miss, total: totalCaps, ratio: ratio, status: status };
});
const scenarioGridHtml = '<div class="scenario-card-grid">' + licScores.map(function (s) {
const pct = (s.ratio * 100).toFixed(0);
return '<div class="scenario-card" data-status="' + escapeAttr(s.status) + '">' +
'<div class="scenario-card__head">' +
'<span class="scenario-card__source">LISENS</span>' +
'<span class="scenario-card__count">' + s.avail + '/' + s.total + '</span>' +
'</div>' +
'<h4 class="scenario-card__title">' + escapeHtml(s.name) + '</h4>' +
'<div class="scenario-card__source">' + pct + '% dekket · ' + s.miss + ' mangler</div>' +
'</div>';
}).join('') + '</div>';
const body = scenarioGridHtml + matrixHtml;
// Utvid capability-keyStats med BESTE LISENS (høyest avail-ratio).
const baseStats = inferKeyStats(data, 'capability');
const top = licScores.reduce(function (a, b) { return b.ratio > (a ? a.ratio : -1) ? b : a; }, null);
const stats = data.keyStats || (top
? baseStats.concat([{
label: 'TOPP-LISENS',
value: String(top.name || '').slice(0, 24),
hint: top.avail + '/' + top.total + ' kapabiliteter',
modifier: top.status === 'met' ? 'low' : top.status === 'partial' ? 'medium' : 'high'
}])
: baseStats);
slot.innerHTML = renderPageShell({
eyebrow: 'LISENS',
title: data.title || 'Lisens-kapabilitetsmatrise',
lede: data.lede || 'Kapabilitetsdekning per lisensnivå med scenario-cards og full matrise.',
verdict: data.verdict || inferVerdict(data, 'capability'),
keyStats: stats
}, body);
}
// ---- Sub-batch D: Documentation (6) ----
@ -3805,23 +3912,57 @@
function renderMigrate(data, slot) {
const phases = data.phases || [];
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
const milestones = phases.map(function (p, i) {
const left = ((i + 1) / (phases.length + 1)) * 100;
return '<div class="aiact-timeline__milestone" data-state="upcoming" style="left: ' + left.toFixed(1) + '%">' +
'<div class="aiact-timeline__dot"></div>' +
'<div class="aiact-timeline__label">' +
'<span class="aiact-timeline__label-date">' + (p.duration_weeks ? p.duration_weeks + ' uker' : '') + '</span>' +
'<span class="aiact-timeline__label-name">' + escapeHtml(p.name) + '</span>' +
// Map fase-status til mat-step data-state. R15: hvis ingen faser har
// status, fall tilbake til "alle future" — eksisterende fixtures uberørt.
const cpi = (typeof data.currentPhaseIndex === 'number') ? data.currentPhaseIndex : -1;
const stepStateFor = function (p, i) {
if (p.status === 'done') return 'completed';
if (p.status === 'active') return 'current';
if (p.status === 'planned' || p.status) return 'future';
// Fallback uten status: bruk currentPhaseIndex hvis satt.
if (cpi < 0) return 'future';
if (i < cpi) return 'completed';
if (i === cpi) return 'current';
return 'future';
};
const stepsHtml = phases.map(function (p, i) {
const state = stepStateFor(p, i);
const num = String(i + 1).padStart(2, '0');
const pill = state === 'current'
? '<span class="mat-step__pill mat-step__pill--current">PÅGÅR</span>'
: state === 'completed'
? '<span class="mat-step__pill mat-step__pill--complete">FERDIG</span>'
: '';
const dur = p.duration_weeks ? '<div class="mat-step__progress"><span>' + p.duration_weeks + ' uker</span></div>' : '';
const desc = (p.milestones && p.milestones.length)
? '<div class="mat-step__desc">' + escapeHtml(p.milestones[0]) + '</div>'
: '';
return '<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
'<div class="mat-step__icon">' + num + '</div>' +
'<div>' +
'<div class="mat-step__name">' + escapeHtml(p.name) + ' ' + pill + '</div>' +
desc +
dur +
'</div>' +
'</div>';
}).join('');
const timelineHtml =
'<div class="aiact-timeline">' +
'<div class="aiact-timeline__track">' +
'<div class="aiact-timeline__progress" style="width: 0%"></div>' +
milestones +
'</div>' +
'</div>';
const ladderHtml = '<div class="mat-ladder">' + stepsHtml + '</div>';
// E4 cycle-ribbon: bare når en fase er aktiv. data-phase=execution som
// standard for migrasjonens "kjøre"-fase.
let ribbonHtml = '';
if (cpi >= 0 && phases[cpi]) {
const cur = phases[cpi];
const cumWeeks = phases.slice(0, cpi).reduce(function (a, p) { return a + (Number(p.duration_weeks) || 0); }, 0);
const weekStart = cumWeeks + 1;
const weekEnd = cumWeeks + (Number(cur.duration_weeks) || 0);
const weekRange = cur.duration_weeks ? ('Uke ' + weekStart + '-' + weekEnd) : '';
ribbonHtml = '<div class="cycle-ribbon" data-phase="execution">' +
'<span class="cycle-ribbon__id">M-' + (cpi + 1) + '</span>' +
(weekRange ? '<span class="cycle-ribbon__week">' + escapeHtml(weekRange) + '</span>' : '') +
'<span class="cycle-ribbon__phase">PÅGÅR</span>' +
'<span class="cycle-ribbon__msg">' + escapeHtml(cur.name) + '</span>' +
'</div>';
}
const detailsHtml = phases.map(function (p) {
const ms = (p.milestones || []).map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('');
const sc = (p.success_criteria || []).map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('');
@ -3837,7 +3978,14 @@
const risksHtml = risksRows
? '<table class="report-table"><thead><tr><th>Risiko</th><th>Sannsynlighet</th><th>Konsekvens</th><th>Tiltak</th></tr></thead><tbody>' + risksRows + '</tbody></table>'
: '';
slot.innerHTML = timelineHtml + detailsHtml + risksHtml;
const body = ribbonHtml + ladderHtml + detailsHtml + risksHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'MIGRASJON',
title: data.title || 'Migrasjonsplan',
lede: data.lede || 'Faseinndelt migrasjon med mat-ladder, cycle-ribbon for aktiv fase og risikomatrise.',
verdict: data.verdict || inferVerdict(data, 'phased-plan'),
keyStats: data.keyStats || inferKeyStats(data, 'phased-plan')
}, body);
}
function renderAdr(data, slot) {
@ -3847,15 +3995,43 @@
(data.date ? '<dt>Date</dt><dd>' + escapeHtml(data.date) + '</dd>' : '') +
(data.deciders ? '<dt>Deciders</dt><dd>' + escapeHtml(data.deciders) + '</dd>' : '') +
'</dl>';
const sectionsHtml = (data.sections || []).map(function (s) {
return '<section><h2>' + escapeHtml(s.heading) + '</h2><div class="adr-body">' + escapeHtml(s.body).replace(/\n/g, '<br>') + '</div></section>';
}).join('');
slot.innerHTML =
'<article class="report-doc">' +
'<h1>' + escapeHtml(data.title || 'ADR') + '</h1>' +
meta +
sectionsHtml +
'</article>';
// D4 critique-card per beslutningsseksjon. Ingen severity (ADR-seksjoner
// er ikke risikorangert), bruker rekkefølge-id ADR-01..n.
const sections = data.sections || [];
const cardsHtml = sections.length ? '<div class="critique-cards">' + sections.map(function (s, i) {
const id = 'ADR-' + String(i + 1).padStart(2, '0');
const body = escapeHtml(s.body || '').replace(/\n/g, '<br>');
return '<div class="critique-card">' +
'<div class="critique-card__header">' +
'<div class="critique-card__title">' + escapeHtml(s.heading) + '</div>' +
'<div class="critique-card__meta">' +
'<span class="critique-card__id">' + id + '</span>' +
'</div>' +
'</div>' +
'<div class="critique-card__recommendation">' + body + '</div>' +
'</div>';
}).join('') + '</div>' : '';
const body = meta + cardsHtml;
// ADR-status til verdict: accepted/godkjent → approved, proposed → go-with-conditions,
// rejected → failed, deprecated/superseded → warning.
const statusMap = {
accepted: 'approved', godkjent: 'approved', approved: 'approved',
proposed: 'go-with-conditions', foreslått: 'go-with-conditions', 'foreslatt': 'go-with-conditions',
rejected: 'failed', avvist: 'failed',
deprecated: 'warning', foreldet: 'warning', superseded: 'warning'
};
const verdict = data.verdict || statusMap[String(data.status || '').toLowerCase().trim()] || 'n-a';
slot.innerHTML = renderPageShell({
eyebrow: 'ADR',
title: data.title || 'Architecture Decision Record',
lede: data.lede || (data.status ? 'Status: ' + data.status : 'MADR v3.0 — beslutningsdokument med kontekst, alternativer og konsekvenser.'),
verdict: verdict,
keyStats: data.keyStats || [
{ label: 'STATUS', value: String(data.status || '—').toUpperCase() },
{ label: 'SEKSJONER', value: sections.length },
{ label: 'BESLUTTERE', value: data.deciders ? String(data.deciders).split(/[,;]/).length : 0, hint: 'antall' }
]
}, body);
}
function renderSummary(data, slot) {
@ -3885,9 +4061,23 @@
'</div>' +
'</div>' +
'</div>';
const rationaleHtml = data.rationale
? '<section><h3>Rationale</h3><p>' + escapeHtml(data.rationale).replace(/\n/g, '<br>') + '</p></section>'
: '';
// E8 read-more: lange rationale (>300 tegn) skjuler hale i <details>.
let rationaleHtml = '';
if (data.rationale) {
const raw = String(data.rationale);
if (raw.length > 300) {
const head = raw.slice(0, 220);
const tail = raw.slice(220);
rationaleHtml = '<section><h3>Rationale</h3>' +
'<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
'<details class="read-more-block"><summary>Vis hele rationale</summary>' +
'<p>' + escapeHtml(tail).replace(/\n/g, '<br>') + '</p>' +
'</details>' +
'</section>';
} else {
rationaleHtml = '<section><h3>Rationale</h3><p>' + escapeHtml(raw).replace(/\n/g, '<br>') + '</p></section>';
}
}
let metricsHtml = '';
if ((data.key_metrics || []).length) {
const headers = data.metrics_headers || Object.keys(data.key_metrics[0] || {});
@ -3901,29 +4091,73 @@
const nextHtml = (data.next_steps || []).length
? '<section><h3>Next Steps</h3><ul>' + data.next_steps.map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('') + '</ul></section>'
: '';
slot.innerHTML = verdictHtml + rationaleHtml + metricsHtml + nextHtml;
const body = verdictHtml + rationaleHtml + metricsHtml + nextHtml;
// Map summary-verdict (allow/warning/block) til canonical verdict for header-pill.
const headerVerdictMap = { allow: 'allow', warning: 'warning', block: 'block' };
const headerVerdict = headerVerdictMap[v.variant] || 'warning';
slot.innerHTML = renderPageShell({
eyebrow: 'SAMMENDRAG',
title: data.title || 'Beslutningsnotat',
lede: data.lede || 'Teknisk sammendrag med verdict, key metrics og neste steg.',
verdict: headerVerdict,
keyStats: data.keyStats || inferKeyStats(data, 'verdict')
}, body);
}
function renderPoc(data, slot) {
const phases = data.phases || [];
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
const stagesHtml = phases.map(function (p, i) {
// E2 mat-ladder (samme mønster som migrate). POC uses currentPhaseIndex/status.
const cpi = (typeof data.currentPhaseIndex === 'number') ? data.currentPhaseIndex : -1;
const stepStateFor = function (p, i) {
if (p.status === 'done') return 'completed';
if (p.status === 'active') return 'current';
if (p.status === 'planned' || p.status) return 'future';
if (cpi < 0) return 'future';
if (i < cpi) return 'completed';
if (i === cpi) return 'current';
return 'future';
};
const stepsHtml = phases.map(function (p, i) {
const state = stepStateFor(p, i);
const num = String(i + 1).padStart(2, '0');
const isCurrent = (i === 0);
return '<div class="pc-stage" data-current="' + (isCurrent ? 'true' : 'false') + '">' +
'<div class="pc-stage__num">' + num + '</div>' +
'<div class="pc-stage__name">' + escapeHtml(p.name) + '</div>' +
'<div class="pc-stage__state" data-state="' + (isCurrent ? 'running' : 'empty') + '">' + (p.duration_weeks || '?') + ' uker</div>' +
const pill = state === 'current'
? '<span class="mat-step__pill mat-step__pill--current">PÅGÅR</span>'
: state === 'completed'
? '<span class="mat-step__pill mat-step__pill--complete">FERDIG</span>'
: '';
const dur = p.duration_weeks ? '<div class="mat-step__progress"><span>' + p.duration_weeks + ' uker</span></div>' : '';
const desc = (p.milestones && p.milestones.length)
? '<div class="mat-step__desc">' + escapeHtml(p.milestones[0]) + '</div>'
: '';
return '<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
'<div class="mat-step__icon">' + num + '</div>' +
'<div>' +
'<div class="mat-step__name">' + escapeHtml(p.name) + ' ' + pill + '</div>' +
desc +
dur +
'</div>' +
'</div>';
}).join('');
const cockpitHtml = '<div class="pipeline-cockpit">' + stagesHtml + '</div>';
const detailsHtml = phases.map(function (p) {
const ladderHtml = '<div class="mat-ladder">' + stepsHtml + '</div>';
// B5 traffic-light per success-kriterie. R15: uten eksplisitt status,
// bruk fasens state — done=go, active=warning, future=neutral.
const detailsHtml = phases.map(function (p, i) {
const state = stepStateFor(p, i);
const tlStatus = state === 'completed' ? 'green' : state === 'current' ? 'yellow' : 'gray';
const ms = (p.milestones || []).map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('');
const sc = (p.success_criteria || []).map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('');
const sc = (p.success_criteria || []).map(function (s) {
return '<li class="traffic-row">' +
'<span class="traffic-light" data-status="' + escapeAttr(tlStatus) + '" aria-label="' + escapeAttr(tlStatus) + '">' +
'<span class="traffic-light__dot"></span>' +
'<span class="traffic-light__label">' + escapeHtml(s) + '</span>' +
'</span>' +
'</li>';
}).join('');
return '<section class="phase-detail">' +
'<h3>' + escapeHtml(p.name) + ' <small>(' + (p.duration_weeks || '?') + ' uker)</small></h3>' +
(ms ? '<h4>Milepæler</h4><ul>' + ms + '</ul>' : '') +
(sc ? '<h4>Suksesskriterier</h4><ul>' + sc + '</ul>' : '') +
(sc ? '<h4>Suksesskriterier</h4><ul class="traffic-list">' + sc + '</ul>' : '') +
'</section>';
}).join('');
const risksRows = (data.risks || []).map(function (r) {
@ -3932,24 +4166,86 @@
const risksHtml = risksRows
? '<table class="report-table"><thead><tr><th>Risiko</th><th>Sannsynlighet</th><th>Konsekvens</th><th>Tiltak</th></tr></thead><tbody>' + risksRows + '</tbody></table>'
: '';
slot.innerHTML = cockpitHtml + detailsHtml + risksHtml;
const body = ladderHtml + detailsHtml + risksHtml;
// B1 verdict-pille: data.pocVerdict styrer (go/go-with-conditions/block).
// R15: hvis ikke satt, fall tilbake til risk-baserte heuristikk.
let verdict = data.verdict || data.pocVerdict;
if (!verdict) {
const risks = data.risks || [];
const critical = risks.some(function (r) { return /high|h[øo]y/i.test(r.consequence || '') && /high|h[øo]y/i.test(r.probability || ''); });
verdict = critical ? 'go-with-conditions' : (risks.length ? 'go-with-conditions' : 'go');
}
slot.innerHTML = renderPageShell({
eyebrow: 'POC',
title: data.title || 'POC-plan',
lede: data.lede || 'Faseinndelt POC med mat-ladder, suksesskriterier og go/no-go-vurdering.',
verdict: verdict,
keyStats: data.keyStats || inferKeyStats(data, 'phased-plan')
}, body);
}
function renderUtredning(data, slot) {
const tocHtml = (data.sections || []).map(function (s, i) {
return '<li><a href="#utr-sec-' + i + '">' + escapeHtml(s.heading) + '</a></li>';
const sections = data.sections || [];
// A4 SCREEN-TABS: kuratert sett av 4 strukturerte tabs.
// R15: Hvis utredningen mangler en av seksjonene, hopp over den taben.
const tabSpec = [
{ id: 'bakgrunn', label: 'Bakgrunn', match: /\bbakgrunn\b/i },
{ id: 'funn', label: 'Funn', match: /\bfunn\b/i },
{ id: 'konklusjon', label: 'Konklusjon', match: /\bkonklusjon\b/i },
{ id: 'anbefaling', label: 'Anbefaling', match: /\banbefaling\b/i }
];
// Heading-normaliser: fjern "1. ", "1.2 " prefiks.
const normalize = function (h) { return String(h || '').replace(/^\s*\d+(\.\d+)*\s*\.?\s*/, '').trim(); };
const findSec = function (m) {
return sections.find(function (s) { return m.test(normalize(s.heading)); });
};
const usedIdx = new Set();
const tabs = tabSpec.map(function (t) {
const sec = findSec(t.match);
if (sec) usedIdx.add(sections.indexOf(sec));
return { id: t.id, label: t.label, sec: sec };
}).filter(function (t) { return t.sec; });
// E8 read-more body: lange seksjoner (>500 tegn) skjuler hale i <details>.
const renderBody = function (raw) {
const txt = String(raw || '');
if (txt.length > 500) {
const head = txt.slice(0, 380);
const tail = txt.slice(380);
return '<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
'<details class="read-more-block"><summary>Vis hele seksjonen</summary>' +
'<p>' + escapeHtml(tail).replace(/\n/g, '<br>') + '</p>' +
'</details>';
}
return '<div>' + escapeHtml(txt).replace(/\n/g, '<br>') + '</div>';
};
const tabsNavHtml = tabs.length ? '<nav class="screen-tabs" role="tablist" aria-label="Utredning">' + tabs.map(function (t, i) {
return '<a class="screen-tab" role="tab" aria-current="' + (i === 0 ? 'true' : 'false') + '" href="#utr-' + escapeAttr(t.id) + '">' + escapeHtml(t.label) + '</a>';
}).join('') + '</nav>' : '';
const tabsBodyHtml = tabs.map(function (t) {
return '<section id="utr-' + escapeAttr(t.id) + '" class="utr-panel">' +
'<h2>' + escapeHtml(normalize(t.sec.heading)) + '</h2>' +
renderBody(t.sec.body) +
'</section>';
}).join('');
const sectionsHtml = (data.sections || []).map(function (s, i) {
return '<section id="utr-sec-' + i + '"><h2>' + escapeHtml(s.heading) + '</h2><div>' + escapeHtml(s.body).replace(/\n/g, '<br>') + '</div></section>';
}).join('');
slot.innerHTML =
'<div class="report-utredning" style="display: grid; grid-template-columns: 220px 1fr; gap: var(--space-6);">' +
'<aside class="utredning-toc"><h4>Innhold</h4><ol>' + tocHtml + '</ol></aside>' +
'<article class="report-doc">' +
'<h1>' + escapeHtml(data.title || 'Utredning') + '</h1>' +
sectionsHtml +
'</article>' +
'</div>';
// Resterende seksjoner (mandat, metode, referanser m.fl.) under en samlende read-more.
const otherSecs = sections.filter(function (s, i) { return !usedIdx.has(i); });
const otherHtml = otherSecs.length ? '<details class="read-more-block utr-other"><summary>Vis øvrige seksjoner (' + otherSecs.length + ')</summary>' +
otherSecs.map(function (s) {
return '<section><h3>' + escapeHtml(normalize(s.heading)) + '</h3>' + renderBody(s.body) + '</section>';
}).join('') +
'</details>' : '';
const body = tabsNavHtml + tabsBodyHtml + otherHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'UTREDNING',
title: data.title || 'AI-arkitekturutredning',
lede: data.lede || 'Strukturert utredning med kuraterte seksjoner: bakgrunn, funn, konklusjon og anbefaling.',
verdict: data.verdict || 'n-a',
keyStats: data.keyStats || [
{ label: 'TABS', value: tabs.length, hint: 'av 4' },
{ label: 'SEKSJONER', value: sections.length },
{ label: 'ØVRIGE', value: otherSecs.length, hint: 'andre seksjoner' }
]
}, body);
}
function renderCompare(data, slot) {
@ -3965,6 +4261,35 @@
else if (fw2 && w.indexOf(fw2) >= 0) count2++;
else lik++;
});
// Vinner: eksplisitt parseComparison.winner ELLER auto fra row-counts.
const explicitWin = String(data.winner || '').toLowerCase();
let winnerIdx = -1;
if (explicitWin) {
if (fw1 && explicitWin.indexOf(fw1) >= 0) winnerIdx = 0;
else if (fw2 && explicitWin.indexOf(fw2) >= 0) winnerIdx = 1;
}
if (winnerIdx < 0 && (count1 || count2)) {
winnerIdx = count1 > count2 ? 0 : count2 > count1 ? 1 : -1;
}
// D1 scenario-cards-grid per kandidat. Vinner får data-status="met",
// taper "partial", tied/no-winner forblir "partial".
const cardSubjects = subjects.map(function (s, i) {
const wins = i === 0 ? count1 : count2;
const status = i === winnerIdx ? 'met' : 'partial';
const total = (data.rows || []).length;
return { name: s, wins: wins, total: total, status: status, isWinner: i === winnerIdx };
});
const cardsHtml = '<div class="scenario-card-grid">' + cardSubjects.map(function (c) {
const winnerBadge = c.isWinner ? '<span class="scenario-card__count" style="background: var(--color-state-success); color: #fff;">VINNER</span>' : '<span class="scenario-card__count">' + c.wins + '/' + c.total + '</span>';
return '<div class="scenario-card" data-status="' + escapeAttr(c.status) + '">' +
'<div class="scenario-card__head">' +
'<span class="scenario-card__source">KANDIDAT</span>' +
winnerBadge +
'</div>' +
'<h4 class="scenario-card__title">' + escapeHtml(c.name) + '</h4>' +
'<div class="scenario-card__source">' + c.wins + ' vinn · ' + (c.total - c.wins) + ' lik/tap</div>' +
'</div>';
}).join('') + '</div>';
const summaryHtml =
'<div class="diff__summary">' +
'<div class="diff__summary-item"><span class="diff__summary-count">' + count1 + '</span> ' + escapeHtml(subjects[0]) + '</div>' +
@ -3986,7 +4311,27 @@
'<div class="diff__cell ' + cls2 + '"><strong>' + escapeHtml(r.aspect) + ':</strong> ' + escapeHtml(r.value2) + '</div>' +
'</div>';
}).join('');
slot.innerHTML = '<div class="diff">' + summaryHtml + headerHtml + rowsHtml + '</div>';
const diffHtml = '<div class="diff">' + summaryHtml + headerHtml + rowsHtml + '</div>';
const body = cardsHtml + diffHtml;
// Verdict-pille: vinner satt → 'go' (klar anbefaling). Tied/uavklart → 'go-with-conditions'.
const verdict = data.verdict || (winnerIdx >= 0 ? 'go' : 'go-with-conditions');
// Utvid comparison-keyStats med VINNER-felt.
const baseStats = inferKeyStats(data, 'comparison');
const stats = data.keyStats || (winnerIdx >= 0
? baseStats.concat([{
label: 'VINNER',
value: String(subjects[winnerIdx] || '').slice(0, 24),
hint: (winnerIdx === 0 ? count1 : count2) + ' vinn',
modifier: 'low'
}])
: baseStats.concat([{ label: 'VINNER', value: 'UAVKLART', modifier: 'medium' }]));
slot.innerHTML = renderPageShell({
eyebrow: 'SAMMENLIGN',
title: data.title || 'Sammenligning',
lede: data.lede || 'Aspekt-for-aspekt-sammenligning av to kandidater med vinner-pille og diff-tabell.',
verdict: verdict,
keyStats: stats
}, body);
}
// === V2_FOUNDATION_BEGIN ===
@ -4368,6 +4713,13 @@
return;
}
const result = parser(markdown);
// Topic 2 strategi A: sentralisert _consumer-tildeling i import-flow.
// Respekterer parser-spesifikk verdi (f.eks. parseMatrixRisk → 'ros').
// Renderere kan bruke _consumer for å velge variant-spesifikk markup
// der parser-arketypen er delt mellom flere commands.
if (result && result.ok && result.data && result.data._consumer == null) {
result.data._consumer = cmd.id;
}
slot.innerHTML = '';
if (result.ok) renderer(result.data, slot);
else renderError(result.errors, slot);

View file

@ -31,6 +31,8 @@ Subject 2: Azure ML + AKS
Azure AI Foundry vinner på time-to-prod, compliance-pakke, og driftbarhet. Azure ML + AKS vinner på pris (-12%) og fleksibilitet. Differansen i pris (~NOK 800k over 3 år) er liten sammenlignet med besparelsen i drift-tid for AI-teamet.
## Vinner: Azure AI Foundry
## Anbefaling
For Acme AS med begrenset KI-driftkapasitet anbefales Azure AI Foundry. For organisasjoner med dedikert MLOps-team kan Azure ML + AKS gi marginalt bedre kost-nytte.

View file

@ -9,6 +9,7 @@ Til: Azure AI Foundry + saksbehandler-co-pilot
### Fase 1 — Foundry-fundament (uker 1-6)
Varighet: 6 uker
Status: done
Milepæler:
- Hub + projects opprettet i West Europe
@ -24,6 +25,7 @@ Suksesskriterier:
### Fase 2 — Modell-trening og baseline (uker 7-14)
Varighet: 8 uker
Status: done
Milepæler:
- Treningsdata kuratert (200k norske objekt-ID, stratifisert)
@ -39,6 +41,7 @@ Suksesskriterier:
### Fase 3 — saksbehandler-co-pilot (uker 15-22)
Varighet: 8 uker
Status: active
Milepæler:
- Forklaringsmodell (GPT-4 Turbo) integrert via Foundry
@ -54,6 +57,7 @@ Suksesskriterier:
### Fase 4 — Compliance og produksjonssetting (uker 23-28)
Varighet: 6 uker
Status: planned
Milepæler:
- FRIA gjennomført og godkjent

View file

@ -8,6 +8,7 @@ POC-mål: Validere at Azure AI Foundry kan dekke OCR + forklaring + audit innen
### Fase 1 — Foundation (uker 1-2)
Varighet: 2 uker
Status: done
Milepæler:
- Foundry hub + project i West Europe
@ -22,6 +23,7 @@ Suksesskriterier:
### Fase 2 — OCR-modell (uker 3-5)
Varighet: 3 uker
Status: active
Milepæler:
- Pre-trent Azure AI Vision OCR pilotert
@ -36,6 +38,7 @@ Suksesskriterier:
### Fase 3 — Forklarings-loop (uker 6-7)
Varighet: 2 uker
Status: planned
Milepæler:
- GPT-4 Turbo via Foundry integrert
@ -50,6 +53,7 @@ Suksesskriterier:
### Fase 4 — Compliance-pre-check (uke 8)
Varighet: 1 uke
Status: planned
Milepæler:
- Audit-logg mot EU AI Act Art. 12-krav
@ -70,6 +74,10 @@ Suksesskriterier:
| saksbehandler-recruitment forsinker fase 3 | medium | low | Bruk interne ressurser i AI-teamet som mock |
| Audit-logg-format ikke kompatibelt med Sentinel | low | medium | Test integrasjon i fase 1 |
## POC-Verdict: BETINGET
Pilot-fase 1 fullført med F1=0.94 og inference-cost 0.038 NOK/kall (under budsjett). Fase 2 pågår — sammenligning av custom fine-tune mot pre-trent OCR i progress. Forklarings-loop og compliance-pre-check planlagt for siste halvdel.
## Total varighet
8 uker. Beslutningskriterium for full prosjektgodkjenning: alle 4 fasers suksesskriterier møtt.

View file

@ -456,6 +456,103 @@ else
fail "suppressed markup mangler (Step 11 must_contain krever >=1)"
fi
# -------------------------------------------------------
# 25e. SC8 — per-renderer verdict-pill emission for Sub-batch C (R7)
# Hver av de 8 Sub-batch C-rendererene må enten emitte data-verdict direkte
# i sin body, eller invokere renderPageShell (som emitter via helper).
# -------------------------------------------------------
SC8_RENDERERS_C="renderCost renderLicense renderMigrate renderAdr renderSummary renderPoc renderUtredning renderCompare"
for fn in $SC8_RENDERERS_C; do
body=$(awk "/function $fn\(/,/^ \}$/" "$HTML_FILE")
if echo "$body" | grep -qE "verdict[^A-Za-z]*data-verdict\s*=\s*[\"'](go|go-with-conditions|block|approved|failed|allow|warning|n-a)[\"']" \
|| echo "$body" | grep -q "renderPageShell"; then
pass "SC8 verdict-pill: $fn (direkte eller via renderPageShell)"
else
fail "SC8 verdict-pill: $fn mangler både data-verdict og renderPageShell"
fi
done
# -------------------------------------------------------
# 25f. Step 12 must_contain — mat-ladder + screen-tabs + _consumer
# -------------------------------------------------------
matladder_count=$( { grep -cE "mat-ladder" "$HTML_FILE" || true; } | tr -d ' ')
if [ "${matladder_count:-0}" -ge 1 ]; then
pass "mat-ladder markup til stede ($matladder_count treff, Step 12 must_contain)"
else
fail "mat-ladder markup mangler (Step 12 must_contain krever >=1)"
fi
screentabs_count=$( { grep -cE "screen-tabs" "$HTML_FILE" || true; } | tr -d ' ')
if [ "${screentabs_count:-0}" -ge 1 ]; then
pass "screen-tabs markup til stede ($screentabs_count treff, Step 12 must_contain)"
else
fail "screen-tabs markup mangler (Step 12 must_contain krever >=1)"
fi
consumer_count=$( { grep -cE "_consumer" "$HTML_FILE" || true; } | tr -d ' ')
if [ "${consumer_count:-0}" -ge 1 ]; then
pass "_consumer-mekanisme til stede ($consumer_count treff, Step 12 must_contain)"
else
fail "_consumer-mekanisme mangler (Step 12 must_contain krever >=1)"
fi
# -------------------------------------------------------
# 25g. Felles grunnskjelett — alle 17 renderers emiter page__eyebrow + h1 + verdict
# Step 12 strukturell assert per R7 + plan-spec.
# -------------------------------------------------------
ALL_RENDERERS="renderAiActPyramid renderRequirements renderTransparency renderFria renderConformity renderDpia renderSecurity renderRos renderReview renderCost renderLicense renderMigrate renderAdr renderSummary renderPoc renderUtredning renderCompare"
shell_count=0
for fn in $ALL_RENDERERS; do
body=$(awk "/function $fn\(/,/^ \}$/" "$HTML_FILE")
if echo "$body" | grep -q "renderPageShell"; then
shell_count=$((shell_count + 1))
fi
done
if [ "$shell_count" -eq 17 ]; then
pass "Alle 17 renderers bruker renderPageShell (felles grunnskjelett: page__eyebrow + h1 + verdict via helper)"
else
fail "Kun $shell_count/17 renderers bruker renderPageShell — felles grunnskjelett ufullstendig"
fi
# -------------------------------------------------------
# 25h. Tier 3-bruk per Step 12 — kanban (conformity/review), mat-ladder (migrate/poc)
# -------------------------------------------------------
for fn in renderConformity renderReview; do
body=$(awk "/function $fn\(/,/^ \}$/" "$HTML_FILE")
if echo "$body" | grep -qE "kanban-board|kanban-col"; then
pass "Tier 3 kanban: $fn bruker kanban-markup"
else
fail "Tier 3 kanban: $fn mangler kanban-markup"
fi
done
for fn in renderMigrate renderPoc; do
body=$(awk "/function $fn\(/,/^ \}$/" "$HTML_FILE")
if echo "$body" | grep -q "mat-ladder"; then
pass "Tier 3 mat-ladder: $fn bruker mat-ladder"
else
fail "Tier 3 mat-ladder: $fn mangler mat-ladder"
fi
done
# -------------------------------------------------------
# 25i. Onboarding-config field-type-distribution (4 strukturerte / 14 fritekst)
# Step 12 strukturell assert per R4 / R7. Counts ONBOARDING_SCHEMA-felter
# med type=select/multiSelect (strukturerte) vs text/textarea (fritekst).
# -------------------------------------------------------
onb_block=$(awk '/const ONBOARDING_SCHEMA = \[/,/^ \];/' "$HTML_FILE")
struct_count=$(printf '%s\n' "$onb_block" | grep -cE "type:\s*'(select|multiSelect)'" | tr -d ' ')
free_count=$(printf '%s\n' "$onb_block" | grep -cE "type:\s*'(text|textarea)'" | tr -d ' ')
if [ "${struct_count:-0}" -ge 4 ] && [ "${struct_count:-0}" -le 5 ]; then
pass "Onboarding strukturerte felter: $struct_count (forventet 4)"
else
fail "Onboarding strukturerte felter: $struct_count (forventet 4)"
fi
if [ "${free_count:-0}" -ge 13 ] && [ "${free_count:-0}" -le 16 ]; then
pass "Onboarding fritekst-felter: $free_count (forventet ~14)"
else
fail "Onboarding fritekst-felter: $free_count (forventet ~14)"
fi
# -------------------------------------------------------
# 25. Inline-script eneste JS — ingen <script src=> til lokale .js-filer
# -------------------------------------------------------