feat(okr): deterministisk frontmatter-projeksjon + vokab-snap + mtime-timestamp (SC idempotens) [skip-docs]
This commit is contained in:
parent
136093da38
commit
d011fb1922
2 changed files with 199 additions and 0 deletions
75
lib/innboks-frontmatter.mjs
Normal file
75
lib/innboks-frontmatter.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// innboks-frontmatter.mjs
|
||||
// Step 5: deterministisk frontmatter-projeksjon for et konsept (fra splitConcepts).
|
||||
// Regel-basert (INGEN LLM): type/tags utledes fra title+sti og snappes mot det
|
||||
// lukkede vokabularet (okf-vocab); resource = original relativ sti (kanonisk navn);
|
||||
// description = foerste ikke-tomme avsnitt (whitespace kollapset, trunkert);
|
||||
// timestamp = ISO-8601 av original-fil-mtime (IKKE veggklokke -> idempotens by
|
||||
// construction per maskin); kilde:innboks provenans-markoer (extension key for
|
||||
// retrieval-rangering). Beregner ogsaa konseptets mal-sti
|
||||
// (concept.destRel = routeLevel(type)/slug.md) FOER relasjons-steget (Step 6),
|
||||
// jf. Pass-2 ordering-fiks (Revisions #21). Serialiserer via writeFrontmatter
|
||||
// (additiv array-gren fra Step 3). Zero npm dependencies.
|
||||
|
||||
import { writeFrontmatter } from './frontmatter.mjs';
|
||||
import { snapType, snapTags, routeLevel, TYPE_VOCAB, TAGS_VOCAB } from './okf-vocab.mjs';
|
||||
|
||||
const DESCRIPTION_MAX = 240;
|
||||
|
||||
// Vokab sortert lengst-foerst saa "Overordnede OKR" matcher foer "OKR".
|
||||
const TYPE_BY_LENGTH = [...TYPE_VOCAB].sort((a, b) => b.length - a.length);
|
||||
|
||||
// Regel-utledning: foerste vokab-term som forekommer i title+sti -> kanonisk type.
|
||||
function deriveType(title, sourcePath) {
|
||||
const hay = `${title} ${sourcePath}`.toLowerCase();
|
||||
for (const t of TYPE_BY_LENGTH) {
|
||||
if (hay.includes(t.toLowerCase())) return snapType(t);
|
||||
}
|
||||
return snapType('');
|
||||
}
|
||||
|
||||
// Tags: vokab-termer som forekommer i title (vokab-rekkefolge bevart), saa snappet.
|
||||
function deriveTags(title) {
|
||||
const hay = String(title).toLowerCase();
|
||||
return snapTags(TAGS_VOCAB.filter((t) => hay.includes(t.toLowerCase())));
|
||||
}
|
||||
|
||||
// Foerste ikke-tomme avsnitt, whitespace kollapset, trunkert deterministisk.
|
||||
function deriveDescription(body) {
|
||||
const para = String(body)
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.replace(/\s+/g, ' ').trim())
|
||||
.find((p) => p !== '');
|
||||
if (!para) return '';
|
||||
return para.length > DESCRIPTION_MAX ? `${para.slice(0, DESCRIPTION_MAX).trimEnd()}...` : para;
|
||||
}
|
||||
|
||||
// concept (fra splitConcepts) -> beriket konsept med OKF-frontmatter + destRel.
|
||||
export function projectFrontmatter(concept, { sourcePath, sourceMtime } = {}) {
|
||||
const resource = String(sourcePath ?? '');
|
||||
const type = deriveType(concept.title, resource);
|
||||
const description = deriveDescription(concept.body);
|
||||
const tags = deriveTags(concept.title);
|
||||
const timestamp = new Date(sourceMtime).toISOString();
|
||||
const destRel = `${routeLevel(type)}/${concept.slug}.md`;
|
||||
|
||||
// Kanonisk noekkel-rekkefolge; description/tags utelates naar tomme.
|
||||
const fields = { type, resource, title: concept.title };
|
||||
if (description) fields.description = description;
|
||||
if (tags.length > 0) fields.tags = tags;
|
||||
fields.timestamp = timestamp;
|
||||
fields.kilde = 'innboks';
|
||||
|
||||
const frontmatter = writeFrontmatter(fields);
|
||||
|
||||
return {
|
||||
...concept,
|
||||
type,
|
||||
resource,
|
||||
description,
|
||||
tags,
|
||||
timestamp,
|
||||
kilde: 'innboks',
|
||||
destRel,
|
||||
frontmatter,
|
||||
};
|
||||
}
|
||||
124
tests/innboks-frontmatter.test.mjs
Normal file
124
tests/innboks-frontmatter.test.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// innboks-frontmatter.test.mjs
|
||||
// Step 5 (SC idempotens): deterministisk frontmatter-projeksjon. projectFrontmatter
|
||||
// utleder type/tags regel-basert (INGEN LLM) og snapper mot lukket vokab; resource =
|
||||
// original relativ sti; description = foerste ikke-tomme avsnitt; timestamp = ISO av
|
||||
// original-mtime (IKKE veggklokke); kilde:innboks; og fester concept.destRel =
|
||||
// routeLevel(type)/slug.md FOER relasjons-steget. Asserterer paa parseFrontmatter().
|
||||
// get() (aldri substring for verdier). Moenster: tests/oppsett-okf-write.test.mjs:93.
|
||||
|
||||
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 { parseFrontmatter } from '../lib/frontmatter.mjs';
|
||||
import { TYPE_VOCAB, routeLevel } from '../lib/okf-vocab.mjs';
|
||||
import { splitConcepts } from '../lib/innboks-split.mjs';
|
||||
import { projectFrontmatter } from '../lib/innboks-frontmatter.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 MTIME = new Date('2026-03-15T08:30:00.000Z');
|
||||
const OPTS = { sourcePath: 'innboks/dok-a.txt', sourceMtime: MTIME };
|
||||
|
||||
// Frontmatter-noekler i rekkefolge (kun linjestart-noekler; list-elementer hoppes over).
|
||||
function fmKeyOrder(fm) {
|
||||
const { raw } = parseFrontmatter(fm);
|
||||
return (raw || '')
|
||||
.split('\n')
|
||||
.map((l) => {
|
||||
const m = l.match(/^([a-z_]+):/);
|
||||
return m ? m[1] : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
test('projectFrontmatter: type snappes til lukket vokab; keyword i title -> kanonisk type', () => {
|
||||
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
const e = projectFrontmatter(c1, OPTS);
|
||||
const { get } = parseFrontmatter(e.frontmatter);
|
||||
assert.ok(TYPE_VOCAB.includes(get('type')), 'type i lukket vokab');
|
||||
assert.equal(get('type'), 'Tildelingsbrev', 'keyword "Tildelingsbrev" i title -> kanonisk type');
|
||||
});
|
||||
|
||||
test('projectFrontmatter: ukjent emne -> safe default Dokument (negativ vokab-sjekk)', () => {
|
||||
const concept = {
|
||||
sourceSlug: 'x',
|
||||
title: 'Helt annerledes emne',
|
||||
slug: 'helt-annerledes-emne',
|
||||
level: 1,
|
||||
body: 'Innhold uten vokab-noekkelord.',
|
||||
};
|
||||
const e = projectFrontmatter(concept, { sourcePath: 'innboks/x.txt', sourceMtime: MTIME });
|
||||
assert.equal(parseFrontmatter(e.frontmatter).get('type'), 'Dokument');
|
||||
assert.doesNotMatch(
|
||||
e.frontmatter,
|
||||
/^type: (Tildelingsbrev|Virksomhetsplan|OKR)$/m,
|
||||
'ikke feil-snappet til en foeringstype',
|
||||
);
|
||||
});
|
||||
|
||||
test('projectFrontmatter: resource = original relativ sti (kanonisk navn, ikke "source")', () => {
|
||||
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
const e = projectFrontmatter(c1, OPTS);
|
||||
const { get } = parseFrontmatter(e.frontmatter);
|
||||
assert.equal(get('resource'), 'innboks/dok-a.txt');
|
||||
assert.equal(get('source'), null, 'noekkelen heter resource, ikke source');
|
||||
});
|
||||
|
||||
test('projectFrontmatter: description deterministisk (2 kjoeringer byte-identiske, foerste avsnitt)', () => {
|
||||
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
const e1 = projectFrontmatter(c1, OPTS);
|
||||
const e2 = projectFrontmatter(c1, OPTS);
|
||||
assert.equal(e1.frontmatter, e2.frontmatter, 'byte-identisk projeksjon');
|
||||
assert.match(parseFrontmatter(e1.frontmatter).get('description'), /Tildelingsbrevet gir overordnede/);
|
||||
});
|
||||
|
||||
test('projectFrontmatter: lang body -> description trunkeres deterministisk', () => {
|
||||
const longBody = 'A'.repeat(500);
|
||||
const concept = { sourceSlug: 'lang', title: 'Lang', slug: 'lang', level: 1, body: longBody };
|
||||
const e = projectFrontmatter(concept, { sourcePath: 'innboks/lang.txt', sourceMtime: MTIME });
|
||||
const desc = parseFrontmatter(e.frontmatter).get('description');
|
||||
assert.ok(desc.length <= 244, 'trunkert til <= 240 + ellipsis');
|
||||
assert.match(desc, /\.\.\.$/, 'ellipsis-markoer ved trunkering');
|
||||
});
|
||||
|
||||
test('projectFrontmatter: timestamp = ISO av sourceMtime (idempotent paa tvers av veggklokke)', () => {
|
||||
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
const e = projectFrontmatter(c1, OPTS);
|
||||
assert.equal(parseFrontmatter(e.frontmatter).get('timestamp'), MTIME.toISOString());
|
||||
});
|
||||
|
||||
test('projectFrontmatter: kilde:innboks provenans-markoer alltid satt', () => {
|
||||
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
const e = projectFrontmatter(c1, OPTS);
|
||||
assert.equal(parseFrontmatter(e.frontmatter).get('kilde'), 'innboks');
|
||||
assert.equal(e.kilde, 'innboks', 'kilde ogsaa paa konsept-objektet');
|
||||
});
|
||||
|
||||
test('projectFrontmatter: concept.destRel satt + konsistent med routeLevel(type)', () => {
|
||||
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
const e = projectFrontmatter(c1, OPTS);
|
||||
assert.ok(e.destRel, 'destRel satt FOER relasjons-steget');
|
||||
assert.equal(e.destRel, `${routeLevel(e.type)}/${c1.slug}.md`);
|
||||
assert.equal(e.destRel, 'strategisk-kontekst/tildelingsbrev-2026.md', 'Tildelingsbrev -> strategisk-kontekst');
|
||||
});
|
||||
|
||||
test('projectFrontmatter: kanonisk noekkel-rekkefolge (type/resource/title/desc/tags/timestamp/kilde)', () => {
|
||||
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
const e = projectFrontmatter(c1, OPTS);
|
||||
const order = fmKeyOrder(e.frontmatter);
|
||||
const canon = ['type', 'resource', 'title', 'description', 'tags', 'timestamp', 'kilde'];
|
||||
assert.deepEqual(order, canon.filter((k) => order.includes(k)), 'present keys i kanonisk rekkefolge');
|
||||
assert.equal(order[0], 'type', 'type foerst');
|
||||
assert.equal(order[order.length - 1], 'kilde', 'kilde sist');
|
||||
});
|
||||
|
||||
test('projectFrontmatter: tags er multi-linje liste (writeFrontmatter array-gren) + i vokab', () => {
|
||||
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
const e = projectFrontmatter(c1, OPTS);
|
||||
assert.match(e.frontmatter, /^tags:$/m, 'multi-linje tags-noekkel');
|
||||
assert.match(e.frontmatter, /^ {2}- Tildelingsbrev$/m, 'tag-element i vokab');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue