feat(linkedin-studio): N7.5 — MR-F9 etterspørsels-sveip (Tier-5-kilder + smertepunkt + vokabular-oversettelse + arc-kontrakt) [skip-docs]

The «innenfra og ut» demand-sweep: the mechanism that FILLS the N6 reader fields
(readerQuestion/painPoint/saturation). Discovery finds "what happened"; this layer
translates it into "the problem the reader is stuck on".

- demand-spotter agent (agents 19->20, inherits session): three passes after
  discovery, before drafting — demand-sweep -> pain-point map -> vocabulary translation.
- Tier 5 demand sources in config/trends-sources.template.md (inverse of Tier 1-4;
  honest blind spots: YouTube API, Reddit approximate, HN/GitHub ground truth).
- demand signal on TrendRecord (strength + answered) — rankable twin of the verbatim
  saturation text; additive-optional, store schema stays v4 (no migration).
- arc.ts (§4 output contract): groupIntoArcs (relatedIds transitive closure),
  rankArcQuestions (etterspørsel x kan-svare x ikke-besvart), classifyMarketGap
  (supply-gap != demand-gap != saturated). Pure + deterministic (TDD).
- arcs CLI verb + /linkedin:trends --demand mode (commands stay 30); morning brief
  shows the per-candidate demand signal.

Suites: trends 276->300, test-runner 139->140, tsc clean. Others unchanged
(brain 134, hooks 140, tests 35, render 60).

MR-F9 built (bygget-men-ubevist) — the (a)/(b)/(c) evidence gate is a runtime
demonstration, proven consumer-side (plugin agents don't resolve in the dev repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bE7VbkmR3cqHFEeGfzgwb
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 22:26:39 +02:00
commit 9de38c4939
16 changed files with 968 additions and 8 deletions

View file

@ -0,0 +1,372 @@
/**
* N7.5 MR-F9 «innenfra og ut» demand-sweep: the mechanism that FILLS the N6 reader fields
* (readerQuestion/painPoint/saturation). Discovery finds "what happened"; this layer translates
* it into "the problem the reader is stuck on". Three passes after discovery, before drafting:
* demand-sweep (Pass 1) pain-point map (Pass 2) vocabulary translation (Pass 3), with a new
* output unit the ARC (åre), not the news-item.
*
* This suite pins the deterministic code half:
* (1) the additive-optional `demand` signal (strength + answered) the rankable twin of the
* verbatim `saturation` text; capture round-trip + hard-fail on malformed, backward-compat;
* (2) `classifyMarketGap` the §4 "ærlig markedsdom" asymmetry (supply-gap demand-gap);
* (3) `rankArcQuestions` the §4 ranking `etterspørsel × kan-svare × ikke-besvart`, total order;
* (4) `groupIntoArcs` relatedIds transitive closure into veins, BÆRENDE vs STØTTE partition;
* (5) `renderArcs` the §4 output-contract, reader's words in the title, honest-null, deterministic.
*
* GENERIC BY ARCHITECTURE: the fixtures use a neutral domain no KTG pillar/source/calibration is
* baked into the mechanism (what counts as demand is the sweep's judgment, carried by the fields).
*/
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { emptyStore, loadStore, saveStore, addTrend } from "../src/store.js";
import { normalizeItem, itemToInput } from "../src/item.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendRecord, DemandSignal } from "../src/types.js";
import {
classifyMarketGap,
toArcQuestion,
rankArcQuestions,
groupIntoArcs,
renderArcs,
} from "../src/arc.js";
const tmp = () => mkdtempSync(join(tmpdir(), "trends-n75-"));
/** Build a store record with N7.5-relevant fields; generic domain, no KTG specifics. */
function rec(over: Partial<TrendRecord> & Pick<TrendRecord, "id" | "title" | "url">): TrendRecord {
return {
source: "tavily",
capturedAt: "2026-07-20",
topics: ["ai"],
...over,
} as TrendRecord;
}
// ── (1) demand schema — additive-optional, capture round-trip ──────────────────────────────
describe("N7.5 — demand signal schema (rankable twin of saturation text)", () => {
test("addTrend persists demand + the F9 reader fields, load/save round-trips them", () => {
const dir = tmp();
try {
const path = join(dir, "trends.json");
const { store } = addTrend(emptyStore(), {
title: "RAG on your own documents",
url: "https://example.com/rag",
source: "tavily",
capturedAt: "2026-07-20",
topics: ["ai", "search"],
readerQuestion: "Can we point a chatbot at our own files?",
painPoint: "competes against the M365 licence they already pay for",
saturation: "setup guides saturated; the cost/ownership questions are open",
demand: { strength: "strong", answered: false },
verdict: "BÆRENDE",
actionability: { formulated: true },
});
saveStore(path, store);
const t = loadStore(path).trends[0];
assert.deepEqual(t.demand, { strength: "strong", answered: false });
assert.equal(t.readerQuestion, "Can we point a chatbot at our own files?");
assert.equal(t.painPoint, "competes against the M365 licence they already pay for");
assert.equal(t.saturation, "setup guides saturated; the cost/ownership questions are open");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("schema version stays 4 — demand is additive-optional, no migration", () => {
assert.equal(SCHEMA_VERSION, 4);
});
test("normalizeItem accepts a valid demand and carries it through itemToInput", () => {
const res = normalizeItem({
title: "T",
url: "https://x.test/a",
source: "tavily",
topics: ["ai"],
demand: { strength: "moderate", answered: true },
});
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.demand, { strength: "moderate", answered: true });
const input = itemToInput(res.item, "2026-07-20");
assert.deepEqual(input.demand, { strength: "moderate", answered: true });
});
test("normalizeItem hard-fails a malformed demand (bad strength vocab)", () => {
const res = normalizeItem({
title: "T",
url: "https://x.test/a",
source: "tavily",
topics: ["ai"],
demand: { strength: "huge", answered: false },
});
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => /demand/i.test(e)), "should report a demand error");
});
test("normalizeItem hard-fails a demand with a non-boolean answered", () => {
const res = normalizeItem({
title: "T",
url: "https://x.test/a",
source: "tavily",
topics: ["ai"],
demand: { strength: "thin", answered: "no" },
});
assert.equal(res.ok, false);
});
test("demand absent → key omitted (a pre-N7.5 record reads unchanged)", () => {
const res = normalizeItem({ title: "T", url: "https://x.test/a", source: "tavily", topics: ["ai"] });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal("demand" in res.item, false);
assert.equal("demand" in itemToInput(res.item, "2026-07-20"), false);
});
});
// ── (2) classifyMarketGap — the §4 "ærlig markedsdom" asymmetry ─────────────────────────────
describe("N7.5 — classifyMarketGap (supply-gap ≠ demand-gap ≠ saturated)", () => {
const strong: DemandSignal = { strength: "strong", answered: false };
test("many ask, none answer → supply-gap (the gold)", () => {
assert.equal(classifyMarketGap({ demand: strong }), "supply-gap");
assert.equal(classifyMarketGap({ demand: { strength: "moderate", answered: false } }), "supply-gap");
});
test("few ask but the sender can answer → demand-gap (highest value, lowest guaranteed audience)", () => {
assert.equal(
classifyMarketGap({ demand: { strength: "thin", answered: false }, verdict: "BÆRENDE" }),
"demand-gap",
);
assert.equal(
classifyMarketGap({ demand: { strength: "thin", answered: false }, actionability: { formulated: true } }),
"demand-gap",
);
});
test("already answered → saturated (don't write), regardless of demand strength", () => {
assert.equal(classifyMarketGap({ demand: { strength: "strong", answered: true } }), "saturated");
});
test("no demand signal → unknown (honest null, never invented)", () => {
assert.equal(classifyMarketGap({}), "unknown");
assert.equal(classifyMarketGap({ demand: { strength: "thin", answered: false } }), "unknown");
});
});
// ── (3) rankArcQuestions — §4 ranking, total order ──────────────────────────────────────────
describe("N7.5 — rankArcQuestions (etterspørsel × kan-svare × ikke-besvart)", () => {
test("supply-gap outranks demand-gap outranks saturated; unmeasured sinks last", () => {
const supply = rec({
id: "aaa1",
title: "supply",
url: "u1",
demand: { strength: "strong", answered: false },
verdict: "BÆRENDE",
actionability: { formulated: true },
});
const demand = rec({
id: "bbb2",
title: "demand",
url: "u2",
demand: { strength: "thin", answered: false },
verdict: "BÆRENDE",
actionability: { formulated: true },
});
const saturated = rec({
id: "ccc3",
title: "saturated",
url: "u3",
demand: { strength: "strong", answered: true },
});
const unmeasured = rec({ id: "ddd4", title: "unmeasured", url: "u4" });
const ranked = rankArcQuestions([saturated, unmeasured, demand, supply]);
assert.deepEqual(
ranked.map((q) => q.id),
["aaa1", "bbb2", "ccc3", "ddd4"],
);
assert.equal(ranked[0].gap, "supply-gap");
assert.equal(ranked[1].gap, "demand-gap");
assert.equal(ranked[2].gap, "saturated");
assert.equal(ranked[3].gap, "unknown");
});
test("total order is deterministic — ties broken by id ascending", () => {
const a = rec({ id: "zzz9", title: "z", url: "uz", demand: { strength: "moderate", answered: false } });
const b = rec({ id: "aaa1", title: "a", url: "ua", demand: { strength: "moderate", answered: false } });
const ranked = rankArcQuestions([a, b]);
assert.deepEqual(ranked.map((q) => q.id), ["aaa1", "zzz9"]);
// Same input, second call → identical order (pure).
assert.deepEqual(rankArcQuestions([a, b]), rankArcQuestions([b, a]));
});
test("saturated ranks strictly below any unanswered measured demand", () => {
const sat = rec({ id: "s", title: "s", url: "us", demand: { strength: "strong", answered: true } });
const open = rec({ id: "o", title: "o", url: "uo", demand: { strength: "thin", answered: false } });
const ranked = rankArcQuestions([sat, open]);
assert.equal(ranked[0].id, "o");
assert.equal(ranked[1].id, "s");
});
});
// ── (4) toArcQuestion + groupIntoArcs — reader's words, relatedIds veins ─────────────────────
describe("N7.5 — arc grouping (the vein, not the news-item)", () => {
test("toArcQuestion uses the reader's words (pass 3), keeping the field's words alongside", () => {
const q = toArcQuestion(
rec({
id: "x1",
title: "OKF-bundle second-brain interop",
url: "u",
readerQuestion: "Will our chat logs become public record?",
painPoint: "arkivplikt",
}),
);
assert.equal(q.question, "Will our chat logs become public record?");
assert.equal(q.fieldQuestion, "OKF-bundle second-brain interop");
assert.equal(q.painPoint, "arkivplikt");
});
test("toArcQuestion falls back to the title when no reader question is set (backward-compat)", () => {
const q = toArcQuestion(rec({ id: "x2", title: "Only a headline", url: "u" }));
assert.equal(q.question, "Only a headline");
});
test("relatedIds cluster transitively into one arc; unrelated records stay separate", () => {
const a = rec({ id: "a1", title: "A", url: "ua", relatedIds: ["b2"] });
const b = rec({ id: "b2", title: "B", url: "ub", relatedIds: ["c3"] }); // a→b→c transitive
const c = rec({ id: "c3", title: "C", url: "uc" });
const lone = rec({ id: "z9", title: "Z", url: "uz" });
const arcs = groupIntoArcs([a, b, c, lone]);
assert.equal(arcs.length, 2);
const vein = arcs.find((ar) => ar.questions.some((q) => q.id === "a1"))!;
assert.deepEqual(vein.questions.map((q) => q.id).sort(), ["a1", "b2", "c3"]);
const solo = arcs.find((ar) => ar.questions.some((q) => q.id === "z9"))!;
assert.deepEqual(solo.questions.map((q) => q.id), ["z9"]);
});
test("an arc partitions its questions into BÆRENDE (bearing) vs the rest (support)", () => {
const bearing = rec({ id: "a1", title: "bear", url: "ua", relatedIds: ["b2"], verdict: "BÆRENDE" });
const support = rec({ id: "b2", title: "supp", url: "ub", verdict: "STØTTE" });
const arc = groupIntoArcs([bearing, support])[0];
assert.deepEqual(arc.bearing.map((q) => q.id), ["a1"]);
assert.deepEqual(arc.support.map((q) => q.id), ["b2"]);
});
test("arcs are ordered by their top question's rank (highest-demand vein first)", () => {
const hot = rec({ id: "h1", title: "hot", url: "uh", demand: { strength: "strong", answered: false }, verdict: "BÆRENDE" });
const cold = rec({ id: "c1", title: "cold", url: "uc", demand: { strength: "thin", answered: true } });
const arcs = groupIntoArcs([cold, hot]);
assert.equal(arcs[0].questions[0].id, "h1");
});
});
// ── (5) renderArcs — §4 output-contract, deterministic, honest-null ──────────────────────────
describe("N7.5 — renderArcs (§4 output-contract)", () => {
const supplyVein = () =>
groupIntoArcs([
rec({
id: "a1",
title: "LLM wiki OKF-bundle",
url: "ua",
relatedIds: ["b2"],
readerQuestion: "Is our knowledge base going to leak?",
painPoint: "informasjonssikkerhet",
saturation: "no independent answer yet",
demand: { strength: "strong", answered: false },
verdict: "BÆRENDE",
actionability: { formulated: true },
}),
rec({
id: "b2",
title: "second brain agent memory",
url: "ub",
readerQuestion: "Who owns the notes when we are five people?",
demand: { strength: "moderate", answered: false },
verdict: "STØTTE",
}),
]);
test("renders the reader's words, not the field's jargon, in the question inventory", () => {
const md = renderArcs(supplyVein());
assert.ok(md.includes("Is our knowledge base going to leak?"), "reader question present");
assert.ok(!/^#.*OKF-bundle/m.test(md), "field jargon must not head a section");
});
test("renders the honest market verdict per vein (supply-gap = write here)", () => {
const md = renderArcs(supplyVein());
assert.ok(/markedsdom/i.test(md), "explicit market verdict");
assert.ok(/hull i tilbudet|supply-gap/i.test(md), "supply-gap named");
});
test("renders the BÆRENDE vs STØTTE arc proposal (reading order)", () => {
const md = renderArcs(supplyVein());
assert.ok(/BÆRENDE/.test(md) && /STØTTE/.test(md), "both roles surfaced");
});
test("an unmeasured / thin vein renders an explicit thin-signal marker (no invented demand)", () => {
const thin = groupIntoArcs([rec({ id: "t1", title: "quiet topic", url: "ut" })]);
const md = renderArcs(thin);
assert.ok(/tynt signal|unknown|umålt/i.test(md), "honest null surfaced, not filled in");
});
test("render is deterministic — same arcs → byte-identical output", () => {
assert.equal(renderArcs(supplyVein()), renderArcs(supplyVein()));
});
});
// ── (6) CLI roundtrip — the AC: a swept item flows verbatim capture → arcs §4 map ───────────
describe("N7.5 — CLI roundtrip (capture demand-swept item → arcs)", () => {
const trendsDir = fileURLToPath(new URL("..", import.meta.url));
const runCli = (args: string[], input = ""): { status: number | null; stdout: string } => {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
input,
encoding: "utf8",
cwd: trendsDir,
});
return { status: res.status, stdout: res.stdout };
};
test("readerQuestion/painPoint/saturation/demand survive capture and drive the arc map", () => {
const dir = tmp();
try {
const store = join(dir, "trends.json");
const item = JSON.stringify({
source: "tavily",
title: "OKF second-brain interop",
url: "https://example.com/okf",
topics: ["ai", "knowledge"],
readerQuestion: "Will our knowledge base become public record?",
painPoint: "arkivplikt + innsyn",
saturation: "setup guides saturated; the ownership question is open",
demand: { strength: "strong", answered: false },
verdict: "BÆRENDE",
actionability: { formulated: true },
});
assert.equal(runCli(["capture", "--store", store, "--json"], item).status, 0);
const md = runCli(["arcs", "--store", store]);
assert.equal(md.status, 0);
assert.ok(
/## Åre 1: Will our knowledge base become public record\?/.test(md.stdout),
"the vein heading uses the reader's words, not the field's jargon",
);
assert.ok(/hull i tilbudet|supply-gap/i.test(md.stdout), "the honest market verdict is rendered");
assert.ok(/feltets ord: OKF second-brain interop/.test(md.stdout), "field words kept alongside");
const j = runCli(["arcs", "--store", store, "--json"]);
const parsed = JSON.parse(j.stdout);
assert.equal(parsed[0].questions[0].demand.strength, "strong");
assert.equal(parsed[0].gap, "supply-gap");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});