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

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