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
70 lines
2.8 KiB
TypeScript
70 lines
2.8 KiB
TypeScript
import { describe, test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
// Resolve the package root (scripts/trends) so the subprocess `src/cli.ts` path + the
|
|
// `tsx` loader resolve regardless of the runner's cwd.
|
|
const trendsDir = fileURLToPath(new URL("..", import.meta.url));
|
|
|
|
function run(args: string[], input: string): { status: number | null; stdout: string } {
|
|
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
|
|
input,
|
|
encoding: "utf8",
|
|
cwd: trendsDir,
|
|
});
|
|
return { status: res.status, stdout: res.stdout };
|
|
}
|
|
|
|
describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
|
|
describe("normalize (stdin JSON in, JSON out)", () => {
|
|
test("happy path: a JSON batch on stdin -> exit 0 + {items,errors} JSON", () => {
|
|
const batch = JSON.stringify([
|
|
{ source: "tavily", title: "Good", url: "https://example.com/a", topics: ["AI", "ai"] },
|
|
{ source: "tavily", title: "", url: "https://example.com/b", topics: ["x"] }, // bad: empty title
|
|
]);
|
|
const { status, stdout } = run(["normalize"], batch);
|
|
assert.equal(status, 0);
|
|
const out = JSON.parse(stdout);
|
|
assert.equal(out.items.length, 1);
|
|
assert.deepEqual(out.items[0].topics, ["ai"]); // deduped + lowercased
|
|
assert.equal(out.errors.length, 1);
|
|
assert.equal(out.errors[0].index, 1);
|
|
});
|
|
|
|
test("bad invocation: unparseable stdin -> exit 2", () => {
|
|
const { status } = run(["normalize"], "not json at all");
|
|
assert.equal(status, 2);
|
|
});
|
|
});
|
|
|
|
describe("score (stdin JSON in, JSON out)", () => {
|
|
test("happy path: scored candidates on stdin -> exit 0 + {kept,dropped} JSON", () => {
|
|
const candidates = JSON.stringify([
|
|
{ id: "high", scores: { pillar: 8, audience: 8, timing: 8, angle: 8, authority: 8 } }, // 8.0
|
|
{ id: "low", scores: { pillar: 2, audience: 2, timing: 2, angle: 2, authority: 2 } }, // 2.0
|
|
]);
|
|
const { status, stdout } = run(["score", "--mode", "kortform", "--threshold", "4.0"], candidates);
|
|
assert.equal(status, 0);
|
|
const out = JSON.parse(stdout);
|
|
assert.deepEqual(
|
|
out.kept.map((k: { id: string }) => k.id),
|
|
["high"],
|
|
);
|
|
assert.equal(out.kept[0].composite, 8.0);
|
|
assert.equal(out.kept[0].band.priority, "Immediate");
|
|
assert.deepEqual(
|
|
out.dropped.map((d: { id: string }) => d.id),
|
|
["low"],
|
|
);
|
|
});
|
|
|
|
test("bad invocation: an unknown --mode -> exit 2", () => {
|
|
const candidates = JSON.stringify([
|
|
{ id: "x", scores: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 } },
|
|
]);
|
|
const { status } = run(["score", "--mode", "bogus"], candidates);
|
|
assert.equal(status, 2);
|
|
});
|
|
});
|
|
});
|