linkedin-studio/scripts/specifics-bank/tests/bank.test.ts
Kjell Tore Guttormsen b390b87ad5 feat(linkedin-studio): specifics-bank store — lived-specifics inventory (fix #2 slice 1)
Fix #2 (kilde-så-draft) slice 1: the deterministic STORE half of the
lived-specifics bank — the operator's real, un-generatable raw material
(measured number / named-but-real case / what broke / contrarian opinion /
mind-change), so drafts start grounded instead of hollow (retning §3;
dream-spec "Lived-Specifics Extraction", the single biggest missing upstream
piece for the authenticity thesis).

scripts/specifics-bank/ (sibling to contract-gate, same tsx convention):
- src/types.ts — Specific/Bank schema (schemaVersion 1), un-generatable taxonomy
- src/bank.ts — pure store: normalizeContent, content-hash id (= dedupe key),
  load/save, addSpecific (dedupe + topicTag union on re-capture), queryByTopic
  (active-only, ranked by tag overlap then recency)
- src/cli.ts — query / add / list; default bank under
  ${LINKEDIN_STUDIO_DATA:-~/.claude/linkedin-studio}/specifics-bank/ so it
  survives plugin upgrades/reinstalls (M0 data-path seam)
- tests/bank.test.ts — 15/15 green; tsc clean
- README + .gitignore for node_modules/build

Content is stored verbatim; never AI-generated. The elicitation interview
(refuses vague answers; abstrakt/ekstern escape hatches) lands in slice 3
(/linkedin:newsletter Step 1.5). Forward-compatible with the profile-evolution
/ "second brain" architecture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:03:18 +02:00

198 lines
7 KiB
TypeScript

import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
normalizeContent,
specificId,
emptyBank,
loadBank,
saveBank,
addSpecific,
queryByTopic,
} from "../src/bank.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { Bank } from "../src/types.js";
const prov = (source = "test", capturedAt = "2026-06-20") => ({ source, capturedAt });
const tmp = () => mkdtempSync(join(tmpdir(), "specifics-bank-"));
describe("specifics-bank store", () => {
describe("normalizeContent + specificId", () => {
test("id is deterministic and content-derived", () => {
assert.equal(specificId("Sparte to dager."), specificId("Sparte to dager."));
});
test("id is stable across case + surrounding/collapsed whitespace", () => {
const a = specificId("Sparte to dager.");
const b = specificId(" sparte TO dager. ");
assert.equal(a, b, "normalization should collapse case + whitespace");
});
test("different content yields different ids", () => {
assert.notEqual(specificId("Sparte to dager."), specificId("Sparte tre dager."));
});
test("normalizeContent collapses whitespace + lowercases", () => {
assert.equal(normalizeContent(" Hei DU\n da "), "hei du da");
});
});
describe("emptyBank + load/save round-trip", () => {
test("emptyBank has the schema version and no specifics", () => {
const b = emptyBank();
assert.equal(b.schemaVersion, SCHEMA_VERSION);
assert.deepEqual(b.specifics, []);
});
test("loadBank on a missing file returns an empty bank (never throws)", () => {
const dir = tmp();
try {
const b = loadBank(join(dir, "does-not-exist.json"));
assert.equal(b.schemaVersion, SCHEMA_VERSION);
assert.deepEqual(b.specifics, []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("saveBank then loadBank round-trips", () => {
const dir = tmp();
const path = join(dir, "bank.json");
try {
let bank: Bank = emptyBank();
bank = addSpecific(bank, {
type: "number",
content: "Saksbehandleren sparte to dager i uka.",
topicTags: ["saksbehandling", "produktivitet"],
provenance: prov("seres/05"),
verification: "unverified",
}).bank;
saveBank(path, bank);
assert.ok(existsSync(path));
const loaded = loadBank(path);
assert.equal(loaded.specifics.length, 1);
assert.equal(loaded.specifics[0].content, "Saksbehandleren sparte to dager i uka.");
assert.equal(loaded.specifics[0].type, "number");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("addSpecific", () => {
test("adds a new specific with a computed id and reports added:true", () => {
const res = addSpecific(emptyBank(), {
type: "named-case",
content: "En kontraktsjurist hos en kunde automatiserte avtalegjennomgang.",
topicTags: ["juss", "automatisering"],
provenance: prov(),
verification: "n/a",
});
assert.equal(res.added, true);
assert.equal(res.bank.specifics.length, 1);
assert.equal(res.bank.specifics[0].id, specificId(res.bank.specifics[0].content));
assert.equal(res.bank.specifics[0].status, "active");
});
test("preserves the operator's content verbatim (normalizes only for id)", () => {
const content = " KI-en nådde IKKE arkivdataene. ";
const res = addSpecific(emptyBank(), {
type: "what-broke",
content,
topicTags: ["data"],
provenance: prov(),
});
assert.equal(res.bank.specifics[0].content, content, "content must not be mutated");
});
test("dedupes on normalized content: same material twice → one entry, tags unioned", () => {
let bank = emptyBank();
bank = addSpecific(bank, {
type: "number",
content: "Sparte to dager.",
topicTags: ["produktivitet"],
provenance: prov("seres/04"),
}).bank;
const res2 = addSpecific(bank, {
type: "number",
content: " sparte to dager. ",
topicTags: ["saksbehandling"],
provenance: prov("seres/05"),
});
assert.equal(res2.added, false, "duplicate content must not add a second entry");
assert.equal(res2.bank.specifics.length, 1);
assert.deepEqual(
[...res2.bank.specifics[0].topicTags].sort(),
["produktivitet", "saksbehandling"],
"tags must be unioned on re-capture",
);
});
test("defaults verification to unverified and status to active when omitted", () => {
const res = addSpecific(emptyBank(), {
type: "contrarian",
content: "De fleste KI-pilotene feiler på data, ikke på modellvalg.",
topicTags: ["data"],
provenance: prov(),
});
assert.equal(res.bank.specifics[0].verification, "unverified");
assert.equal(res.bank.specifics[0].status, "active");
});
});
describe("queryByTopic", () => {
const seed = (): Bank => {
let bank = emptyBank();
bank = addSpecific(bank, {
type: "named-case",
content: "Saksbehandler-case A.",
topicTags: ["saksbehandling", "arkiv"],
provenance: prov("seres/04", "2026-05-01"),
}).bank;
bank = addSpecific(bank, {
type: "number",
content: "Tallet B.",
topicTags: ["saksbehandling", "produktivitet", "ledelse"],
provenance: prov("seres/05", "2026-06-01"),
}).bank;
bank = addSpecific(bank, {
type: "other",
content: "Irrelevant C.",
topicTags: ["privatliv"],
provenance: prov("manual", "2026-06-10"),
}).bank;
return bank;
};
test("ranks hits by tag overlap (desc)", () => {
const hits = queryByTopic(seed(), ["saksbehandling", "produktivitet"]);
assert.equal(hits.length, 2);
assert.equal(hits[0].specific.content, "Tallet B."); // overlap 2
assert.equal(hits[0].tagOverlap, 2);
assert.equal(hits[1].specific.content, "Saksbehandler-case A."); // overlap 1
assert.equal(hits[1].tagOverlap, 1);
});
test("returns [] when nothing matches", () => {
assert.deepEqual(queryByTopic(seed(), ["ingenting"]), []);
});
test("excludes archived specifics", () => {
const bank = seed();
bank.specifics[1].status = "archived"; // archive "Tallet B."
const hits = queryByTopic(bank, ["saksbehandling"]);
assert.equal(hits.length, 1);
assert.equal(hits[0].specific.content, "Saksbehandler-case A.");
});
test("ties on overlap are broken by capturedAt (newest first)", () => {
const hits = queryByTopic(seed(), ["saksbehandling"]); // both A and B overlap 1
assert.equal(hits[0].specific.content, "Tallet B."); // 2026-06-01 newer than 2026-05-01
assert.equal(hits[1].specific.content, "Saksbehandler-case A.");
});
});
});