From 136093da3828ba32c99489f4844a69d6bf1dc62b Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 30 Jun 2026 12:54:57 +0200 Subject: [PATCH] feat(okr): deterministisk heading-split for ingestion (SC idempotens) [skip-docs] --- lib/innboks-split.mjs | 96 ++++++++++++++++++++++++++++++++++++ tests/innboks-split.test.mjs | 77 +++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 lib/innboks-split.mjs create mode 100644 tests/innboks-split.test.mjs diff --git a/lib/innboks-split.mjs b/lib/innboks-split.mjs new file mode 100644 index 0000000..e44e255 --- /dev/null +++ b/lib/innboks-split.mjs @@ -0,0 +1,96 @@ +// innboks-split.mjs +// Step 4: deterministisk heading-split av (allerede konvertert) markdown til +// konsepter. REN funksjon -- fil-settet er en funksjon av input alene (INGEN +// LLM, ingen veggklokke), saa run1 og run2 gir byte-identisk resultat (SC +// idempotens by construction). Split paa #/## (MarkdownHeaderTextSplitter- +// moenster): hver #/##-heading starter et nytt konsept; ### og dypere forblir +// body-innhold. Dokument uten brukbare overskrifter -> fallback "ett dokument = +// ett konsept". Innhold foer foerste heading bevares som ledende konsept (ingen +// datatap). Zero npm dependencies. + +// Kebab-slug: lowercase, strip diakritika defensivt, ikke-alfanum -> '-', trim. +// Never-empty fallback 'konsept' (deterministisk; aldri tomt filnavn). +function slugify(text) { + const s = String(text) + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return s || 'konsept'; +} + +// #/## heading-linje -> { level, title }. ### og dypere matcher IKKE: '#{1,2}' +// kan ikke etterfoelges av '\s+' naar tredje tegn er '#', saa de forblir body. +const HEADING_RE = /^(#{1,2})\s+(.+?)\s*$/; + +// markdown -> Concept[]; opts.sourceSlug identifiserer kilde-dokumentet (brukt +// som tittel/slug for flat-fallback + preamble). Concept-form: +// { sourceSlug, title, slug, level, body } +export function splitConcepts(markdown, { sourceSlug } = {}) { + const src = + typeof sourceSlug === 'string' && sourceSlug.trim() !== '' ? sourceSlug : 'konsept'; + const normalized = String(markdown).replace(/\r\n/g, '\n'); + const lines = normalized.split('\n'); + + // Segmenter: ett per #/##-heading. Linjer foer foerste heading -> preamble. + const segments = []; + const preambleLines = []; + let current = null; + for (const line of lines) { + const m = line.match(HEADING_RE); + if (m) { + current = { title: m[2].trim(), level: m[1].length, lines: [] }; + segments.push(current); + } else if (current) { + current.lines.push(line); + } else { + preambleLines.push(line); + } + } + + const concepts = []; + const seen = new Map(); + const uniqueSlug = (base) => { + const n = seen.get(base) || 0; + seen.set(base, n + 1); + return n === 0 ? base : `${base}-${n + 1}`; + }; + + // Reelt innhold foer foerste heading -> ledende konsept (titulert av kilde). + if (preambleLines.join('').trim() !== '') { + concepts.push({ + sourceSlug: src, + title: src, + slug: uniqueSlug(slugify(src)), + level: 1, + body: preambleLines.join('\n').trim(), + }); + } + + // Flat dokument (ingen #/##): hele dokumentet = ett konsept. + if (segments.length === 0) { + if (concepts.length === 0) { + concepts.push({ + sourceSlug: src, + title: src, + slug: uniqueSlug(slugify(src)), + level: 1, + body: normalized.trim(), + }); + } + return concepts; + } + + for (const seg of segments) { + concepts.push({ + sourceSlug: src, + title: seg.title, + slug: uniqueSlug(slugify(seg.title)), + level: seg.level, + body: seg.lines.join('\n').trim(), + }); + } + + return concepts; +} diff --git a/tests/innboks-split.test.mjs b/tests/innboks-split.test.mjs new file mode 100644 index 0000000..c8d6d0b --- /dev/null +++ b/tests/innboks-split.test.mjs @@ -0,0 +1,77 @@ +// innboks-split.test.mjs +// Step 4 (SC idempotens): deterministisk heading-split av (allerede konvertert) +// markdown til konsepter. splitConcepts er en REN funksjon -- samme input gir +// identisk konsept-sett + filnavn (kebab-slug). Header-tekst -> title; flat +// dokument (ingen #/##) -> ett konsept; slug-kollisjon -> stabil numerisk +// disambiguering; preamble foer foerste heading bevares (ingen datatap). +// Direkte import (zero npm deps). Moenster: tests/frontmatter.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { splitConcepts } from '../lib/innboks-split.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIX = join(HERE, 'fixtures', 'inbox-converted'); +const dokA = readFileSync(join(FIX, 'dok-a.md'), 'utf8'); +const dokB = readFileSync(join(FIX, 'dok-b.md'), 'utf8'); + +test('splitConcepts: deterministisk -- samme input 2x gir identisk konsept-sett', () => { + const a1 = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const a2 = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + assert.deepEqual(a1, a2, 'ren funksjon: identisk output for identisk input'); +}); + +test('splitConcepts: #/## heading -> ett konsept per seksjon, header-tekst -> title', () => { + const concepts = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + assert.equal(concepts.length, 2, 'dok-a har en H1 + en H2 -> 2 konsepter'); + assert.equal(concepts[0].title, 'Tildelingsbrev 2026'); + assert.equal(concepts[0].slug, 'tildelingsbrev-2026'); + assert.equal(concepts[0].level, 1); + assert.equal(concepts[1].title, 'Oppfolging mot Virksomhetsplan 2026'); + assert.equal(concepts[1].level, 2); + // body baerer seksjons-innholdet (uten heading-linja). + assert.match(concepts[0].body, /Tildelingsbrevet gir overordnede/); + assert.doesNotMatch(concepts[0].body, /^#/, 'heading-linja er ikke i body'); +}); + +test('splitConcepts: slug stripper klammer/parenteser (adversarial heading)', () => { + const concepts = splitConcepts(dokB, { sourceSlug: 'dok-b' }); + assert.equal(concepts.length, 2); + assert.equal(concepts[1].title, 'Mal og rammer (2026) [utkast]'); + assert.equal(concepts[1].slug, 'mal-og-rammer-2026-utkast', 'klammer/parenteser strippet fra slug'); +}); + +test('splitConcepts: ### og dypere er IKKE split-punkt (forblir body)', () => { + const md = '# Topp\n\nIntro.\n\n### Underseksjon\n\nDetalj.'; + const concepts = splitConcepts(md, { sourceSlug: 'dyp' }); + assert.equal(concepts.length, 1, 'kun H1 splitter; ### forblir i body'); + assert.match(concepts[0].body, /### Underseksjon/, '### bevart som body-innhold'); +}); + +test('splitConcepts: flat dokument (ingen #/##) -> ett konsept', () => { + const flat = 'Bare en paragraf uten overskrift.\n\nEnda en linje.'; + const concepts = splitConcepts(flat, { sourceSlug: 'notat' }); + assert.equal(concepts.length, 1); + assert.equal(concepts[0].slug, 'notat', 'flat-fallback slug fra sourceSlug'); + assert.match(concepts[0].body, /Bare en paragraf/); +}); + +test('splitConcepts: slug-kollisjon -> stabil numerisk disambiguering', () => { + const md = '# Samme tittel\n\nA\n\n## Samme tittel\n\nB'; + const concepts = splitConcepts(md, { sourceSlug: 'kollisjon' }); + assert.equal(concepts.length, 2); + assert.equal(concepts[0].slug, 'samme-tittel'); + assert.equal(concepts[1].slug, 'samme-tittel-2', 'andre forekomst faar -2 suffiks'); +}); + +test('splitConcepts: preamble foer foerste heading bevares som ledende konsept', () => { + const md = 'Forord uten overskrift.\n\n# Ekte overskrift\n\nKropp.'; + const concepts = splitConcepts(md, { sourceSlug: 'med-forord' }); + assert.equal(concepts.length, 2, 'preamble + en heading -> 2 konsepter (ingen datatap)'); + assert.equal(concepts[0].slug, 'med-forord'); + assert.match(concepts[0].body, /Forord uten overskrift/); + assert.equal(concepts[1].title, 'Ekte overskrift'); +});