feat(linkedin-studio): RE-R1 — item-schema (B1) + triage-scorer (B2) as tested code behind CLI seam [skip-docs]

Lift the research engine's deterministic core out of agents/trend-spotter.md prose
into pure, tested TypeScript under scripts/trends/, behind a CLI seam the agent calls.

- B1 src/item.ts: TrendItem ingress envelope + normalizeItem/normalizeItems
  (required-field validation, topic normalize+dedupe via store's normalizeField,
  optional publishedAt ISO-validate). No id (store derives it); no store bridge
  (capturedAt injection is R2).
- B2 src/score.ts: per-mode weight consts mirroring the SSOT
  (references/trend-scoring-modes.md), composite (weighted sum, [1,10] guard),
  band (5-band map + exact SSOT action strings), triage (keep>=threshold, rank desc,
  annotate composite+band). Owns ONLY the arithmetic; the five judgment scores stay
  model-side.
- CLI normalize/score: JSON payload on STDIN, JSON to stdout (the existing --json
  output toggle is untouched); exit 2 on bad invocation, 0 otherwise.
- Wire trend-spotter.md to name 'src/cli.ts score' as the deterministic-step owner
  (prose pointer; the agent still supplies the five scores). Domain-general.
- Gate: TRENDS_TESTS_FLOOR 24->62; new unconditional Section 16g (score.ts both-mode
  weight-sets + trend-spotter scorer-pointer + non-vacuity self-test);
  ASSERT_BASELINE_FLOOR 84->87.

TDD: logic-RED proven (33/34 item+score fail on assertions, not module-not-found),
then GREEN (trends suite 62/62); CLI RED 2/4 -> GREEN 4/4. Full gate 102/0/0.
No store-schema change (SCHEMA_VERSION stays 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 10:09:45 +02:00
commit 24775f4493
8 changed files with 793 additions and 10 deletions

View file

@ -7,14 +7,23 @@
* 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]
* 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]
*
* The capture agent (research-engine) calls `add` to fold a freshly-polled trend
* into the store, and `query`/`list` to reason over accumulated history. The
* polling + relevance-scoring itself lives upstream; this is the deterministic store.
*
* Exit code: 0 on success, 2 on usage error.
* `normalize` + `score` (RE-R1) are the deterministic research-engine seam: both read
* their JSON PAYLOAD FROM STDIN (so they do not overload `--json`, which stays an
* output toggle) and print JSON to stdout. `normalize` validates raw items into the
* canonical envelope; `score` triages scored candidates (composite/band/threshold).
*
* Exit code: 0 on success, 2 on usage error (incl. unparseable stdin / bad flag).
*/
import { readFileSync } from "node:fs";
import {
addTrend,
defaultStorePath,
@ -24,6 +33,9 @@ import {
queryByTopic,
saveStore,
} from "./store.js";
import { normalizeItem, normalizeItems } from "./item.js";
import { triage } from "./score.js";
import type { ScoreMode } from "./score.js";
function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {};
@ -58,11 +70,29 @@ function usage(msg: string): never {
' add --title "<t>" --url "<u>" --topics <a,b> [--source <s>] [--summary "<s>"] [--store <path>]\n' +
" query --topics <a,b> [--store <path>] [--json]\n" +
" list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" +
" status [--store <path>] [--json]",
" status [--store <path>] [--json]\n" +
" normalize < raw-item-or-batch.json\n" +
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json",
);
process.exit(2);
}
/** Read the full JSON payload from stdin, or exit 2 if it is empty/unparseable. */
function readStdinJson(): unknown {
let raw = "";
try {
raw = readFileSync(0, "utf8").trim();
} catch {
raw = "";
}
if (raw.length === 0) usage("expected a JSON payload on stdin");
try {
return JSON.parse(raw);
} catch {
usage("stdin is not valid JSON");
}
}
function today(): string {
return new Date().toISOString().slice(0, 10);
}
@ -166,6 +196,38 @@ function main(): void {
return;
}
if (command === "normalize") {
const payload = readStdinJson();
const out = Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload);
console.log(JSON.stringify(out, null, 2));
return;
}
if (command === "score") {
const mode = flags.mode && flags.mode !== "true" ? flags.mode : "kortform";
if (mode !== "kortform" && mode !== "long-form") {
usage('score --mode must be "kortform" or "long-form"');
}
let threshold = 4.0;
if (flags.threshold && flags.threshold !== "true") {
const t = Number.parseFloat(flags.threshold);
if (Number.isNaN(t)) usage("--threshold must be a number");
threshold = t;
}
const payload = readStdinJson();
if (!Array.isArray(payload)) usage("score expects a JSON array of scored candidates on stdin");
try {
const result = triage(payload as Array<{ scores: Record<string, number> }>, {
mode: mode as ScoreMode,
threshold,
});
console.log(JSON.stringify(result, null, 2));
} catch (e) {
usage(`scoring failed: ${(e as Error).message}`);
}
return;
}
usage(command ? `unknown command: ${command}` : "no command given");
}