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:
Kjell Tore Guttormsen 2026-07-21 09:09:51 +02:00
commit 65b60337fe
9 changed files with 732 additions and 33 deletions

View file

@ -67,6 +67,12 @@ export interface BriefRanking {
singleMatches: BriefEntry[];
/** overlap >= 1 AND NOT fresh. */
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 {
@ -132,9 +138,16 @@ export function rankForBrief(
const wantedLower = pillars.map((p) => p.toLowerCase());
const entries: BriefEntry[] = [];
const inProduction: TrendRecord[] = [];
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).
if (effectiveStatus(trend) !== "new") continue;
const st = effectiveStatus(trend);
// 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 matchedPillars: string[] = [];
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 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 {
today,
freshDays,
@ -192,6 +211,7 @@ export function rankForBrief(
topMatches,
singleMatches,
olderMatched,
inProduction,
};
}
@ -241,19 +261,72 @@ function temporalToken(e: BriefEntry): string {
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[] {
const lines = [
`### ${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}\``,
];
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
lines.push(...proposalLines(e.trend));
lines.push(`- 🔗 ${e.trend.url}`);
lines.push("");
return lines;
}
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(`summary: ${briefSummary(ranking, diff)}`);
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
// emitted (even blank for an empty store); independent of --no-mark (a property of the render).
lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`);
@ -323,6 +396,9 @@ export function renderBrief(
ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e)));
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("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_");

View file

@ -7,7 +7,7 @@
* 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 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 '<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]
@ -26,8 +26,10 @@
* 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`.
* `add` is the MANUAL single-trend path (raw flags, no
* normalization, publish-date-free). `act`/`skip`/`reset --id` set a trend's lifecycle status
* (RE-R3b): the morning brief EXCLUDES acted/skipped and records each surfacing (per-day-idempotent
* normalization, publish-date-free). `act`/`skip`/`reset`/`select` set a trend's lifecycle status
* (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
* (timing decays). The polling + relevance-scoring itself lives upstream; this is the deterministic store.
*
@ -63,6 +65,7 @@ import {
queryByTopic,
saveStore,
setStatus,
setStatusMany,
} from "./store.js";
import type { TrendStatus } from "./types.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" +
" list [--since <YYYY-MM-DD>] [--limit <n>] [--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" +
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
@ -244,18 +247,28 @@ function main(): void {
return;
}
if (command === "act" || command === "skip" || command === "reset") {
const id = flags.id;
if (!id || id === "true") usage(`${command} needs --id <id>`);
const status: TrendStatus = command === "act" ? "acted" : command === "skip" ? "skipped" : "new";
if (command === "act" || command === "skip" || command === "reset" || command === "select") {
// Ids from --ids <a,b,c> (batch, A1-9) or a single --id <id>; --ids wins when both are given.
// splitTopics is the generic comma-split+trim+drop-blank helper (ids are case-sensitive, not lowercased).
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 res = setStatus(store, id, status);
if (!res.found) {
console.error(`error: no trend with id: ${id}`);
const res = setStatusMany(store, ids, status);
// All-miss is an argument-class error (exit 2, store untouched); any hit saves + reports the misses (exit 0).
if (res.found.length === 0) {
console.error(`error: no trend with id: ${res.notFound.join(", ")}`);
process.exit(2);
}
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;
}

View file

@ -20,6 +20,7 @@ import { normalizeField } from "./store.js";
import type { TrendInput } from "./store.js";
import { requiredDimensions, scoreEnvelope } from "./score.js";
import type { ScoreMode, DimensionScores } from "./score.js";
import type { TrendVerdict, Actionability } from "./types.js";
export interface TrendItem {
/** 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.
*/
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[] };
@ -106,6 +128,49 @@ function validateScore(
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). */
function normalizeTopics(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
@ -155,8 +220,33 @@ export function normalizeItem(raw: unknown): NormalizeResult {
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 };
// 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 = {
source: r.source as string,
title: r.title as string,
@ -165,6 +255,10 @@ export function normalizeItem(raw: unknown): NormalizeResult {
...(publishedAt !== undefined ? { publishedAt } : {}),
...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}),
...(score !== undefined ? { score } : {}),
...freeText,
...(relatedIds.length > 0 ? { relatedIds } : {}),
...(actionability !== undefined ? { actionability } : {}),
...(verdict !== undefined ? { verdict } : {}),
};
return { ok: true, item };
}
@ -190,6 +284,16 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
...(item.summary !== undefined ? { summary: item.summary } : {}),
...(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 } : {}),
};
}

View file

@ -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 } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus, TrendVerdict, Actionability } from "./types.js";
import type { TrendScore } from "./score.js";
export { SCHEMA_VERSION } from "./types.js";
@ -35,6 +35,25 @@ export interface TrendInput {
summary?: string;
/** The persisted relevance envelope (RE-R3a), if the caller computed one. First-sight, never updated on re-capture. */
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 {
@ -131,8 +150,9 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
let mutated = changed;
// 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
// on an identical re-score. Provenance (source/capturedAt/publishedAt) and lifecycle
// (status/surfacedCount/lastSurfacedAt) are untouched.
// on an identical re-score. Provenance (source/capturedAt/publishedAt), lifecycle
// (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)) {
existing.score = input.score;
mutated = true;
@ -149,6 +169,16 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
topics: [...input.topics],
...(input.summary !== undefined ? { summary: input.summary } : {}),
...(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);
return { store, added: true, merged: false };
@ -177,6 +207,30 @@ export function setStatus(
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).
* PER-DAY IDEMPOTENT: a record already surfaced on `today` is skipped, so re-running the

View file

@ -26,8 +26,35 @@
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 {
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
@ -76,6 +103,34 @@ export interface TrendRecord {
surfacedCount?: number;
/** ISO date of the most recent surfacing (RE-R3b). Absent ⇒ never. The per-day idempotency key. */
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. praktikerbeslutter)
* 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 {
@ -90,4 +145,11 @@ export interface TrendQueryHit {
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;