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

@ -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