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
|
|
@ -43,7 +43,11 @@
|
|||
# with a non-vacuity self-test) in Section 16g; the trends-capture wiring guard (RE-R2a:
|
||||
# scripts/trends/src/cli.ts dispatches `capture` AND agents/trend-spotter.md references the
|
||||
# capture CLI 'src/cli.ts capture', with a non-vacuity self-test) in Section 16h; the
|
||||
# assertion-count anti-erosion floor (SC6) in Section 18. All are live below (Sections 8–18).
|
||||
# trends-brief wiring guard (RE-R2b: scripts/trends/src/cli.ts dispatches `brief`,
|
||||
# agents/trend-spotter.md references the brief CLI 'src/cli.ts brief', AND
|
||||
# hooks/scripts/session-start.mjs surfaces it via 'latestMorningBrief', with a non-vacuity
|
||||
# self-test) in Section 16i; the assertion-count anti-erosion floor (SC6) in Section 18. All
|
||||
# are live below (Sections 8–18).
|
||||
#
|
||||
# Usage: bash scripts/test-runner.sh
|
||||
# bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`.
|
||||
|
|
@ -694,7 +698,7 @@ if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then
|
|||
TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" )
|
||||
TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
|
||||
TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||
TRENDS_TESTS_FLOOR=79 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt)
|
||||
TRENDS_TESTS_FLOOR=104 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief)
|
||||
if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then
|
||||
pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)"
|
||||
else
|
||||
|
|
@ -1115,6 +1119,57 @@ fi
|
|||
|
||||
echo ""
|
||||
|
||||
# --- Section 16i: Trends Brief Wiring (research-engine RE-R2b) ---
|
||||
echo "--- Trends Brief Wiring ---"
|
||||
|
||||
# RE-R2b makes the store VISIBLE: a `brief` CLI ranks the store by pillar-overlap + recency and
|
||||
# writes a dated Markdown file the SessionStart hook surfaces. Three literals must hold, grepped
|
||||
# EXACT (grep -F), deps-absent-safe (pure grep, no tsx):
|
||||
# (1) cli.ts dispatches the `brief` subcommand, by the literal 'command === "brief"';
|
||||
# (2) agents/trend-spotter.md generates the brief after capture, by the literal 'src/cli.ts brief';
|
||||
# (3) hooks/scripts/session-start.mjs surfaces it, by the literal 'latestMorningBrief' (the
|
||||
# surfacing is wired, not merely documented).
|
||||
# Non-vacuity self-test mirrors Section 16h: the wiring predicate must accept a probe carrying the
|
||||
# brief-pointer literal and reject one without it. Placed after Section 16h / before Section 18
|
||||
# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts
|
||||
# toward ASSERT_BASELINE_FLOOR.
|
||||
BRIEF_HANDLER_LIT='command === "brief"'
|
||||
BRIEF_WIRE_LIT='src/cli.ts brief'
|
||||
BRIEF_SURFACE_LIT='latestMorningBrief'
|
||||
|
||||
I16_SELFTEST_OK=1
|
||||
if ! echo 'after capture, run src/cli.ts brief --pillars to write the dated brief' | grep -qF "$BRIEF_WIRE_LIT"; then
|
||||
I16_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired brief-pointer probe was not detected"
|
||||
fi
|
||||
if echo 'the agent renders the morning brief itself' | grep -qF "$BRIEF_WIRE_LIT"; then
|
||||
I16_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the brief pointer"
|
||||
fi
|
||||
if [ "$I16_SELFTEST_OK" -eq 1 ]; then
|
||||
pass "trends-brief self-test: brief-pointer predicate detects wiring, rejects the under-wired form"
|
||||
else
|
||||
fail "trends-brief self-test failed — the brief-wiring lint is vacuous or over-eager"
|
||||
fi
|
||||
|
||||
if grep -qF "$BRIEF_HANDLER_LIT" scripts/trends/src/cli.ts; then
|
||||
pass "cli.ts dispatches the brief subcommand ('$BRIEF_HANDLER_LIT')"
|
||||
else
|
||||
fail "cli.ts has no brief handler — add a '$BRIEF_HANDLER_LIT' branch (RE-R2b morning brief)"
|
||||
fi
|
||||
|
||||
if grep -qF "$BRIEF_WIRE_LIT" agents/trend-spotter.md; then
|
||||
pass "trend-spotter.md references the brief CLI ('$BRIEF_WIRE_LIT') as the post-capture surfacing step"
|
||||
else
|
||||
fail "trend-spotter.md does not reference the brief CLI — wire Step 4.6 to a '$BRIEF_WIRE_LIT' call (RE-R2b)"
|
||||
fi
|
||||
|
||||
if grep -qF "$BRIEF_SURFACE_LIT" hooks/scripts/session-start.mjs; then
|
||||
pass "session-start.mjs surfaces the morning brief ('$BRIEF_SURFACE_LIT')"
|
||||
else
|
||||
fail "session-start.mjs does not surface the brief — add the '$BRIEF_SURFACE_LIT' reader (RE-R2b hull 4)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
|
||||
# The lint self-modifies its own checks, so a green run could mask a silently dropped
|
||||
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
|
||||
|
|
@ -1128,12 +1183,14 @@ echo ""
|
|||
# +3 for RE-R1's three UNCONDITIONAL Section-16g checks (trends-scorer self-test + score.ts
|
||||
# both-modes weight-set grep + trend-spotter scorer-pointer grep) = 87; +3 for RE-R2a's three
|
||||
# UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep +
|
||||
# trend-spotter capture-pointer grep) = 90.
|
||||
# trend-spotter capture-pointer grep) = 90; +4 for RE-R2b's four UNCONDITIONAL Section-16i checks
|
||||
# (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep +
|
||||
# session-start surfacing grep) = 94.
|
||||
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
|
||||
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
|
||||
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
|
||||
# clone). Runs last so TOTAL_CHECKS sees every prior check.
|
||||
ASSERT_BASELINE_FLOOR=90
|
||||
ASSERT_BASELINE_FLOOR=94
|
||||
TOTAL_CHECKS=$((PASS + FAIL))
|
||||
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
|
||||
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"
|
||||
|
|
|
|||
|
|
@ -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