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>
198 lines
7 KiB
TypeScript
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.");
|
|
});
|
|
});
|
|
});
|