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
|
|
@ -319,3 +319,123 @@ describe("trends CLI — brief subcommand (RE-R2b / Step 3)", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)", () => {
|
||||
// One temp dir per fixture holds both the store file and the brief out dir, so the
|
||||
// real per-user data dir (defaultBriefDir) is never touched.
|
||||
const fixture = () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "trends-r3b-"));
|
||||
return { dir, store: join(dir, "trends.json"), out: join(dir, "briefs") };
|
||||
};
|
||||
const listJson = (store: string): Array<Record<string, any>> =>
|
||||
JSON.parse(run(["list", "--store", store, "--json"], "").stdout);
|
||||
const seedScored = (store: string, title: string, url: string, timing = 9): void => {
|
||||
const batch = JSON.stringify([
|
||||
{
|
||||
source: "tavily",
|
||||
title,
|
||||
url,
|
||||
topics: ["ai", "gov"],
|
||||
publishedAt: "2026-06-23",
|
||||
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing, angle: 7, authority: 6 } },
|
||||
},
|
||||
]);
|
||||
run(["capture", "--store", store], batch);
|
||||
};
|
||||
|
||||
test("RED: act --id → acted; skip → skipped; reset → new (read back via list --json)", () => {
|
||||
const { dir, store } = fixture();
|
||||
try {
|
||||
seedScored(store, "Lifecycle", "https://e/lc");
|
||||
const id = listJson(store)[0].id;
|
||||
assert.equal(run(["act", "--id", id, "--store", store], "").status, 0);
|
||||
assert.equal(listJson(store)[0].status, "acted");
|
||||
run(["skip", "--id", id, "--store", store], "");
|
||||
assert.equal(listJson(store)[0].status, "skipped");
|
||||
run(["reset", "--id", id, "--store", store], "");
|
||||
assert.equal(listJson(store)[0].status, "new");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RED: an unknown id → exit 2 + store unchanged; a missing --id → exit 2", () => {
|
||||
const { dir, store } = fixture();
|
||||
try {
|
||||
seedScored(store, "X", "https://e/x");
|
||||
const before = readFileSync(store, "utf8");
|
||||
assert.equal(run(["act", "--id", "nope", "--store", store], "").status, 2);
|
||||
assert.equal(readFileSync(store, "utf8"), before, "store untouched on a not-found id");
|
||||
assert.equal(run(["skip", "--store", store], "").status, 2, "missing --id → exit 2");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RED: brief records surfacing (surfacedCount + lastSurfacedAt + marked); a second same-day run is idempotent", () => {
|
||||
const { dir, store, out } = fixture();
|
||||
try {
|
||||
seedScored(store, "Surf", "https://e/surf");
|
||||
const o1 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
|
||||
assert.equal(o1.marked, 1, "first brief marks 1");
|
||||
const rec = listJson(store)[0];
|
||||
assert.equal(rec.surfacedCount, 1);
|
||||
assert.match(rec.lastSurfacedAt, /^\d{4}-\d{2}-\d{2}$/);
|
||||
const o2 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
|
||||
assert.equal(o2.marked, 0, "second same-day brief is idempotent");
|
||||
assert.equal(listJson(store)[0].surfacedCount, 1, "count unchanged on the second same-day run");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RED: brief --no-mark writes no surfacedCount", () => {
|
||||
const { dir, store, out } = fixture();
|
||||
try {
|
||||
seedScored(store, "Dry", "https://e/dry");
|
||||
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--no-mark", "--out", out, "--store", store, "--json"], "").stdout);
|
||||
assert.equal(o.marked, 0);
|
||||
assert.equal("surfacedCount" in listJson(store)[0], false, "--no-mark must not write surfacedCount");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RED: the brief .md omits an acted record", () => {
|
||||
const { dir, store, out } = fixture();
|
||||
try {
|
||||
seedScored(store, "Handled", "https://e/handled");
|
||||
const id = listJson(store)[0].id;
|
||||
run(["act", "--id", id, "--store", store], "");
|
||||
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
|
||||
const md = readFileSync(o.path, "utf8");
|
||||
assert.ok(!md.includes("Handled"), "an acted record must not appear in the brief");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RED: a re-capture with a changed score reports merged:1 + refreshes the composite", () => {
|
||||
const { dir, store } = fixture();
|
||||
try {
|
||||
seedScored(store, "Re", "https://e/re", 9);
|
||||
const c1 = listJson(store)[0].score.composite;
|
||||
const batch = JSON.stringify([
|
||||
{
|
||||
source: "tavily",
|
||||
title: "Re",
|
||||
url: "https://e/re",
|
||||
topics: ["ai", "gov"],
|
||||
publishedAt: "2026-06-23",
|
||||
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 2, angle: 7, authority: 6 } },
|
||||
},
|
||||
]);
|
||||
const o = JSON.parse(run(["capture", "--store", store, "--json"], batch).stdout);
|
||||
assert.equal(o.merged, 1, "a changed score on a duplicate → merged");
|
||||
const c2 = listJson(store)[0].score.composite;
|
||||
assert.ok(c2 < c1, "a lower timing → lower composite (re-score last-wins)");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue