feat(linkedin-studio): RE-R2b — dated morning-brief artifact + session-start surfacing [skip-docs]
The visible layer of R2. Pure brief.ts: rankForBrief (pillar-overlap -> recency over the store; publishedAt ?? capturedAt freshness, 7d window; total-order sort), renderBrief (dated Markdown + hook-surfaceable summary frontmatter), briefSummary (one summary source), defaultBriefDir (derived from defaultStorePath). CLI `brief` writes <data>/trends/morning-brief/YYYY-MM-DD.md; session-start surfaces the latest zero-tsx (latestMorningBrief). Wired into trend-spotter Step 4.6 (scan->capture->brief->surfaced). No store-schema/scoring change; no scheduler (R3). 25 new trends tests (21 brief.test + 4 cli brief, RED-first) + 3 hook tests (morning-brief surfacing). trends 104/104 (floor 104), hook-suite 139/139, gate FAIL=0 (ASSERT floor 94, Section 16i: cli brief-handler + trend-spotter brief-pointer + session-start surfacing greps), tsc clean. 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
88fbbadb1b
commit
fa7551070e
9 changed files with 712 additions and 10 deletions
201
scripts/trends/src/brief.ts
Normal file
201
scripts/trends/src/brief.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* The dated morning-brief layer (research-engine §5, RE-R2b — the visible layer).
|
||||
*
|
||||
* Pure read-only view over the persistent trend store: rank the accumulated,
|
||||
* publish-dated signals against the user's content pillars (overlap), filter to a
|
||||
* freshness window, and render a dated Markdown artifact a later session surfaces.
|
||||
* No fs, no clock, no AI, no network — `today` and `pillars` are injected by the
|
||||
* caller (the CLI edge), exactly like the store's `capturedAt`. Determinism is the
|
||||
* contract: same (store, pillars, today, freshDays) -> byte-identical output.
|
||||
*
|
||||
* Ranking uses ONLY persisted fields (pillar overlap + publishedAt/capturedAt
|
||||
* recency). A persisted relevance/saturation score, an autonomous trigger, and a
|
||||
* seen-log freshness model are all later slices (R3); this module ships the
|
||||
* deterministic read the surfacing needs and nothing more.
|
||||
*/
|
||||
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
import { defaultStorePath } from "./store.js";
|
||||
import type { TrendStore, TrendRecord } from "./types.js";
|
||||
|
||||
/** The morning-brief artifact's own format version (distinct from the store's SCHEMA_VERSION). */
|
||||
export const BRIEF_SCHEMA_VERSION = 1;
|
||||
|
||||
/** One ranked trend in the brief, with its pillar overlap + freshness. */
|
||||
export interface BriefEntry {
|
||||
trend: TrendRecord;
|
||||
/** How many of the user's pillars the trend's topics matched. */
|
||||
overlap: number;
|
||||
/** The matched pillar names, in pillar order, original case preserved. */
|
||||
matchedPillars: string[];
|
||||
/** publishedAt ?? capturedAt — the date freshness + ordering use. */
|
||||
effectiveDate: string;
|
||||
/** Whole days from effectiveDate to the injected `today` (negative if future). */
|
||||
ageDays: number;
|
||||
}
|
||||
|
||||
/** The full ranking the brief renders from. */
|
||||
export interface BriefRanking {
|
||||
today: string;
|
||||
freshDays: number;
|
||||
totals: { trends: number; matched: number; fresh: number };
|
||||
/** overlap >= 2 AND fresh. */
|
||||
topMatches: BriefEntry[];
|
||||
/** overlap === 1 AND fresh. */
|
||||
singleMatches: BriefEntry[];
|
||||
/** overlap >= 1 AND NOT fresh. */
|
||||
olderMatched: BriefEntry[];
|
||||
}
|
||||
|
||||
export interface RankOptions {
|
||||
/** Freshness window in days (effectiveDate within N days of today). Default 7. */
|
||||
freshDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole days from `effectiveDate` to `today` (floor). Local to this module — NOT
|
||||
* imported from cli.ts's daysBetween: cli.ts imports brief.ts, so importing back
|
||||
* would invert the dependency direction. brief.ts stays a leaf the CLI composes.
|
||||
*/
|
||||
function ageDaysBetween(effectiveDate: string, today: string): number {
|
||||
return Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank the store against the user's pillars. Off-pillar trends (overlap 0) are
|
||||
* dropped; the rest bucket into top (>=2 & fresh), single (==1 & fresh), and older
|
||||
* (>=1 & stale). Each bucket is a TOTAL order: overlap desc, then effectiveDate
|
||||
* desc (freshest first), then title asc, then url asc — so the output is fixed
|
||||
* regardless of store insertion order.
|
||||
*/
|
||||
export function rankForBrief(
|
||||
store: TrendStore,
|
||||
pillars: string[],
|
||||
today: string,
|
||||
opts: RankOptions = {},
|
||||
): BriefRanking {
|
||||
const freshDays = opts.freshDays ?? 7;
|
||||
const wantedLower = pillars.map((p) => p.toLowerCase());
|
||||
|
||||
const entries: BriefEntry[] = [];
|
||||
for (const trend of store.trends) {
|
||||
const have = new Set(trend.topics.map((t) => t.toLowerCase()));
|
||||
const matchedPillars: string[] = [];
|
||||
for (let i = 0; i < pillars.length; i++) {
|
||||
if (have.has(wantedLower[i])) matchedPillars.push(pillars[i]);
|
||||
}
|
||||
const overlap = matchedPillars.length;
|
||||
if (overlap === 0) continue; // off-pillar noise
|
||||
const effectiveDate = trend.publishedAt ?? trend.capturedAt;
|
||||
entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) });
|
||||
}
|
||||
|
||||
const cmp = (a: BriefEntry, b: BriefEntry): number =>
|
||||
b.overlap - a.overlap ||
|
||||
b.effectiveDate.localeCompare(a.effectiveDate) ||
|
||||
a.trend.title.localeCompare(b.trend.title) ||
|
||||
a.trend.url.localeCompare(b.trend.url);
|
||||
|
||||
const isFresh = (e: BriefEntry): boolean => e.ageDays <= freshDays;
|
||||
|
||||
const topMatches = entries.filter((e) => e.overlap >= 2 && isFresh(e)).sort(cmp);
|
||||
const singleMatches = entries.filter((e) => e.overlap === 1 && isFresh(e)).sort(cmp);
|
||||
const olderMatched = entries.filter((e) => !isFresh(e)).sort(cmp); // overlap>=1 (0 already excluded)
|
||||
|
||||
return {
|
||||
today,
|
||||
freshDays,
|
||||
totals: { trends: store.trends.length, matched: entries.length, fresh: topMatches.length + singleMatches.length },
|
||||
topMatches,
|
||||
singleMatches,
|
||||
olderMatched,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The single source of the brief's one-line summary — used by renderBrief (the
|
||||
* frontmatter line the SessionStart hook surfaces verbatim) AND the CLI --json.
|
||||
* One line, no embedded double-quote, no newline, so the hook's extractYaml regex
|
||||
* (^summary: *"?([^"\n]*)"?) captures it whole.
|
||||
*/
|
||||
export function briefSummary(ranking: BriefRanking): string {
|
||||
const fresh = ranking.totals.fresh;
|
||||
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).`;
|
||||
}
|
||||
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`;
|
||||
}
|
||||
|
||||
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(", ")}`,
|
||||
];
|
||||
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
|
||||
lines.push(`- 🔗 ${e.trend.url}`);
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderBulletEntry(e: BriefEntry): string {
|
||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d) · 🔗 ${e.trend.url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the full dated Markdown artifact: YAML frontmatter (date, the shared
|
||||
* summary, store stats, ranking descriptor, schemaVersion) + a three-section body.
|
||||
* All three section headers are always emitted (stable structure → determinism).
|
||||
*/
|
||||
export function renderBrief(ranking: BriefRanking): string {
|
||||
const { totals } = ranking;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("---");
|
||||
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(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
lines.push(`# Morgen-brief — ${ranking.today}`);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`**${totals.fresh} ferske signaler** (publisert ≤${ranking.freshDays} dager) matcher temaene dine, av ${totals.trends} i lager.`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## 🎯 Topp-treff (2+ pillarer)");
|
||||
if (ranking.topMatches.length === 0) {
|
||||
lines.push("_Ingen i dag._", "");
|
||||
} else {
|
||||
ranking.topMatches.forEach((e, i) => lines.push(...renderTopEntry(e, i + 1)));
|
||||
}
|
||||
|
||||
lines.push("## 📌 Enkelt-treff (1 pillar)");
|
||||
if (ranking.singleMatches.length === 0) lines.push("_Ingen i dag._");
|
||||
else ranking.singleMatches.forEach((e) => lines.push(renderBulletEntry(e)));
|
||||
lines.push("");
|
||||
|
||||
lines.push(`## 💤 Eldre i lager (matcher, men >${ranking.freshDays}d) — ${ranking.olderMatched.length} stk`);
|
||||
ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e)));
|
||||
lines.push("");
|
||||
|
||||
lines.push("---");
|
||||
lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_");
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Default brief directory under the per-user data dir, DERIVED from
|
||||
* defaultStorePath() so root resolution lives in exactly one place:
|
||||
* <root>/trends/trends.json -> <root>/trends/morning-brief. Colocated with the
|
||||
* store the brief reads. Pure path computation, no fs.
|
||||
*/
|
||||
export function defaultBriefDir(): string {
|
||||
return join(dirname(defaultStorePath()), "morning-brief");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue