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
This commit is contained in:
parent
bb0725af66
commit
65b60337fe
9 changed files with 732 additions and 33 deletions
|
|
@ -4,7 +4,7 @@ description: |
|
||||||
Run a trend discovery pass over the user's own content pillars and source list:
|
Run a trend discovery pass over the user's own content pillars and source list:
|
||||||
delegate the scan to the trend-spotter agent, make sure kept candidates are persisted
|
delegate the scan to the trend-spotter agent, make sure kept candidates are persisted
|
||||||
to the trend store (dedup) and the dated morning brief is written, then return a
|
to the trend store (dedup) and the dated morning brief is written, then return a
|
||||||
triage-ranked candidate list the user resolves per id (act/skip). Default scoring mode
|
triage-ranked candidate list the user resolves per id (select/skip, batched). Default scoring mode
|
||||||
is long-form (chronicle/newsletter material); `--mode kortform` overrides for feed posts.
|
is long-form (chronicle/newsletter material); `--mode kortform` overrides for feed posts.
|
||||||
Use when the user wants a discovery pass, a trend scan, or a morning-brief refresh.
|
Use when the user wants a discovery pass, a trend scan, or a morning-brief refresh.
|
||||||
Triggers on: "linkedin trends", "trend discovery", "discovery pass", "run a trend scan",
|
Triggers on: "linkedin trends", "trend discovery", "discovery pass", "run a trend scan",
|
||||||
|
|
@ -107,19 +107,23 @@ Resolve the top of the queue now instead of leaving it as homework. For the cand
|
||||||
top bands (Immediate + High; cap at 8), use AskUserQuestion — one question per candidate, up
|
top bands (Immediate + High; cap at 8), use AskUserQuestion — one question per candidate, up
|
||||||
to 4 candidates per call, options:
|
to 4 candidates per call, options:
|
||||||
|
|
||||||
- **Act** — writing about it now/soon: mark handled so the brief stops re-surfacing it
|
- **Velg** — you'll write about this: mark `selected`, moving it onto the brief's "I produksjon"
|
||||||
- **Skip** — not for me: same effect, opposite verdict
|
board (valgt) so the queue stops re-surfacing it while it's in progress
|
||||||
|
- **Skip** — not for me: mark `skipped` (dropped from the queue)
|
||||||
- **Leave** — keep it in the queue untouched
|
- **Leave** — keep it in the queue untouched
|
||||||
|
|
||||||
Then apply each decision through the store CLI, one call per resolved id:
|
Then apply the decisions through the store CLI. **Batch by verb** — collect all the "Velg" ids
|
||||||
|
and all the "Skip" ids and resolve each set in ONE call (ten candidates ≤ two calls):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CLI act --id <id> # or: skip --id <id>
|
CLI select --ids <id1,id2,id3> # everything chosen this pass
|
||||||
|
CLI skip --ids <id4,id5> # everything rejected this pass
|
||||||
```
|
```
|
||||||
|
|
||||||
"Leave" means no call. Finish with a one-line summary: N acted, N skipped, N left in queue.
|
Single-id form (`--id <id>`) still works for a one-off. "Leave" means no call. A partial batch
|
||||||
(The CLI currently takes one `--id` per call; a `selected` status and `--ids` batching are
|
(some id unknown) still applies the matches, reports the misses, and exits 0. Finish with a
|
||||||
planned upgrades — keep decisions per-id so this step absorbs them without contract change.)
|
one-line summary: N valgt, N skipped, N left in queue. (`act` — already written — and the
|
||||||
|
auto-`act` when an edition reaches scheduling arrive with the N7 trend→newsletter bridge.)
|
||||||
|
|
||||||
## Step 6: Write the last-run marker (skip on `--dry-run`)
|
## Step 6: Write the last-run marker (skip on `--dry-run`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ export interface BriefRanking {
|
||||||
singleMatches: BriefEntry[];
|
singleMatches: BriefEntry[];
|
||||||
/** overlap >= 1 AND NOT fresh. */
|
/** overlap >= 1 AND NOT fresh. */
|
||||||
olderMatched: BriefEntry[];
|
olderMatched: BriefEntry[];
|
||||||
|
/**
|
||||||
|
* The "I produksjon" board (N6, A1-7): records the operator has pulled out of the work queue —
|
||||||
|
* status `selected` (valgt, in progress) or `acted` (skrevet, done). Pillar-independent (already
|
||||||
|
* chosen), so they bypass the overlap filter entirely; sorted selected-before-acted, then title/url.
|
||||||
|
*/
|
||||||
|
inProduction: TrendRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RankOptions {
|
export interface RankOptions {
|
||||||
|
|
@ -132,9 +138,16 @@ export function rankForBrief(
|
||||||
const wantedLower = pillars.map((p) => p.toLowerCase());
|
const wantedLower = pillars.map((p) => p.toLowerCase());
|
||||||
|
|
||||||
const entries: BriefEntry[] = [];
|
const entries: BriefEntry[] = [];
|
||||||
|
const inProduction: TrendRecord[] = [];
|
||||||
for (const trend of store.trends) {
|
for (const trend of store.trends) {
|
||||||
// RE-R3b (A3): acted/skipped are handled — drop from the work queue (the brief is a queue, not an archive).
|
const st = effectiveStatus(trend);
|
||||||
if (effectiveStatus(trend) !== "new") continue;
|
// N6 (A1-7): selected/acted are "in production" — collected for their own board, out of the queue.
|
||||||
|
if (st === "selected" || st === "acted") {
|
||||||
|
inProduction.push(trend);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// RE-R3b (A3): skipped is handled — drop from the work queue (the brief is a queue, not an archive).
|
||||||
|
if (st !== "new") continue;
|
||||||
const have = new Set(trend.topics.map((t) => t.toLowerCase()));
|
const have = new Set(trend.topics.map((t) => t.toLowerCase()));
|
||||||
const matchedPillars: string[] = [];
|
const matchedPillars: string[] = [];
|
||||||
for (let i = 0; i < pillars.length; i++) {
|
for (let i = 0; i < pillars.length; i++) {
|
||||||
|
|
@ -185,6 +198,12 @@ export function rankForBrief(
|
||||||
const singleMatches = entries.filter((e) => e.overlap === 1 && isFresh(e)).sort(cmp);
|
const singleMatches = entries.filter((e) => e.overlap === 1 && isFresh(e)).sort(cmp);
|
||||||
const olderMatched = entries.filter((e) => !isFresh(e)).sort(cmp); // overlap>=1 (0 already excluded)
|
const olderMatched = entries.filter((e) => !isFresh(e)).sort(cmp); // overlap>=1 (0 already excluded)
|
||||||
|
|
||||||
|
// A total order for the production board: selected (in progress) before acted (done), then title, then url.
|
||||||
|
const prodRank = (t: TrendRecord): number => (effectiveStatus(t) === "selected" ? 0 : 1);
|
||||||
|
inProduction.sort(
|
||||||
|
(a, b) => prodRank(a) - prodRank(b) || a.title.localeCompare(b.title) || a.url.localeCompare(b.url),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
today,
|
today,
|
||||||
freshDays,
|
freshDays,
|
||||||
|
|
@ -192,6 +211,7 @@ export function rankForBrief(
|
||||||
topMatches,
|
topMatches,
|
||||||
singleMatches,
|
singleMatches,
|
||||||
olderMatched,
|
olderMatched,
|
||||||
|
inProduction,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,19 +261,72 @@ function temporalToken(e: BriefEntry): string {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The N6 proposal fields as detailed `- ` lines (top entries only) — each emitted ONLY when present,
|
||||||
|
* so an unproposed trend renders exactly as before (backward-compat). Order is fixed for determinism.
|
||||||
|
* The verdict + reader-grip share one line (the N7 band-cap gate's two inputs, shown together).
|
||||||
|
*/
|
||||||
|
function proposalLines(t: TrendRecord): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (t.angle) out.push(`- 💡 Vinkel: ${t.angle}`);
|
||||||
|
if (t.targetLevel) out.push(`- 🎚️ Målnivå: ${t.targetLevel}`);
|
||||||
|
if (t.rationale) out.push(`- 🧭 Hvorfor nå: ${t.rationale}`);
|
||||||
|
if (t.verdict || t.actionability) {
|
||||||
|
const grip = t.actionability
|
||||||
|
? `${t.actionability.formulated ? "ja" : "nei"}${t.actionability.note ? ` («${t.actionability.note}»)` : ""}`
|
||||||
|
: "—";
|
||||||
|
out.push(`- 🎯 Dom: ${t.verdict ?? "—"} · leser-grep: ${grip}`);
|
||||||
|
}
|
||||||
|
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}`);
|
||||||
|
if (t.relatedIds && t.relatedIds.length > 0) out.push(`- ↔️ Relatert: ${t.relatedIds.join(", ")}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The compact proposal token for single-line bullets: verdict + a reader-grip flag, when present. */
|
||||||
|
function proposalToken(t: TrendRecord): string {
|
||||||
|
const bits: string[] = [];
|
||||||
|
if (t.verdict) bits.push(`🎯 ${t.verdict}`);
|
||||||
|
if (t.actionability) bits.push(`grep: ${t.actionability.formulated ? "ja" : "nei"}`);
|
||||||
|
return bits.length > 0 ? ` · ${bits.join(" · ")}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
function renderTopEntry(e: BriefEntry, n: number): string[] {
|
function renderTopEntry(e: BriefEntry, n: number): string[] {
|
||||||
const lines = [
|
const lines = [
|
||||||
`### ${n}. ${e.trend.title}`,
|
`### ${n}. ${e.trend.title}`,
|
||||||
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``,
|
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``,
|
||||||
];
|
];
|
||||||
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
|
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
|
||||||
|
lines.push(...proposalLines(e.trend));
|
||||||
lines.push(`- 🔗 ${e.trend.url}`);
|
lines.push(`- 🔗 ${e.trend.url}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBulletEntry(e: BriefEntry): string {
|
function renderBulletEntry(e: BriefEntry): string {
|
||||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
|
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)}${proposalToken(e.trend)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "I produksjon" board (N6, A1-7): the selected/acted records rankForBrief set aside, each a
|
||||||
|
* compact line tagged [valgt]/[skrevet]. Pure. The section header is always emitted (stable structure);
|
||||||
|
* an empty board renders the explicit "_Ingen i produksjon._" marker.
|
||||||
|
*/
|
||||||
|
function renderInProduction(records: TrendRecord[]): string[] {
|
||||||
|
const lines = ["## 🚧 I produksjon (valgt + skrevet)"];
|
||||||
|
if (records.length === 0) {
|
||||||
|
lines.push("_Ingen i produksjon._", "");
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
for (const t of records) {
|
||||||
|
const tag = t.status === "acted" ? "skrevet" : "valgt";
|
||||||
|
const angle = t.angle ? ` · 💡 ${t.angle}` : "";
|
||||||
|
const verdict = t.verdict ? ` · 🎯 ${t.verdict}` : "";
|
||||||
|
lines.push(`- [${tag}] **${t.title}**${angle}${verdict} · \`${t.id}\``);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -272,7 +345,7 @@ export function renderBrief(
|
||||||
lines.push(`date: ${ranking.today}`);
|
lines.push(`date: ${ranking.today}`);
|
||||||
lines.push(`summary: ${briefSummary(ranking, diff)}`);
|
lines.push(`summary: ${briefSummary(ranking, diff)}`);
|
||||||
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
|
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
|
||||||
lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`);
|
lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped/selected (selected+acted shown in I produksjon)`);
|
||||||
// RE-R3e: the set of ids this brief showed — the record the NEXT day's diff reads. Always
|
// RE-R3e: the set of ids this brief showed — the record the NEXT day's diff reads. Always
|
||||||
// emitted (even blank for an empty store); independent of --no-mark (a property of the render).
|
// emitted (even blank for an empty store); independent of --no-mark (a property of the render).
|
||||||
lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`);
|
lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`);
|
||||||
|
|
@ -323,6 +396,9 @@ export function renderBrief(
|
||||||
ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e)));
|
ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e)));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
|
// N6 (A1-7): the production board — where a triaged candidate lives once it leaves the queue.
|
||||||
|
lines.push(...renderInProduction(ranking.inProduction));
|
||||||
|
|
||||||
lines.push("---");
|
lines.push("---");
|
||||||
lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_");
|
lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* node --import tsx src/cli.ts query --topics <a,b> [--store <path>] [--json]
|
* node --import tsx src/cli.ts query --topics <a,b> [--store <path>] [--json]
|
||||||
* node --import tsx src/cli.ts list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]
|
* node --import tsx src/cli.ts list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]
|
||||||
* node --import tsx src/cli.ts status [--store <path>] [--json]
|
* node --import tsx src/cli.ts status [--store <path>] [--json]
|
||||||
* node --import tsx src/cli.ts act|skip|reset --id <id> [--store <path>]
|
* node --import tsx src/cli.ts act|skip|reset|select (--id <id> | --ids <a,b,c>) [--store <path>]
|
||||||
* echo '<raw item|batch>' | node --import tsx src/cli.ts normalize
|
* echo '<raw item|batch>' | node --import tsx src/cli.ts normalize
|
||||||
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
|
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
|
||||||
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
|
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
|
||||||
|
|
@ -26,8 +26,10 @@
|
||||||
* SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated
|
* SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated
|
||||||
* down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`.
|
* down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`.
|
||||||
* `add` is the MANUAL single-trend path (raw flags, no
|
* `add` is the MANUAL single-trend path (raw flags, no
|
||||||
* normalization, publish-date-free). `act`/`skip`/`reset --id` set a trend's lifecycle status
|
* normalization, publish-date-free). `act`/`skip`/`reset`/`select` set a trend's lifecycle status
|
||||||
* (RE-R3b): the morning brief EXCLUDES acted/skipped and records each surfacing (per-day-idempotent
|
* (RE-R3b + N6 `selected`), one id (`--id`) or a batch (`--ids a,b,c`, A1-9): the morning brief
|
||||||
|
* EXCLUDES acted/skipped/selected from the work queue (selected+acted show in "I produksjon"
|
||||||
|
* instead) and records each surfacing (per-day-idempotent
|
||||||
* `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score
|
* `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score
|
||||||
* (timing decays). The polling + relevance-scoring itself lives upstream; this is the deterministic store.
|
* (timing decays). The polling + relevance-scoring itself lives upstream; this is the deterministic store.
|
||||||
*
|
*
|
||||||
|
|
@ -63,6 +65,7 @@ import {
|
||||||
queryByTopic,
|
queryByTopic,
|
||||||
saveStore,
|
saveStore,
|
||||||
setStatus,
|
setStatus,
|
||||||
|
setStatusMany,
|
||||||
} from "./store.js";
|
} from "./store.js";
|
||||||
import type { TrendStatus } from "./types.js";
|
import type { TrendStatus } from "./types.js";
|
||||||
import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
|
import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
|
||||||
|
|
@ -115,7 +118,7 @@ function usage(msg: string): never {
|
||||||
" query --topics <a,b> [--store <path>] [--json]\n" +
|
" query --topics <a,b> [--store <path>] [--json]\n" +
|
||||||
" list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" +
|
" list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" +
|
||||||
" status [--store <path>] [--json]\n" +
|
" status [--store <path>] [--json]\n" +
|
||||||
" act|skip|reset --id <id> [--store <path>]\n" +
|
" act|skip|reset|select (--id <id> | --ids <a,b,c>) [--store <path>]\n" +
|
||||||
" normalize < raw-item-or-batch.json\n" +
|
" normalize < raw-item-or-batch.json\n" +
|
||||||
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
|
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
|
||||||
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
|
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
|
||||||
|
|
@ -244,18 +247,28 @@ function main(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === "act" || command === "skip" || command === "reset") {
|
if (command === "act" || command === "skip" || command === "reset" || command === "select") {
|
||||||
const id = flags.id;
|
// Ids from --ids <a,b,c> (batch, A1-9) or a single --id <id>; --ids wins when both are given.
|
||||||
if (!id || id === "true") usage(`${command} needs --id <id>`);
|
// splitTopics is the generic comma-split+trim+drop-blank helper (ids are case-sensitive, not lowercased).
|
||||||
const status: TrendStatus = command === "act" ? "acted" : command === "skip" ? "skipped" : "new";
|
const ids =
|
||||||
|
flags.ids && flags.ids !== "true"
|
||||||
|
? splitTopics(flags.ids)
|
||||||
|
: flags.id && flags.id !== "true"
|
||||||
|
? [flags.id]
|
||||||
|
: [];
|
||||||
|
if (ids.length === 0) usage(`${command} needs --id <id> or --ids <a,b,c>`);
|
||||||
|
const status: TrendStatus =
|
||||||
|
command === "act" ? "acted" : command === "skip" ? "skipped" : command === "select" ? "selected" : "new";
|
||||||
const store = loadStore(storePath);
|
const store = loadStore(storePath);
|
||||||
const res = setStatus(store, id, status);
|
const res = setStatusMany(store, ids, status);
|
||||||
if (!res.found) {
|
// All-miss is an argument-class error (exit 2, store untouched); any hit saves + reports the misses (exit 0).
|
||||||
console.error(`error: no trend with id: ${id}`);
|
if (res.found.length === 0) {
|
||||||
|
console.error(`error: no trend with id: ${res.notFound.join(", ")}`);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
saveStore(storePath, store);
|
saveStore(storePath, store);
|
||||||
console.log(`Marked ${id} ${status}`);
|
const miss = res.notFound.length > 0 ? ` (${res.notFound.length} not found: ${res.notFound.join(", ")})` : "";
|
||||||
|
console.log(`Marked ${res.found.length} ${status}${miss}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { normalizeField } from "./store.js";
|
||||||
import type { TrendInput } from "./store.js";
|
import type { TrendInput } from "./store.js";
|
||||||
import { requiredDimensions, scoreEnvelope } from "./score.js";
|
import { requiredDimensions, scoreEnvelope } from "./score.js";
|
||||||
import type { ScoreMode, DimensionScores } from "./score.js";
|
import type { ScoreMode, DimensionScores } from "./score.js";
|
||||||
|
import type { TrendVerdict, Actionability } from "./types.js";
|
||||||
|
|
||||||
export interface TrendItem {
|
export interface TrendItem {
|
||||||
/** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
|
/** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
|
||||||
|
|
@ -45,6 +46,27 @@ export interface TrendItem {
|
||||||
* Absent/invalid -> the key is omitted.
|
* Absent/invalid -> the key is omitted.
|
||||||
*/
|
*/
|
||||||
score?: { mode: ScoreMode; dimensions: DimensionScores };
|
score?: { mode: ScoreMode; dimensions: DimensionScores };
|
||||||
|
|
||||||
|
// ── N6 proposal fields. Free-text ones follow the `summary` idiom (blank -> key omitted, never an
|
||||||
|
// error); the two TYPED ones (verdict/actionability) hard-fail when present-but-malformed, like score. ──
|
||||||
|
/** Proposed article angle (A1-4), VERBATIM. Blank/absent -> omitted. */
|
||||||
|
angle?: string;
|
||||||
|
/** Proposed target level on the user profile's own span — free string, never an enum. Blank/absent -> omitted. */
|
||||||
|
targetLevel?: string;
|
||||||
|
/** Why-now / applicability rationale (A1-4), VERBATIM. Blank/absent -> omitted. */
|
||||||
|
rationale?: string;
|
||||||
|
/** Related trend ids (A1-5): normalized to non-empty strings, deduped. Non-array/empty -> omitted. */
|
||||||
|
relatedIds?: string[];
|
||||||
|
/** Reader-grip signal (MR-F7). Present-but-malformed -> validation error. */
|
||||||
|
actionability?: Actionability;
|
||||||
|
/** Reader-side utility verdict (MR-F7). Present-but-out-of-vocab -> validation error. */
|
||||||
|
verdict?: TrendVerdict;
|
||||||
|
/** Reader's own question (MR-F9, filled downstream by N7.5). Blank/absent -> omitted. */
|
||||||
|
readerQuestion?: string;
|
||||||
|
/** Cost/risk/duty/tool the topic hits (MR-F9). Blank/absent -> omitted. */
|
||||||
|
painPoint?: string;
|
||||||
|
/** Market saturation judgment (MR-F9). Blank/absent -> omitted. */
|
||||||
|
saturation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };
|
export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };
|
||||||
|
|
@ -106,6 +128,49 @@ function validateScore(
|
||||||
return { ok: true, score: { mode: mode as ScoreMode, dimensions: validated } };
|
return { ok: true, score: { mode: mode as ScoreMode, dimensions: validated } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The closed reader-side utility vocabulary (MR-F7) — mechanism, not niche calibration. */
|
||||||
|
const VERDICTS = ["BÆRENDE", "STØTTE", "NYHET"] as const;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an optional `actionability` (MR-F7) — never throws. `formulated` is a REQUIRED boolean
|
||||||
|
* (the yes/no the N7 band-cap gate reads); `note` is an optional string (blank -> omitted). Returns
|
||||||
|
* the validated value (note carried verbatim only when non-blank).
|
||||||
|
*/
|
||||||
|
function validateActionability(raw: unknown): { ok: true; value: Actionability } | { ok: false; reason: string } {
|
||||||
|
if (!isPlainObject(raw)) return { ok: false, reason: "actionability must be an object" };
|
||||||
|
if (typeof raw.formulated !== "boolean") {
|
||||||
|
return { ok: false, reason: `actionability.formulated must be a boolean (got ${String(raw.formulated)})` };
|
||||||
|
}
|
||||||
|
const value: Actionability = { formulated: raw.formulated };
|
||||||
|
if (raw.note !== undefined && raw.note !== null) {
|
||||||
|
if (typeof raw.note !== "string") return { ok: false, reason: "actionability.note must be a string" };
|
||||||
|
if (raw.note.trim().length > 0) value.note = raw.note;
|
||||||
|
}
|
||||||
|
return { ok: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize related ids (A1-5): keep non-empty trimmed strings, first-seen dedupe. A non-array -> [].
|
||||||
|
* Ids are carried VERBATIM (not lowercased like topics — they are content hashes, not tags), so a
|
||||||
|
* caller-supplied id matches the store's `trendId` exactly.
|
||||||
|
*/
|
||||||
|
function normalizeIds(raw: unknown): string[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const v of raw) {
|
||||||
|
if (typeof v !== "string") continue;
|
||||||
|
const s = v.trim();
|
||||||
|
if (s.length === 0 || seen.has(s)) continue;
|
||||||
|
seen.add(s);
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */
|
/** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */
|
||||||
function normalizeTopics(raw: unknown): string[] {
|
function normalizeTopics(raw: unknown): string[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
@ -155,8 +220,33 @@ export function normalizeItem(raw: unknown): NormalizeResult {
|
||||||
else score = res.score;
|
else score = res.score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// N6 typed fields: hard-fail when present-but-malformed (like score), so a bad payload is caught
|
||||||
|
// at the seam, not persisted silently.
|
||||||
|
let actionability: Actionability | undefined;
|
||||||
|
if (r.actionability !== undefined && r.actionability !== null) {
|
||||||
|
const res = validateActionability(r.actionability);
|
||||||
|
if (!res.ok) errors.push(`invalid actionability: ${res.reason}`);
|
||||||
|
else actionability = res.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let verdict: TrendVerdict | undefined;
|
||||||
|
if (r.verdict !== undefined && r.verdict !== null) {
|
||||||
|
if (typeof r.verdict !== "string" || !(VERDICTS as readonly string[]).includes(r.verdict)) {
|
||||||
|
errors.push(`invalid verdict: must be one of ${VERDICTS.join(", ")} (got ${String(r.verdict)})`);
|
||||||
|
} else {
|
||||||
|
verdict = r.verdict as TrendVerdict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.length > 0) return { ok: false, errors };
|
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).
|
||||||
|
const freeText: Partial<Record<(typeof FREE_TEXT_FIELDS)[number], string>> = {};
|
||||||
|
for (const f of FREE_TEXT_FIELDS) {
|
||||||
|
if (isNonEmptyString(r[f])) freeText[f] = r[f] as string;
|
||||||
|
}
|
||||||
|
const relatedIds = normalizeIds(r.relatedIds);
|
||||||
|
|
||||||
const item: TrendItem = {
|
const item: TrendItem = {
|
||||||
source: r.source as string,
|
source: r.source as string,
|
||||||
title: r.title as string,
|
title: r.title as string,
|
||||||
|
|
@ -165,6 +255,10 @@ export function normalizeItem(raw: unknown): NormalizeResult {
|
||||||
...(publishedAt !== undefined ? { publishedAt } : {}),
|
...(publishedAt !== undefined ? { publishedAt } : {}),
|
||||||
...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}),
|
...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}),
|
||||||
...(score !== undefined ? { score } : {}),
|
...(score !== undefined ? { score } : {}),
|
||||||
|
...freeText,
|
||||||
|
...(relatedIds.length > 0 ? { relatedIds } : {}),
|
||||||
|
...(actionability !== undefined ? { actionability } : {}),
|
||||||
|
...(verdict !== undefined ? { verdict } : {}),
|
||||||
};
|
};
|
||||||
return { ok: true, item };
|
return { ok: true, item };
|
||||||
}
|
}
|
||||||
|
|
@ -190,6 +284,16 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
|
||||||
...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
|
...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
|
||||||
...(item.summary !== undefined ? { summary: item.summary } : {}),
|
...(item.summary !== undefined ? { summary: item.summary } : {}),
|
||||||
...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}),
|
...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}),
|
||||||
|
// N6 proposal fields carried through to the store input (validated already; key omitted when absent).
|
||||||
|
...(item.angle !== undefined ? { angle: item.angle } : {}),
|
||||||
|
...(item.targetLevel !== undefined ? { targetLevel: item.targetLevel } : {}),
|
||||||
|
...(item.rationale !== undefined ? { rationale: item.rationale } : {}),
|
||||||
|
...(item.relatedIds !== undefined ? { relatedIds: [...item.relatedIds] } : {}),
|
||||||
|
...(item.actionability !== undefined ? { actionability: item.actionability } : {}),
|
||||||
|
...(item.verdict !== undefined ? { verdict: item.verdict } : {}),
|
||||||
|
...(item.readerQuestion !== undefined ? { readerQuestion: item.readerQuestion } : {}),
|
||||||
|
...(item.painPoint !== undefined ? { painPoint: item.painPoint } : {}),
|
||||||
|
...(item.saturation !== undefined ? { saturation: item.saturation } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { homedir } from "node:os";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import { SCHEMA_VERSION } from "./types.js";
|
import { SCHEMA_VERSION } from "./types.js";
|
||||||
import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus } from "./types.js";
|
import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus, TrendVerdict, Actionability } from "./types.js";
|
||||||
import type { TrendScore } from "./score.js";
|
import type { TrendScore } from "./score.js";
|
||||||
|
|
||||||
export { SCHEMA_VERSION } from "./types.js";
|
export { SCHEMA_VERSION } from "./types.js";
|
||||||
|
|
@ -35,6 +35,25 @@ export interface TrendInput {
|
||||||
summary?: string;
|
summary?: string;
|
||||||
/** The persisted relevance envelope (RE-R3a), if the caller computed one. First-sight, never updated on re-capture. */
|
/** The persisted relevance envelope (RE-R3a), if the caller computed one. First-sight, never updated on re-capture. */
|
||||||
score?: TrendScore;
|
score?: TrendScore;
|
||||||
|
// ── N6 proposal fields (all first-sight, like source/capturedAt/publishedAt/summary). ──
|
||||||
|
/** Proposed article angle (A1-4). */
|
||||||
|
angle?: string;
|
||||||
|
/** Proposed target level on the user profile's own span (free string, never a hard-coded enum). */
|
||||||
|
targetLevel?: string;
|
||||||
|
/** Why-now / applicability rationale (A1-4). */
|
||||||
|
rationale?: string;
|
||||||
|
/** Related trend ids — multi-source candidate (A1-5). */
|
||||||
|
relatedIds?: string[];
|
||||||
|
/** Reader-grip signal the N7 band-cap gate reads (MR-F7). */
|
||||||
|
actionability?: Actionability;
|
||||||
|
/** Reader-side utility verdict BÆRENDE/STØTTE/NYHET (MR-F7). */
|
||||||
|
verdict?: TrendVerdict;
|
||||||
|
/** Reader's own question, filled by the N7.5 demand-sweep (MR-F9). */
|
||||||
|
readerQuestion?: string;
|
||||||
|
/** Cost/risk/duty/tool the topic hits (MR-F9). */
|
||||||
|
painPoint?: string;
|
||||||
|
/** Market saturation judgment (MR-F9). */
|
||||||
|
saturation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddResult {
|
export interface AddResult {
|
||||||
|
|
@ -131,8 +150,9 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
|
||||||
let mutated = changed;
|
let mutated = changed;
|
||||||
// RE-R3b: re-score on re-capture (last-wins). `score` is the ONE mutable field — a fresh
|
// RE-R3b: re-score on re-capture (last-wins). `score` is the ONE mutable field — a fresh
|
||||||
// judgment (timing decays) replaces the stored one; the JSON compare avoids a false-merge
|
// judgment (timing decays) replaces the stored one; the JSON compare avoids a false-merge
|
||||||
// on an identical re-score. Provenance (source/capturedAt/publishedAt) and lifecycle
|
// on an identical re-score. Provenance (source/capturedAt/publishedAt), lifecycle
|
||||||
// (status/surfacedCount/lastSurfacedAt) are untouched.
|
// (status/surfacedCount/lastSurfacedAt), AND the N6 proposal fields are all first-sight —
|
||||||
|
// untouched here, so re-capture never clobbers an operator's triaged angle/verdict.
|
||||||
if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) {
|
if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) {
|
||||||
existing.score = input.score;
|
existing.score = input.score;
|
||||||
mutated = true;
|
mutated = true;
|
||||||
|
|
@ -149,6 +169,16 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
|
||||||
topics: [...input.topics],
|
topics: [...input.topics],
|
||||||
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
||||||
...(input.score !== undefined ? { score: input.score } : {}),
|
...(input.score !== undefined ? { score: input.score } : {}),
|
||||||
|
// N6 proposal fields — conditional-spread (key omitted when absent), mirroring the idiom above.
|
||||||
|
...(input.angle !== undefined ? { angle: input.angle } : {}),
|
||||||
|
...(input.targetLevel !== undefined ? { targetLevel: input.targetLevel } : {}),
|
||||||
|
...(input.rationale !== undefined ? { rationale: input.rationale } : {}),
|
||||||
|
...(input.relatedIds !== undefined ? { relatedIds: [...input.relatedIds] } : {}),
|
||||||
|
...(input.actionability !== undefined ? { actionability: input.actionability } : {}),
|
||||||
|
...(input.verdict !== undefined ? { verdict: input.verdict } : {}),
|
||||||
|
...(input.readerQuestion !== undefined ? { readerQuestion: input.readerQuestion } : {}),
|
||||||
|
...(input.painPoint !== undefined ? { painPoint: input.painPoint } : {}),
|
||||||
|
...(input.saturation !== undefined ? { saturation: input.saturation } : {}),
|
||||||
};
|
};
|
||||||
store.trends.push(trend);
|
store.trends.push(trend);
|
||||||
return { store, added: true, merged: false };
|
return { store, added: true, merged: false };
|
||||||
|
|
@ -177,6 +207,30 @@ export function setStatus(
|
||||||
return { store, found: true };
|
return { store, found: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the same status on a batch of ids in one pass (N6, A1-9 — ten candidates, one call).
|
||||||
|
* Partitions the ids into `found` (matched + mutated) and `notFound` (no such record), each
|
||||||
|
* order-stable on the input. Mutates the matched records in place; unknown ids are skipped,
|
||||||
|
* never an error. Pure (no fs) — the CLI decides the exit code from the partition (all-miss ⇒
|
||||||
|
* usage error; any hit ⇒ success + report). Duplicate input ids collapse to one mutation.
|
||||||
|
*/
|
||||||
|
export function setStatusMany(
|
||||||
|
store: TrendStore,
|
||||||
|
ids: string[],
|
||||||
|
status: TrendStatus,
|
||||||
|
): { store: TrendStore; found: string[]; notFound: string[] } {
|
||||||
|
const found: string[] = [];
|
||||||
|
const notFound: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const id of ids) {
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
const res = setStatus(store, id, status);
|
||||||
|
(res.found ? found : notFound).push(id);
|
||||||
|
}
|
||||||
|
return { store, found, notFound };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record that the given trends were surfaced in a brief on `today` (the seen-log, B4).
|
* Record that the given trends were surfaced in a brief on `today` (the seen-log, B4).
|
||||||
* PER-DAY IDEMPOTENT: a record already surfaced on `today` is skipped, so re-running the
|
* PER-DAY IDEMPOTENT: a record already surfaced on `today` is skipped, so re-running the
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,35 @@
|
||||||
|
|
||||||
import type { TrendScore } from "./score.js";
|
import type { TrendScore } from "./score.js";
|
||||||
|
|
||||||
/** The lifecycle state of a trend (RE-R3b). Absent on a record ⇒ "new" (see effectiveStatus). */
|
/**
|
||||||
export type TrendStatus = "new" | "acted" | "skipped";
|
* The lifecycle state of a trend. Absent on a record ⇒ "new" (see effectiveStatus).
|
||||||
|
* RE-R3b introduced new/acted/skipped; N6 (A1-7) inserts `selected` — the operator's
|
||||||
|
* triage pick — so the full arc is new → selected → acted | skipped. `selected` and
|
||||||
|
* `acted` are the two "in production" states the morning brief surfaces separately.
|
||||||
|
*/
|
||||||
|
export type TrendStatus = "new" | "selected" | "acted" | "skipped";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reader-side utility verdict (N6 / MR-F7, mechanism from `nytteloftet.md`): a
|
||||||
|
* candidate is BÆRENDE (carries an edition on its own), STØTTE (supports one), or NYHET
|
||||||
|
* (news only — no reader grip yet). A CLOSED mechanism vocabulary — avsender-neutral, so
|
||||||
|
* the three names are the mechanism, NOT niche calibration (what counts as a grip lives in
|
||||||
|
* the user profile / data dir, never here). The N7 band-cap gate reads verdict+actionability.
|
||||||
|
*/
|
||||||
|
export type TrendVerdict = "BÆRENDE" | "STØTTE" | "NYHET";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reader-grip signal (N6 / MR-F7): whether a reader-actionable grip is formulated, plus
|
||||||
|
* an optional one-line statement of it. The five relevance dimensions all measure SENDER fit;
|
||||||
|
* this is the missing reader side. The N7 band-cap gate caps a candidate whose grip is not
|
||||||
|
* formulated (`formulated: false`) to at most `High`, regardless of composite.
|
||||||
|
*/
|
||||||
|
export interface Actionability {
|
||||||
|
/** Is a reader-actionable grip formulated? The yes/no the N7 gate reads. */
|
||||||
|
formulated: boolean;
|
||||||
|
/** Optional short statement of the grip (or of why none). */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrendRecord {
|
export interface TrendRecord {
|
||||||
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
|
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
|
||||||
|
|
@ -76,6 +103,34 @@ export interface TrendRecord {
|
||||||
surfacedCount?: number;
|
surfacedCount?: number;
|
||||||
/** ISO date of the most recent surfacing (RE-R3b). Absent ⇒ never. The per-day idempotency key. */
|
/** ISO date of the most recent surfacing (RE-R3b). Absent ⇒ never. The per-day idempotency key. */
|
||||||
lastSurfacedAt?: string;
|
lastSurfacedAt?: string;
|
||||||
|
|
||||||
|
// ── N6 proposal layer (A1-4/A1-5): the discovery agent's proposal, persisted (was discarded).
|
||||||
|
// All first-sight (like source/capturedAt) — a re-capture unions topics + refreshes score only.
|
||||||
|
/** The proposed article angle — the agent's take, stored VERBATIM. Absent on the score-free `add` path. */
|
||||||
|
angle?: string;
|
||||||
|
/**
|
||||||
|
* The proposed target level, on a span DEFINED BY THE USER PROFILE (e.g. praktiker→beslutter) —
|
||||||
|
* a free string, NEVER a hard-coded enum. The plugin owns the field; the profile owns the span's values.
|
||||||
|
*/
|
||||||
|
targetLevel?: string;
|
||||||
|
/** Why now / practical applicability — the agent's timeliness rationale, VERBATIM. */
|
||||||
|
rationale?: string;
|
||||||
|
/** Related trend ids (A1-5, multi-source candidate): other records this trend is a facet of. */
|
||||||
|
relatedIds?: string[];
|
||||||
|
|
||||||
|
// ── N6 reader-side (MR-F7): the reader-grip signal the N7 band-cap gate reads. ──
|
||||||
|
/** Whether a reader-actionable grip is formulated (+ optional note). See {@link Actionability}. */
|
||||||
|
actionability?: Actionability;
|
||||||
|
/** The reader-side utility verdict (BÆRENDE/STØTTE/NYHET). See {@link TrendVerdict}. */
|
||||||
|
verdict?: TrendVerdict;
|
||||||
|
|
||||||
|
// ── N6 reader-side (MR-F9): schema only here — the demand-sweep that FILLS these is N7.5. ──
|
||||||
|
/** The reader's OWN formulation of the question, in the reader's words (not the field's jargon). */
|
||||||
|
readerQuestion?: string;
|
||||||
|
/** The cost/risk/duty/tool the topic hits for the reader (what it competes against). */
|
||||||
|
painPoint?: string;
|
||||||
|
/** The market saturation judgment — is this already answered by others? (metningsdom), VERBATIM. */
|
||||||
|
saturation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrendStore {
|
export interface TrendStore {
|
||||||
|
|
@ -90,4 +145,11 @@ export interface TrendQueryHit {
|
||||||
topicOverlap: number;
|
topicOverlap: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store schema version. Still 4 after N6: 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.)
|
||||||
|
*/
|
||||||
export const SCHEMA_VERSION = 4;
|
export const SCHEMA_VERSION = 4;
|
||||||
|
|
|
||||||
|
|
@ -416,11 +416,11 @@ describe("RE-R3b — render id + surfaced marker + descriptor (D4/D5)", () => {
|
||||||
assert.ok(md.includes("· sett 2x"), "surfacedCount 2 → warming → · sett 2x");
|
assert.ok(md.includes("· sett 2x"), "surfacedCount 2 → warming → · sett 2x");
|
||||||
assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker (the preserved ≥2 gate)");
|
assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker (the preserved ≥2 gate)");
|
||||||
});
|
});
|
||||||
test("RED: the ranking: descriptor ends with '; excludes acted/skipped'", () => {
|
test("RED: the ranking: descriptor names the acted/skipped/selected exclusion (N6)", () => {
|
||||||
const md = renderBrief(rankForBrief(mkStore([]), pillars, TODAY));
|
const md = renderBrief(rankForBrief(mkStore([]), pillars, TODAY));
|
||||||
assert.match(
|
assert.match(
|
||||||
md,
|
md,
|
||||||
/\nranking: composite desc, then pillar-overlap desc, then temporal \(first-mover↑\/saturated↓\), then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\n/,
|
/\nranking: composite desc, then pillar-overlap desc, then temporal \(first-mover↑\/saturated↓\), then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\/selected \(selected\+acted shown in I produksjon\)\n/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -401,7 +401,7 @@ describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("RED: the brief .md omits an acted record", () => {
|
test("RED: an acted record leaves the work queue and moves to the 'I produksjon' board (N6)", () => {
|
||||||
const { dir, store, out } = fixture();
|
const { dir, store, out } = fixture();
|
||||||
try {
|
try {
|
||||||
seedScored(store, "Handled", "https://e/handled");
|
seedScored(store, "Handled", "https://e/handled");
|
||||||
|
|
@ -409,7 +409,12 @@ describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)",
|
||||||
run(["act", "--id", id, "--store", store], "");
|
run(["act", "--id", id, "--store", store], "");
|
||||||
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
|
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
|
||||||
const md = readFileSync(o.path, "utf8");
|
const md = readFileSync(o.path, "utf8");
|
||||||
assert.ok(!md.includes("Handled"), "an acted record must not appear in the brief");
|
// N6: acted no longer vanishes from the whole brief — it leaves the QUEUE and shows in "I produksjon".
|
||||||
|
const idx = md.indexOf("## 🚧 I produksjon");
|
||||||
|
assert.ok(idx >= 0, "the production section is present");
|
||||||
|
assert.ok(!md.slice(0, idx).includes("Handled"), "an acted record must not appear in the work queue");
|
||||||
|
assert.ok(md.slice(idx).includes("Handled"), "the acted record appears in I produksjon");
|
||||||
|
assert.ok(md.slice(idx).includes("skrevet"), "tagged skrevet (acted)");
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
381
scripts/trends/tests/n6-proposal-layer.test.ts
Normal file
381
scripts/trends/tests/n6-proposal-layer.test.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue