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
|
|
@ -7,10 +7,11 @@
|
|||
* node --import tsx src/cli.ts query --topics <a,b> [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts status [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts act|skip|reset --id <id> [--store <path>]
|
||||
* 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]
|
||||
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--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 →
|
||||
|
|
@ -20,8 +21,10 @@
|
|||
* 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.
|
||||
* normalization, publish-date-free). `act`/`skip`/`reset --id` set a trend's lifecycle status
|
||||
* (RE-R3b): the morning brief EXCLUDES acted/skipped and records each surfacing (per-day-idempotent
|
||||
* `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score
|
||||
* (timing decays). 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
|
||||
|
|
@ -30,7 +33,8 @@
|
|||
* `capture` normalizes + folds each valid item into the store (persisting `publishedAt`),
|
||||
* reporting content-invalid items in the summary `errors[]`, never via the exit code.
|
||||
*
|
||||
* Exit code: 0 on success, 2 on usage error (incl. unparseable stdin / bad flag).
|
||||
* Exit code: 0 on success, 2 on usage error or a not-found id (act/skip/reset). A wrong --id is an
|
||||
* argument-class error; capture's content-invalid items stay in errors[] (never via the exit code).
|
||||
*/
|
||||
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
|
|
@ -41,14 +45,17 @@ import {
|
|||
defaultStorePath,
|
||||
history,
|
||||
loadStore,
|
||||
markSurfaced,
|
||||
newestCaptureDate,
|
||||
queryByTopic,
|
||||
saveStore,
|
||||
setStatus,
|
||||
} from "./store.js";
|
||||
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 } from "./brief.js";
|
||||
import { rankForBrief, renderBrief, briefSummary, defaultBriefDir, surfacedIds } from "./brief.js";
|
||||
|
||||
function parseFlags(args: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
|
@ -84,10 +91,11 @@ function usage(msg: string): never {
|
|||
" query --topics <a,b> [--store <path>] [--json]\n" +
|
||||
" list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" +
|
||||
" status [--store <path>] [--json]\n" +
|
||||
" act|skip|reset --id <id> [--store <path>]\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\n" +
|
||||
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json]",
|
||||
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
@ -211,6 +219,21 @@ function main(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (command === "act" || command === "skip" || command === "reset") {
|
||||
const id = flags.id;
|
||||
if (!id || id === "true") usage(`${command} needs --id <id>`);
|
||||
const status: TrendStatus = command === "act" ? "acted" : command === "skip" ? "skipped" : "new";
|
||||
const store = loadStore(storePath);
|
||||
const res = setStatus(store, id, status);
|
||||
if (!res.found) {
|
||||
console.error(`error: no trend with id: ${id}`);
|
||||
process.exit(2);
|
||||
}
|
||||
saveStore(storePath, store);
|
||||
console.log(`Marked ${id} ${status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "normalize") {
|
||||
const payload = readStdinJson();
|
||||
const out = Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload);
|
||||
|
|
@ -249,7 +272,7 @@ function main(): void {
|
|||
const { items, errors } = normalizeItems(raw);
|
||||
const store = loadStore(storePath);
|
||||
// Tally derived from AddResult {added, merged} (no `duplicates` field): a fold is
|
||||
// `added` (new), else `merged` (existing gained topics), else a plain `duplicate`.
|
||||
// `added` (new), else `merged` (existing gained topics and/or a refreshed score), else a plain `duplicate`.
|
||||
let added = 0;
|
||||
let merged = 0;
|
||||
let duplicates = 0;
|
||||
|
|
@ -283,17 +306,24 @@ function main(): void {
|
|||
// 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 store = loadStore(storePath); // hoisted: also needed for the surfacing write below
|
||||
const ranking = rankForBrief(store, pillars, day, { freshDays });
|
||||
const md = renderBrief(ranking);
|
||||
const path = join(outDir, `${day}.md`);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(path, md, "utf8");
|
||||
// RE-R3b: record surfacing on the store AFTER the pure render (per-day idempotent), unless --no-mark.
|
||||
// The handled (acted/skipped) records were filtered from the ranking but remain in `store`, so the
|
||||
// resave preserves them; only the surfaced ids' surfacedCount/lastSurfacedAt change.
|
||||
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
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary }, null, 2));
|
||||
console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary, marked }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh)`);
|
||||
console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue