feat(okr): aarsrapport del III-generator

This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 06:49:59 +02:00
commit 77031b0c9a
3 changed files with 384 additions and 65 deletions

View file

@ -56,6 +56,11 @@ function krRad(kr, erCommitted) {
return `| ${kr.navn} | ${kr.baseline} | ${kr.target} | ${kr.naa} | ${score} | ${formatAvvik(kr, erCommitted)} | |`;
}
const krTabell = (krer, erCommitted) => [
...TABELLHODE,
...krer.map((kr) => krRad(kr, erCommitted)),
];
// Ett avsnitt per OKR, med KR-ene som tabellrader. Rekkefolgen arves fra
// lesSyklus (sortert filnavn) og er del av determinisme-kontrakten.
function seksjon(okrer, type, erCommitted) {
@ -65,9 +70,7 @@ function seksjon(okrer, type, erCommitted) {
const krer = okr.krer.filter((kr) => kr.type === type);
if (krer.length === 0) continue;
antallKr += krer.length;
linjer.push(`### ${okr.tittel}`, '', ...TABELLHODE);
for (const kr of krer) linjer.push(krRad(kr, erCommitted));
linjer.push('');
linjer.push(`### ${okr.tittel}`, '', ...krTabell(krer, erCommitted), '');
}
return { linjer, antallKr };
}
@ -94,6 +97,36 @@ function aspirationalSnitt(okrer) {
return `**Snitt aspirational: ${snitt.toFixed(2)}** over ${scorer.length} KR${hale}.`;
}
// Felles inngangsvakt for alle rapportformene. En form som stille rapporterte
// en tom syklus ville produsert et styringsdokument uten innhold, som ser
// komplett ut.
function krevSyklus(syklus, hvor) {
if (!syklus || typeof syklus !== 'object' || !Array.isArray(syklus.okrer)) {
throw new Error(`${hvor}: forventet en syklus fra lesSyklus()`);
}
if (syklus.okrer.length === 0) {
throw new Error(`${hvor}: syklusen ${syklus.id ?? ''} inneholder ingen OKR`.trim());
}
}
// Skala- og confidence-avsnittet er felles for alle formene, ikke fordi det
// sparer linjer, men fordi de tre governance-invariantene ikke kan gjelde bare
// den ene rapporten mottakeren tilfeldigvis leser.
const SKALAFORKLARING = [
// Governance-invariant 3: skalaen forklares, og score presenteres ALDRI som
// prosent maaloppnaaelse -- en leser som tror 0.7 betyr "70 % av maalet"
// trekker feil konklusjon om baade ambisjonsniva og resultat.
'Score er en andel paa skalaen 0 til 1.0, beregnet som',
'(naa - baseline) / (target - baseline). Den er ikke prosent maaloppnaaelse.',
`Et KR der target er lik baseline har ingen definert andel og staar som ${UDEFINERT}.`,
'',
'Confidence fylles ut av /okr:sporing. Den utledes ikke av tallene her --',
'confidence er en sannsynlighetsvurdering, ikke en funksjon av score.',
'',
];
const klokke = (opts) => opts.naa || process.env.OKR_NOW || new Date().toISOString();
/**
* Bygger tertialrapporten for en syklus lest av lesSyklus().
*
@ -103,14 +136,9 @@ function aspirationalSnitt(okrer) {
* @returns {string} markdown
*/
export function tertialrapport(syklus, opts = {}) {
if (!syklus || typeof syklus !== 'object' || !Array.isArray(syklus.okrer)) {
throw new Error('tertialrapport: forventet en syklus fra lesSyklus()');
}
if (syklus.okrer.length === 0) {
throw new Error(`tertialrapport: syklusen ${syklus.id ?? ''} inneholder ingen OKR`.trim());
}
krevSyklus(syklus, 'tertialrapport');
const naa = opts.naa || process.env.OKR_NOW || new Date().toISOString();
const naa = klokke(opts);
const committed = seksjon(syklus.okrer, 'committed', true);
const aspirational = seksjon(syklus.okrer, 'aspirational', false);
@ -124,16 +152,7 @@ export function tertialrapport(syklus, opts = {}) {
'',
`Generert: ${naa}`,
'',
// Governance-invariant 3: skalaen forklares, og score presenteres ALDRI som
// prosent maaloppnaaelse -- en leser som tror 0.7 betyr "70 % av maalet"
// trekker feil konklusjon om baade ambisjonsniva og resultat.
'Score er en andel paa skalaen 0 til 1.0, beregnet som',
'(naa - baseline) / (target - baseline). Den er ikke prosent maaloppnaaelse.',
`Et KR der target er lik baseline har ingen definert andel og staar som ${UDEFINERT}.`,
'',
'Confidence fylles ut av /okr:sporing. Den utledes ikke av tallene her --',
'confidence er en sannsynlighetsvurdering, ikke en funksjon av score.',
'',
...SKALAFORKLARING,
];
if (committed.antallKr > 0) {
@ -164,3 +183,116 @@ export function tertialrapport(syklus, opts = {}) {
return `${ut.join('\n').replace(/\n+$/, '')}\n`;
}
// --- Aarsrapport del III (steg 11) ---
//
// Del III «Aarets aktiviteter og resultater» er hovedplassen for OKR i den
// statlige aarsrapporten (okr-offentlig-governance.md:125-133).
//
// ANTIPATTERNET generatoren maa unngaa, ordrett fra primaerkilden --
// Riksrevisjonen (2020), «Undersoekelse av etats- og virksomhetsstyringen av
// Norsk institutt for biooekonomi (NIBIO)», del av Dokument 1 (2020-2021) s. 107:
// framstillingen «synliggjoer i mindre grad NIBIOs analyser av maaloppnaaelsen og
// framstaar som et oeyeblikksbilde av NIBIOs aktiviteter og resultater». Saken ble
// senere avsluttet etter forbedringer i maal- og resultatstyringen (gjengitt i
// DFOe-notat 2026:2 s. 48) -- funnet er en historisk dokumentert svakhet
// forvaltningen har rettet, ikke gjeldende kritikk av NIBIO.
//
// Konsekvensen for koden er konkret: en generator som bare dumper KR-tabeller
// PRODUSERER nettopp det oeyeblikksbildet. Den reserverer derfor plass til
// vurderingen av maaloppnaaelse per Objective -- og fyller den aldri selv, av
// samme grunn som den ikke setter confidence: vurderingen krever skjoenn.
const VURDERINGSFELT = [
'[Fylles ut av virksomheten: analysen av maaloppnaaelsen for dette Objectivet --',
'hva tallene over betyr, hva som forklarer avvikene, og hva som er laert.',
'Generatoren fyller ikke feltet; en vurdering av maaloppnaaelse krever skjoenn.',
'Uten analysen staar del III igjen som et oeyeblikksbilde av aktiviteter og',
'resultater, som er nettopp det revisjonen har paapekt. Slett klammene naar',
'feltet er fylt ut.]',
];
// DFOe-notat 2026:2 kap. 5.3.3 (s. 61): virksomhetene «skal planlegge med baade
// ettaarig og flerarig perspektiv», men «i tildelingsbrevene og aarsrapportene vi
// har sett paa omtales imidlertid i liten grad det ettaarige i et flerarig
// perspektiv». Generatoren PEKER derfor paa materialet fra tidligere sykluser --
// den regner ikke trend paa tvers av dem. Historikk-filene baerer retrospektiv-
// prosa, ikke KR-tall; en beregnet utvikling ville vaert oppdiktet.
function flerarigSeksjon(historikk) {
if (historikk.length === 0) return [];
return [
'## Flerarig perspektiv',
'',
'Virksomheten skal planlegge med baade ettaarig og flerarig perspektiv.',
'Materialet fra tidligere sykluser ligger i historikk-katalogen:',
'',
...historikk.map((h) => `- ${h.tittel} (${h.fil})`),
'',
'[Fylles ut av virksomheten: utviklingen sett over tid. Generatoren viser',
'hvilket materiale som finnes, og sammenligner ikke sykluser den ikke har',
'tall fra.]',
'',
];
}
/**
* Bygger aarsrapportens del III for en syklus lest av lesSyklus().
*
* @param {object} syklus struktur fra lesSyklus()
* @param {{naa?: string, historikk?: Array<{tittel: string, fil: string}>}} [opts]
* historikk leses av kalleren (scripts/syklus-rapport.mjs), ikke her -- modulen
* holdes fri for disk, saa formen er testbar uten et tre paa filsystemet.
* @returns {string} markdown
*/
export function arsrapportDelIII(syklus, opts = {}) {
krevSyklus(syklus, 'arsrapportDelIII');
const naa = klokke(opts);
const historikk = Array.isArray(opts.historikk) ? opts.historikk : [];
const antallKr = syklus.okrer.reduce((n, okr) => n + okr.krer.length, 0);
if (antallKr === 0) {
throw new Error(`arsrapportDelIII: syklusen ${syklus.id} inneholder ingen KR aa rapportere`);
}
const ut = [
`# Aarsrapport del III - Aarets aktiviteter og resultater (${syklus.id})`,
'',
`Generert: ${naa}`,
'',
...SKALAFORKLARING,
];
// Per Objective, fordi del III dokumenterer maaloppnaaelse mot tildelingsbrevets
// krav -- og kravene henger paa Objectives, ikke paa rapportformen.
for (const okr of syklus.okrer) {
ut.push(`## ${okr.tittel}`, '');
for (const [type, erCommitted, overskrift] of [
['committed', true, '### Committed Key Results'],
['aspirational', false, '### Aspirational Key Results'],
]) {
const krer = okr.krer.filter((kr) => kr.type === type);
if (krer.length === 0) continue;
ut.push(overskrift, '', ...krTabell(krer, erCommitted), '');
}
ut.push('### Vurdering av maaloppnaaelse', '', ...VURDERINGSFELT, '');
}
// Snittet hoerer PAA TVERS av Objectives (okr-framework.md:557) og staar derfor
// ikke i noen enkelt Objective-seksjon. Committed har bevisst ingen motpart:
// et snitt av binaere krav er ikke en stoerrelse.
const snitt = aspirationalSnitt(syklus.okrer);
if (snitt) {
ut.push(
'## Aspirational maaloppnaaelse paa tvers av Objectives',
'',
'Aspirational KR forventes aa lande rundt 0.7 med hoey varians, og vurderes',
'paa snittet -- ikke paa ett enkelt KR.',
'',
snitt,
'',
);
}
ut.push(...flerarigSeksjon(historikk));
return `${ut.join('\n').replace(/\n+$/, '')}\n`;
}