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
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
import { describe, test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { normalizeItem, normalizeItems } from "../src/item.js";
|
|
import { normalizeField } from "../src/store.js";
|
|
|
|
describe("trends item normalizer (RE-R1 / B1)", () => {
|
|
describe("normalizeItem — well-formed", () => {
|
|
test("a well-formed raw item normalizes to a canonical item (string fields verbatim)", () => {
|
|
const raw = {
|
|
source: "tavily",
|
|
title: "OpenAI ships a new reasoning model",
|
|
url: "https://example.com/Article-Path",
|
|
topics: ["ai", "reasoning"],
|
|
summary: "A short summary.",
|
|
};
|
|
const res = normalizeItem(raw);
|
|
assert.equal(res.ok, true);
|
|
if (!res.ok) return;
|
|
assert.equal(res.item.source, "tavily");
|
|
assert.equal(res.item.title, "OpenAI ships a new reasoning model"); // verbatim, case preserved
|
|
assert.equal(res.item.url, "https://example.com/Article-Path"); // verbatim, case-sensitive path
|
|
assert.equal(res.item.summary, "A short summary.");
|
|
assert.deepEqual(res.item.topics, ["ai", "reasoning"]);
|
|
});
|
|
|
|
test("topics are normalized (lowercase + whitespace) and deduped, order-stable", () => {
|
|
const res = normalizeItem({
|
|
source: "manual",
|
|
title: "T",
|
|
url: "https://example.com/t",
|
|
topics: ["AI", " Machine Learning ", "ai", "Machine Learning"],
|
|
});
|
|
assert.equal(res.ok, true);
|
|
if (!res.ok) return;
|
|
// "AI"/"ai" dedupe -> "ai"; " Machine Learning "/"Machine Learning" dedupe -> "machine learning"
|
|
assert.deepEqual(res.item.topics, ["ai", "machine learning"]);
|
|
// each topic equals store.normalizeField of the raw form (the same normalization)
|
|
assert.equal(res.item.topics[1], normalizeField(" Machine Learning "));
|
|
});
|
|
|
|
test("the canonical item carries NO id (the store derives it via addTrend->trendId)", () => {
|
|
const res = normalizeItem({
|
|
source: "tavily",
|
|
title: "No id here",
|
|
url: "https://example.com/x",
|
|
topics: ["x"],
|
|
});
|
|
assert.equal(res.ok, true);
|
|
if (!res.ok) return;
|
|
assert.equal((res.item as Record<string, unknown>).id, undefined);
|
|
assert.equal(Object.prototype.hasOwnProperty.call(res.item, "id"), false);
|
|
});
|
|
|
|
test("summary is optional — absent -> no summary key", () => {
|
|
const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t", topics: ["x"] });
|
|
assert.equal(res.ok, true);
|
|
if (!res.ok) return;
|
|
assert.equal("summary" in res.item, false);
|
|
});
|
|
|
|
test("topics absent -> empty topics array", () => {
|
|
const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t" });
|
|
assert.equal(res.ok, true);
|
|
if (!res.ok) return;
|
|
assert.deepEqual(res.item.topics, []);
|
|
});
|
|
});
|
|
|
|
describe("normalizeItem — required-field validation", () => {
|
|
for (const field of ["source", "title", "url"] as const) {
|
|
test(`missing ${field} -> {ok:false} naming the field`, () => {
|
|
const base: Record<string, unknown> = {
|
|
source: "tavily",
|
|
title: "T",
|
|
url: "https://example.com/t",
|
|
topics: ["x"],
|
|
};
|
|
delete base[field];
|
|
const res = normalizeItem(base);
|
|
assert.equal(res.ok, false);
|
|
if (res.ok) return;
|
|
assert.ok(
|
|
res.errors.some((e) => e.includes(field)),
|
|
`error should name ${field}: ${res.errors.join("; ")}`,
|
|
);
|
|
});
|
|
|
|
test(`empty/whitespace ${field} -> {ok:false} naming the field (no silent partial)`, () => {
|
|
const base: Record<string, unknown> = {
|
|
source: "tavily",
|
|
title: "T",
|
|
url: "https://example.com/t",
|
|
topics: ["x"],
|
|
};
|
|
base[field] = " ";
|
|
const res = normalizeItem(base);
|
|
assert.equal(res.ok, false);
|
|
if (res.ok) return;
|
|
assert.ok(res.errors.some((e) => e.includes(field)));
|
|
});
|
|
}
|
|
|
|
test("a non-object raw -> {ok:false}", () => {
|
|
const res = normalizeItem("not an object" as unknown);
|
|
assert.equal(res.ok, false);
|
|
});
|
|
});
|
|
|
|
describe("normalizeItem — publishedAt", () => {
|
|
test("present and valid ISO date -> kept", () => {
|
|
const res = normalizeItem({
|
|
source: "tavily",
|
|
title: "T",
|
|
url: "https://example.com/t",
|
|
topics: ["x"],
|
|
publishedAt: "2026-06-20",
|
|
});
|
|
assert.equal(res.ok, true);
|
|
if (!res.ok) return;
|
|
assert.equal(res.item.publishedAt, "2026-06-20");
|
|
});
|
|
|
|
test("absent -> undefined (no key)", () => {
|
|
const res = normalizeItem({ source: "tavily", title: "T", url: "https://example.com/t", topics: ["x"] });
|
|
assert.equal(res.ok, true);
|
|
if (!res.ok) return;
|
|
assert.equal(res.item.publishedAt, undefined);
|
|
assert.equal("publishedAt" in res.item, false);
|
|
});
|
|
|
|
test("present but invalid -> {ok:false} naming publishedAt", () => {
|
|
const res = normalizeItem({
|
|
source: "tavily",
|
|
title: "T",
|
|
url: "https://example.com/t",
|
|
topics: ["x"],
|
|
publishedAt: "not-a-date",
|
|
});
|
|
assert.equal(res.ok, false);
|
|
if (res.ok) return;
|
|
assert.ok(res.errors.some((e) => e.includes("publishedAt")));
|
|
});
|
|
|
|
test("present but impossible calendar date -> {ok:false}", () => {
|
|
const res = normalizeItem({
|
|
source: "tavily",
|
|
title: "T",
|
|
url: "https://example.com/t",
|
|
topics: ["x"],
|
|
publishedAt: "2026-13-45",
|
|
});
|
|
assert.equal(res.ok, false);
|
|
});
|
|
});
|
|
|
|
describe("normalizeItems — batch partition", () => {
|
|
test("partitions a batch into {items, errors} with error indices", () => {
|
|
const raw = [
|
|
{ source: "tavily", title: "Good A", url: "https://example.com/a", topics: ["x"] },
|
|
{ source: "tavily", title: "", url: "https://example.com/b", topics: ["y"] }, // bad: empty title
|
|
{ source: "manual", title: "Good C", url: "https://example.com/c", topics: ["z", "z"] },
|
|
];
|
|
const { items, errors } = normalizeItems(raw);
|
|
assert.equal(items.length, 2);
|
|
assert.equal(errors.length, 1);
|
|
assert.equal(errors[0].index, 1);
|
|
assert.ok(errors[0].errors.some((e) => e.includes("title")));
|
|
assert.deepEqual(
|
|
items.map((i) => i.title),
|
|
["Good A", "Good C"],
|
|
);
|
|
assert.deepEqual(items[1].topics, ["z"]); // deduped
|
|
});
|
|
|
|
test("an empty batch -> empty partition", () => {
|
|
const { items, errors } = normalizeItems([]);
|
|
assert.deepEqual(items, []);
|
|
assert.deepEqual(errors, []);
|
|
});
|
|
});
|
|
});
|