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
|
|
@ -69,11 +69,32 @@ node --import tsx src/cli.ts query --topics "agents,engineering" [--json]
|
|||
|
||||
# Time-scoped history — newest first, optionally windowed/capped
|
||||
node --import tsx src/cli.ts list [--since 2026-06-01] [--limit 10] [--json]
|
||||
|
||||
# Dated morning brief — rank the store by pillar-overlap then recency, write a dated
|
||||
# Markdown file the SessionStart hook surfaces. Pillars come from the caller (user config).
|
||||
node --import tsx src/cli.ts brief --pillars "agents,engineering" \
|
||||
[--fresh-days 7] [--out <dir>] [--store <path>] [--json]
|
||||
```
|
||||
|
||||
Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend
|
||||
never appends a duplicate, it only unions any new topics in.
|
||||
|
||||
## Morning brief (RE-R2b)
|
||||
|
||||
`brief` is the dated, surfaced read over the store (distinct from `query`/`list`, which are
|
||||
interactive dumps). It ranks the store against the user's pillars — overlap desc, then
|
||||
`publishedAt ?? capturedAt` recency — buckets into top (2+ pillars), single (1 pillar), and
|
||||
older (matched but outside the freshness window, default 7 days), and writes:
|
||||
|
||||
```
|
||||
${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/morning-brief/YYYY-MM-DD.md
|
||||
```
|
||||
|
||||
The file's YAML frontmatter carries a single-line `summary` the SessionStart hook surfaces
|
||||
verbatim (zero-tsx — it reads the Markdown, never the TS CLI). Ranking uses only persisted
|
||||
fields; a persisted relevance score, an autonomous nightly trigger, and a seen-log freshness
|
||||
model are later slices.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
|
|
|
|||
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");
|
||||
}
|
||||
|
||||
|
|
|
|||
173
scripts/trends/tests/brief.test.ts
Normal file
173
scripts/trends/tests/brief.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
rankForBrief,
|
||||
renderBrief,
|
||||
briefSummary,
|
||||
defaultBriefDir,
|
||||
BRIEF_SCHEMA_VERSION,
|
||||
} from "../src/brief.js";
|
||||
import type { TrendRecord, TrendStore } from "../src/types.js";
|
||||
|
||||
const TODAY = "2026-06-24";
|
||||
|
||||
function mkTrend(
|
||||
p: { title: string; url: string; topics: string[]; capturedAt: string; publishedAt?: string; source?: string; summary?: string },
|
||||
): TrendRecord {
|
||||
return {
|
||||
id: p.title + "|" + p.url,
|
||||
title: p.title,
|
||||
url: p.url,
|
||||
source: p.source ?? "tavily",
|
||||
capturedAt: p.capturedAt,
|
||||
...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}),
|
||||
topics: p.topics,
|
||||
...(p.summary !== undefined ? { summary: p.summary } : {}),
|
||||
};
|
||||
}
|
||||
function mkStore(trends: TrendRecord[]): TrendStore {
|
||||
return { schemaVersion: 2, trends };
|
||||
}
|
||||
|
||||
describe("rankForBrief — grouping (SC1)", () => {
|
||||
const pillars = ["AI", "gov"];
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "T1 top", url: "https://e/1", topics: ["ai", "gov", "x"], publishedAt: "2026-06-22", capturedAt: "2026-06-23" }),
|
||||
mkTrend({ title: "T2 single", url: "https://e/2", topics: ["AI"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||||
mkTrend({ title: "T3 older1", url: "https://e/3", topics: ["gov"], publishedAt: "2026-06-01", capturedAt: "2026-06-01" }),
|
||||
mkTrend({ title: "T4 noise", url: "https://e/4", topics: ["unrelated"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
|
||||
mkTrend({ title: "T5 older2", url: "https://e/5", topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
|
||||
test("topMatches = overlap>=2 & fresh only", () => {
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["T1 top"]);
|
||||
});
|
||||
test("singleMatches = overlap===1 & fresh only", () => {
|
||||
assert.deepEqual(r.singleMatches.map((e) => e.trend.title), ["T2 single"]);
|
||||
});
|
||||
test("olderMatched = overlap>=1 & stale, sorted overlap desc", () => {
|
||||
assert.deepEqual(r.olderMatched.map((e) => e.trend.title), ["T5 older2", "T3 older1"]);
|
||||
});
|
||||
test("overlap===0 excluded everywhere", () => {
|
||||
const all = [...r.topMatches, ...r.singleMatches, ...r.olderMatched].map((e) => e.trend.title);
|
||||
assert.ok(!all.includes("T4 noise"));
|
||||
});
|
||||
test("totals reflect trends/matched/fresh", () => {
|
||||
assert.deepEqual(r.totals, { trends: 5, matched: 4, fresh: 2 });
|
||||
});
|
||||
test("matchedPillars preserve pillar case (case-insensitive match)", () => {
|
||||
assert.deepEqual(r.topMatches[0].matchedPillars, ["AI", "gov"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankForBrief — within-group total order (SC1)", () => {
|
||||
test("effectiveDate desc orders before title", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||||
mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const r = rankForBrief(store, ["a", "b"], TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Alpha", "Bravo"]);
|
||||
});
|
||||
test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => {
|
||||
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, ["a", "b"], TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankForBrief — freshness (SC2)", () => {
|
||||
const pillars = ["a"];
|
||||
test("effectiveDate = publishedAt when present (fresh despite old capturedAt)", () => {
|
||||
const r = rankForBrief(mkStore([mkTrend({ title: "P", url: "https://e/p", topics: ["a"], publishedAt: "2026-06-22", capturedAt: "2026-01-01" })]), pillars, TODAY);
|
||||
assert.equal(r.singleMatches.length, 1);
|
||||
assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22");
|
||||
assert.equal(r.olderMatched.length, 0);
|
||||
});
|
||||
test("fallback to capturedAt when publishedAt absent", () => {
|
||||
const r = rankForBrief(mkStore([mkTrend({ title: "C", url: "https://e/c", topics: ["a"], capturedAt: "2026-06-22" })]), pillars, TODAY);
|
||||
assert.equal(r.singleMatches.length, 1);
|
||||
assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22");
|
||||
});
|
||||
test("stale when capturedAt old and no publishedAt", () => {
|
||||
const r = rankForBrief(mkStore([mkTrend({ title: "S", url: "https://e/s", topics: ["a"], capturedAt: "2026-01-01" })]), pillars, TODAY);
|
||||
assert.equal(r.olderMatched.length, 1);
|
||||
assert.equal(r.singleMatches.length, 0);
|
||||
});
|
||||
test("boundary: ageDays === freshDays is fresh (<=)", () => {
|
||||
const r = rankForBrief(mkStore([mkTrend({ title: "B", url: "https://e/bd", topics: ["a"], publishedAt: "2026-06-17", capturedAt: "2026-06-17" })]), pillars, TODAY, { freshDays: 7 });
|
||||
assert.equal(r.singleMatches.length, 1, "7d with freshDays 7 must be fresh");
|
||||
assert.equal(r.singleMatches[0].ageDays, 7);
|
||||
});
|
||||
test("freshDays configurable: 10d fresh at 14, stale at 7", () => {
|
||||
const t = mkTrend({ title: "X", url: "https://e/x", topics: ["a"], publishedAt: "2026-06-14", capturedAt: "2026-06-14" });
|
||||
assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 14 }).singleMatches.length, 1);
|
||||
assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 7 }).olderMatched.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderBrief + briefSummary (SC3)", () => {
|
||||
const pillars = ["AI", "gov"];
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", summary: "A short summary" }),
|
||||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||||
mkTrend({ title: "Gamma", url: "https://e/g", topics: ["gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
const md = renderBrief(r);
|
||||
|
||||
test("starts with YAML frontmatter", () => {
|
||||
assert.ok(md.startsWith("---\n"), "brief must open with YAML frontmatter");
|
||||
});
|
||||
test("frontmatter carries date, store, schemaVersion", () => {
|
||||
assert.match(md, /\ndate: 2026-06-24\n/);
|
||||
assert.match(md, new RegExp("\\nschemaVersion: " + BRIEF_SCHEMA_VERSION + "\\n"));
|
||||
assert.match(md, /\nstore:/);
|
||||
});
|
||||
test("frontmatter summary === briefSummary(ranking); single line, no quote/newline", () => {
|
||||
const summary = briefSummary(r);
|
||||
assert.ok(!summary.includes('"'), "summary must not contain a double-quote");
|
||||
assert.ok(!summary.includes("\n"), "summary must be a single line");
|
||||
const m = md.match(/^summary: (.*)$/m);
|
||||
assert.ok(m, "frontmatter has a summary line");
|
||||
assert.equal(m![1], summary);
|
||||
});
|
||||
test("summary names the top entry when fresh matches exist", () => {
|
||||
assert.ok(briefSummary(r).includes("Alpha"));
|
||||
});
|
||||
test("body has the three section markers", () => {
|
||||
assert.ok(md.includes("Topp-treff"), "top section");
|
||||
assert.ok(md.includes("Enkelt-treff"), "single section");
|
||||
assert.ok(md.includes("Eldre i lager"), "older section");
|
||||
});
|
||||
test("deterministic: identical input -> identical bytes", () => {
|
||||
assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
|
||||
});
|
||||
test("empty ranking renders a valid no-fresh brief", () => {
|
||||
const empty = rankForBrief(mkStore([]), pillars, TODAY);
|
||||
const emd = renderBrief(empty);
|
||||
assert.ok(emd.startsWith("---\n"));
|
||||
assert.ok(briefSummary(empty).startsWith("Ingen ferske"), "no-fresh summary line");
|
||||
const m = emd.match(/^summary: (.*)$/m);
|
||||
assert.equal(m![1], briefSummary(empty));
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultBriefDir", () => {
|
||||
test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => {
|
||||
const prev = process.env.LINKEDIN_STUDIO_DATA;
|
||||
process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-brief-root";
|
||||
try {
|
||||
assert.equal(defaultBriefDir(), join("/tmp/lis-brief-root", "trends", "morning-brief"));
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
|
||||
else process.env.LINKEDIN_STUDIO_DATA = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import { describe, test } from "node:test";
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
|
|
@ -159,3 +159,94 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("trends CLI — brief subcommand (RE-R2b / Step 3)", () => {
|
||||
// brief is flag-driven (reads the store, not stdin). spawn with an env-overridable
|
||||
// LINKEDIN_STUDIO_DATA so defaultBriefDir() resolves into a temp root (never the real HOME).
|
||||
function runBrief(args: string[], env: Record<string, string> = {}): { status: number | null; stdout: string } {
|
||||
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "brief", ...args], {
|
||||
input: "",
|
||||
encoding: "utf8",
|
||||
cwd: trendsDir,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout };
|
||||
}
|
||||
const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10);
|
||||
function seedStore(trends: unknown[]): string {
|
||||
const store = join(mkdtempSync(join(tmpdir(), "trends-brief-")), "trends.json");
|
||||
writeFileSync(store, JSON.stringify({ schemaVersion: 2, trends }));
|
||||
return store;
|
||||
}
|
||||
|
||||
test("happy: writes a dated brief, --json carries path/date/totals/summary", () => {
|
||||
const store = seedStore([
|
||||
{ id: "a", title: "Fresh Match", url: "https://e/a", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai", "gov"] },
|
||||
]);
|
||||
const out = mkdtempSync(join(tmpdir(), "brief-out-"));
|
||||
try {
|
||||
const { status, stdout } = runBrief(["--pillars", "ai,gov", "--store", store, "--out", out, "--json"]);
|
||||
assert.equal(status, 0);
|
||||
const summary = JSON.parse(stdout);
|
||||
assert.match(summary.path, /\d{4}-\d{2}-\d{2}\.md$/);
|
||||
assert.match(summary.date, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.equal(summary.totals.trends, 1);
|
||||
assert.equal(summary.totals.fresh, 1);
|
||||
assert.ok(existsSync(summary.path), "the dated brief file is written");
|
||||
const md = readFileSync(summary.path, "utf8");
|
||||
const m = md.match(/^summary: (.*)$/m);
|
||||
assert.ok(m, "the brief frontmatter has a summary line");
|
||||
assert.equal(m![1], summary.summary, "--json summary equals the file frontmatter summary (one source)");
|
||||
} finally {
|
||||
rmSync(join(store, ".."), { recursive: true, force: true });
|
||||
rmSync(out, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bad invocation: --fresh-days non-numeric -> exit 2", () => {
|
||||
const store = seedStore([]);
|
||||
try {
|
||||
const { status } = runBrief(["--pillars", "ai", "--store", store, "--fresh-days", "xyz"]);
|
||||
assert.equal(status, 2);
|
||||
} finally {
|
||||
rmSync(join(store, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("empty --pillars -> exit 0 + a no-match brief is still written", () => {
|
||||
const store = seedStore([
|
||||
{ id: "a", title: "X", url: "https://e/x", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"] },
|
||||
]);
|
||||
const out = mkdtempSync(join(tmpdir(), "brief-out-"));
|
||||
try {
|
||||
const { status, stdout } = runBrief(["--store", store, "--out", out, "--json"]);
|
||||
assert.equal(status, 0);
|
||||
const summary = JSON.parse(stdout);
|
||||
assert.equal(summary.totals.matched, 0, "no pillars -> nothing matched");
|
||||
assert.ok(existsSync(summary.path), "a dated no-match brief is still written");
|
||||
} finally {
|
||||
rmSync(join(store, ".."), { recursive: true, force: true });
|
||||
rmSync(out, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bare --out (no value) -> falls back to defaultBriefDir, never ./true", () => {
|
||||
const store = seedStore([]);
|
||||
const dataRoot = mkdtempSync(join(tmpdir(), "brief-data-"));
|
||||
try {
|
||||
// bare --out --json: parseFlags yields out:"true"; the !== "true" guard must
|
||||
// fall back to defaultBriefDir() = <LINKEDIN_STUDIO_DATA>/trends/morning-brief.
|
||||
const { status, stdout } = runBrief(["--pillars", "ai", "--store", store, "--json", "--out"], { LINKEDIN_STUDIO_DATA: dataRoot });
|
||||
assert.equal(status, 0);
|
||||
const summary = JSON.parse(stdout);
|
||||
assert.ok(
|
||||
summary.path.startsWith(join(dataRoot, "trends", "morning-brief")),
|
||||
"bare --out must fall back to defaultBriefDir under LINKEDIN_STUDIO_DATA, not ./true",
|
||||
);
|
||||
assert.ok(!existsSync(join(trendsDir, "true")), "no ./true dir was created");
|
||||
} finally {
|
||||
rmSync(join(store, ".."), { recursive: true, force: true });
|
||||
rmSync(dataRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue