linkedin-studio/scripts/trends/tests/n6-proposal-layer.test.ts
Kjell Tore Guttormsen 65b60337fe feat(linkedin-studio): N6 — forslags-lag i trends (angle/targetLevel/rationale/relatedIds + selected + --ids + F7/F9-felt actionability/verdict/readerQuestion/painPoint/saturation) [skip-docs]
Artikkelforslaget blir en persistert entitet (steg 2, A1-4) og godkjenningen får
et hjem (steg 3, A1-7) — før dette genererte agenten vinkel/begrunnelse som ble kastet.

Åtte additivt-valgfrie felt på TrendRecord/TrendInput/TrendItem:
- Forslag (A1-4/A1-5): angle · targetLevel (fritekst, brukerdefinert spenn — aldri enum) ·
  rationale · relatedIds[] (flerkilde).
- F7 leser-side: actionability {formulated, note?} (N7-bånd-cap-gaten leser `formulated`) ·
  verdict BÆRENDE/STØTTE/NYHET (lukket mekanisme-vokab).
- F9 leser-side: readerQuestion · painPoint · saturation (N7.5-sveipet fyller dem).

Ingen schema-bump: feltene er additivt-valgfrie, ingen record trenger migrering →
SCHEMA_VERSION forblir 4 (loadStore Math.max håndterer det). First-sight-persistering
(re-capture unionerer topics + re-scorer kun; klobrer aldri en triagert vinkel).

Validering: typede felt (verdict/actionability) feiler hardt ved malformert input;
fritekst/id-liste normaliseres lempelig (summary/topics-idiomet).

Livssyklus: TrendStatus += "selected" → new→selected→acted|skipped. Ny select-verb +
--ids-batch (act/skip/reset/select); partiell suksess = exit 0 + miss-rapport, all-miss = exit 2.
setStatusMany(): ren batch-mutasjon, per-id found/notFound.

Brief: forslagsfeltene rendres per kandidat (detaljert i topp-treff, kompakt token i bullets) +
ny «🚧 I produksjon»-seksjon (selected=valgt + acted=skrevet), pillar-uavhengig, deterministisk
sortert. selected/acted forlater arbeidskøen men vises i produksjons-boardet.

commands/trends.md Step 5 oppgradert: triage → Velg (select) / Skip, batchet per verb (--ids).

Trends-suite 245→266 (ny floor, +21 N6-tester). To eksisterende tester oppdatert for den
endrede brief-kontrakten (acted vises nå i I produksjon; ranking-descriptoren ekskluderer selected).
tsc rent. Roundtrip bevist: capture m/ alle felt → query → brief rendrer feltene → select → I produksjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7SQpXJpBSNvpWaTNq1kWZ
2026-07-21 09:09:51 +02:00

381 lines
17 KiB
TypeScript

/**
* N6 — the proposal layer: the discovery agent's angle/rationale becomes a PERSISTED
* entity (step 2, A1-4) and the operator's approval gets a home (step 3, A1-7). Eight
* additive-optional fields carry the proposal (angle/targetLevel/rationale/relatedIds),
* the reader-side F7 signal the N7 band-cap gate reads (actionability/verdict), and the
* F9 reader fields the N7.5 demand-sweep fills (readerQuestion/painPoint/saturation).
* Plus the `selected` lifecycle state, the `--ids` batch, and the brief's "I produksjon"
* section. Every field is optional → an existing store reads UNCHANGED (backward-compat).
*/
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import {
emptyStore,
loadStore,
saveStore,
addTrend,
effectiveStatus,
setStatus,
setStatusMany,
} from "../src/store.js";
import { normalizeItem, itemToInput } from "../src/item.js";
import { rankForBrief, renderBrief } from "../src/brief.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendRecord, TrendStore } from "../src/types.js";
const tmp = () => mkdtempSync(join(tmpdir(), "trends-n6-"));
// The full proposal payload, reused across the schema tests.
const FULL_INPUT = {
title: "AI agents reshape public-sector workflows",
url: "https://example.com/agents",
source: "tavily",
capturedAt: "2026-07-20",
topics: ["ai", "public sector"],
angle: "What a caseworker actually does differently on Monday",
targetLevel: "praktiker", // a value from the user's OWN profile span, never a hardcoded enum
rationale: "The procurement window opens in Q3 — timely for buyers deciding now",
relatedIds: ["abc123def456", "0011223344ff"],
actionability: { formulated: true, note: "reader can pilot one workflow this week" },
verdict: "BÆRENDE" as const,
readerQuestion: "How do I start without a platform team?",
painPoint: "competes against the M365 licence they already pay for",
saturation: "thin — two vendor blogs, no independent walkthrough",
};
describe("N6 — schema round-trip (the proposal becomes a persisted entity)", () => {
test("addTrend persists all eight proposal fields, and load/save round-trips them byte-for-byte", () => {
const dir = tmp();
try {
const path = join(dir, "trends.json");
const { store } = addTrend(emptyStore(), FULL_INPUT);
saveStore(path, store);
const reloaded = loadStore(path);
const t = reloaded.trends[0];
assert.equal(t.angle, FULL_INPUT.angle);
assert.equal(t.targetLevel, FULL_INPUT.targetLevel);
assert.equal(t.rationale, FULL_INPUT.rationale);
assert.deepEqual(t.relatedIds, FULL_INPUT.relatedIds);
assert.deepEqual(t.actionability, FULL_INPUT.actionability);
assert.equal(t.verdict, "BÆRENDE");
assert.equal(t.readerQuestion, FULL_INPUT.readerQuestion);
assert.equal(t.painPoint, FULL_INPUT.painPoint);
assert.equal(t.saturation, FULL_INPUT.saturation);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("the new fields are additive-optional — schema stays v4, no bump (no record needs migrating)", () => {
assert.equal(SCHEMA_VERSION, 4);
});
test("an existing v4 store WITHOUT the new fields loads and re-saves unchanged (backward-compat)", () => {
const dir = tmp();
try {
const path = join(dir, "trends.json");
// A record shaped exactly like a pre-N6 v4 record (no proposal fields at all).
const legacy: TrendStore = {
schemaVersion: 4,
trends: [
{
id: "legacy00",
title: "Old trend",
url: "https://example.com/old",
source: "websearch",
capturedAt: "2026-06-01",
topics: ["ai"],
},
],
};
writeFileSync(path, JSON.stringify(legacy, null, 2) + "\n", "utf8");
const loaded = loadStore(path);
assert.equal(loaded.schemaVersion, 4);
const t = loaded.trends[0];
assert.equal(t.angle, undefined);
assert.equal(t.verdict, undefined);
assert.equal(Object.prototype.hasOwnProperty.call(t, "angle"), false, "no phantom key added on load");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("proposal fields are first-sight — a re-capture with a different angle does NOT overwrite the stored one", () => {
const first = addTrend(emptyStore(), FULL_INPUT).store;
const second = addTrend(first, { ...FULL_INPUT, angle: "A completely different angle", topics: ["ai", "govtech"] });
assert.equal(second.added, false);
// topics still union (existing discipline); angle is provenance — first sight wins.
assert.equal(first.trends[0].angle, FULL_INPUT.angle);
assert.deepEqual(first.trends[0].topics, ["ai", "public sector", "govtech"]);
});
});
describe("N6 — lifecycle: selected + setStatusMany batch", () => {
test("effectiveStatus round-trips the new `selected` state", () => {
const rec: TrendRecord = { id: "x", title: "t", url: "u", source: "s", capturedAt: "2026-07-20", topics: [], status: "selected" };
assert.equal(effectiveStatus(rec), "selected");
});
test("setStatus accepts `selected` (widened TrendStatus)", () => {
const { store } = addTrend(emptyStore(), FULL_INPUT);
const id = store.trends[0].id;
const res = setStatus(store, id, "selected");
assert.equal(res.found, true);
assert.equal(store.trends[0].status, "selected");
});
test("setStatusMany marks every matched id and reports the unknown ones (partial batch)", () => {
let store = emptyStore();
store = addTrend(store, { ...FULL_INPUT, title: "A", url: "https://example.com/a" }).store;
store = addTrend(store, { ...FULL_INPUT, title: "B", url: "https://example.com/b" }).store;
const ids = store.trends.map((t) => t.id);
const res = setStatusMany(store, [ids[0], ids[1], "ghost99"], "selected");
assert.deepEqual(res.found.sort(), [ids[0], ids[1]].sort());
assert.deepEqual(res.notFound, ["ghost99"]);
assert.equal(store.trends[0].status, "selected");
assert.equal(store.trends[1].status, "selected");
});
test("setStatusMany on an all-unknown batch mutates nothing and reports every id not-found", () => {
const { store } = addTrend(emptyStore(), FULL_INPUT);
const res = setStatusMany(store, ["nope1", "nope2"], "acted");
assert.deepEqual(res.found, []);
assert.deepEqual(res.notFound, ["nope1", "nope2"]);
assert.equal(effectiveStatus(store.trends[0]), "new");
});
});
describe("N6 — item validation (typed fields hard-fail, free-text lenient)", () => {
test("a well-formed item with every proposal field normalizes, and itemToInput carries them all", () => {
const res = normalizeItem({
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
angle: "the angle",
targetLevel: "beslutter",
rationale: "why now",
relatedIds: ["id1", "id2"],
actionability: { formulated: true, note: "do X" },
verdict: "STØTTE",
readerQuestion: "how?",
painPoint: "cost",
saturation: "saturated",
});
assert.equal(res.ok, true);
if (!res.ok) return;
const input = itemToInput(res.item, "2026-07-20");
assert.equal(input.angle, "the angle");
assert.equal(input.targetLevel, "beslutter");
assert.equal(input.verdict, "STØTTE");
assert.deepEqual(input.actionability, { formulated: true, note: "do X" });
assert.deepEqual(input.relatedIds, ["id1", "id2"]);
assert.equal(input.readerQuestion, "how?");
assert.equal(input.painPoint, "cost");
assert.equal(input.saturation, "saturated");
});
test("an out-of-vocabulary verdict is a hard error (closed mechanism vocabulary)", () => {
const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"], verdict: "MEGABÆRENDE" });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.toLowerCase().includes("verdict")), "error names the offending field");
});
test("a malformed actionability (formulated not a boolean) is a hard error", () => {
const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"], actionability: { formulated: "yes" } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.toLowerCase().includes("actionability")));
});
test("blank free-text fields are dropped, not errors (summary-idiom); relatedIds junk is normalized away", () => {
const res = normalizeItem({
source: "s",
title: "t",
url: "https://example.com/t",
topics: ["ai"],
angle: " ",
rationale: "",
relatedIds: ["ok1", "", 42, "ok1"], // blank + non-string dropped, dupe collapsed
});
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(Object.prototype.hasOwnProperty.call(res.item, "angle"), false);
assert.equal(Object.prototype.hasOwnProperty.call(res.item, "rationale"), false);
assert.deepEqual(res.item.relatedIds, ["ok1"]);
});
test("an item with NONE of the new fields still normalizes (backward-compat)", () => {
const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"] });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(res.item.verdict, undefined);
assert.equal(res.item.actionability, undefined);
});
});
// ── Brief rendering ──────────────────────────────────────────────────────────
const TODAY = "2026-07-20";
function mk(p: Partial<TrendRecord> & { title: string; url: string; topics: string[] }): TrendRecord {
return {
id: p.title + "|" + p.url,
source: "tavily",
capturedAt: "2026-07-20",
...p,
} as TrendRecord;
}
describe("N6 — brief renders the proposal fields + the 'I produksjon' section", () => {
test("a fresh, top-matched candidate renders its angle/målnivå/verdict/reader fields in the brief", () => {
const store: TrendStore = {
schemaVersion: 4,
trends: [
mk({
title: "Agents in casework",
url: "https://example.com/a",
topics: ["ai", "public sector"],
publishedAt: "2026-07-20",
angle: "Monday-morning angle",
targetLevel: "praktiker",
rationale: "timely for Q3 buyers",
actionability: { formulated: true, note: "pilot one workflow" },
verdict: "BÆRENDE",
readerQuestion: "How do I start without a platform team?",
painPoint: "competes with M365",
saturation: "thin market",
}),
],
};
const md = renderBrief(rankForBrief(store, ["ai", "public sector"], TODAY));
assert.ok(md.includes("Monday-morning angle"), "angle rendered");
assert.ok(md.includes("praktiker"), "målnivå rendered");
assert.ok(md.includes("BÆRENDE"), "verdict rendered");
assert.ok(md.includes("How do I start without a platform team?"), "readerQuestion rendered");
assert.ok(md.includes("competes with M365"), "painPoint rendered");
assert.ok(md.includes("thin market"), "saturation rendered");
});
test("the 'I produksjon' section lists selected (valgt) and acted (skrevet) trends", () => {
const store: TrendStore = {
schemaVersion: 4,
trends: [
mk({ title: "Chosen one", url: "https://example.com/s", topics: ["ai"], status: "selected", angle: "selected angle", verdict: "BÆRENDE" }),
mk({ title: "Written one", url: "https://example.com/w", topics: ["ai"], status: "acted" }),
mk({ title: "Fresh queue item", url: "https://example.com/n", topics: ["ai"], publishedAt: "2026-07-20" }),
],
};
const md = renderBrief(rankForBrief(store, ["ai"], TODAY));
assert.ok(md.includes("I produksjon"), "section header present");
assert.ok(md.includes("Chosen one") && md.includes("valgt"), "selected trend shown as valgt");
assert.ok(md.includes("Written one") && md.includes("skrevet"), "acted trend shown as skrevet");
// A selected/acted trend must NOT leak back into the 'new' queue sections.
assert.ok(!md.includes("### 1. Chosen one"), "selected trend is not in the ranked queue");
});
test("an empty in-production board renders the explicit empty marker", () => {
const store: TrendStore = {
schemaVersion: 4,
trends: [mk({ title: "Only new", url: "https://example.com/n", topics: ["ai"], publishedAt: "2026-07-20" })],
};
const md = renderBrief(rankForBrief(store, ["ai"], TODAY));
assert.ok(md.includes("I produksjon"), "section header always present (stable structure)");
assert.ok(md.includes("Ingen i produksjon"), "explicit empty marker");
});
test("the render is deterministic (same store → byte-identical brief)", () => {
const store: TrendStore = {
schemaVersion: 4,
trends: [
mk({ title: "A", url: "https://example.com/a", topics: ["ai"], status: "selected", angle: "x" }),
mk({ title: "B", url: "https://example.com/b", topics: ["ai"], publishedAt: "2026-07-20", verdict: "NYHET" }),
],
};
const a = renderBrief(rankForBrief(store, ["ai"], TODAY));
const b = renderBrief(rankForBrief(store, ["ai"], TODAY));
assert.equal(a, b);
});
});
// ── CLI: select verb + --ids batch ───────────────────────────────────────────
const trendsRoot = fileURLToPath(new URL("..", import.meta.url));
function run(args: string[], input = ""): { status: number | null; stdout: string } {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
cwd: trendsRoot,
input,
encoding: "utf8",
});
return { status: res.status, stdout: res.stdout + res.stderr };
}
function seed(store: string): string[] {
// capture three trends; return their ids (read via list --json).
const batch = JSON.stringify([
{ source: "tavily", title: "One", url: "https://example.com/1", topics: ["ai"] },
{ source: "tavily", title: "Two", url: "https://example.com/2", topics: ["ai"] },
{ source: "tavily", title: "Three", url: "https://example.com/3", topics: ["ai"] },
]);
run(["capture", "--store", store], batch);
const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string }>;
return rows.map((r) => r.id);
}
describe("N6 — CLI select verb + --ids batch", () => {
test("select --id sets `selected`; read back via list --json", () => {
const dir = tmp();
try {
const store = join(dir, "trends.json");
const [id] = seed(store);
assert.equal(run(["select", "--id", id, "--store", store]).status, 0);
const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string; status?: string }>;
assert.equal(rows.find((r) => r.id === id)!.status, "selected");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("--ids marks a whole batch in one call (ten candidates, one call)", () => {
const dir = tmp();
try {
const store = join(dir, "trends.json");
const ids = seed(store);
const res = run(["act", "--ids", ids.join(","), "--store", store]);
assert.equal(res.status, 0);
const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ status?: string }>;
assert.ok(rows.every((r) => r.status === "acted"), "every seeded trend is acted");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a partial batch (some ids unknown) saves the matches, exits 0, and reports the misses", () => {
const dir = tmp();
try {
const store = join(dir, "trends.json");
const ids = seed(store);
const res = run(["skip", "--ids", `${ids[0]},ghost`, "--store", store]);
assert.equal(res.status, 0, "partial success is exit 0");
assert.ok(res.stdout.toLowerCase().includes("ghost") || res.stdout.toLowerCase().includes("not found"), "misses reported");
const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string; status?: string }>;
assert.equal(rows.find((r) => r.id === ids[0])!.status, "skipped");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an all-unknown batch changes nothing and exits 2", () => {
const dir = tmp();
try {
const store = join(dir, "trends.json");
seed(store);
assert.equal(run(["select", "--ids", "nope1,nope2", "--store", store]).status, 2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});