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

@ -2,7 +2,9 @@ import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js";
import type { TrendItem } from "../src/item.js";
import { normalizeField } from "../src/store.js";
import { scoreEnvelope } from "../src/score.js";
describe("trends item normalizer (RE-R1 / B1)", () => {
describe("normalizeItem — well-formed", () => {
@ -224,4 +226,115 @@ describe("trends item normalizer (RE-R1 / B1)", () => {
assert.equal(input.capturedAt, "2026-06-24");
});
});
describe("normalizeItem — score validation (RE-R3a / SC2)", () => {
const base = {
source: "tavily",
title: "Scored item",
url: "https://example.com/s",
topics: ["ai"],
};
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
test("a valid kortform score -> carried with the validated dims", () => {
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: validDims } });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.score, { mode: "kortform", dimensions: validDims });
});
test("a valid long-form score -> carried with its five dims", () => {
const longDims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
const res = normalizeItem({ ...base, score: { mode: "long-form", dimensions: longDims } });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.score, { mode: "long-form", dimensions: longDims });
});
test("absent score -> key omitted", () => {
const res = normalizeItem(base);
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal("score" in res.item, false);
});
test("a bad mode -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: { mode: "bogus", dimensions: validDims } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")), res.errors.join("; "));
});
test("a missing dimension -> structured error (no throw)", () => {
const { authority, ...missing } = validDims;
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: missing } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("a dimension out of [1,10] (0 or 11) -> structured error (no throw)", () => {
const lo = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 0 } } });
assert.equal(lo.ok, false);
const hi = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 11 } } });
assert.equal(hi.ok, false);
});
test("a non-object score -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: "high" });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("an array dimensions -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: [9, 8, 9, 7, 6] } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("an array score -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: [] });
assert.equal(res.ok, false);
});
});
describe("itemToInput — score bridge + the throw contract (RE-R3a / SC2)", () => {
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
test("a scored item -> input.score equals scoreEnvelope(mode, dimensions)", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
score: { mode: "kortform", dimensions: validDims },
};
const input = itemToInput(item, "2026-06-24");
assert.deepEqual(input.score, scoreEnvelope("kortform", validDims));
});
test("an unscored item -> no score key", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
};
const input = itemToInput(item, "2026-06-24") as Record<string, unknown>;
assert.equal("score" in input, false);
});
test("called directly with an out-of-range dim -> throws by contract (defense-in-depth)", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
score: { mode: "kortform", dimensions: { ...validDims, timing: 99 } },
};
assert.throws(() => itemToInput(item, "2026-06-24"));
});
});
});