feat(linkedin-studio): per-edition lived-specifics binding + kilder artifact (fix #2 slice 2)
Slice 2 of fix #2 (kilde-så-draft): the per-edition bridge between the global specifics-bank (slice 1) and a draft. In scripts/specifics-bank: - binding.ts — livedSpecifics slot-map model + validateBinding, the «vaghet avvises» gate (G4). BLOCK on unresolved / dangling specific-id / unjustified abstrakt; WARN on an unverified-number-backed slot (regel 6/7); PASS otherwise. - kilder.ts — renderKilder, the read-only NN-kilder.md sources ledger, rendered deterministically from the slot-map + bank (coverage via validateBinding, so the artifact and the skeleton-gate cannot disagree). - cli.ts — validate-binding (exit 1 = BLOCK) + render-kilder subcommands. edition-state schema gains articles.NN.livedSpecifics ({ slots, status }) + _doc. Design record + the Step 2 research re-scope spec: docs/fix2/slice2-binding.md. Wiring into newsletter.md (Step 1.5 elicitation + Step 2.5 gate + pipeline 17->18) is slice 3 — not in this commit. Tests 28/28 (specifics-bank; +9 binding +4 kilder), tsc clean, plugin gate 83/0/0 unaffected, gitleaks clean on all 8 files. [skip-docs] internal plumbing — no command/agent/pipeline surface change until slice 3 wires it; documenting it in the user-facing root CLAUDE.md/README now would overclaim unreachable functionality. Docs ARE updated where they belong: the design record docs/fix2/slice2-binding.md, scripts/specifics-bank/README.md, and the edition-state _doc. Root-doc update lands with the slice-3 wiring feat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b390b87ad5
commit
33f18c35e6
8 changed files with 744 additions and 1 deletions
|
|
@ -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<N>-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  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<N>-kandidat.png (attempts) | cover-raw.png (optional external pre-edit source) | fig<N>.png (inline). credit + caption are recorded in <serie>/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 <edition-state.json>`). 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: <serie>/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) <serie>/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,
|
||||
|
|
|
|||
118
docs/fix2/slice2-binding.md
Normal file
118
docs/fix2/slice2-binding.md
Normal file
|
|
@ -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": "<claim>",
|
||||
"binding": { "type": "specific", "specificId": "<bank id>" } },
|
||||
{ "slotId": "kp2", "kind": "key-point", "label": "<claim>",
|
||||
"binding": { "type": "abstrakt", "rationale": "<why abstract here>" } },
|
||||
{ "slotId": "kp3", "kind": "key-point", "label": "<claim>",
|
||||
"binding": { "type": "ekstern", "source": "<research ref, optional>" } },
|
||||
{ "slotId": "kp4", "kind": "key-point", "label": "<claim>",
|
||||
"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 `<serie>/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 "<abs>/linkedin/edition-state.json" # exit 1 = BLOCK
|
||||
node --import tsx src/cli.ts render-kilder --edition "<abs>/linkedin/edition-state.json" \
|
||||
--out "<abs>/linkedin/NN-kilder.md"
|
||||
```
|
||||
|
||||
`--article <id>` overrides `currentArticle`; `--bank <path>` 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.
|
||||
|
|
@ -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 "<abs>/linkedin/edition-state.json"
|
||||
# Render the sources ledger
|
||||
node --import tsx src/cli.ts render-kilder --edition "<abs>/linkedin/edition-state.json" --out "<abs>/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
|
||||
|
|
|
|||
156
scripts/specifics-bank/src/binding.ts
Normal file
156
scripts/specifics-bank/src/binding.ts
Normal file
|
|
@ -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<string, Specific>(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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 <a,b> [--bank <path>] [--json]\n" +
|
||||
' add --type <t> --content "<text>" --tags <a,b> [--source <s>] [--verification <v>] [--bank <path>]\n' +
|
||||
" list [--bank <path>] [--json]",
|
||||
" list [--bank <path>] [--json]\n" +
|
||||
" validate-binding --edition <edition-state.json> [--article <id>] [--bank <path>] [--json]\n" +
|
||||
" render-kilder --edition <edition-state.json> [--article <id>] [--bank <path>] [--out <path>]",
|
||||
);
|
||||
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<string, EditionArticle>;
|
||||
}
|
||||
|
||||
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 <path>");
|
||||
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 <path>");
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
|
|||
93
scripts/specifics-bank/src/kilder.ts
Normal file
93
scripts/specifics-bank/src/kilder.ts
Normal file
|
|
@ -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
|
||||
* `<serie>/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, Specific>): 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<string, Specific>(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";
|
||||
}
|
||||
151
scripts/specifics-bank/tests/binding.test.ts
Normal file
151
scripts/specifics-bank/tests/binding.test.ts
Normal file
|
|
@ -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<Specific> & { 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> = {}): 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);
|
||||
});
|
||||
});
|
||||
130
scripts/specifics-bank/tests/kilder.test.ts
Normal file
130
scripts/specifics-bank/tests/kilder.test.ts
Normal file
|
|
@ -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<Specific> & { 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/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue