feat(linkedin-studio): RE-R3e — brief history + day-over-day diff (surfaced: frontmatter + Nytt siden sist) [skip-docs]
Closes hull #7 ("ingen brief-historikk"). Each morning brief now records the trend ids it showed into its own YAML frontmatter (surfaced: <id-csv> = surfacedIds(ranking)) and renders a day-over-day diff against the most recent prior brief — a "## Nytt siden sist (<prior-date>)" section that leads the ranked list, plus a " N nye siden sist." marker on the one-line summary the SessionStart hook surfaces (no hook change). - brief.ts: BRIEF_SCHEMA_VERSION 1->2 (artifact frontmatter gained surfaced:; the store's SCHEMA_VERSION stays 4 — no store field). Three PURE helpers (diffSurfaced / parseSurfacedFrontmatter / selectPriorBriefFile) + the surfaced: emit + the section + the summary marker. No fs/clock in brief.ts. - cli.ts: the brief handler discovers the prior dated file (existsSync-guarded readdirSync -> selectPriorBriefFile, strict < today so a same-day re-run is byte-identical), parses its surfaced: line, computes the diff, threads it into renderBrief AND the shared briefSummary(ranking, diff) (one-source: file frontmatter == --json summary, cli.test one-source invariant). --json gains a diff:{priorDate,added,carried,dropped} counts object; the console line appends the delta. Any fs error degrades to the empty-prior (first-brief) path. TDD two-phase: stubs -> 17 value-RED (no module-not-found) -> GREEN. Trends suite 216 -> 245 (brief +27, cli +2), 0 fail. New unconditional gate Section 16n (6 checks); ASSERT_BASELINE_FLOOR 117 -> 123; TRENDS_TESTS_FLOOR -> 245. Full gate FAIL=0; hook suite 139/139 + R3c schedule/run-daily green untouched. Behavioural: real two-day rename-real-write diff + same-day byte-identity confirmed. Counts 29/19/27 unchanged; no version bump (additive, v0.5.2 dev). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
This commit is contained in:
parent
ddedb3d1de
commit
5b51b4baeb
7 changed files with 462 additions and 18 deletions
|
|
@ -20,7 +20,7 @@ import { defaultStorePath, effectiveStatus } 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;
|
||||
export const BRIEF_SCHEMA_VERSION = 2;
|
||||
|
||||
/**
|
||||
* The live temporal overlay (RE-R3d): two derived signals — first-mover (recent AND
|
||||
|
|
@ -201,7 +201,12 @@ export function rankForBrief(
|
|||
* 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 {
|
||||
export function briefSummary(ranking: BriefRanking, diff?: BriefDiff): string {
|
||||
// RE-R3e: the day-over-day delta marker — appended only when a prior brief exists AND
|
||||
// something is new (suppressed on the first brief / when nothing changed). Digits + ASCII
|
||||
// + a period: no `"`/`\n`, so the hook's ^summary: regex still captures the line whole.
|
||||
const delta =
|
||||
diff && diff.priorDate !== null && diff.added.length > 0 ? ` ${diff.added.length} nye siden sist.` : "";
|
||||
const fresh = ranking.totals.fresh;
|
||||
if (fresh > 0) {
|
||||
const top = ranking.topMatches[0] ?? ranking.singleMatches[0];
|
||||
|
|
@ -210,9 +215,9 @@ export function briefSummary(ranking: BriefRanking): string {
|
|||
const band = top.trend.score ? ` · ${top.trend.score.priority}` : "";
|
||||
// RE-R3d: surface the first-mover marker on the one-line headline ("act now, you're early").
|
||||
const fm = top.temporal.firstMover ? " · 🥇 først ute" : "";
|
||||
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).`;
|
||||
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).${delta}`;
|
||||
}
|
||||
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`;
|
||||
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).${delta}`;
|
||||
}
|
||||
|
||||
/** ` · <priority> (<mode>)` when scored, else "" — the band+mode token shared by both renders (RE-R3a). */
|
||||
|
|
@ -256,15 +261,21 @@ function renderBulletEntry(e: BriefEntry): string {
|
|||
* 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 {
|
||||
export function renderBrief(
|
||||
ranking: BriefRanking,
|
||||
diff: BriefDiff = { priorDate: null, added: [], carried: [], dropped: [] },
|
||||
): string {
|
||||
const { totals } = ranking;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("---");
|
||||
lines.push(`date: ${ranking.today}`);
|
||||
lines.push(`summary: ${briefSummary(ranking)}`);
|
||||
lines.push(`summary: ${briefSummary(ranking, diff)}`);
|
||||
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
|
||||
lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`);
|
||||
// RE-R3e: the set of ids this brief showed — the record the NEXT day's diff reads. Always
|
||||
// emitted (even blank for an empty store); independent of --no-mark (a property of the render).
|
||||
lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`);
|
||||
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
|
|
@ -275,6 +286,27 @@ export function renderBrief(ranking: BriefRanking): string {
|
|||
);
|
||||
lines.push("");
|
||||
|
||||
// RE-R3e: the day-over-day delta leads, then the full ranked list below.
|
||||
lines.push(diff.priorDate !== null ? `## 🆕 Nytt siden sist (${diff.priorDate})` : "## 🆕 Nytt siden sist");
|
||||
if (diff.priorDate === null) {
|
||||
lines.push(diff.added.length > 0 ? "_Første brief — alt nedenfor er nytt._" : "_Første brief._", "");
|
||||
} else if (diff.added.length === 0) {
|
||||
lines.push(
|
||||
`_Ingenting nytt siden ${diff.priorDate}._`,
|
||||
`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`,
|
||||
"",
|
||||
);
|
||||
} else {
|
||||
const byId = new Map(
|
||||
[...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched].map((e) => [e.trend.id, e]),
|
||||
);
|
||||
for (const id of diff.added) {
|
||||
const e = byId.get(id);
|
||||
if (e) lines.push(renderBulletEntry(e));
|
||||
}
|
||||
lines.push(`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, "");
|
||||
}
|
||||
|
||||
lines.push("## 🎯 Topp-treff (2+ pillarer)");
|
||||
if (ranking.topMatches.length === 0) {
|
||||
lines.push("_Ingen i dag._", "");
|
||||
|
|
@ -306,6 +338,61 @@ export function surfacedIds(ranking: BriefRanking): string[] {
|
|||
return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* RE-R3e — the day-over-day diff of a brief's surfaced cohort against the most recent
|
||||
* prior brief. `priorDate` null ⇒ no prior (the first brief / a fresh data dir).
|
||||
* `added`/`carried`/`dropped` are the three DISJOINT partitions of the symmetric set
|
||||
* difference (an id is in exactly one), each order-stable. Pure: the id lists + the
|
||||
* prior date are injected by the CLI edge (no fs/clock here — like `today`/`pillars`).
|
||||
*/
|
||||
export interface BriefDiff {
|
||||
priorDate: string | null;
|
||||
added: string[];
|
||||
carried: string[];
|
||||
dropped: string[];
|
||||
}
|
||||
|
||||
/** The symmetric set difference of today's surfaced ids against the prior brief's. Pure (no fs/clock). */
|
||||
export function diffSurfaced(currentIds: string[], priorIds: string[], priorDate: string | null): BriefDiff {
|
||||
const prior = new Set(priorIds);
|
||||
const cur = new Set(currentIds);
|
||||
return {
|
||||
priorDate,
|
||||
added: currentIds.filter((id) => !prior.has(id)),
|
||||
carried: currentIds.filter((id) => prior.has(id)),
|
||||
dropped: priorIds.filter((id) => !cur.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a brief's `surfaced:` id list from its full text via one line-anchored regex
|
||||
* (the hook's `extractYaml` idiom). Absent / blank / malformed → [] (a pre-R3e or
|
||||
* hand-edited brief degrades to "empty prior"); never throws. Ids are comma-free hex.
|
||||
*/
|
||||
export function parseSurfacedFrontmatter(md: string): string[] {
|
||||
const m = md.match(/^surfaced: *([^\n]*)/m);
|
||||
if (!m) return [];
|
||||
return m[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent prior brief's filename: the lexicographically greatest `YYYY-MM-DD.md`
|
||||
* strictly < `${today}.md` (ISO dates sort = date order), else null. Excludes today + any
|
||||
* future-dated file, so a same-day re-run diffs against the true previous day (determinism).
|
||||
*/
|
||||
export function selectPriorBriefFile(filenames: string[], today: string): string | null {
|
||||
const todayFile = `${today}.md`;
|
||||
return (
|
||||
filenames
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f) && f < todayFile)
|
||||
.sort()
|
||||
.pop() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default brief directory under the per-user data dir, DERIVED from
|
||||
* defaultStorePath() so root resolution lives in exactly one place:
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
* `schedule` adds no new exit code: an autonomy install never RUNS the system mutation.
|
||||
*/
|
||||
|
||||
import { readFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -68,7 +68,16 @@ import type { TrendStatus } from "./types.js";
|
|||
import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
|
||||
import { triage } from "./score.js";
|
||||
import type { ScoreMode } from "./score.js";
|
||||
import { rankForBrief, renderBrief, briefSummary, defaultBriefDir, surfacedIds } from "./brief.js";
|
||||
import {
|
||||
rankForBrief,
|
||||
renderBrief,
|
||||
briefSummary,
|
||||
defaultBriefDir,
|
||||
surfacedIds,
|
||||
diffSurfaced,
|
||||
parseSurfacedFrontmatter,
|
||||
selectPriorBriefFile,
|
||||
} from "./brief.js";
|
||||
import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js";
|
||||
import type { ScheduleSpec } from "./schedule.js";
|
||||
|
||||
|
|
@ -337,7 +346,26 @@ function main(): void {
|
|||
const day = today(); // one wall-clock read for both the ranking and the filename
|
||||
const store = loadStore(storePath); // hoisted: also needed for the surfacing write below
|
||||
const ranking = rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt });
|
||||
const md = renderBrief(ranking);
|
||||
// RE-R3e: discover the most recent prior brief in outDir and diff today's surfaced cohort
|
||||
// against its `surfaced:` line. Any fs error (no dir, unreadable file) degrades to the
|
||||
// empty-prior (first-brief) path. The dir read lives HERE (the edge) — brief.ts stays fs-free.
|
||||
const todayIds = surfacedIds(ranking);
|
||||
let priorIds: string[] = [];
|
||||
let priorDate: string | null = null;
|
||||
try {
|
||||
if (existsSync(outDir)) {
|
||||
const priorFile = selectPriorBriefFile(readdirSync(outDir), day);
|
||||
if (priorFile) {
|
||||
priorIds = parseSurfacedFrontmatter(readFileSync(join(outDir, priorFile), "utf8"));
|
||||
priorDate = priorFile.slice(0, 10);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
priorIds = [];
|
||||
priorDate = null;
|
||||
}
|
||||
const diff = diffSurfaced(todayIds, priorIds, priorDate);
|
||||
const md = renderBrief(ranking, diff);
|
||||
const path = join(outDir, `${day}.md`);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(path, md, "utf8");
|
||||
|
|
@ -347,12 +375,33 @@ function main(): void {
|
|||
const mark = flags["no-mark"] !== "true";
|
||||
const marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0;
|
||||
if (mark) saveStore(storePath, store);
|
||||
const summary = briefSummary(ranking); // SAME source the frontmatter carries
|
||||
const summary = briefSummary(ranking, diff); // SAME source the frontmatter carries (RE-R3e: incl. the delta marker)
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary, marked }, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
path,
|
||||
date: ranking.today,
|
||||
totals: ranking.totals,
|
||||
summary,
|
||||
marked,
|
||||
diff: {
|
||||
priorDate: diff.priorDate,
|
||||
added: diff.added.length,
|
||||
carried: diff.carried.length,
|
||||
dropped: diff.dropped.length,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)`);
|
||||
const deltaNote = diff.added.length > 0 && diff.priorDate !== null ? `, ${diff.added.length} nye siden sist` : "";
|
||||
console.log(
|
||||
`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)${deltaNote}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue