feat(linkedin-studio): RE-R3a — persist relevance score on the store record + rank the morning brief on it [skip-docs]

R3 slice 1 (research-deepening). Stop discarding the relevance judgment the
trend-spotter already computes: persist a 4-field TrendScore {mode, dimensions,
composite, priority} on TrendRecord (schema v2->v3, additive lossless migrate),
computed by the existing score.ts composite()+band() (one owner, no new arithmetic),
threaded item->store; then rankForBrief sorts each bucket composite-first (sentinel
-1 for unscored) and renderBrief surfaces "· <priority> (<mode>)" per body entry
(briefSummary shows the band only). First-sight only; mode-blind ranking with the mode
shown so the operator can disambiguate instruments.

- score.ts: TrendScore + requiredDimensions(mode) (ordered) + scoreEnvelope (composes
  composite+band; throws on bad dim by contract)
- types.ts: SCHEMA_VERSION 2->3; TrendRecord.score?
- store.ts: TrendInput.score?; addTrend persists first-sight (duplicate keeps it);
  migrate comment v1->v2->v3 (logic unchanged, JSON.stringify preserves the field)
- item.ts: TrendItem.score?; normalizeItem validates (non-array score/dimensions + the
  mode's five dims in [1,10]) -> structured error never throw, carries validated dims;
  itemToInput -> scoreEnvelope (no throw on the capture path; direct call throws by contract)
- brief.ts: composite-primary comparator; band+mode render; exact ranking: descriptor
- cli.ts: capture persists score via itemToInput (doc-only); add/score paths unchanged
- agents/trend-spotter.md Step 4.5: capture batch carries the Step-2 dimensions
- gate: TRENDS_TESTS_FLOOR 104->146; new unconditional Section 16j; ASSERT floor 94->99

Tests: trends 146/146 (RED two-phase: logic-RED store/brief/cli; stub-first then
assertion-RED score/item). Gate green (Passed 114 / Failed 0; 113 checks >= 99).
Hook suite 139/139 untouched. Counts 27/19/29 unchanged. No new source file/agent/command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 14:05:27 +02:00
commit e169c78710
14 changed files with 829 additions and 40 deletions

View file

@ -91,7 +91,14 @@ export function rankForBrief(
entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) });
}
// Composite is the PRIMARY within-bucket key (RE-R3a / D2): a higher persisted relevance
// composite sorts first; an unscored record uses the sentinel -1 (composite is a weighted
// sum of [1,10] dims, so it is always >= 1.0 — -1 sorts unscored last and subtracts
// cleanly, where -Infinity - -Infinity = NaN would corrupt the comparator). Buckets are
// unchanged; composite only re-orders WITHIN a bucket. The existing overlap → effectiveDate
// → title → url chain still gives a total order (the (title,url) pair is the unique dedupe id).
const cmp = (a: BriefEntry, b: BriefEntry): number =>
(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) ||
b.overlap - a.overlap ||
b.effectiveDate.localeCompare(a.effectiveDate) ||
a.trend.title.localeCompare(b.trend.title) ||
@ -124,15 +131,23 @@ export function briefSummary(ranking: BriefRanking): string {
if (fresh > 0) {
const top = ranking.topMatches[0] ?? ranking.singleMatches[0];
const pillar = top.matchedPillars[0];
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar} · ${top.ageDays}d).`;
// Band only (no mode) — the mode stays a body-entry detail to keep the one-line headline clean.
const band = top.trend.score ? ` · ${top.trend.score.priority}` : "";
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band} · ${top.ageDays}d).`;
}
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`;
}
/** ` · <priority> (<mode>)` when scored, else "" — the band+mode token shared by both renders (RE-R3a). */
function scoreToken(e: BriefEntry): string {
const s = e.trend.score;
return s ? ` · ${s.priority} (${s.mode})` : "";
}
function renderTopEntry(e: BriefEntry, n: number): string[] {
const lines = [
`### ${n}. ${e.trend.title}`,
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d) · Pillarer: ${e.matchedPillars.join(", ")}`,
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · Pillarer: ${e.matchedPillars.join(", ")}`,
];
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
lines.push(`- 🔗 ${e.trend.url}`);
@ -141,7 +156,7 @@ function renderTopEntry(e: BriefEntry, n: number): string[] {
}
function renderBulletEntry(e: BriefEntry): string {
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d) · 🔗 ${e.trend.url}`;
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · 🔗 ${e.trend.url}`;
}
/**
@ -157,7 +172,7 @@ export function renderBrief(ranking: BriefRanking): string {
lines.push(`date: ${ranking.today}`);
lines.push(`summary: ${briefSummary(ranking)}`);
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
lines.push(`ranking: pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
lines.push(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
lines.push("---");
lines.push("");

View file

@ -14,7 +14,10 @@
*
* The capture agent (research-engine) folds freshly-polled trends into the store via
* `capture` (the normalizing batch path: stdin normalizeItem(s) itemToInput
* addTrend), and reasons over accumulated history via `query`/`list`. `brief` (RE-R2b)
* addTrend) which, when an item carries the agent's five judgment scores (RE-R3a),
* persists an optional relevance `score` (the deterministically-computed composite + band,
* one owner) first-sight on the record so the morning brief ranks on it and reasons over
* accumulated history via `query`/`list`. `brief` (RE-R2b)
* renders a dated, pillar-ranked morning brief over the store to a Markdown file the
* SessionStart hook surfaces. `add` is the MANUAL single-trend path (raw flags, no
* normalization, publish-date-free). The polling + relevance-scoring itself lives

View file

@ -18,6 +18,8 @@
import { normalizeField } from "./store.js";
import type { TrendInput } from "./store.js";
import { requiredDimensions, scoreEnvelope } from "./score.js";
import type { ScoreMode, DimensionScores } from "./score.js";
export interface TrendItem {
/** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
@ -36,6 +38,13 @@ export interface TrendItem {
topics: string[];
/** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */
summary?: string;
/**
* The agent's relevance JUDGMENT (RE-R3a): the mode + the five 110 dimension scores
* NOT a precomputed composite (the store computes that, one owner). Validated by
* normalizeItem; turned into the persisted envelope by itemToInputscoreEnvelope.
* Absent/invalid -> the key is omitted.
*/
score?: { mode: ScoreMode; dimensions: DimensionScores };
}
export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };
@ -63,6 +72,40 @@ function isNonEmptyString(v: unknown): v is string {
return typeof v === "string" && v.trim().length > 0;
}
/** A plain (non-array, non-null) object. */
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
const SCORE_MODES = ["kortform", "long-form"] as const;
/**
* Validate a raw `score` structurally never throws (returns a reason on failure). The
* mode must be known; `dimensions` must be a non-array object carrying every key the mode
* requires (requiredDimensions) as a number in [1,10]. On success returns the VALIDATED
* envelope (the validated dimensions object, not the raw one).
*/
function validateScore(
raw: unknown,
): { ok: true; score: { mode: ScoreMode; dimensions: DimensionScores } } | { ok: false; reason: string } {
if (!isPlainObject(raw)) return { ok: false, reason: "score must be an object" };
const mode = raw.mode;
if (typeof mode !== "string" || !(SCORE_MODES as readonly string[]).includes(mode)) {
return { ok: false, reason: `mode must be one of ${SCORE_MODES.join(", ")} (got ${String(mode)})` };
}
const dims = raw.dimensions;
if (!isPlainObject(dims)) return { ok: false, reason: "dimensions must be an object" };
const validated: DimensionScores = {};
for (const key of requiredDimensions(mode as ScoreMode)) {
const value = dims[key];
if (typeof value !== "number" || Number.isNaN(value) || value < 1 || value > 10) {
return { ok: false, reason: `dimension "${key}" must be a number in [1,10] (got ${String(value)})` };
}
validated[key] = value;
}
return { ok: true, score: { mode: mode as ScoreMode, dimensions: validated } };
}
/** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */
function normalizeTopics(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
@ -105,6 +148,13 @@ export function normalizeItem(raw: unknown): NormalizeResult {
}
}
let score: { mode: ScoreMode; dimensions: DimensionScores } | undefined;
if (r.score !== undefined && r.score !== null) {
const res = validateScore(r.score);
if (!res.ok) errors.push(`invalid score: ${res.reason}`);
else score = res.score;
}
if (errors.length > 0) return { ok: false, errors };
const item: TrendItem = {
@ -114,6 +164,7 @@ export function normalizeItem(raw: unknown): NormalizeResult {
topics: normalizeTopics(r.topics),
...(publishedAt !== undefined ? { publishedAt } : {}),
...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}),
...(score !== undefined ? { score } : {}),
};
return { ok: true, item };
}
@ -123,8 +174,11 @@ export function normalizeItem(raw: unknown): NormalizeResult {
* Pure: injects `capturedAt` (the store's "when WE saw it", supplied by the caller
* never derived here) and carries the rest verbatim. Does NOT re-validate (the item is
* already validated by normalizeItem) and does NOT derive an `id` (the store owns id via
* addTrendtrendId). `publishedAt`/`summary` are carried only when present (key omitted
* otherwise), mirroring the store's conditional-spread idiom.
* addTrendtrendId). `publishedAt`/`summary`/`score` are carried only when present (key
* omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned
* into the persisted envelope here (judgment composite, via scoreEnvelope). On the capture
* path the dims are pre-validated by normalizeItem, so scoreEnvelopecomposite cannot throw;
* called DIRECTLY with bad dims it throws by contract (defense-in-depth SC2).
*/
export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
return {
@ -135,6 +189,7 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
topics: [...item.topics],
...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
...(item.summary !== undefined ? { summary: item.summary } : {}),
...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}),
};
}

View file

@ -96,6 +96,38 @@ export function band(composite: number): Band {
return toBand(BANDS[BANDS.length - 1]);
}
/**
* The persist-ready relevance envelope (RE-R3a): the agent's judgment (mode + the five
* dimension scores) plus the deterministically-derived composite + priority band. Lives
* in score.ts (the score domain owns it); types.ts imports it (one-way score.ts imports
* nothing internal, so no cycle).
*/
export interface TrendScore {
mode: ScoreMode;
dimensions: DimensionScores;
composite: number;
priority: Priority;
}
/**
* The mode's five dimension keys, in SSOT weight-literal order. `normalizeItem` consumes
* this as a membership set; score.test pins the order so a silent SSOT reorder fails.
*/
export function requiredDimensions(mode: ScoreMode): string[] {
return Object.keys(WEIGHTS[mode]);
}
/**
* Compose the persist-ready envelope from the agent's judgment: the composite is
* `composite(dimensions, mode)` and the priority is `band(composite).priority` the
* existing pure functions stay the single owners (no new arithmetic). Throws via
* `composite` on an out-of-range dimension (its contract).
*/
export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore {
const c = composite(dimensions, mode);
return { mode, dimensions, composite: c, priority: band(c).priority };
}
export interface TriageOptions {
mode: ScoreMode;
threshold: number;

View file

@ -19,6 +19,7 @@ import { createHash } from "node:crypto";
import { SCHEMA_VERSION } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit } from "./types.js";
import type { TrendScore } from "./score.js";
export { SCHEMA_VERSION } from "./types.js";
@ -32,6 +33,8 @@ export interface TrendInput {
publishedAt?: string;
topics: string[];
summary?: string;
/** The persisted relevance envelope (RE-R3a), if the caller computed one. First-sight, never updated on re-capture. */
score?: TrendScore;
}
export interface AddResult {
@ -76,10 +79,11 @@ export function emptyStore(): TrendStore {
export function loadStore(path: string): TrendStore {
if (!existsSync(path)) return emptyStore();
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TrendStore>;
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2 is
// purely additive-optional (an old record is already a valid v2 record that simply
// lacks the optional publishedAt), so the migration is the version stamp alone —
// records pass through untouched (lossless + idempotent for any well-formed store).
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2→v3 are
// all purely additive-optional (an old record is already a valid v3 record that simply
// lacks the optional publishedAt [v2] / score [v3]), so the migration is the version
// stamp alone — records pass through untouched (lossless + idempotent for any
// well-formed store; a new optional field survives JSON.stringify on resave).
// A string / NaN / absent version coerces to the current version (never crashes); the
// non-array `trends` coercion below is unchanged and out of the losslessness claim.
const onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION;
@ -134,6 +138,7 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {}),
topics: [...input.topics],
...(input.summary !== undefined ? { summary: input.summary } : {}),
...(input.score !== undefined ? { score: input.score } : {}),
};
store.trends.push(trend);
return { store, added: true, merged: false };

View file

@ -19,10 +19,13 @@
* a typed store in the per-user data dir (`${LINKEDIN_STUDIO_DATA}`), so the
* trend history survives plugin upgrades/reinstalls via the M0 data-path seam.
* The minimal core here (title, url, source, capturedAt, topics, optional
* summary) can gain fields (relevance score, first-mover timing, status) in a
* later slice without breaking the shape.
* summary) can gain fields (first-mover timing, status) in a later slice without
* breaking the shape the relevance `score` field (RE-R3a) is the first such
* realized addition.
*/
import type { TrendScore } from "./score.js";
export interface TrendRecord {
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
id: string;
@ -45,6 +48,14 @@ export interface TrendRecord {
topics: string[];
/** Optional short summary of the trend, stored VERBATIM. */
summary?: string;
/**
* The persisted relevance assessment (RE-R3a): the agent's judgment (mode + the
* five 110 dimension scores) plus the deterministically-derived composite + band,
* computed once at first sight by the store's single scorer owner. First-sight,
* never updated on re-capture (re-score pairs with the R3b status slice). Absent on
* pre-R3a records and on the score-free `add` manual path (key omitted).
*/
score?: TrendScore;
}
export interface TrendStore {
@ -59,4 +70,4 @@ export interface TrendQueryHit {
topicOverlap: number;
}
export const SCHEMA_VERSION = 2;
export const SCHEMA_VERSION = 3;