feat(linkedin-studio): RE-R3b — trend lifecycle (re-score on re-capture · status · seen-log) [skip-docs]
The lifecycle layer over the trend store: what happens to a trend AFTER first capture.
- re-score on re-capture (last-wins; addTrend duplicate branch, score the one mutable
field; provenance + lifecycle untouched; no false-merge via JSON compare). Reverses
R3a's first-sight D3 — that R3a test reconciled to the new behaviour.
- status new/acted/skipped (effectiveStatus/setStatus + act/skip/reset CLI verbs);
rankForBrief EXCLUDES handled trends (a work queue, not an archive).
- seen-log surfacedCount/lastSurfacedAt (markSurfaced, per-day idempotent); the brief
CLI records surfacing on the store AFTER the pure render, unless --no-mark.
- render: entry id in backticks (copy-paste for act/skip) + · sett Nx prior-day hint.
- schema v3→v4 (additive lossless); the R3a migration block reconciled to the bump,
the new R3b block committed against SCHEMA_VERSION (breaks the reconcile cycle).
score.ts + item.ts untouched (re-score reuses the R3a capture path). RED-first (two
phase: 16 logic-RED + 4 stub-RED). Gate: Section 16k (6 emitters), TRENDS_TESTS_FLOOR
146→171, ASSERT_BASELINE_FLOOR 99→105. trends 171/171, gate 120/0/0, hook suite 139/139.
Plan: docs/research-engine/{brief,plan}-re-r3b.md (light-Voyage hardened @ c40b937).
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
c40b937856
commit
b185db9a12
10 changed files with 661 additions and 44 deletions
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
import { defaultStorePath } from "./store.js";
|
||||
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). */
|
||||
|
|
@ -80,6 +80,8 @@ export function rankForBrief(
|
|||
|
||||
const entries: BriefEntry[] = [];
|
||||
for (const trend of store.trends) {
|
||||
// RE-R3b (A3): acted/skipped are handled — drop from the work queue (the brief is a queue, not an archive).
|
||||
if (effectiveStatus(trend) !== "new") continue;
|
||||
const have = new Set(trend.topics.map((t) => t.toLowerCase()));
|
||||
const matchedPillars: string[] = [];
|
||||
for (let i = 0; i < pillars.length; i++) {
|
||||
|
|
@ -144,10 +146,20 @@ function scoreToken(e: BriefEntry): string {
|
|||
return s ? ` · ${s.priority} (${s.mode})` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* ` · sett Nx` when surfacedCount>=2, else "" — the seen-log saturation HINT (RE-R3b). The count
|
||||
* is PRIOR-DAY: the brief renders before the CLI records today's surfacing, so it reads "shown on
|
||||
* N prior distinct days". Not the saturation SCORING of slice (b) nor the day-over-day diff of (d).
|
||||
*/
|
||||
function surfacedToken(e: BriefEntry): string {
|
||||
const c = e.trend.surfacedCount;
|
||||
return c && c >= 2 ? ` · sett ${c}x` : "";
|
||||
}
|
||||
|
||||
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)} · Pillarer: ${e.matchedPillars.join(", ")}`,
|
||||
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``,
|
||||
];
|
||||
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
|
||||
lines.push(`- 🔗 ${e.trend.url}`);
|
||||
|
|
@ -156,7 +168,7 @@ function renderTopEntry(e: BriefEntry, n: number): string[] {
|
|||
}
|
||||
|
||||
function renderBulletEntry(e: BriefEntry): string {
|
||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · 🔗 ${e.trend.url}`;
|
||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${surfacedToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -172,7 +184,7 @@ export function renderBrief(ranking: BriefRanking): string {
|
|||
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: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
|
||||
lines.push(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`);
|
||||
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
|
|
@ -205,6 +217,15 @@ export function renderBrief(ranking: BriefRanking): string {
|
|||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default brief directory under the per-user data dir, DERIVED from
|
||||
* defaultStorePath() so root resolution lives in exactly one place:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue