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
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 1–10 dimension scores —
|
||||
* NOT a precomputed composite (the store computes that, one owner). Validated by
|
||||
* normalizeItem; turned into the persisted envelope by itemToInput→scoreEnvelope.
|
||||
* 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
|
||||
* addTrend→trendId). `publishedAt`/`summary` are carried only when present (key omitted
|
||||
* otherwise), mirroring the store's conditional-spread idiom.
|
||||
* addTrend→trendId). `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 scoreEnvelope→composite 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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 1–10 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue