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:
parent
0b34218a38
commit
9de38c4939
16 changed files with 968 additions and 8 deletions
255
scripts/trends/src/arc.ts
Normal file
255
scripts/trends/src/arc.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* The arc (åre) layer — MR-F9 «innenfra og ut» output contract (N7.5).
|
||||
*
|
||||
* Discovery ranks candidates on SENDER fit ("does this suit my pillars"). This layer inverts the
|
||||
* view: it reasons over the demand-sweep's READER-side judgment — which questions are actually asked
|
||||
* (etterspørsel), whether the sender can answer them (kan-svare), and whether they are already
|
||||
* answered elsewhere (ikke-besvart) — and groups related records into VEINS (arcs), the unit a warm
|
||||
* topic really is. Each question in a vein is one actionable edition.
|
||||
*
|
||||
* Pure + deterministic by contract (like brief.ts): no fs, no clock, no AI, no network. The reader
|
||||
* judgments (readerQuestion / painPoint / saturation / demand / verdict / actionability) are the
|
||||
* demand-spotter agent's output, persisted on the record; this module only combines + classifies +
|
||||
* ranks + renders them. GENERIC BY ARCHITECTURE: no niche calibration here — what counts as demand
|
||||
* or as a grip is the sweep's judgment, carried by the fields, never hard-coded.
|
||||
*/
|
||||
|
||||
import type { TrendRecord, TrendVerdict, DemandSignal } from "./types.js";
|
||||
|
||||
/**
|
||||
* The honest market verdict for a question (§4 "ærlig markedsdom"). The asymmetry the engine MUST
|
||||
* express: a gap in the SUPPLY (many ask, none answer = gold) is not a gap in the DEMAND (none ask,
|
||||
* but the sender can answer = highest value, lowest guaranteed audience). `saturated` = answered
|
||||
* already (don't write). `unknown` = the sweep did not measure enough to judge (honest null).
|
||||
*/
|
||||
export type MarketGap = "supply-gap" | "demand-gap" | "saturated" | "unknown";
|
||||
|
||||
/** A reader-side view of the demand inputs, redeclared structurally so callers pass a plain shape. */
|
||||
interface GapInputs {
|
||||
demand?: DemandSignal;
|
||||
verdict?: TrendVerdict;
|
||||
actionability?: { formulated: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the honest market verdict (§4). Answered ⇒ saturated. Unanswered-and-asked (strong/
|
||||
* moderate) ⇒ supply-gap. Unanswered-but-thin, yet the sender can carry it (BÆRENDE verdict or a
|
||||
* formulated grip) ⇒ demand-gap. Everything else (no demand, or thin with no way to answer) ⇒
|
||||
* unknown — never invented into a gap. Pure.
|
||||
*/
|
||||
export function classifyMarketGap(r: GapInputs): MarketGap {
|
||||
const d = r.demand;
|
||||
if (!d) return "unknown";
|
||||
if (d.answered) return "saturated";
|
||||
if (d.strength === "strong" || d.strength === "moderate") return "supply-gap";
|
||||
// thin, unanswered — a demand-gap only when the sender can actually answer it.
|
||||
if (r.verdict === "BÆRENDE" || r.actionability?.formulated === true) return "demand-gap";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** One reader question inside a vein — the record projected onto its demand-side facets, plus the derived gap + rank. */
|
||||
export interface ArcQuestion {
|
||||
id: string;
|
||||
/** The reader's OWN words (pass 3) — falls back to the record title when no readerQuestion is set. */
|
||||
question: string;
|
||||
/** The field's words (the record title) — kept alongside so the "begge, side om side" contract holds. */
|
||||
fieldQuestion: string;
|
||||
painPoint?: string;
|
||||
saturation?: string;
|
||||
verdict?: TrendVerdict;
|
||||
/** actionability.formulated — whether a reader-actionable grip is formulated. */
|
||||
gripFormulated?: boolean;
|
||||
demand?: DemandSignal;
|
||||
/** The honest market verdict (§4). */
|
||||
gap: MarketGap;
|
||||
/** Descending demand rank: `etterspørsel × kan-svare × ikke-besvart` (higher = write this first). */
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/** etterspørsel — how strongly asked. Absent (unmeasured) ⇒ 0, so an unmeasured question never floats up. */
|
||||
function demandWeight(d: DemandSignal | undefined): number {
|
||||
if (!d) return 0;
|
||||
return d.strength === "strong" ? 3 : d.strength === "moderate" ? 2 : 1;
|
||||
}
|
||||
|
||||
/** ikke-besvart — false (open) outweighs unmeasured outweighs answered (saturated ⇒ 0, sinks). */
|
||||
function openWeight(d: DemandSignal | undefined): number {
|
||||
if (!d) return 1; // unmeasured openness — between answered and known-open
|
||||
return d.answered ? 0 : 2;
|
||||
}
|
||||
|
||||
/** kan-avsenderen-svare — the verdict (BÆRENDE > STØTTE > NYHET) plus a bump for a formulated grip. Min 1. */
|
||||
function answerWeight(verdict: TrendVerdict | undefined, grip: boolean | undefined): number {
|
||||
const base = verdict === "BÆRENDE" ? 3 : verdict === "STØTTE" ? 2 : 1;
|
||||
return base + (grip ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a record onto a reader question: reader's words with a title fallback, the demand-side
|
||||
* facets, the derived gap, and the multiplicative rank. Multiplicative by design (§4's `×`): a zero
|
||||
* on any axis kills the rank — answered ⇒ saturated ⇒ 0, unmeasured demand ⇒ 0 — so saturated and
|
||||
* unmeasured questions sink beneath any genuinely-asked, unanswered one. Pure.
|
||||
*/
|
||||
export function toArcQuestion(t: TrendRecord): ArcQuestion {
|
||||
const grip = t.actionability?.formulated;
|
||||
const rank = demandWeight(t.demand) * openWeight(t.demand) * answerWeight(t.verdict, grip);
|
||||
return {
|
||||
id: t.id,
|
||||
question: t.readerQuestion && t.readerQuestion.trim().length > 0 ? t.readerQuestion : t.title,
|
||||
fieldQuestion: t.title,
|
||||
...(t.painPoint !== undefined ? { painPoint: t.painPoint } : {}),
|
||||
...(t.saturation !== undefined ? { saturation: t.saturation } : {}),
|
||||
...(t.verdict !== undefined ? { verdict: t.verdict } : {}),
|
||||
...(grip !== undefined ? { gripFormulated: grip } : {}),
|
||||
...(t.demand !== undefined ? { demand: t.demand } : {}),
|
||||
gap: classifyMarketGap(t),
|
||||
rank,
|
||||
};
|
||||
}
|
||||
|
||||
/** rank desc, then id asc — a total order (id is the store's unique dedupe hash), so the output is fixed. */
|
||||
function byRankThenId(a: ArcQuestion, b: ArcQuestion): number {
|
||||
return b.rank - a.rank || a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
/** Rank a set of records as arc questions on `etterspørsel × kan-svare × ikke-besvart`. Pure, total order. */
|
||||
export function rankArcQuestions(records: TrendRecord[]): ArcQuestion[] {
|
||||
return records.map(toArcQuestion).sort(byRankThenId);
|
||||
}
|
||||
|
||||
/** A vein: related records clustered, their questions ranked, and split into bearing (BÆRENDE) vs support. */
|
||||
export interface Arc {
|
||||
/** Stable arc id — the lexicographically-smallest member id (deterministic, independent of input order). */
|
||||
id: string;
|
||||
/** The vein's questions, ranked (rankArcQuestions order). */
|
||||
questions: ArcQuestion[];
|
||||
/** The bearing questions (verdict BÆRENDE) — what an edition is built on. */
|
||||
bearing: ArcQuestion[];
|
||||
/** The supporting questions (STØTTE / NYHET / unjudged) — context around the bearing ones. */
|
||||
support: ArcQuestion[];
|
||||
/** The vein's honest market verdict — the top-ranked question's gap (the dominant signal). */
|
||||
gap: MarketGap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster records into veins by relatedIds TRANSITIVE closure (A→B, B→C ⇒ {A,B,C}), undirected: an
|
||||
* edge exists if either record names the other. A record with no link and none pointing at it is its
|
||||
* own singleton vein. Union-find over the record set; ids naming absent records are ignored. Pure.
|
||||
*/
|
||||
export function groupIntoArcs(records: TrendRecord[]): Arc[] {
|
||||
const index = new Map(records.map((r) => [r.id, r]));
|
||||
const parent = new Map<string, string>(records.map((r) => [r.id, r.id]));
|
||||
|
||||
const find = (x: string): string => {
|
||||
let root = x;
|
||||
while (parent.get(root) !== root) root = parent.get(root)!;
|
||||
// Path-compress for stability across repeated finds.
|
||||
let cur = x;
|
||||
while (parent.get(cur) !== root) {
|
||||
const next = parent.get(cur)!;
|
||||
parent.set(cur, root);
|
||||
cur = next;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
const union = (a: string, b: string): void => {
|
||||
const ra = find(a);
|
||||
const rb = find(b);
|
||||
if (ra === rb) return;
|
||||
// Attach the lexicographically-greater root under the smaller, so the component root is its min id.
|
||||
if (ra < rb) parent.set(rb, ra);
|
||||
else parent.set(ra, rb);
|
||||
};
|
||||
|
||||
for (const r of records) {
|
||||
for (const rel of r.relatedIds ?? []) {
|
||||
if (index.has(rel)) union(r.id, rel);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = new Map<string, TrendRecord[]>();
|
||||
for (const r of records) {
|
||||
const root = find(r.id);
|
||||
const bucket = groups.get(root);
|
||||
if (bucket) bucket.push(r);
|
||||
else groups.set(root, [r]);
|
||||
}
|
||||
|
||||
const arcs: Arc[] = [];
|
||||
for (const [root, members] of groups) {
|
||||
const questions = rankArcQuestions(members);
|
||||
arcs.push({
|
||||
id: root,
|
||||
questions,
|
||||
bearing: questions.filter((q) => q.verdict === "BÆRENDE"),
|
||||
support: questions.filter((q) => q.verdict !== "BÆRENDE"),
|
||||
gap: questions[0]?.gap ?? "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
// Veins ordered by their hottest question, then arc id — highest-demand vein first, deterministically.
|
||||
return arcs.sort((a, b) => (b.questions[0]?.rank ?? 0) - (a.questions[0]?.rank ?? 0) || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** The human-readable market verdict line per gap (§4) — the honest "write here / don't". */
|
||||
function marketVerdictLine(gap: MarketGap): string {
|
||||
switch (gap) {
|
||||
case "supply-gap":
|
||||
return "hull i tilbudet (mange spør, ingen svarer) — skriv her";
|
||||
case "demand-gap":
|
||||
return "hull i etterspørselen (få spør, men avsenderen kan svare) — høyest verdi, lavest garantert publikum";
|
||||
case "saturated":
|
||||
return "mettet (allerede besvart) — ikke skriv";
|
||||
case "unknown":
|
||||
return "tynt / umålt signal — ikke fyll ut";
|
||||
}
|
||||
}
|
||||
|
||||
/** The compact demand token for a question line: strength + answered, when measured. */
|
||||
function demandToken(q: ArcQuestion): string {
|
||||
if (!q.demand) return "umålt etterspørsel";
|
||||
return `etterspørsel: ${q.demand.strength} · besvart: ${q.demand.answered ? "ja" : "nei"}`;
|
||||
}
|
||||
|
||||
/** Render one question's inventory entry — the reader's words lead; the field's words follow (both, side by side). */
|
||||
function renderQuestion(q: ArcQuestion, n: number): string[] {
|
||||
const out = [`${n}. ${q.question} · ${demandToken(q)} · [${q.gap}] · rank ${q.rank}`];
|
||||
if (q.fieldQuestion !== q.question) out.push(` - feltets ord: ${q.fieldQuestion}`);
|
||||
if (q.painPoint) out.push(` - 🩹 smertepunkt: ${q.painPoint}`);
|
||||
const grip = q.gripFormulated === undefined ? "—" : q.gripFormulated ? "ja" : "nei";
|
||||
out.push(` - 🎯 dom: ${q.verdict ?? "—"} · leser-grep: ${grip}`);
|
||||
if (q.saturation) out.push(` - 🌡️ metning: ${q.saturation}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A short, jargon-free vein label — the reader's words of its top question (never the field's title). */
|
||||
function arcTitle(arc: Arc): string {
|
||||
return arc.questions[0]?.question ?? arc.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the §4 output contract for a set of veins: per arc a jargon-free heading (the reader's
|
||||
* words), the honest market verdict, the ranked question inventory, and the BÆRENDE-vs-STØTTE reading
|
||||
* order. Deterministic — same arcs → byte-identical output (pure, no clock/fs). An empty set renders
|
||||
* an explicit marker rather than nothing.
|
||||
*/
|
||||
export function renderArcs(arcs: Arc[]): string {
|
||||
const lines: string[] = ["# Årer — etterspørsels-kart (innenfra og ut)", ""];
|
||||
if (arcs.length === 0) {
|
||||
lines.push("_Ingen årer — tynt signal i denne kjøringen._", "");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
arcs.forEach((arc, i) => {
|
||||
lines.push(`## Åre ${i + 1}: ${arcTitle(arc)}`);
|
||||
lines.push(`- **Ærlig markedsdom:** ${marketVerdictLine(arc.gap)}`);
|
||||
lines.push("");
|
||||
lines.push("### Spørsmålsinventar (rangert: etterspørsel × kan-svare × ikke-besvart)");
|
||||
arc.questions.forEach((q, n) => lines.push(...renderQuestion(q, n + 1)));
|
||||
lines.push("");
|
||||
lines.push("### Arc-forslag (leserekkefølge)");
|
||||
lines.push(`- BÆRENDE: ${arc.bearing.length > 0 ? arc.bearing.map((q) => q.question).join(" · ") : "—"}`);
|
||||
lines.push(`- STØTTE: ${arc.support.length > 0 ? arc.support.map((q) => q.question).join(" · ") : "—"}`);
|
||||
lines.push("");
|
||||
});
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
|
@ -280,6 +280,8 @@ function proposalLines(t: TrendRecord): string[] {
|
|||
if (t.readerQuestion) out.push(`- ❓ Leserspørsmål: ${t.readerQuestion}`);
|
||||
if (t.painPoint) out.push(`- 🩹 Smertepunkt: ${t.painPoint}`);
|
||||
if (t.saturation) out.push(`- 🌡️ Metning: ${t.saturation}`);
|
||||
// N7.5 (MR-F9): the demand-sweep's rankable signal, shown alongside the verbatim saturation above.
|
||||
if (t.demand) out.push(`- 📊 Etterspørsel: ${t.demand.strength} · besvart: ${t.demand.answered ? "ja" : "nei"}`);
|
||||
if (t.relatedIds && t.relatedIds.length > 0) out.push(`- ↔️ Relatert: ${t.relatedIds.join(", ")}`);
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N]
|
||||
* [--out <dir>] [--no-mark] [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts arcs [--topics <a,b>] [--out <dir>] [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
|
||||
* [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
|
||||
*
|
||||
|
|
@ -83,6 +84,7 @@ import {
|
|||
} from "./brief.js";
|
||||
import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js";
|
||||
import type { ScheduleSpec } from "./schedule.js";
|
||||
import { groupIntoArcs, renderArcs } from "./arc.js";
|
||||
|
||||
function parseFlags(args: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
|
@ -123,6 +125,7 @@ function usage(msg: string): never {
|
|||
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
|
||||
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
|
||||
" brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out <dir>] [--no-mark] [--store <path>] [--json]\n" +
|
||||
" arcs [--topics <a,b>] [--out <dir>] [--store <path>] [--json]\n" +
|
||||
" schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]",
|
||||
);
|
||||
process.exit(2);
|
||||
|
|
@ -418,6 +421,44 @@ function main(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (command === "arcs") {
|
||||
// The demand-sweep's §4 output (MR-F9, N7.5): group the demand-swept records into veins (arcs)
|
||||
// and render the reader-side output contract — question inventory ranked on etterspørsel × kan-svare
|
||||
// × ikke-besvart, per-vein market verdict (supply-gap ≠ demand-gap ≠ saturated), BÆRENDE/STØTTE order.
|
||||
// Reads only records that carry a demand-side signal (the sweep populated them); an optional --topics
|
||||
// narrows the pool to a theme. Print to stdout by default; --out <dir> writes a dated arc map; --json
|
||||
// emits the arc structures. No store mutation (a read-only view, like brief without the surfacing write).
|
||||
const store = loadStore(storePath);
|
||||
const wanted = splitTopics(flags.topics).map((t) => t.toLowerCase());
|
||||
const swept = store.trends.filter((t) => {
|
||||
const hasSignal =
|
||||
t.demand !== undefined ||
|
||||
t.readerQuestion !== undefined ||
|
||||
t.painPoint !== undefined ||
|
||||
t.saturation !== undefined;
|
||||
if (!hasSignal) return false;
|
||||
if (wanted.length === 0) return true;
|
||||
const have = new Set(t.topics.map((x) => x.toLowerCase()));
|
||||
return wanted.some((w) => have.has(w));
|
||||
});
|
||||
const arcs = groupIntoArcs(swept);
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(arcs, null, 2));
|
||||
return;
|
||||
}
|
||||
const md = renderArcs(arcs);
|
||||
if (flags.out && flags.out !== "true") {
|
||||
const outDir = flags.out;
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const path = join(outDir, `${today()}-arcs.md`);
|
||||
writeFileSync(path, md, "utf8");
|
||||
console.log(`Wrote arc map: ${path} (${arcs.length} åre(r), ${swept.length} swept record(s))`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(md);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "schedule") {
|
||||
// RE-R3c — print-first autonomous trigger. Emits (or installs) a launchd plist / cron line firing
|
||||
// the DETERMINISTIC brief daily via run-daily.sh; never runs launchctl or the cron table (C2).
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { normalizeField } from "./store.js";
|
|||
import type { TrendInput } from "./store.js";
|
||||
import { requiredDimensions, scoreEnvelope, capForActionability } from "./score.js";
|
||||
import type { ScoreMode, DimensionScores } from "./score.js";
|
||||
import type { TrendVerdict, Actionability } from "./types.js";
|
||||
import type { TrendVerdict, Actionability, DemandSignal } from "./types.js";
|
||||
|
||||
export interface TrendItem {
|
||||
/** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
|
||||
|
|
@ -67,6 +67,8 @@ export interface TrendItem {
|
|||
painPoint?: string;
|
||||
/** Market saturation judgment (MR-F9). Blank/absent -> omitted. */
|
||||
saturation?: string;
|
||||
/** Demand-sweep signal (MR-F9, N7.5). Present-but-malformed -> validation error (like actionability). */
|
||||
demand?: DemandSignal;
|
||||
}
|
||||
|
||||
export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };
|
||||
|
|
@ -131,6 +133,26 @@ function validateScore(
|
|||
/** The closed reader-side utility vocabulary (MR-F7) — mechanism, not niche calibration. */
|
||||
const VERDICTS = ["BÆRENDE", "STØTTE", "NYHET"] as const;
|
||||
|
||||
/** The closed demand-strength vocabulary (MR-F9, N7.5) — the sweep's honest ordinal, not niche calibration. */
|
||||
const DEMAND_STRENGTHS = ["strong", "moderate", "thin"] as const;
|
||||
|
||||
/**
|
||||
* Validate an optional `demand` (MR-F9, N7.5) — never throws. `strength` must be one of the closed
|
||||
* vocabulary; `answered` is a REQUIRED boolean (the rankable "is it already answered" the arc layer
|
||||
* reads). Hard-fail when present-but-malformed, like actionability — a bad payload is caught at the
|
||||
* seam, not persisted silently.
|
||||
*/
|
||||
function validateDemand(raw: unknown): { ok: true; value: DemandSignal } | { ok: false; reason: string } {
|
||||
if (!isPlainObject(raw)) return { ok: false, reason: "demand must be an object" };
|
||||
if (typeof raw.strength !== "string" || !(DEMAND_STRENGTHS as readonly string[]).includes(raw.strength)) {
|
||||
return { ok: false, reason: `demand.strength must be one of ${DEMAND_STRENGTHS.join(", ")} (got ${String(raw.strength)})` };
|
||||
}
|
||||
if (typeof raw.answered !== "boolean") {
|
||||
return { ok: false, reason: `demand.answered must be a boolean (got ${String(raw.answered)})` };
|
||||
}
|
||||
return { ok: true, value: { strength: raw.strength as DemandSignal["strength"], answered: raw.answered } };
|
||||
}
|
||||
|
||||
/** The free-text proposal fields, all validated by the `summary` idiom (non-empty string -> kept verbatim, else omitted). */
|
||||
const FREE_TEXT_FIELDS = ["angle", "targetLevel", "rationale", "readerQuestion", "painPoint", "saturation"] as const;
|
||||
|
||||
|
|
@ -238,6 +260,13 @@ export function normalizeItem(raw: unknown): NormalizeResult {
|
|||
}
|
||||
}
|
||||
|
||||
let demand: DemandSignal | undefined;
|
||||
if (r.demand !== undefined && r.demand !== null) {
|
||||
const res = validateDemand(r.demand);
|
||||
if (!res.ok) errors.push(`invalid demand: ${res.reason}`);
|
||||
else demand = res.value;
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { ok: false, errors };
|
||||
|
||||
// N6 free-text fields: the summary idiom (non-empty string kept verbatim, else the key is omitted).
|
||||
|
|
@ -259,6 +288,7 @@ export function normalizeItem(raw: unknown): NormalizeResult {
|
|||
...(relatedIds.length > 0 ? { relatedIds } : {}),
|
||||
...(actionability !== undefined ? { actionability } : {}),
|
||||
...(verdict !== undefined ? { verdict } : {}),
|
||||
...(demand !== undefined ? { demand } : {}),
|
||||
};
|
||||
return { ok: true, item };
|
||||
}
|
||||
|
|
@ -298,6 +328,7 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
|
|||
...(item.readerQuestion !== undefined ? { readerQuestion: item.readerQuestion } : {}),
|
||||
...(item.painPoint !== undefined ? { painPoint: item.painPoint } : {}),
|
||||
...(item.saturation !== undefined ? { saturation: item.saturation } : {}),
|
||||
...(item.demand !== undefined ? { demand: item.demand } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { homedir } from "node:os";
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import { SCHEMA_VERSION } from "./types.js";
|
||||
import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus, TrendVerdict, Actionability } from "./types.js";
|
||||
import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus, TrendVerdict, Actionability, DemandSignal } from "./types.js";
|
||||
import type { TrendScore } from "./score.js";
|
||||
|
||||
export { SCHEMA_VERSION } from "./types.js";
|
||||
|
|
@ -54,6 +54,8 @@ export interface TrendInput {
|
|||
painPoint?: string;
|
||||
/** Market saturation judgment (MR-F9). */
|
||||
saturation?: string;
|
||||
/** Demand-sweep signal — rankable etterspørsel/ikke-besvart (MR-F9, N7.5). */
|
||||
demand?: DemandSignal;
|
||||
}
|
||||
|
||||
export interface AddResult {
|
||||
|
|
@ -179,6 +181,7 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
|
|||
...(input.readerQuestion !== undefined ? { readerQuestion: input.readerQuestion } : {}),
|
||||
...(input.painPoint !== undefined ? { painPoint: input.painPoint } : {}),
|
||||
...(input.saturation !== undefined ? { saturation: input.saturation } : {}),
|
||||
...(input.demand !== undefined ? { demand: input.demand } : {}),
|
||||
};
|
||||
store.trends.push(trend);
|
||||
return { store, added: true, merged: false };
|
||||
|
|
|
|||
|
|
@ -56,6 +56,25 @@ export interface Actionability {
|
|||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The demand-side signal (N7.5 / MR-F9): the «innenfra og ut» demand-sweep's RANKABLE judgment of
|
||||
* a reader question — how strongly it is actually asked (etterspørsel) and whether a good answer is
|
||||
* already out there (ikke-besvart). The rankable twin of the verbatim `saturation` text, exactly as
|
||||
* `actionability.formulated` (bool) is the rankable twin of its verbatim `note` — one for arithmetic,
|
||||
* one for the human. Avsender-neutral mechanism: what counts as demand is the sweep's judgment, never
|
||||
* niche calibration hard-coded here. Absent on a record ⇒ UNMEASURED (the honest null — the sweep is
|
||||
* allowed to say "thin signal" via `strength: "thin"`, or nothing at all, rather than invent demand).
|
||||
*/
|
||||
export interface DemandSignal {
|
||||
/**
|
||||
* How strongly the question is asked — the sweep's honest ordinal. `"thin"` is a real thin-signal
|
||||
* FINDING (measured, and low), distinct from the field being absent (never measured).
|
||||
*/
|
||||
strength: "strong" | "moderate" | "thin";
|
||||
/** Is a good answer already out there? `true` ⇒ saturated (the supply gap is closed — don't write). */
|
||||
answered: boolean;
|
||||
}
|
||||
|
||||
export interface TrendRecord {
|
||||
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
|
||||
id: string;
|
||||
|
|
@ -131,6 +150,11 @@ export interface TrendRecord {
|
|||
painPoint?: string;
|
||||
/** The market saturation judgment — is this already answered by others? (metningsdom), VERBATIM. */
|
||||
saturation?: string;
|
||||
/**
|
||||
* The demand-sweep's rankable demand signal (N7.5). First-sight like the other N6/F9 fields — a
|
||||
* re-capture never clobbers a triaged demand read. Absent ⇒ unmeasured. See {@link DemandSignal}.
|
||||
*/
|
||||
demand?: DemandSignal;
|
||||
}
|
||||
|
||||
export interface TrendStore {
|
||||
|
|
@ -146,10 +170,11 @@ export interface TrendQueryHit {
|
|||
}
|
||||
|
||||
/**
|
||||
* The store schema version. Still 4 after N6: the eight N6 proposal fields
|
||||
* The store schema version. Still 4 after N6 AND N7.5: the eight N6 proposal fields
|
||||
* (angle/targetLevel/rationale/relatedIds/actionability/verdict/readerQuestion/painPoint/saturation)
|
||||
* are additive-optional — a pre-N6 v4 record is already a valid post-N6 v4 record that simply lacks
|
||||
* them, so NO record needs migrating and a version bump would be a marker with no migration behind it.
|
||||
* (Earlier slices bumped because they were the first to add fields at all; the choice is deliberate.)
|
||||
* plus the N7.5 `demand` signal are all additive-optional — a pre-N6 v4 record is already a valid
|
||||
* post-N7.5 v4 record that simply lacks them, so NO record needs migrating and a version bump would
|
||||
* be a marker with no migration behind it. (Earlier slices bumped because they were the first to add
|
||||
* fields at all; the choice is deliberate.)
|
||||
*/
|
||||
export const SCHEMA_VERSION = 4;
|
||||
|
|
|
|||
372
scripts/trends/tests/n7_5-demand-sweep.test.ts
Normal file
372
scripts/trends/tests/n7_5-demand-sweep.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue