feat(okr): deterministisk heading-split for ingestion (SC idempotens) [skip-docs]
This commit is contained in:
parent
ca6c89e7be
commit
136093da38
2 changed files with 173 additions and 0 deletions
96
lib/innboks-split.mjs
Normal file
96
lib/innboks-split.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
77
tests/innboks-split.test.mjs
Normal file
77
tests/innboks-split.test.mjs
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue