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
381 lines
17 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|