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:
parent
0e95ca8cce
commit
24775f4493
8 changed files with 793 additions and 10 deletions
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
130
scripts/trends/src/item.ts
Normal file
130
scripts/trends/src/item.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Canonical ingress item schema + normalizer for the research engine (RE-R1, B1).
|
||||
*
|
||||
* A `TrendItem` is the ONE envelope every source/adapter emits before a trend reaches
|
||||
* the store — "the one schema downstream never branches on". This module validates +
|
||||
* normalizes that envelope deterministically (no AI, no network): required-field
|
||||
* validation, topic normalize + dedupe, optional publishedAt ISO validation. It is the
|
||||
* trend-side twin of the store's own normalize/dedupe discipline (scripts/trends/src/store.ts).
|
||||
*
|
||||
* Scope (RE-R1): the validated envelope + normalizer ONLY. The item->store bridge —
|
||||
* injecting the store's `capturedAt` and persisting `publishedAt` — is R2 orchestration
|
||||
* and lives in the CLI/agent layer, not here. The envelope carries NO `id`: the store
|
||||
* derives it via addTrend->trendId, so an id here would be a second source of truth.
|
||||
*
|
||||
* GENERIC BY ARCHITECTURE: nothing niche-specific lives here. Which topics matter and
|
||||
* which sources to poll are decided upstream (config/profile + the capture agent).
|
||||
*/
|
||||
|
||||
import { normalizeField } from "./store.js";
|
||||
|
||||
export interface TrendItem {
|
||||
/** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
|
||||
source: string;
|
||||
/** The trend headline, VERBATIM (case + spacing preserved — the store keeps it verbatim too). */
|
||||
title: string;
|
||||
/** The source URL, VERBATIM (case-sensitive paths must survive). */
|
||||
url: string;
|
||||
/**
|
||||
* The SOURCE's own publish date (ISO-8601), validated-if-present. Carried for
|
||||
* forward-compat (B4 freshness) — distinct from the store's `capturedAt`, and NOT
|
||||
* persisted in R1. Absent -> the key is omitted.
|
||||
*/
|
||||
publishedAt?: string;
|
||||
/** Topic tags, normalized (lowercase + whitespace-collapsed via normalizeField) + deduped. */
|
||||
topics: string[];
|
||||
/** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };
|
||||
|
||||
/** One failed entry in a batch: its index in the input + the field errors. */
|
||||
export interface ItemError {
|
||||
index: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const REQUIRED_FIELDS = ["source", "title", "url"] as const;
|
||||
|
||||
/** Strict ISO-8601: a calendar date (YYYY-MM-DD), optionally with a time/zone. Rejects impossible dates. */
|
||||
function isValidIso(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const ms = Date.parse(value);
|
||||
if (Number.isNaN(ms)) return false;
|
||||
// Round-trip the date part: catches 2026-02-30 / out-of-range that the regex lets through.
|
||||
return new Date(ms).toISOString().slice(0, 10) === value.slice(0, 10);
|
||||
}
|
||||
|
||||
function isNonEmptyString(v: unknown): v is string {
|
||||
return typeof v === "string" && v.trim().length > 0;
|
||||
}
|
||||
|
||||
/** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */
|
||||
function normalizeTopics(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const t of raw) {
|
||||
if (typeof t !== "string") continue;
|
||||
const norm = normalizeField(t);
|
||||
if (norm.length === 0 || seen.has(norm)) continue;
|
||||
seen.add(norm);
|
||||
out.push(norm);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalize one raw item into the canonical envelope. Pure. Returns a
|
||||
* structured error (never a silent partial) when a required field is missing/empty
|
||||
* or publishedAt is present-but-invalid.
|
||||
*/
|
||||
export function normalizeItem(raw: unknown): NormalizeResult {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return { ok: false, errors: ["raw item must be an object"] };
|
||||
}
|
||||
const r = raw as Record<string, unknown>;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!isNonEmptyString(r[field])) {
|
||||
errors.push(`missing or empty required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
let publishedAt: string | undefined;
|
||||
if (r.publishedAt !== undefined && r.publishedAt !== null) {
|
||||
if (typeof r.publishedAt !== "string" || !isValidIso(r.publishedAt)) {
|
||||
errors.push(`invalid publishedAt (expected an ISO-8601 date): ${String(r.publishedAt)}`);
|
||||
} else {
|
||||
publishedAt = r.publishedAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { ok: false, errors };
|
||||
|
||||
const item: TrendItem = {
|
||||
source: r.source as string,
|
||||
title: r.title as string,
|
||||
url: r.url as string,
|
||||
topics: normalizeTopics(r.topics),
|
||||
...(publishedAt !== undefined ? { publishedAt } : {}),
|
||||
...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}),
|
||||
};
|
||||
return { ok: true, item };
|
||||
}
|
||||
|
||||
/** Partition a raw batch into normalized items + per-index errors (never throws). */
|
||||
export function normalizeItems(raw: unknown[]): { items: TrendItem[]; errors: ItemError[] } {
|
||||
const items: TrendItem[] = [];
|
||||
const errors: ItemError[] = [];
|
||||
raw.forEach((entry, index) => {
|
||||
const res = normalizeItem(entry);
|
||||
if (res.ok) items.push(res.item);
|
||||
else errors.push({ index, errors: res.errors });
|
||||
});
|
||||
return { items, errors };
|
||||
}
|
||||
122
scripts/trends/src/score.ts
Normal file
122
scripts/trends/src/score.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Deterministic triage scorer for the research engine (RE-R1, B2).
|
||||
*
|
||||
* Owns ONLY the arithmetic the SSOT (references/trend-scoring-modes.md) defines: the
|
||||
* per-mode weighted composite, the composite->band map, and the threshold triage.
|
||||
* Producing the five 1-10 dimension scores stays MODEL JUDGMENT by design — this module
|
||||
* never scores; it only combines + classifies + ranks. No AI, no network: pure and tested.
|
||||
*
|
||||
* SSOT discipline: the weights, the four band thresholds, and the ten band action strings
|
||||
* below MIRROR references/trend-scoring-modes.md (the human source of truth). score.test.ts
|
||||
* pins all three against the SSOT values so silent drift in any of them fails loudly. The
|
||||
* ORDERING of the weights is the signal; the exact percentages are a documented choice, not
|
||||
* a measured coefficient (SSOT "How to read this file").
|
||||
*/
|
||||
|
||||
export type ScoreMode = "kortform" | "long-form";
|
||||
export type DimensionScores = Record<string, number>;
|
||||
|
||||
/** kortform weights (SSOT "Mode: kortform"). Sigma = 1.0. */
|
||||
export const KORTFORM_WEIGHTS = {
|
||||
pillar: 0.3,
|
||||
audience: 0.25,
|
||||
timing: 0.2,
|
||||
angle: 0.15,
|
||||
authority: 0.1,
|
||||
} as const;
|
||||
|
||||
/** long-form weights (SSOT "Mode: long-form"). Sigma = 1.0. */
|
||||
export const LONG_FORM_WEIGHTS = {
|
||||
pillar: 0.3,
|
||||
depth: 0.25,
|
||||
angle: 0.2,
|
||||
authority: 0.15,
|
||||
currency: 0.1,
|
||||
} as const;
|
||||
|
||||
const WEIGHTS: Record<ScoreMode, Record<string, number>> = {
|
||||
kortform: KORTFORM_WEIGHTS,
|
||||
"long-form": LONG_FORM_WEIGHTS,
|
||||
};
|
||||
|
||||
export type Priority = "Immediate" | "High" | "Medium" | "Low" | "Skip";
|
||||
|
||||
export interface Band {
|
||||
priority: Priority;
|
||||
kortformAction: string;
|
||||
longformAction: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite->band map (SSOT "Composite -> action"). Descending by `min`; the first band
|
||||
* whose `min` the composite reaches wins. Thresholds + action strings are pinned by
|
||||
* score.test.ts against the SSOT, so any drift here fails the gate.
|
||||
*/
|
||||
const BANDS: ReadonlyArray<{ readonly min: number } & Band> = [
|
||||
{ min: 8.0, priority: "Immediate", kortformAction: "Draft within 24h", longformAction: "Promote to the edition backlog now" },
|
||||
{ min: 6.0, priority: "High", kortformAction: "Publish within 48–72h", longformAction: "Strong edition candidate — schedule it" },
|
||||
{ min: 4.0, priority: "Medium", kortformAction: "Add to this week's calendar", longformAction: "Hold as a backlog candidate, revisit" },
|
||||
{ min: 2.0, priority: "Low", kortformAction: "Note, skip for now", longformAction: "Park unless the angle sharpens" },
|
||||
{ min: 0, priority: "Skip", kortformAction: "Off positioning", longformAction: "Off positioning" },
|
||||
];
|
||||
|
||||
function round1(x: number): number {
|
||||
return Math.round(x * 10) / 10;
|
||||
}
|
||||
|
||||
function toBand(b: { readonly min: number } & Band): Band {
|
||||
return { priority: b.priority, kortformAction: b.kortformAction, longformAction: b.longformAction };
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted composite on the shared 0-10 scale, rounded to 1 decimal (the SSOT's display
|
||||
* granularity). Validates each of the mode's five dimensions in [1,10]; a missing or
|
||||
* out-of-range dimension throws — the scores are model output, and a bad one is a contract
|
||||
* violation, not a value to silently clamp.
|
||||
*/
|
||||
export function composite(scores: DimensionScores, mode: ScoreMode): number {
|
||||
const weights = WEIGHTS[mode];
|
||||
let sum = 0;
|
||||
for (const [dim, weight] of Object.entries(weights)) {
|
||||
const value = scores[dim];
|
||||
if (typeof value !== "number" || Number.isNaN(value) || value < 1 || value > 10) {
|
||||
throw new RangeError(`dimension "${dim}" must be a number in [1,10] (got ${String(value)})`);
|
||||
}
|
||||
sum += value * weight;
|
||||
}
|
||||
return round1(sum);
|
||||
}
|
||||
|
||||
/** Map a composite to its priority band + the mode-specific action strings. */
|
||||
export function band(composite: number): Band {
|
||||
for (const b of BANDS) {
|
||||
if (composite >= b.min) return toBand(b);
|
||||
}
|
||||
// composite < 0 (off the scale) — classify as Skip rather than throw; band is a classifier.
|
||||
return toBand(BANDS[BANDS.length - 1]);
|
||||
}
|
||||
|
||||
export interface TriageOptions {
|
||||
mode: ScoreMode;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export type Triaged<T> = T & { composite: number; band: Band };
|
||||
|
||||
/**
|
||||
* Score each candidate, keep composite >= threshold (ranked composite-desc), drop below
|
||||
* (also composite-desc). Each returned entry is annotated with its composite + band. Pure.
|
||||
*/
|
||||
export function triage<T extends { scores: DimensionScores }>(
|
||||
candidates: T[],
|
||||
opts: TriageOptions,
|
||||
): { kept: Array<Triaged<T>>; dropped: Array<Triaged<T>> } {
|
||||
const annotated: Array<Triaged<T>> = candidates.map((c) => {
|
||||
const comp = composite(c.scores, opts.mode);
|
||||
return { ...c, composite: comp, band: band(comp) };
|
||||
});
|
||||
const byCompositeDesc = (a: Triaged<T>, b: Triaged<T>) => b.composite - a.composite;
|
||||
const kept = annotated.filter((a) => a.composite >= opts.threshold).sort(byCompositeDesc);
|
||||
const dropped = annotated.filter((a) => a.composite < opts.threshold).sort(byCompositeDesc);
|
||||
return { kept, dropped };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue