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");
|
||||
}
|
||||
|
|
@ -10,12 +10,15 @@
|
|||
* echo '<raw item|batch>' | node --import tsx src/cli.ts normalize
|
||||
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
|
||||
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json]
|
||||
*
|
||||
* 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`. `add` is the
|
||||
* MANUAL single-trend path (raw flags, no normalization, publish-date-free). The
|
||||
* polling + relevance-scoring itself lives upstream; this is the deterministic store.
|
||||
* addTrend), 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
|
||||
* upstream; this is the deterministic store.
|
||||
*
|
||||
* `normalize` + `score` (RE-R1) and `capture` (RE-R2a) are the deterministic
|
||||
* research-engine seam: all read their JSON PAYLOAD FROM STDIN (so they do not overload
|
||||
|
|
@ -27,7 +30,8 @@
|
|||
* Exit code: 0 on success, 2 on usage error (incl. unparseable stdin / bad flag).
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
addTrend,
|
||||
|
|
@ -41,6 +45,7 @@ import {
|
|||
import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
|
||||
import { triage } from "./score.js";
|
||||
import type { ScoreMode } from "./score.js";
|
||||
import { rankForBrief, renderBrief, briefSummary, defaultBriefDir } from "./brief.js";
|
||||
|
||||
function parseFlags(args: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
|
@ -78,7 +83,8 @@ function usage(msg: string): never {
|
|||
" status [--store <path>] [--json]\n" +
|
||||
" normalize < raw-item-or-batch.json\n" +
|
||||
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
|
||||
" capture [--store <path>] [--json] < raw-item-or-batch.json",
|
||||
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
|
||||
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
@ -262,6 +268,32 @@ function main(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (command === "brief") {
|
||||
const pillars = splitTopics(flags.pillars);
|
||||
let freshDays = 7;
|
||||
if (flags["fresh-days"] && flags["fresh-days"] !== "true") {
|
||||
const n = Number.parseInt(flags["fresh-days"], 10);
|
||||
if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer");
|
||||
freshDays = n;
|
||||
}
|
||||
// A bare `--out` yields the string "true" (parseFlags); the guard falls back to
|
||||
// defaultBriefDir() so it never writes to ./true.
|
||||
const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir();
|
||||
const day = today(); // one wall-clock read for both the ranking and the filename
|
||||
const ranking = rankForBrief(loadStore(storePath), pillars, day, { freshDays });
|
||||
const md = renderBrief(ranking);
|
||||
const path = join(outDir, `${day}.md`);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(path, md, "utf8");
|
||||
const summary = briefSummary(ranking); // SAME source the frontmatter carries
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh)`);
|
||||
return;
|
||||
}
|
||||
|
||||
usage(command ? `unknown command: ${command}` : "no command given");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue