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:
parent
4d3b9f4711
commit
e169c78710
14 changed files with 829 additions and 40 deletions
|
|
@ -13,8 +13,24 @@ import type { TrendRecord, TrendStore } from "../src/types.js";
|
|||
|
||||
const TODAY = "2026-06-24";
|
||||
|
||||
type TestScore = {
|
||||
mode: "kortform" | "long-form";
|
||||
dimensions: Record<string, number>;
|
||||
composite: number;
|
||||
priority: "Immediate" | "High" | "Medium" | "Low" | "Skip";
|
||||
};
|
||||
|
||||
function mkTrend(
|
||||
p: { title: string; url: string; topics: string[]; capturedAt: string; publishedAt?: string; source?: string; summary?: string },
|
||||
p: {
|
||||
title: string;
|
||||
url: string;
|
||||
topics: string[];
|
||||
capturedAt: string;
|
||||
publishedAt?: string;
|
||||
source?: string;
|
||||
summary?: string;
|
||||
score?: TestScore;
|
||||
},
|
||||
): TrendRecord {
|
||||
return {
|
||||
id: p.title + "|" + p.url,
|
||||
|
|
@ -25,8 +41,14 @@ function mkTrend(
|
|||
...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}),
|
||||
topics: p.topics,
|
||||
...(p.summary !== undefined ? { summary: p.summary } : {}),
|
||||
...(p.score !== undefined ? { score: p.score } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A composite-bearing score for the rank tests (mode/priority kept consistent for render asserts). */
|
||||
function mkScore(composite: number, priority: TestScore["priority"], mode: TestScore["mode"] = "kortform"): TestScore {
|
||||
return { mode, dimensions: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }, composite, priority };
|
||||
}
|
||||
function mkStore(trends: TrendRecord[]): TrendStore {
|
||||
return { schemaVersion: 2, trends };
|
||||
}
|
||||
|
|
@ -159,6 +181,161 @@ describe("renderBrief + briefSummary (SC3)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("rankForBrief — composite primary within bucket (RE-R3a / SC5)", () => {
|
||||
const pillars = ["a", "b"];
|
||||
|
||||
test("higher composite sorts first at the same overlap + freshness", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Low", url: "https://e/low", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
|
||||
mkTrend({ title: "High", url: "https://e/high", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["High", "Low"], "composite 9 before composite 6");
|
||||
});
|
||||
|
||||
test("an unscored record sorts after every scored record in its bucket (sentinel -1)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Unscored", url: "https://e/u", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
mkTrend({ title: "Scored low", url: "https://e/s", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(2.0, "Low") }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Scored low", "Unscored"], "any scored beats unscored");
|
||||
});
|
||||
|
||||
test("both-unscored same-title/diff-url pair falls back to url asc (total order intact)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
|
||||
});
|
||||
|
||||
test("composite overrides effectiveDate within the bucket (composite is the leading key)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Fresher lower", url: "https://e/fl", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(5.0, "Medium") }),
|
||||
mkTrend({ title: "Older higher", url: "https://e/oh", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Older higher", "Fresher lower"]);
|
||||
});
|
||||
|
||||
test("deterministic: identical scored input -> identical ranking order", () => {
|
||||
const trends = [
|
||||
mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
|
||||
];
|
||||
const r1 = rankForBrief(mkStore(trends), pillars, TODAY);
|
||||
const r2 = rankForBrief(mkStore(trends), pillars, TODAY);
|
||||
assert.deepEqual(r1.topMatches.map((e) => e.trend.title), r2.topMatches.map((e) => e.trend.title));
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
|
||||
const pillars = ["AI", "gov"];
|
||||
|
||||
test("scored top-entry meta line is the full pinned shape (· <priority> (<mode>) between age and Pillarer)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Immediate (kortform) · Pillarer: AI, gov"),
|
||||
"scored top-entry meta line must carry · <priority> (<mode>) between (<age>d) and · Pillarer",
|
||||
);
|
||||
});
|
||||
|
||||
test("unscored top-entry meta line is UNCHANGED (no token)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Pillarer: AI, gov"),
|
||||
"unscored top-entry meta line must be unchanged",
|
||||
);
|
||||
});
|
||||
|
||||
test("scored bullet (single match) is the full pinned shape (· <priority> (<mode>) before · 🔗)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · High (kortform) · 🔗 https://e/b"),
|
||||
"scored bullet must carry · <priority> (<mode>) before · 🔗",
|
||||
);
|
||||
});
|
||||
|
||||
test("unscored bullet (single match) is UNCHANGED (no token)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · 🔗 https://e/b"),
|
||||
"unscored bullet must be unchanged",
|
||||
);
|
||||
});
|
||||
|
||||
test("briefSummary names the band (no mode) on a scored top", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 2d)."), `summary should carry the band: ${s}`);
|
||||
assert.ok(!s.includes("kortform"), "summary must not carry the mode");
|
||||
});
|
||||
|
||||
test("briefSummary omits the band token on an unscored top", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(s.includes("Topp: «Alpha» (AI · 2d)."), `unscored summary should omit the band: ${s}`);
|
||||
});
|
||||
|
||||
test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "«Quoted» take", url: "https://e/q", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(!s.includes('"'), "summary must not contain a double-quote");
|
||||
assert.ok(!s.includes("\n"), "summary must be a single line");
|
||||
assert.ok(s.includes("Immediate"), "summary still carries the band");
|
||||
});
|
||||
|
||||
test("single-pillar unscored top -> summary renders with no · <priority> token, one line", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, ["AI"], TODAY));
|
||||
assert.ok(!s.includes("· ·"), "no empty priority slot");
|
||||
assert.ok(s.includes("Topp: «Solo» (AI · 2d)."), `single-pillar unscored summary: ${s}`);
|
||||
assert.ok(!s.includes("\n"));
|
||||
});
|
||||
|
||||
test("ranking: descriptor equals the exact pinned string", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY, { freshDays: 7 }));
|
||||
assert.ok(
|
||||
md.includes("ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays 7"),
|
||||
"the ranking descriptor must match the pinned RE-R3a string verbatim",
|
||||
);
|
||||
});
|
||||
|
||||
test("deterministic: identical scored input -> identical bytes", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultBriefDir", () => {
|
||||
test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => {
|
||||
const prev = process.env.LINKEDIN_STUDIO_DATA;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue