diff --git a/config/edition-state.template.json b/config/edition-state.template.json index 83692cd..584cc7e 100644 --- a/config/edition-state.template.json +++ b/config/edition-state.template.json @@ -26,6 +26,7 @@ "articleStatusValues": ["pending", "in-progress", "locked", "scheduled"], "editorialReview": "Per-article editorial-review record written by Step 5.5 (editorial-review phase). Runs AFTER fact-check (Step 5) and BEFORE the persona sweep (Step 6): the editorial-reviewer agent judges CRAFT (prose-craft + narrative-architecture), not reader-response, mirroring the Maskinrommet skrivekontrakt §C2. The report (≤10 flags, each with kategori P1–P5/A1–A5, quote/line-ref, direction, severity BLOCK/REWORK/NICE) is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { reportPath, flagCount, byAxis: { prosa, arkitektur }, bySeverity: { block, rework, nice }, foldedIn, waived, status }. status ladder: pending → reviewed → folded. null until Step 5.5 runs. This is the craft companion to factcheckLog (truth) and personaSweep (response).", "visualAssets": "Per-article visual-asset record written by Step 7.5 (visual-assets phase). Runs BEFORE lock because render/build-linkedin.mjs picks up linkedin/NN/cover.png + the edition-config credit/caption when it builds POST.html — generating images after lock would force a re-render. Shape: { format: \"standard\" | \"carousel\"; cover: { brief, route, candidates[], approved, status }; figures: [ { id, brief, placement, status } ]; carousel: null | { source, pdf, status } }. format \"standard\" = cover + optional inline figures (cover.png is mandatory per the KTG cover-directive); format \"carousel\" = typografisk deck via render/build-carousel.mjs instead of cover+inline (cover/figures stay empty). route: \"mcp-image\" (default, via mcp__mcp-image__generate_image) | \"external\" (DALL·E / Midjourney / photographer → linkedin/NN/cover-raw.png). status ladder: pending → briefed → generated → approved. candidates[] holds the cover-v-kandidat.png attempts; approved is the fixed approved name (\"cover.png\") once the operator-gate passes. figures[].id = \"fig1\"..; placement = section reference in NN-utkast.md (figures are referenced in the draft via ![alt](linkedin/NN/figN.png) and uploaded manually in the LinkedIn editor — build-linkedin.mjs does NOT embed them). Naming convention: cover.png (approved, fixed — what build-linkedin.mjs reads) | cover-v-kandidat.png (attempts) | cover-raw.png (optional external pre-edit source) | fig.png (inline). credit + caption are recorded in /linkedin/image-credit-caption.md and flow into edition-config.json coverCredit + captions[NN].", + "livedSpecifics": "Per-article lived-specifics binding (Fix #2 slice 2, kilde-så-draft). A slot-map binding each LOAD-BEARING key-point (or, later, section) of this edition to either a real specific from the specifics-bank OR an explicit escape decision — so a draft starts grounded in the operator's real material instead of inventing plausible filler. Resolved at Step 1.5 (lived-specifics extraction, BETWEEN brief (Step 1) and research (Step 2); wired in slice 3) via a guided interview that REFUSES vague answers. THE GATE (drømme-spec G4, «vaghet avvises», enforced at the Step 2.5 skeleton-gate): every slot must resolve to \"specific\" (grounded), \"abstrakt\" (deliberately abstract here, with a rationale), or \"ekstern\" (backed by research, not lived material) — a slot left \"unresolved\" is the vagueness that BLOCKs. Numbers stay guilty-until-checked: a slot backed by an UNVERIFIED number is allowed but WARNED (regel 6/7), so Step 2 research verifies it before the draft asserts it. The deterministic validator + the rendered NN-kilder.md artifact live in scripts/specifics-bank (binding.ts/kilder.ts; `node --import tsx src/cli.ts validate-binding|render-kilder --edition `). Shape: { slots: [ { slotId, kind: \"key-point\" | \"section\", label, binding: ({ type: \"specific\", specificId } | { type: \"abstrakt\", rationale } | { type: \"ekstern\", source? } | { type: \"unresolved\" }) } ], status: \"pending\" | \"bound\" }. Default { slots: [], status: \"pending\" }: populated on first Step 1.5. Companion artifact: /linkedin/NN-kilder.md, rendered from this + the bank (regenerated, never hand-edited). This is the per-edition consumer of the global specifics-bank.", "personas": "Per-article resolved reader-persona set (input config), written/confirmed in Step 1. This makes personas configurable PER ARTIFACT, not just from one global plugin library: Step 1 resolves them in order — (1) already present here → use as-is; (2) /linkedin/personas.md (per-series file) → load; (3) plugin config/personas.local.md (or personas.template.md) library → select a subset; (4) none/insufficient → DEFINE interactively via AskUserQuestion. Exactly one entry has tier \"primær\" (the rest \"sekundær\"); «primær trumfer» on conflict. This set feeds BOTH the in-session sweep (Step 6) and the headless package (Step 6.5 / persona-reviewer). Each entry: { name, tier: \"primær\" | \"sekundær\", rolle, avkobler, overbeviser, ekspertise, sjargong, source: \"edition-state\" | \"series-file\" | \"plugin-library\" | \"interactive\" }. Default []: resolved on first Step 1.", "headlessReview": "Per-article headless-review record written by Step 6.5 (headless-review phase). Runs AFTER the in-session persona sweep (Step 6) and BEFORE lock (Step 8), on a FROZEN snapshot of the publish-ready (or pivoted) draft, fanned out from the command layer (foreground) or invoked standalone via /linkedin:headless-review in a fresh/cold session. Five archetypes judge independently with NO drafting-session context: content-reviewer (argument integrity), language-reviewer (Norwegian language), fact-reviewer (cold re-verification incl. claims a late pivot bolted on), persona-reviewer mode=resonans (per active persona), persona-reviewer mode=konverter (primær, hook only). The consolidated report is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { frozenDraft, reviewers: { content, language, fact, personaResonance, personaConversion } (each { reportPath, summary, status }), consolidatedReport, foldedIn, waived, status }. status ladder: pending → run → folded. null until Step 6.5 runs. This is the adversarial-independence companion to the in-session gates (editorialReview, personaSweep, factcheckLog) — deliberately redundant: a cold reader catches what the framing-biased in-session pass missed.", "pivots": "Per-article pivot log (Endring 9c). A pivot is a substantive change to a draft AFTER a gate had already cleared — e.g. a new argument anchor / section added late (the Del 4 Security Champions case: +~530 words, 2 new sections, +42 %). Each /linkedin:pivot invocation appends one entry and moves currentPhase back so the cleared gates (Steps 5–6.5) re-run on the pivoted version before lock. Heuristic (documented, checked at the Step 8 lock precondition): if the current draft's word count differs > 20 % from the version that last cleared Step 6, OR it has > 2 new sections, a pivot-reopen is suggested/required. Each entry: { timestamp, reason, fromPhase, toPhase, wordCountBefore, wordCountAfter, deltaPct, newSections, gatesToRerun: [phase…] }. Default [].", @@ -47,6 +48,10 @@ "phase": "load-context", "status": "pending", "personas": [], + "livedSpecifics": { + "slots": [], + "status": "pending" + }, "immutableRules": null, "factcheckLog": null, "editorialReview": null, diff --git a/docs/fix2/slice2-binding.md b/docs/fix2/slice2-binding.md new file mode 100644 index 0000000..fe4d582 --- /dev/null +++ b/docs/fix2/slice2-binding.md @@ -0,0 +1,118 @@ +# Fix #2 slice 2 — per-edition lived-specifics binding + +> **Kilde-så-draft.** Slice 1 built the global *store* (`scripts/specifics-bank`). +> Slice 2 builds the per-EDITION *binding*: the bridge that ties one edition's +> load-bearing claims to real material before any prose exists. Slice 3 wires it +> into `/linkedin:newsletter` (Step 1.5 + the skeleton-gate); slice 4 touches the +> maskinrommet contract. This doc is the design record slice 3 implements against. + +## Why (retning §3, drømme-spec «Lived-Specifics Extraction» `[CORE]`) + +Sourcing, not styling, is where authenticity is won. A draft that starts from the +operator's real number / named case / what-actually-broke is forwardable; a draft +that invents plausible filler is hollow, and every downstream voice gate then +polishes hollow content. So **before prose**, each load-bearing claim of an edition +must be bound to real inventory — or the operator must *consciously* decide it does +not need a concrete instance. The binding is that ledger; vagueness is rejected. + +## The slot-map (`articles.NN.livedSpecifics` in `edition-state.json`) + +A per-article field, resolved at Step 1.5 (between brief and research). Each slot +is one load-bearing key-point (`kind: "key-point"`) or, after the skeleton exists, +a section (`kind: "section"` — the same shape serves both, no migration): + +```jsonc +"livedSpecifics": { + "slots": [ + { "slotId": "kp1", "kind": "key-point", "label": "", + "binding": { "type": "specific", "specificId": "" } }, + { "slotId": "kp2", "kind": "key-point", "label": "", + "binding": { "type": "abstrakt", "rationale": "" } }, + { "slotId": "kp3", "kind": "key-point", "label": "", + "binding": { "type": "ekstern", "source": "" } }, + { "slotId": "kp4", "kind": "key-point", "label": "", + "binding": { "type": "unresolved" } } + ], + "status": "pending" // pending → bound (set by the command when the gate passes) +} +``` + +### The gate — `validateBinding(livedSpecifics, bank)` (drømme-spec G4, «vaghet avvises») + +Deterministic, pure (`scripts/specifics-bank/src/binding.ts`). **BLOCK** iff any slot: + +| reason | when | +|--------|------| +| `unresolved` | binding type is `unresolved` — the vagueness the gate exists to reject | +| `dangling-specific` | a `specific` binding points at a `specificId` absent from the bank | +| `unjustified-abstrakt` | an `abstrakt` escape with a blank rationale (= vagueness wearing an escape hatch) | + +Otherwise **PASS**. Escape hatches are legitimate: `abstrakt` (with a rationale) +and `ekstern` (research-backed, not lived; `source` optional — Step 2 fills it). + +**WARN (does not block)** — regel 6/7: a slot backed by an **unverified `number`** +specific surfaces as `unverified-number`. Numbers stay guilty-until-checked; Step 2 +research must verify them before the draft asserts them as fact. + +`coverage` counts by binding TYPE (`total / specific / abstrakt / ekstern / +unresolved`); a dangling specific still counts under `specific` (the binding type +is `specific`, the *problem* is separately reported). Empty slot-map → PASS, total +0 — whether an edition *needs* ≥1 slot is the command layer's call (slice 3). + +## The `NN-kilder.md` artifact + +Read-only, deterministically **rendered** from `livedSpecifics` + the bank +(`scripts/specifics-bank/src/kilder.ts`, `renderKilder`) — regenerated like +POST.html, never hand-edited. Lives at `/linkedin/NN-kilder.md`. Per +load-bearing slot: binding type, the material (KTG's words, verbatim), provenance ++ capture date, topic tags, and the number-verification marker (`✓` / `⚠`); then a +coverage block + a «Tall som må verifiseres (regel 6/7)» line. The coverage block +and the number list are computed by `validateBinding`, so the artifact and the +skeleton-gate can never disagree. (Golden-tested; pure + dateless on purpose.) + +## CLI surface (the seam slice 3 calls) + +Run from the package dir (`tsx` lives there); pass absolute paths: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank" +node --import tsx src/cli.ts validate-binding --edition "/linkedin/edition-state.json" # exit 1 = BLOCK +node --import tsx src/cli.ts render-kilder --edition "/linkedin/edition-state.json" \ + --out "/linkedin/NN-kilder.md" +``` + +`--article ` overrides `currentArticle`; `--bank ` overrides the default +data-dir bank; `--json` emits machine output for `validate-binding`. A missing +`livedSpecifics` field is treated as an empty slot-map (back-compat with older +editions / the template default). + +## Step 2 (research) re-scope spec — for slice 3 to wire + +Today Step 2 decomposes the edition's key points into scoped research sub-questions +and triangulates verified notes. Once Step 1.5 + the slot-map exist, Step 2 is +**scoped by the binding** rather than by the raw key-points: + +1. **Fill the gaps, don't re-research the grounded.** Research targets slots bound + `ekstern` (and any still `unresolved` at entry) — the claims that explicitly + need an external source. Slots bound to a `specific` are already grounded in + lived material and do **not** need their substance re-sourced (research may + still corroborate, but it is not the gap). +2. **Verify KTG's own numbers (regel 6/7).** Every `unverified-number` warning from + `validateBinding` is a research target: confirm the operator's own figure + against a primary source where possible, then flip the bank specific + `unverified → verified` (`cli.ts add`/a verify path). A number that cannot be + verified stays `unverified` and is carried to the Step 5 fact-check sweep as 🟡 + — never silently asserted. +3. **Re-render after research.** When research resolves an `ekstern` source or a + number flips to verified, regenerate `NN-kilder.md` so the ledger stays current. + +Boundary: slice 2 ships the data model, the gate, the artifact renderer, and the +CLI. Slice 3 adds Step 1.5 (the elicitation interview + `add` to the bank + +populating the slot-map), wires `validate-binding` into the Step 2.5 skeleton-gate, +applies this Step 2 re-scope, and bumps the pipeline 17 → 18 phases. + +## Tests & verification + +`cd scripts/specifics-bank && npm test` (binding + kilder suites) and `npm run +build` (tsc) stay clean. The gate's three BLOCK reasons + the number WARN + the +golden `NN-kilder.md` render are all covered. diff --git a/scripts/specifics-bank/README.md b/scripts/specifics-bank/README.md index 0c06a23..d4225da 100644 --- a/scripts/specifics-bank/README.md +++ b/scripts/specifics-bank/README.md @@ -57,6 +57,28 @@ guilty-until-checked — regel 6/7) and `--source` to `manual`. recency (`capturedAt` desc); archived material is kept but never offered. - **Content is stored verbatim**; only the id is normalized. +## Per-edition binding (slice 2) + +The bank is the *global* inventory; an edition *binds* its load-bearing claims to +real material before any prose exists. That bridge lives here too: + +- `binding.ts` — the per-edition slot-map model + `validateBinding`, the + «vaghet avvises» gate (a slot must be `specific` / `abstrakt` / `ekstern`; an + `unresolved` slot, a dangling specific id, or an unjustified `abstrakt` → BLOCK; + an unverified-number-backed slot → WARN per regel 6/7). +- `kilder.ts` — `renderKilder`, the read-only `NN-kilder.md` sources artifact, + rendered deterministically from the slot-map + the bank. + +```bash +# Gate one edition's binding (exit 1 = BLOCK) +node --import tsx src/cli.ts validate-binding --edition "/linkedin/edition-state.json" +# Render the sources ledger +node --import tsx src/cli.ts render-kilder --edition "/linkedin/edition-state.json" --out "/linkedin/NN-kilder.md" +``` + +Design record + the Step 2 research re-scope spec: `docs/fix2/slice2-binding.md`. +The elicitation interview that POPULATES the slot-map (Step 1.5) is slice 3. + ## Test ```bash diff --git a/scripts/specifics-bank/src/binding.ts b/scripts/specifics-bank/src/binding.ts new file mode 100644 index 0000000..f6d10dc --- /dev/null +++ b/scripts/specifics-bank/src/binding.ts @@ -0,0 +1,156 @@ +/** + * Per-edition lived-specifics BINDING (Fix #2, slice 2 — kilde-så-draft). + * + * The bank (`bank.ts`) is the operator's global inventory of real material. This + * module is the per-EDITION bridge: a slot-map that binds each load-bearing + * key-point (or, later, section) of one edition to either a real specific from + * the bank OR an explicit escape decision. It lives in the edition's + * `edition-state.json` under `articles.NN.livedSpecifics`; this module only + * validates and reports on it (pure, testable). The elicitation that POPULATES + * the slot-map — the guided interview that refuses vague answers — lives in the + * command layer (`/linkedin:newsletter` Step 1.5, slice 3). + * + * THE GATE (retning §3 + drømme-spec G4, «vaghet avvises»): a load-bearing claim + * must be grounded in real material (`specific`) or explicitly escape-hatched + * (`abstrakt` with a justification, or `ekstern` = backed by research, not lived). + * A slot left `unresolved` is the vagueness the gate rejects → BLOCK. Numbers + * stay guilty-until-checked: a slot backed by an UNVERIFIED number is allowed but + * WARNED (regel 6/7), so Step 2 research verifies it before a draft asserts it. + */ + +import type { Bank, Specific } from "./types.js"; + +/** A slot is a load-bearing key-point (Step 1.5) or a section (Step 2.5 refinement). */ +export type SlotKind = "key-point" | "section"; + +/** How a slot is grounded. `unresolved` is the vagueness the gate blocks on. */ +export type Binding = + | { type: "specific"; specificId: string } // grounded in real bank material (best) + | { type: "abstrakt"; rationale: string } // deliberately abstract here — must justify + | { type: "ekstern"; source?: string } // backed by external research, not lived material + | { type: "unresolved" }; // not yet decided → BLOCK + +export interface Slot { + slotId: string; + kind: SlotKind; + /** Human-readable claim/key-point text. */ + label: string; + binding: Binding; +} + +export type LivedSpecificsStatus = "pending" | "bound"; + +export interface LivedSpecifics { + slots: Slot[]; + status: LivedSpecificsStatus; +} + +export type BindingVerdict = "PASS" | "BLOCK"; + +export type SlotProblemReason = "unresolved" | "dangling-specific" | "unjustified-abstrakt"; + +export interface SlotProblem { + slotId: string; + reason: SlotProblemReason; + detail: string; +} + +export interface SlotWarning { + slotId: string; + reason: "unverified-number"; + detail: string; +} + +/** Coverage counts by binding TYPE (a dangling specific still counts under `specific`). */ +export interface BindingCoverage { + total: number; + specific: number; + abstrakt: number; + ekstern: number; + unresolved: number; +} + +export interface BindingResult { + verdict: BindingVerdict; + /** BLOCK reasons — every offending slot, not short-circuited. */ + problems: SlotProblem[]; + /** Non-blocking direction (regel 6/7 number-verification). */ + warnings: SlotWarning[]; + coverage: BindingCoverage; +} + +const isBlank = (s: string): boolean => s.trim().length === 0; + +/** + * Validate one edition's slot-map against the bank. Pure: no I/O, no clock. + * BLOCK iff any slot is `unresolved`, points at a `specificId` absent from the + * bank, or is an `abstrakt` escape with a blank rationale. Otherwise PASS. + * Warnings (do not block) flag slots backed by an unverified number (regel 6/7). + */ +export function validateBinding(ls: LivedSpecifics, bank: Bank): BindingResult { + const byId = new Map(bank.specifics.map((s) => [s.id, s])); + const problems: SlotProblem[] = []; + const warnings: SlotWarning[] = []; + const coverage: BindingCoverage = { + total: ls.slots.length, + specific: 0, + abstrakt: 0, + ekstern: 0, + unresolved: 0, + }; + + for (const slot of ls.slots) { + const b = slot.binding; + switch (b.type) { + case "specific": { + coverage.specific++; + const spec = byId.get(b.specificId); + if (!spec) { + problems.push({ + slotId: slot.slotId, + reason: "dangling-specific", + detail: `specificId '${b.specificId}' is not in the bank`, + }); + } else if (spec.type === "number" && spec.verification === "unverified") { + warnings.push({ + slotId: slot.slotId, + reason: "unverified-number", + detail: `number '${b.specificId}' is unverified — verify before the draft asserts it (regel 6/7)`, + }); + } + break; + } + case "abstrakt": { + coverage.abstrakt++; + if (isBlank(b.rationale)) { + problems.push({ + slotId: slot.slotId, + reason: "unjustified-abstrakt", + detail: "an abstrakt escape needs a rationale — an unjustified abstraction is vaghet", + }); + } + break; + } + case "ekstern": { + coverage.ekstern++; // source optional — Step 2 research fills it + break; + } + case "unresolved": { + coverage.unresolved++; + problems.push({ + slotId: slot.slotId, + reason: "unresolved", + detail: "slot is unresolved — bind it to a specific, or escape-hatch as abstrakt/ekstern", + }); + break; + } + } + } + + return { + verdict: problems.length === 0 ? "PASS" : "BLOCK", + problems, + warnings, + coverage, + }; +} diff --git a/scripts/specifics-bank/src/cli.ts b/scripts/specifics-bank/src/cli.ts index c715a69..6ade4ca 100644 --- a/scripts/specifics-bank/src/cli.ts +++ b/scripts/specifics-bank/src/cli.ts @@ -14,6 +14,8 @@ * Exit code: 0 on success, 2 on usage error. */ +import { readFileSync, writeFileSync } from "node:fs"; + import { addSpecific, defaultBankPath, @@ -21,6 +23,9 @@ import { queryByTopic, saveBank, } from "./bank.js"; +import { validateBinding } from "./binding.js"; +import { renderKilder } from "./kilder.js"; +import type { LivedSpecifics } from "./binding.js"; import type { SpecificType, Verification } from "./types.js"; const VALID_TYPES: SpecificType[] = [ @@ -65,11 +70,35 @@ function usage(msg: string): never { "usage:\n" + " query --tags [--bank ] [--json]\n" + ' add --type --content "" --tags [--source ] [--verification ] [--bank ]\n' + - " list [--bank ] [--json]", + " list [--bank ] [--json]\n" + + " validate-binding --edition [--article ] [--bank ] [--json]\n" + + " render-kilder --edition [--article ] [--bank ] [--out ]", ); process.exit(2); } +/** Minimal shape of edition-state.json that the binding subcommands read. */ +interface EditionArticle { + title?: string; + livedSpecifics?: LivedSpecifics; +} +interface EditionState { + currentArticle?: string; + articles?: Record; +} + +const EMPTY_LS: LivedSpecifics = { slots: [], status: "pending" }; + +function loadEdition(path: string): EditionState { + return JSON.parse(readFileSync(path, "utf8")) as EditionState; +} + +/** Resolve the target article: explicit --article, else currentArticle, else "01". */ +function resolveArticle(edition: EditionState, flag?: string): { id: string; article: EditionArticle } { + const id = flag ?? edition.currentArticle ?? "01"; + return { id, article: edition.articles?.[id] ?? {} }; +} + function today(): string { return new Date().toISOString().slice(0, 10); } @@ -145,6 +174,45 @@ function main(): void { return; } + if (command === "validate-binding") { + if (!flags.edition || flags.edition === "true") usage("validate-binding needs --edition "); + const edition = loadEdition(flags.edition); + const { id, article } = resolveArticle(edition, flags.article); + const ls = article.livedSpecifics ?? EMPTY_LS; + const res = validateBinding(ls, loadBank(bankPath)); + if (asJson) { + console.log(JSON.stringify({ article: id, ...res }, null, 2)); + } else { + const c = res.coverage; + console.log(`Binding for article ${id}: ${res.verdict}`); + console.log( + ` slots ${c.total} — levd ${c.specific} · abstrakt ${c.abstrakt} · ekstern ${c.ekstern} · uavklart ${c.unresolved}`, + ); + for (const p of res.problems) console.log(` ⛔ ${p.slotId}: ${p.reason} — ${p.detail}`); + for (const w of res.warnings) console.log(` ⚠ ${w.slotId}: ${w.detail}`); + } + process.exit(res.verdict === "BLOCK" ? 1 : 0); + } + + if (command === "render-kilder") { + if (!flags.edition || flags.edition === "true") usage("render-kilder needs --edition "); + const edition = loadEdition(flags.edition); + const { id, article } = resolveArticle(edition, flags.article); + const md = renderKilder({ + articleId: id, + title: article.title ?? id, + livedSpecifics: article.livedSpecifics ?? EMPTY_LS, + bank: loadBank(bankPath), + }); + if (flags.out && flags.out !== "true") { + writeFileSync(flags.out, md, "utf8"); + console.log(`Wrote ${flags.out}`); + } else { + process.stdout.write(md); + } + return; + } + usage(command ? `unknown command: ${command}` : "no command given"); } diff --git a/scripts/specifics-bank/src/kilder.ts b/scripts/specifics-bank/src/kilder.ts new file mode 100644 index 0000000..a86d9c5 --- /dev/null +++ b/scripts/specifics-bank/src/kilder.ts @@ -0,0 +1,93 @@ +/** + * Render the per-edition `NN-kilder.md` artifact (Fix #2, slice 2). + * + * A read-only, deterministically-rendered sources ledger: for each load-bearing + * slot, what backs the claim. The single source of truth is the edition's + * `livedSpecifics` slot-map + the specifics-bank — `NN-kilder.md` is regenerated + * from them (like POST.html), never hand-edited. The coverage block and the + * regel-6/7 number list are computed by `validateBinding`, so the artifact and + * the skeleton-gate can never disagree. + * + * Pure + dateless on purpose (golden-testable). The CLI writes the string to + * `/linkedin/NN-kilder.md`. + */ + +import type { Bank, Specific } from "./types.js"; +import type { LivedSpecifics, Slot } from "./binding.js"; +import { validateBinding } from "./binding.js"; + +export interface RenderKilderInput { + articleId: string; + title: string; + livedSpecifics: LivedSpecifics; + bank: Bank; +} + +function verifMarker(spec: Specific): string { + if (spec.verification === "verified") return " · verifisert ✓"; + if (spec.verification === "unverified") return " · uverifisert ⚠"; + return ""; // n/a — no marker +} + +function bindingLines(slot: Slot, byId: Map): string[] { + const b = slot.binding; + switch (b.type) { + case "specific": { + const spec = byId.get(b.specificId); + if (!spec) { + return [`- **Binding:** ⛔ levd materiale — id \`${b.specificId}\` mangler i banken (dangling)`]; + } + return [ + `- **Binding:** levd materiale (${spec.type}) · \`${spec.id}\`${verifMarker(spec)}`, + `- **Materiale:** «${spec.content}»`, + `- **Opphav:** ${spec.provenance.source}, fanget ${spec.provenance.capturedAt}`, + `- **Tema-tagger:** ${spec.topicTags.join(", ")}`, + ]; + } + case "abstrakt": + return b.rationale.trim().length === 0 + ? ["- **Binding:** ⛔ abstrakt uten begrunnelse (avvist — vaghet)"] + : [`- **Binding:** abstrakt (med vilje) — ${b.rationale}`]; + case "ekstern": + return [`- **Binding:** ekstern kilde — ${b.source ?? "(fylles av research, Step 2)"}`]; + case "unresolved": + return ["- **Binding:** ⛔ uavklart — må bindes før skjelett-gaten (vaghet avvises)"]; + } +} + +export function renderKilder(input: RenderKilderInput): string { + const { articleId, title, livedSpecifics, bank } = input; + const byId = new Map(bank.specifics.map((s) => [s.id, s])); + const { coverage, warnings } = validateBinding(livedSpecifics, bank); + + const out: string[] = []; + out.push(`# Kilder — ${title} (${articleId})`, ""); + out.push( + "> Auto-generert fra edition-state `livedSpecifics` + specifics-bank. Per lastbærende påstand: hva backer den.", + "> Levd materiale er KTGs egne ord, aldri AI-oppdiktet (retning §3). Tall er uverifiserte til fakta-sjekket (regel 6/7).", + "", + ); + out.push("## Lastbærende påstander", ""); + if (livedSpecifics.slots.length === 0) { + out.push("_Ingen lastbærende slots bundet ennå._", ""); + } else { + for (const slot of livedSpecifics.slots) { + out.push(`### ${slot.slotId} · ${slot.label}`); + out.push(...bindingLines(slot, byId)); + out.push(""); + } + } + + out.push("## Dekning"); + out.push(`- Lastbærende slots: ${coverage.total}`); + out.push(`- Levd materiale: ${coverage.specific}`); + out.push(`- Abstrakt (escape): ${coverage.abstrakt}`); + out.push(`- Ekstern: ${coverage.ekstern}`); + out.push(`- Uavklart (BLOCK): ${coverage.unresolved}`); + out.push(""); + + const numSlots = warnings.filter((w) => w.reason === "unverified-number").map((w) => w.slotId); + out.push(`Tall som må verifiseres (regel 6/7): ${numSlots.length > 0 ? numSlots.join(", ") : "ingen"}`); + + return out.join("\n") + "\n"; +} diff --git a/scripts/specifics-bank/tests/binding.test.ts b/scripts/specifics-bank/tests/binding.test.ts new file mode 100644 index 0000000..a1dae9d --- /dev/null +++ b/scripts/specifics-bank/tests/binding.test.ts @@ -0,0 +1,151 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { validateBinding } from "../src/binding.js"; +import type { LivedSpecifics, Slot } from "../src/binding.js"; +import type { Bank, Specific } from "../src/types.js"; + +// ---- fixtures --------------------------------------------------------------- + +function mkSpecific(over: Partial & { id: string }): Specific { + return { + id: over.id, + type: over.type ?? "other", + content: over.content ?? "x", + topicTags: over.topicTags ?? ["t"], + provenance: over.provenance ?? { capturedAt: "2026-06-20", source: "test" }, + verification: over.verification ?? "n/a", + status: over.status ?? "active", + }; +} + +const mkBank = (specifics: Specific[]): Bank => ({ schemaVersion: 1, specifics }); + +const slot = (slotId: string, binding: Slot["binding"], over: Partial = {}): Slot => ({ + slotId, + kind: over.kind ?? "key-point", + label: over.label ?? `label ${slotId}`, + binding, +}); + +const ls = (slots: Slot[]): LivedSpecifics => ({ slots, status: "pending" }); + +// ---- tests ------------------------------------------------------------------ + +describe("validateBinding — the «vaghet avvises» gate (G4)", () => { + test("every slot resolved (specific + abstrakt + ekstern) → PASS, coverage counted", () => { + const bank = mkBank([mkSpecific({ id: "aaa", type: "named-case" })]); + const res = validateBinding( + ls([ + slot("kp1", { type: "specific", specificId: "aaa" }), + slot("kp2", { type: "abstrakt", rationale: "bevisst abstrakt her" }), + slot("kp3", { type: "ekstern", source: "research note 2" }), + ]), + bank, + ); + assert.equal(res.verdict, "PASS"); + assert.equal(res.problems.length, 0); + assert.deepEqual(res.coverage, { + total: 3, + specific: 1, + abstrakt: 1, + ekstern: 1, + unresolved: 0, + }); + }); + + test("an unresolved slot → BLOCK with reason 'unresolved'", () => { + const res = validateBinding( + ls([ + slot("kp1", { type: "specific", specificId: "aaa" }), + slot("kp2", { type: "unresolved" }), + ]), + mkBank([mkSpecific({ id: "aaa" })]), + ); + assert.equal(res.verdict, "BLOCK"); + assert.ok( + res.problems.some((p) => p.slotId === "kp2" && p.reason === "unresolved"), + "kp2 should be flagged unresolved", + ); + assert.equal(res.coverage.unresolved, 1); + }); + + test("a specific slot whose id is not in the bank → BLOCK 'dangling-specific'", () => { + const res = validateBinding( + ls([slot("kp1", { type: "specific", specificId: "missing" })]), + mkBank([]), + ); + assert.equal(res.verdict, "BLOCK"); + assert.ok( + res.problems.some((p) => p.slotId === "kp1" && p.reason === "dangling-specific"), + "dangling id must be caught", + ); + // dangling still counts under `specific` in coverage (binding TYPE is specific) + assert.equal(res.coverage.specific, 1); + }); + + test("an abstrakt escape with a blank rationale is vaghet → BLOCK 'unjustified-abstrakt'", () => { + const res = validateBinding(ls([slot("kp1", { type: "abstrakt", rationale: " " })]), mkBank([])); + assert.equal(res.verdict, "BLOCK"); + assert.ok( + res.problems.some((p) => p.slotId === "kp1" && p.reason === "unjustified-abstrakt"), + "an unjustified abstraction must be rejected", + ); + }); + + test("ekstern needs no source (research fills it later) → PASS", () => { + const res = validateBinding( + ls([ + slot("a", { type: "ekstern" }), + slot("b", { type: "abstrakt", rationale: "x" }), + slot("c", { type: "abstrakt", rationale: "y" }), + ]), + mkBank([]), + ); + assert.equal(res.verdict, "PASS"); + assert.deepEqual(res.coverage, { + total: 3, + specific: 0, + abstrakt: 2, + ekstern: 1, + unresolved: 0, + }); + }); + + test("a slot backed by an UNVERIFIED number → PASS + warning (regel 6/7)", () => { + const bank = mkBank([mkSpecific({ id: "num1", type: "number", verification: "unverified" })]); + const res = validateBinding(ls([slot("kp1", { type: "specific", specificId: "num1" })]), bank); + assert.equal(res.verdict, "PASS", "an unverified number warns, it does not block"); + assert.ok( + res.warnings.some((w) => w.slotId === "kp1" && w.reason === "unverified-number"), + "unverified number must surface as a warning", + ); + }); + + test("a slot backed by a VERIFIED number → PASS, no warning", () => { + const bank = mkBank([mkSpecific({ id: "num1", type: "number", verification: "verified" })]); + const res = validateBinding(ls([slot("kp1", { type: "specific", specificId: "num1" })]), bank); + assert.equal(res.verdict, "PASS"); + assert.equal(res.warnings.length, 0); + }); + + test("empty slot-map → PASS, total 0 (the command decides whether ≥1 slot is required)", () => { + const res = validateBinding(ls([]), mkBank([])); + assert.equal(res.verdict, "PASS"); + assert.equal(res.coverage.total, 0); + assert.equal(res.problems.length, 0); + }); + + test("multiple problems are all reported (gate is not short-circuited)", () => { + const res = validateBinding( + ls([ + slot("a", { type: "unresolved" }), + slot("b", { type: "specific", specificId: "nope" }), + slot("c", { type: "abstrakt", rationale: "" }), + ]), + mkBank([]), + ); + assert.equal(res.verdict, "BLOCK"); + assert.equal(res.problems.length, 3); + }); +}); diff --git a/scripts/specifics-bank/tests/kilder.test.ts b/scripts/specifics-bank/tests/kilder.test.ts new file mode 100644 index 0000000..55515a8 --- /dev/null +++ b/scripts/specifics-bank/tests/kilder.test.ts @@ -0,0 +1,130 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { renderKilder } from "../src/kilder.js"; +import type { LivedSpecifics, Slot } from "../src/binding.js"; +import type { Bank, Specific } from "../src/types.js"; + +function mkSpecific(over: Partial & { id: string }): Specific { + return { + id: over.id, + type: over.type ?? "other", + content: over.content ?? "x", + topicTags: over.topicTags ?? ["t"], + provenance: over.provenance ?? { capturedAt: "2026-06-20", source: "test" }, + verification: over.verification ?? "n/a", + status: over.status ?? "active", + }; +} + +const mkBank = (specifics: Specific[]): Bank => ({ schemaVersion: 1, specifics }); +const ls = (slots: Slot[]): LivedSpecifics => ({ slots, status: "pending" }); + +describe("renderKilder — the NN-kilder.md artifact", () => { + test("golden render of a mixed edition (specific-number-unverified + abstrakt + ekstern + unresolved)", () => { + const bank = mkBank([ + mkSpecific({ + id: "num1", + type: "number", + verification: "unverified", + content: "Sparte to dager i uka.", + topicTags: ["saksbehandling", "tid"], + provenance: { capturedAt: "2026-06-19", source: "seres/05" }, + }), + ]); + const livedSpecifics = ls([ + { + slotId: "kp1", + kind: "key-point", + label: "Saksbehandling ble raskere", + binding: { type: "specific", specificId: "num1" }, + }, + { + slotId: "kp2", + kind: "key-point", + label: "Verktøyet bærer faget", + binding: { type: "abstrakt", rationale: "Poenget er prinsippet, ikke et enkelttilfelle." }, + }, + { + slotId: "kp3", + kind: "key-point", + label: "LinkedIn ranker på relevans", + binding: { type: "ekstern" }, + }, + { + slotId: "kp4", + kind: "key-point", + label: "Noe uavklart", + binding: { type: "unresolved" }, + }, + ]); + + const expected = `# Kilder — Maskinrommet (05) + +> Auto-generert fra edition-state \`livedSpecifics\` + specifics-bank. Per lastbærende påstand: hva backer den. +> Levd materiale er KTGs egne ord, aldri AI-oppdiktet (retning §3). Tall er uverifiserte til fakta-sjekket (regel 6/7). + +## Lastbærende påstander + +### kp1 · Saksbehandling ble raskere +- **Binding:** levd materiale (number) · \`num1\` · uverifisert ⚠ +- **Materiale:** «Sparte to dager i uka.» +- **Opphav:** seres/05, fanget 2026-06-19 +- **Tema-tagger:** saksbehandling, tid + +### kp2 · Verktøyet bærer faget +- **Binding:** abstrakt (med vilje) — Poenget er prinsippet, ikke et enkelttilfelle. + +### kp3 · LinkedIn ranker på relevans +- **Binding:** ekstern kilde — (fylles av research, Step 2) + +### kp4 · Noe uavklart +- **Binding:** ⛔ uavklart — må bindes før skjelett-gaten (vaghet avvises) + +## Dekning +- Lastbærende slots: 4 +- Levd materiale: 1 +- Abstrakt (escape): 1 +- Ekstern: 1 +- Uavklart (BLOCK): 1 + +Tall som må verifiseres (regel 6/7): kp1 +`; + + assert.equal(renderKilder({ articleId: "05", title: "Maskinrommet", livedSpecifics, bank }), expected); + }); + + test("a verified named-case renders ✓ and no number-verification line", () => { + const bank = mkBank([ + mkSpecific({ id: "c1", type: "named-case", verification: "verified", content: "Etat X." }), + ]); + const out = renderKilder({ + articleId: "01", + title: "T", + livedSpecifics: ls([ + { slotId: "kp1", kind: "key-point", label: "L", binding: { type: "specific", specificId: "c1" } }, + ]), + bank, + }); + assert.match(out, /levd materiale \(named-case\) · `c1` · verifisert ✓/); + assert.match(out, /Tall som må verifiseres \(regel 6\/7\): ingen/); + }); + + test("a dangling specific id renders an explicit dangling note", () => { + const out = renderKilder({ + articleId: "01", + title: "T", + livedSpecifics: ls([ + { slotId: "kp1", kind: "key-point", label: "L", binding: { type: "specific", specificId: "ghost" } }, + ]), + bank: mkBank([]), + }); + assert.match(out, /⛔ levd materiale — id `ghost` mangler i banken \(dangling\)/); + }); + + test("an empty slot-map renders the 'ingen slots' placeholder, not an empty section", () => { + const out = renderKilder({ articleId: "01", title: "T", livedSpecifics: ls([]), bank: mkBank([]) }); + assert.match(out, /_Ingen lastbærende slots bundet ennå\._/); + assert.match(out, /- Lastbærende slots: 0/); + }); +});