Artikkelforslaget blir en persistert entitet (steg 2, A1-4) og godkjenningen får
et hjem (steg 3, A1-7) — før dette genererte agenten vinkel/begrunnelse som ble kastet.
Åtte additivt-valgfrie felt på TrendRecord/TrendInput/TrendItem:
- Forslag (A1-4/A1-5): angle · targetLevel (fritekst, brukerdefinert spenn — aldri enum) ·
rationale · relatedIds[] (flerkilde).
- F7 leser-side: actionability {formulated, note?} (N7-bånd-cap-gaten leser `formulated`) ·
verdict BÆRENDE/STØTTE/NYHET (lukket mekanisme-vokab).
- F9 leser-side: readerQuestion · painPoint · saturation (N7.5-sveipet fyller dem).
Ingen schema-bump: feltene er additivt-valgfrie, ingen record trenger migrering →
SCHEMA_VERSION forblir 4 (loadStore Math.max håndterer det). First-sight-persistering
(re-capture unionerer topics + re-scorer kun; klobrer aldri en triagert vinkel).
Validering: typede felt (verdict/actionability) feiler hardt ved malformert input;
fritekst/id-liste normaliseres lempelig (summary/topics-idiomet).
Livssyklus: TrendStatus += "selected" → new→selected→acted|skipped. Ny select-verb +
--ids-batch (act/skip/reset/select); partiell suksess = exit 0 + miss-rapport, all-miss = exit 2.
setStatusMany(): ren batch-mutasjon, per-id found/notFound.
Brief: forslagsfeltene rendres per kandidat (detaljert i topp-treff, kompakt token i bullets) +
ny «🚧 I produksjon»-seksjon (selected=valgt + acted=skrevet), pillar-uavhengig, deterministisk
sortert. selected/acted forlater arbeidskøen men vises i produksjons-boardet.
commands/trends.md Step 5 oppgradert: triage → Velg (select) / Skip, batchet per verb (--ids).
Trends-suite 245→266 (ny floor, +21 N6-tester). To eksisterende tester oppdatert for den
endrede brief-kontrakten (acted vises nå i I produksjon; ranking-descriptoren ekskluderer selected).
tsc rent. Roundtrip bevist: capture m/ alle felt → query → brief rendrer feltene → select → I produksjon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7SQpXJpBSNvpWaTNq1kWZ
480 lines
22 KiB
TypeScript
480 lines
22 KiB
TypeScript
/**
|
||
* 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, 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 = 2;
|
||
|
||
/**
|
||
* The live temporal overlay (RE-R3d): two derived signals — first-mover (recent AND
|
||
* never surfaced on a prior day) and saturation (surfaced on >= saturationAt prior
|
||
* days). DERIVED at brief time from already-persisted fields (publishedAt/capturedAt
|
||
* -> ageDays, surfacedCount); never stored (no SCHEMA_VERSION bump), so it can never go
|
||
* stale. Mirrors ageDays: a function of the record + today, computed per rank, not persisted.
|
||
*/
|
||
export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated";
|
||
export interface TemporalSignal {
|
||
/** recent AND never surfaced on a prior day — "you'd be early". */
|
||
firstMover: boolean;
|
||
/** prior-day surfacings (surfacedCount ?? 0) — the self-exposure level saturation reads. */
|
||
surfacings: number;
|
||
/** the ordinal class (best -> worst opportunity). */
|
||
tier: TemporalTier;
|
||
/** descending sort rank: first-mover 3 > neutral 2 > warming 1 > saturated 0. */
|
||
rank: number;
|
||
}
|
||
|
||
/** 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 live temporal overlay (RE-R3d) — derived per rank from ageDays + surfacedCount. */
|
||
temporal: TemporalSignal;
|
||
}
|
||
|
||
/** 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[];
|
||
/**
|
||
* The "I produksjon" board (N6, A1-7): records the operator has pulled out of the work queue —
|
||
* status `selected` (valgt, in progress) or `acted` (skrevet, done). Pillar-independent (already
|
||
* chosen), so they bypass the overlap filter entirely; sorted selected-before-acted, then title/url.
|
||
*/
|
||
inProduction: TrendRecord[];
|
||
}
|
||
|
||
export interface RankOptions {
|
||
/** Freshness window in days (effectiveDate within N days of today). Default 7. */
|
||
freshDays?: number;
|
||
/** first-mover recency window in days (ageDays <= N AND unsurfaced). Default 2 (RE-R3d). */
|
||
firstMoverDays?: number;
|
||
/** surfacedCount at/above which a trend is "saturated". Default 3 (RE-R3d). */
|
||
saturationAt?: 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);
|
||
}
|
||
|
||
/**
|
||
* The live temporal overlay (RE-R3d): classify a trend's opportunity window from its age +
|
||
* self-exposure. Pure — every input is injected. first-mover = recent AND never surfaced on a
|
||
* prior day (the `ageDays >= 0` guard keeps a future-dated glitch out of "act now"); saturated =
|
||
* surfaced on >= saturationAt prior days; warming = surfaced 1..at-1; neutral = the no-signal
|
||
* baseline. `at` is clamped to >= 1 so a stray `saturationAt 0` cannot mark every trend saturated.
|
||
*/
|
||
export function temporalSignal(
|
||
ageDays: number,
|
||
surfacedCount: number | undefined,
|
||
opts: { firstMoverDays: number; saturationAt: number },
|
||
): TemporalSignal {
|
||
const surfacings = surfacedCount ?? 0;
|
||
const at = Math.max(1, opts.saturationAt);
|
||
const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0;
|
||
const tier: TemporalTier = firstMover
|
||
? "first-mover"
|
||
: surfacings >= at
|
||
? "saturated"
|
||
: surfacings >= 1
|
||
? "warming"
|
||
: "neutral";
|
||
const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0;
|
||
return { tier, firstMover, surfacings, rank };
|
||
}
|
||
|
||
/**
|
||
* 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 firstMoverDays = opts.firstMoverDays ?? 2;
|
||
const saturationAt = opts.saturationAt ?? 3;
|
||
const wantedLower = pillars.map((p) => p.toLowerCase());
|
||
|
||
const entries: BriefEntry[] = [];
|
||
const inProduction: TrendRecord[] = [];
|
||
for (const trend of store.trends) {
|
||
const st = effectiveStatus(trend);
|
||
// N6 (A1-7): selected/acted are "in production" — collected for their own board, out of the queue.
|
||
if (st === "selected" || st === "acted") {
|
||
inProduction.push(trend);
|
||
continue;
|
||
}
|
||
// RE-R3b (A3): skipped is handled — drop from the work queue (the brief is a queue, not an archive).
|
||
if (st !== "new") continue;
|
||
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;
|
||
const ageDays = ageDaysBetween(effectiveDate, today);
|
||
// Prior-DAY surfacings: the seen-log count EXCLUDING today. The brief renders BEFORE the CLI
|
||
// records today's surfacing, so on the first run of a day surfacedCount is already prior-day —
|
||
// but a same-day RE-RUN loads a count that already includes today (lastSurfacedAt === today),
|
||
// so subtract it back out. This keeps a same-day re-render byte-identical (RE-R3c SC7) and makes
|
||
// "first-mover" (prior surfacings === 0) stable across the render → mark step.
|
||
const surfaced = trend.surfacedCount ?? 0;
|
||
const priorSurfacings = trend.lastSurfacedAt === today ? Math.max(0, surfaced - 1) : surfaced;
|
||
entries.push({
|
||
trend,
|
||
overlap,
|
||
matchedPillars,
|
||
effectiveDate,
|
||
ageDays,
|
||
temporal: temporalSignal(ageDays, priorSurfacings, { firstMoverDays, saturationAt }),
|
||
});
|
||
}
|
||
|
||
// Composite is the PRIMARY within-bucket key (RE-R3a / D2): a higher persisted relevance
|
||
// composite sorts first; an unscored record uses the sentinel -1 (composite is a weighted
|
||
// sum of [1,10] dims, so it is always >= 1.0 — -1 sorts unscored last and subtracts
|
||
// cleanly, where -Infinity - -Infinity = NaN would corrupt the comparator). Buckets are
|
||
// unchanged; composite only re-orders WITHIN a bucket.
|
||
// RE-R3d inserts the temporal-overlay rank (first-mover↑ / saturated↓) AFTER overlap and
|
||
// BEFORE effectiveDate: composite + overlap stay primary, and the overlay refines recency
|
||
// (a coarse temporal class) ahead of the raw effectiveDate it sits in front of. The existing
|
||
// overlap → temporal → effectiveDate → title → url chain still gives a total order (the
|
||
// (title,url) pair is the unique dedupe id; rank is a small integer, no NaN risk).
|
||
const cmp = (a: BriefEntry, b: BriefEntry): number =>
|
||
(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) ||
|
||
b.overlap - a.overlap ||
|
||
b.temporal.rank - a.temporal.rank ||
|
||
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)
|
||
|
||
// A total order for the production board: selected (in progress) before acted (done), then title, then url.
|
||
const prodRank = (t: TrendRecord): number => (effectiveStatus(t) === "selected" ? 0 : 1);
|
||
inProduction.sort(
|
||
(a, b) => prodRank(a) - prodRank(b) || a.title.localeCompare(b.title) || a.url.localeCompare(b.url),
|
||
);
|
||
|
||
return {
|
||
today,
|
||
freshDays,
|
||
totals: { trends: store.trends.length, matched: entries.length, fresh: topMatches.length + singleMatches.length },
|
||
topMatches,
|
||
singleMatches,
|
||
olderMatched,
|
||
inProduction,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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, 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];
|
||
const pillar = top.matchedPillars[0];
|
||
// Band only (no mode) — the mode stays a body-entry detail to keep the one-line headline clean.
|
||
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).${delta}`;
|
||
}
|
||
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). */
|
||
function scoreToken(e: BriefEntry): string {
|
||
const s = e.trend.score;
|
||
return s ? ` · ${s.priority} (${s.mode})` : "";
|
||
}
|
||
|
||
/**
|
||
* The temporal-overlay badge (RE-R3d): promotes the R3b `· sett Nx` hint into a graded set.
|
||
* `first-mover` → `· 🥇 først ute`; `saturated` → `· 🔁 mettet (Nx)`; `warming` → `· sett Nx`
|
||
* ONLY at surfacings>=2 (preserving the EXACT R3b ≥2 display contract — the warming TIER still
|
||
* demotes a surfaced-once trend in rank, but its badge stays suppressed); `neutral` → "".
|
||
* The count is PRIOR-DAY (the brief renders before the CLI records today's surfacing).
|
||
*/
|
||
function temporalToken(e: BriefEntry): string {
|
||
const t = e.temporal;
|
||
if (t.tier === "first-mover") return " · 🥇 først ute";
|
||
if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`;
|
||
if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`;
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* The N6 proposal fields as detailed `- ` lines (top entries only) — each emitted ONLY when present,
|
||
* so an unproposed trend renders exactly as before (backward-compat). Order is fixed for determinism.
|
||
* The verdict + reader-grip share one line (the N7 band-cap gate's two inputs, shown together).
|
||
*/
|
||
function proposalLines(t: TrendRecord): string[] {
|
||
const out: string[] = [];
|
||
if (t.angle) out.push(`- 💡 Vinkel: ${t.angle}`);
|
||
if (t.targetLevel) out.push(`- 🎚️ Målnivå: ${t.targetLevel}`);
|
||
if (t.rationale) out.push(`- 🧭 Hvorfor nå: ${t.rationale}`);
|
||
if (t.verdict || t.actionability) {
|
||
const grip = t.actionability
|
||
? `${t.actionability.formulated ? "ja" : "nei"}${t.actionability.note ? ` («${t.actionability.note}»)` : ""}`
|
||
: "—";
|
||
out.push(`- 🎯 Dom: ${t.verdict ?? "—"} · leser-grep: ${grip}`);
|
||
}
|
||
if (t.readerQuestion) out.push(`- ❓ Leserspørsmål: ${t.readerQuestion}`);
|
||
if (t.painPoint) out.push(`- 🩹 Smertepunkt: ${t.painPoint}`);
|
||
if (t.saturation) out.push(`- 🌡️ Metning: ${t.saturation}`);
|
||
if (t.relatedIds && t.relatedIds.length > 0) out.push(`- ↔️ Relatert: ${t.relatedIds.join(", ")}`);
|
||
return out;
|
||
}
|
||
|
||
/** The compact proposal token for single-line bullets: verdict + a reader-grip flag, when present. */
|
||
function proposalToken(t: TrendRecord): string {
|
||
const bits: string[] = [];
|
||
if (t.verdict) bits.push(`🎯 ${t.verdict}`);
|
||
if (t.actionability) bits.push(`grep: ${t.actionability.formulated ? "ja" : "nei"}`);
|
||
return bits.length > 0 ? ` · ${bits.join(" · ")}` : "";
|
||
}
|
||
|
||
function renderTopEntry(e: BriefEntry, n: number): string[] {
|
||
const lines = [
|
||
`### ${n}. ${e.trend.title}`,
|
||
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``,
|
||
];
|
||
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
|
||
lines.push(...proposalLines(e.trend));
|
||
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)${scoreToken(e)}${temporalToken(e)}${proposalToken(e.trend)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
|
||
}
|
||
|
||
/**
|
||
* The "I produksjon" board (N6, A1-7): the selected/acted records rankForBrief set aside, each a
|
||
* compact line tagged [valgt]/[skrevet]. Pure. The section header is always emitted (stable structure);
|
||
* an empty board renders the explicit "_Ingen i produksjon._" marker.
|
||
*/
|
||
function renderInProduction(records: TrendRecord[]): string[] {
|
||
const lines = ["## 🚧 I produksjon (valgt + skrevet)"];
|
||
if (records.length === 0) {
|
||
lines.push("_Ingen i produksjon._", "");
|
||
return lines;
|
||
}
|
||
for (const t of records) {
|
||
const tag = t.status === "acted" ? "skrevet" : "valgt";
|
||
const angle = t.angle ? ` · 💡 ${t.angle}` : "";
|
||
const verdict = t.verdict ? ` · 🎯 ${t.verdict}` : "";
|
||
lines.push(`- [${tag}] **${t.title}**${angle}${verdict} · \`${t.id}\``);
|
||
}
|
||
lines.push("");
|
||
return lines;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
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, 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/selected (selected+acted shown in I produksjon)`);
|
||
// 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("");
|
||
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("");
|
||
|
||
// 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._", "");
|
||
} 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("");
|
||
|
||
// N6 (A1-7): the production board — where a triaged candidate lives once it leaves the queue.
|
||
lines.push(...renderInProduction(ranking.inProduction));
|
||
|
||
lines.push("---");
|
||
lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_");
|
||
|
||
return lines.join("\n") + "\n";
|
||
}
|
||
|
||
/**
|
||
* The ids of the entries renderBrief actually shows: topMatches ∪ singleMatches ∪ the first 5
|
||
* olderMatched (mirroring the render's .slice(0,5)). The brief CLI feeds these to markSurfaced so
|
||
* the seen-log records exactly what the operator saw. Pure (RE-R3b).
|
||
*/
|
||
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:
|
||
* <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");
|
||
}
|