feat(linkedin-studio): RE-R3a — persist relevance score on the store record + rank the morning brief on it [skip-docs]
R3 slice 1 (research-deepening). Stop discarding the relevance judgment the
trend-spotter already computes: persist a 4-field TrendScore {mode, dimensions,
composite, priority} on TrendRecord (schema v2->v3, additive lossless migrate),
computed by the existing score.ts composite()+band() (one owner, no new arithmetic),
threaded item->store; then rankForBrief sorts each bucket composite-first (sentinel
-1 for unscored) and renderBrief surfaces "· <priority> (<mode>)" per body entry
(briefSummary shows the band only). First-sight only; mode-blind ranking with the mode
shown so the operator can disambiguate instruments.
- score.ts: TrendScore + requiredDimensions(mode) (ordered) + scoreEnvelope (composes
composite+band; throws on bad dim by contract)
- types.ts: SCHEMA_VERSION 2->3; TrendRecord.score?
- store.ts: TrendInput.score?; addTrend persists first-sight (duplicate keeps it);
migrate comment v1->v2->v3 (logic unchanged, JSON.stringify preserves the field)
- item.ts: TrendItem.score?; normalizeItem validates (non-array score/dimensions + the
mode's five dims in [1,10]) -> structured error never throw, carries validated dims;
itemToInput -> scoreEnvelope (no throw on the capture path; direct call throws by contract)
- brief.ts: composite-primary comparator; band+mode render; exact ranking: descriptor
- cli.ts: capture persists score via itemToInput (doc-only); add/score paths unchanged
- agents/trend-spotter.md Step 4.5: capture batch carries the Step-2 dimensions
- gate: TRENDS_TESTS_FLOOR 104->146; new unconditional Section 16j; ASSERT floor 94->99
Tests: trends 146/146 (RED two-phase: logic-RED store/brief/cli; stub-first then
assertion-RED score/item). Gate green (Passed 114 / Failed 0; 113 checks >= 99).
Hook suite 139/139 untouched. Counts 27/19/29 unchanged. No new source file/agent/command.
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
4d3b9f4711
commit
e169c78710
14 changed files with 829 additions and 40 deletions
|
|
@ -46,7 +46,11 @@
|
|||
# trends-brief wiring guard (RE-R2b: scripts/trends/src/cli.ts dispatches `brief`,
|
||||
# agents/trend-spotter.md references the brief CLI 'src/cli.ts brief', AND
|
||||
# hooks/scripts/session-start.mjs surfaces it via 'latestMorningBrief', with a non-vacuity
|
||||
# self-test) in Section 16i; the assertion-count anti-erosion floor (SC6) in Section 18. All
|
||||
# self-test) in Section 16i; the trends-score wiring guard (RE-R3a: scripts/trends/src/score.ts
|
||||
# exports the 'export interface TrendScore' persist envelope, scripts/trends/src/types.ts carries
|
||||
# 'score?: TrendScore' on the record, agents/trend-spotter.md carries the judgment via '"dimensions"',
|
||||
# AND scripts/trends/src/brief.ts ranks on 'score?.composite', with a non-vacuity self-test) in
|
||||
# Section 16j; the assertion-count anti-erosion floor (SC6) in Section 18. All
|
||||
# are live below (Sections 8–18).
|
||||
#
|
||||
# Usage: bash scripts/test-runner.sh
|
||||
|
|
@ -698,7 +702,7 @@ if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then
|
|||
TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" )
|
||||
TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
|
||||
TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||
TRENDS_TESTS_FLOOR=104 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief)
|
||||
TRENDS_TESTS_FLOOR=146 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank)
|
||||
if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then
|
||||
pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)"
|
||||
else
|
||||
|
|
@ -1170,6 +1174,66 @@ fi
|
|||
|
||||
echo ""
|
||||
|
||||
# --- Section 16j: Trends Score Wiring (research-engine RE-R3a) ---
|
||||
echo "--- Trends Score Wiring ---"
|
||||
|
||||
# RE-R3a persists the relevance score the trend-spotter agent already computes and ranks the
|
||||
# morning brief on its composite. Four literals must hold, grepped EXACT (grep -F),
|
||||
# deps-absent-safe (pure grep, no tsx):
|
||||
# (1) score.ts exports the persist envelope, by the literal 'export interface TrendScore';
|
||||
# (2) types.ts carries it on the record, by the literal 'score?: TrendScore';
|
||||
# (3) agents/trend-spotter.md carries the judgment in the capture batch, by 'dimensions'
|
||||
# (verified absent pre-R3a -> the grep is non-vacuous);
|
||||
# (4) brief.ts ranks on the composite, by the literal 'score?.composite' (the payoff is wired,
|
||||
# not merely doc'd).
|
||||
# Non-vacuity self-test mirrors Section 16i: the rank predicate must accept a probe carrying the
|
||||
# composite-rank literal and reject one without it. Placed after Section 16i / before Section 18
|
||||
# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts
|
||||
# toward ASSERT_BASELINE_FLOOR.
|
||||
SCORE_IFACE_LIT='export interface TrendScore'
|
||||
SCORE_TYPE_LIT='score?: TrendScore'
|
||||
SCORE_DIMS_LIT='"dimensions"'
|
||||
SCORE_RANK_LIT='score?.composite'
|
||||
|
||||
I16J_SELFTEST_OK=1
|
||||
if ! echo 'rankForBrief sorts on (b.trend.score?.composite ?? -1) first' | grep -qF "$SCORE_RANK_LIT"; then
|
||||
I16J_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired composite-rank probe was not detected"
|
||||
fi
|
||||
if echo 'the brief ranks on pillar overlap only' | grep -qF "$SCORE_RANK_LIT"; then
|
||||
I16J_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the composite-rank pointer"
|
||||
fi
|
||||
if [ "$I16J_SELFTEST_OK" -eq 1 ]; then
|
||||
pass "trends-score self-test: composite-rank predicate detects wiring, rejects the under-wired form"
|
||||
else
|
||||
fail "trends-score self-test failed — the score-wiring lint is vacuous or over-eager"
|
||||
fi
|
||||
|
||||
if grep -qF "$SCORE_IFACE_LIT" scripts/trends/src/score.ts; then
|
||||
pass "score.ts exports the persist envelope ('$SCORE_IFACE_LIT')"
|
||||
else
|
||||
fail "score.ts has no TrendScore envelope — add '$SCORE_IFACE_LIT' (RE-R3a persist)"
|
||||
fi
|
||||
|
||||
if grep -qF "$SCORE_TYPE_LIT" scripts/trends/src/types.ts; then
|
||||
pass "types.ts carries the score on the record ('$SCORE_TYPE_LIT')"
|
||||
else
|
||||
fail "types.ts does not carry the score — add '$SCORE_TYPE_LIT' to TrendRecord (RE-R3a schema v3)"
|
||||
fi
|
||||
|
||||
if grep -qF "$SCORE_DIMS_LIT" agents/trend-spotter.md; then
|
||||
pass "trend-spotter.md carries the judgment in the capture batch ($SCORE_DIMS_LIT)"
|
||||
else
|
||||
fail "trend-spotter.md does not carry the judgment — add the per-item $SCORE_DIMS_LIT to Step 4.5 (RE-R3a wiring)"
|
||||
fi
|
||||
|
||||
if grep -qF "$SCORE_RANK_LIT" scripts/trends/src/brief.ts; then
|
||||
pass "brief.ts ranks on the composite ('$SCORE_RANK_LIT')"
|
||||
else
|
||||
fail "brief.ts does not rank on the composite — add the '$SCORE_RANK_LIT' comparator term (RE-R3a payoff)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
|
||||
# The lint self-modifies its own checks, so a green run could mask a silently dropped
|
||||
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
|
||||
|
|
@ -1185,12 +1249,14 @@ echo ""
|
|||
# UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep +
|
||||
# trend-spotter capture-pointer grep) = 90; +4 for RE-R2b's four UNCONDITIONAL Section-16i checks
|
||||
# (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep +
|
||||
# session-start surfacing grep) = 94.
|
||||
# session-start surfacing grep) = 94; +5 for RE-R3a's five UNCONDITIONAL Section-16j checks
|
||||
# (trends-score self-test + score.ts TrendScore-iface grep + types.ts score-field grep +
|
||||
# trend-spotter dimensions grep + brief.ts composite-rank grep) = 99.
|
||||
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
|
||||
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
|
||||
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
|
||||
# clone). Runs last so TOTAL_CHECKS sees every prior check.
|
||||
ASSERT_BASELINE_FLOOR=94
|
||||
ASSERT_BASELINE_FLOOR=99
|
||||
TOTAL_CHECKS=$((PASS + FAIL))
|
||||
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
|
||||
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"
|
||||
|
|
|
|||
|
|
@ -39,22 +39,32 @@ interface TrendRecord {
|
|||
publishedAt?: string;// optional source publish date (ISO-8601); distinct from capturedAt, first-sight, never back-filled
|
||||
topics: string[]; // query tags; unioned across re-captures
|
||||
summary?: string; // optional, verbatim
|
||||
score?: TrendScore; // optional persisted relevance (RE-R3a): { mode, dimensions, composite, priority } — first-sight, never re-scored
|
||||
}
|
||||
```
|
||||
|
||||
Fields (relevance score, first-mover timing, status) can be added in a later
|
||||
slice without breaking the shape.
|
||||
`score` is the persisted relevance envelope (RE-R3a): a capture **item** carries the
|
||||
agent's **judgment** — `{ mode, dimensions }` (the five 1–10 dimension scores) — and the
|
||||
store turns that into the persisted `TrendScore` `{ mode, dimensions, composite, priority }`,
|
||||
computing the composite + band once via the single scorer owner (`src/score.ts`). It is
|
||||
set **first-sight** (never updated on re-capture); the score-free `add` manual path omits it.
|
||||
The morning brief ranks each bucket on `composite` first (schema v3). Further fields
|
||||
(first-mover timing, status) can still be added in a later slice without breaking the shape.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# Capture freshly-polled trends — the NORMALIZING BATCH path (the research agent's path):
|
||||
# raw items on stdin → validate+normalize each → dedupe on title+url → union topics on
|
||||
# re-capture → persist the source's publishedAt. Content-invalid items are reported in the
|
||||
# summary errors[], never fail the run; the summary is {added, duplicates, merged, errors}.
|
||||
# re-capture → persist the source's publishedAt → persist the relevance score (when carried).
|
||||
# Content-invalid items (incl. a malformed/out-of-range score) are reported in the summary
|
||||
# errors[], never fail the run; the summary is {added, duplicates, merged, errors}.
|
||||
# An item's "score" carries the agent's judgment (mode + the five 1–10 dimensions); the store
|
||||
# computes the composite + band and persists the full TrendScore first-sight.
|
||||
echo '[{"source":"tavily","title":"Agentic workflows hit production",
|
||||
"url":"https://example.com/agentic","topics":["agents","engineering"],
|
||||
"publishedAt":"2026-06-20","summary":"Teams ship multi-step agents past the demo stage."}]' \
|
||||
"publishedAt":"2026-06-20","summary":"Teams ship multi-step agents past the demo stage.",
|
||||
"score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}]' \
|
||||
| node --import tsx src/cli.ts capture [--store <path>] [--json]
|
||||
|
||||
# Add a SINGLE trend MANUALLY — raw flags, no normalization, publish-date-free:
|
||||
|
|
@ -91,9 +101,10 @@ ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/morning-brief/YYYY
|
|||
```
|
||||
|
||||
The file's YAML frontmatter carries a single-line `summary` the SessionStart hook surfaces
|
||||
verbatim (zero-tsx — it reads the Markdown, never the TS CLI). Ranking uses only persisted
|
||||
fields; a persisted relevance score, an autonomous nightly trigger, and a seen-log freshness
|
||||
model are later slices.
|
||||
verbatim (zero-tsx — it reads the Markdown, never the TS CLI). As of RE-R3a the brief ranks
|
||||
each bucket on the persisted relevance **composite first** (then pillar-overlap, then recency);
|
||||
a scored entry shows `· <priority> (<mode>)` and the summary names the top entry's band. An
|
||||
autonomous nightly trigger and a seen-log freshness model remain later slices.
|
||||
|
||||
## Tests
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,14 @@ export function rankForBrief(
|
|||
entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) });
|
||||
}
|
||||
|
||||
// Composite is the PRIMARY within-bucket key (RE-R3a / D2): a higher persisted relevance
|
||||
// composite sorts first; an unscored record uses the sentinel -1 (composite is a weighted
|
||||
// sum of [1,10] dims, so it is always >= 1.0 — -1 sorts unscored last and subtracts
|
||||
// cleanly, where -Infinity - -Infinity = NaN would corrupt the comparator). Buckets are
|
||||
// unchanged; composite only re-orders WITHIN a bucket. The existing overlap → effectiveDate
|
||||
// → title → url chain still gives a total order (the (title,url) pair is the unique dedupe id).
|
||||
const cmp = (a: BriefEntry, b: BriefEntry): number =>
|
||||
(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) ||
|
||||
b.overlap - a.overlap ||
|
||||
b.effectiveDate.localeCompare(a.effectiveDate) ||
|
||||
a.trend.title.localeCompare(b.trend.title) ||
|
||||
|
|
@ -124,15 +131,23 @@ export function briefSummary(ranking: BriefRanking): string {
|
|||
if (fresh > 0) {
|
||||
const top = ranking.topMatches[0] ?? ranking.singleMatches[0];
|
||||
const pillar = top.matchedPillars[0];
|
||||
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar} · ${top.ageDays}d).`;
|
||||
// Band only (no mode) — the mode stays a body-entry detail to keep the one-line headline clean.
|
||||
const band = top.trend.score ? ` · ${top.trend.score.priority}` : "";
|
||||
return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band} · ${top.ageDays}d).`;
|
||||
}
|
||||
return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`;
|
||||
}
|
||||
|
||||
/** ` · <priority> (<mode>)` when scored, else "" — the band+mode token shared by both renders (RE-R3a). */
|
||||
function scoreToken(e: BriefEntry): string {
|
||||
const s = e.trend.score;
|
||||
return s ? ` · ${s.priority} (${s.mode})` : "";
|
||||
}
|
||||
|
||||
function renderTopEntry(e: BriefEntry, n: number): string[] {
|
||||
const lines = [
|
||||
`### ${n}. ${e.trend.title}`,
|
||||
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d) · Pillarer: ${e.matchedPillars.join(", ")}`,
|
||||
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · Pillarer: ${e.matchedPillars.join(", ")}`,
|
||||
];
|
||||
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
|
||||
lines.push(`- 🔗 ${e.trend.url}`);
|
||||
|
|
@ -141,7 +156,7 @@ function renderTopEntry(e: BriefEntry, n: number): string[] {
|
|||
}
|
||||
|
||||
function renderBulletEntry(e: BriefEntry): string {
|
||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d) · 🔗 ${e.trend.url}`;
|
||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)} · 🔗 ${e.trend.url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,7 +172,7 @@ export function renderBrief(ranking: BriefRanking): string {
|
|||
lines.push(`date: ${ranking.today}`);
|
||||
lines.push(`summary: ${briefSummary(ranking)}`);
|
||||
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
|
||||
lines.push(`ranking: pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
|
||||
lines.push(`ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`);
|
||||
lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@
|
|||
*
|
||||
* The capture agent (research-engine) folds freshly-polled trends into the store via
|
||||
* `capture` (the normalizing batch path: stdin → normalizeItem(s) → itemToInput →
|
||||
* addTrend), and reasons over accumulated history via `query`/`list`. `brief` (RE-R2b)
|
||||
* addTrend) — which, when an item carries the agent's five judgment scores (RE-R3a),
|
||||
* persists an optional relevance `score` (the deterministically-computed composite + band,
|
||||
* one owner) first-sight on the record so the morning brief ranks on it — and reasons over
|
||||
* accumulated history via `query`/`list`. `brief` (RE-R2b)
|
||||
* renders a dated, pillar-ranked morning brief over the store to a Markdown file the
|
||||
* SessionStart hook surfaces. `add` is the MANUAL single-trend path (raw flags, no
|
||||
* normalization, publish-date-free). The polling + relevance-scoring itself lives
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
|
||||
import { normalizeField } from "./store.js";
|
||||
import type { TrendInput } from "./store.js";
|
||||
import { requiredDimensions, scoreEnvelope } from "./score.js";
|
||||
import type { ScoreMode, DimensionScores } from "./score.js";
|
||||
|
||||
export interface TrendItem {
|
||||
/** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
|
||||
|
|
@ -36,6 +38,13 @@ export interface TrendItem {
|
|||
topics: string[];
|
||||
/** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */
|
||||
summary?: string;
|
||||
/**
|
||||
* The agent's relevance JUDGMENT (RE-R3a): the mode + the five 1–10 dimension scores —
|
||||
* NOT a precomputed composite (the store computes that, one owner). Validated by
|
||||
* normalizeItem; turned into the persisted envelope by itemToInput→scoreEnvelope.
|
||||
* Absent/invalid -> the key is omitted.
|
||||
*/
|
||||
score?: { mode: ScoreMode; dimensions: DimensionScores };
|
||||
}
|
||||
|
||||
export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };
|
||||
|
|
@ -63,6 +72,40 @@ function isNonEmptyString(v: unknown): v is string {
|
|||
return typeof v === "string" && v.trim().length > 0;
|
||||
}
|
||||
|
||||
/** A plain (non-array, non-null) object. */
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
const SCORE_MODES = ["kortform", "long-form"] as const;
|
||||
|
||||
/**
|
||||
* Validate a raw `score` structurally — never throws (returns a reason on failure). The
|
||||
* mode must be known; `dimensions` must be a non-array object carrying every key the mode
|
||||
* requires (requiredDimensions) as a number in [1,10]. On success returns the VALIDATED
|
||||
* envelope (the validated dimensions object, not the raw one).
|
||||
*/
|
||||
function validateScore(
|
||||
raw: unknown,
|
||||
): { ok: true; score: { mode: ScoreMode; dimensions: DimensionScores } } | { ok: false; reason: string } {
|
||||
if (!isPlainObject(raw)) return { ok: false, reason: "score must be an object" };
|
||||
const mode = raw.mode;
|
||||
if (typeof mode !== "string" || !(SCORE_MODES as readonly string[]).includes(mode)) {
|
||||
return { ok: false, reason: `mode must be one of ${SCORE_MODES.join(", ")} (got ${String(mode)})` };
|
||||
}
|
||||
const dims = raw.dimensions;
|
||||
if (!isPlainObject(dims)) return { ok: false, reason: "dimensions must be an object" };
|
||||
const validated: DimensionScores = {};
|
||||
for (const key of requiredDimensions(mode as ScoreMode)) {
|
||||
const value = dims[key];
|
||||
if (typeof value !== "number" || Number.isNaN(value) || value < 1 || value > 10) {
|
||||
return { ok: false, reason: `dimension "${key}" must be a number in [1,10] (got ${String(value)})` };
|
||||
}
|
||||
validated[key] = value;
|
||||
}
|
||||
return { ok: true, score: { mode: mode as ScoreMode, dimensions: validated } };
|
||||
}
|
||||
|
||||
/** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */
|
||||
function normalizeTopics(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
|
@ -105,6 +148,13 @@ export function normalizeItem(raw: unknown): NormalizeResult {
|
|||
}
|
||||
}
|
||||
|
||||
let score: { mode: ScoreMode; dimensions: DimensionScores } | undefined;
|
||||
if (r.score !== undefined && r.score !== null) {
|
||||
const res = validateScore(r.score);
|
||||
if (!res.ok) errors.push(`invalid score: ${res.reason}`);
|
||||
else score = res.score;
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { ok: false, errors };
|
||||
|
||||
const item: TrendItem = {
|
||||
|
|
@ -114,6 +164,7 @@ export function normalizeItem(raw: unknown): NormalizeResult {
|
|||
topics: normalizeTopics(r.topics),
|
||||
...(publishedAt !== undefined ? { publishedAt } : {}),
|
||||
...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}),
|
||||
...(score !== undefined ? { score } : {}),
|
||||
};
|
||||
return { ok: true, item };
|
||||
}
|
||||
|
|
@ -123,8 +174,11 @@ export function normalizeItem(raw: unknown): NormalizeResult {
|
|||
* Pure: injects `capturedAt` (the store's "when WE saw it", supplied by the caller —
|
||||
* never derived here) and carries the rest verbatim. Does NOT re-validate (the item is
|
||||
* already validated by normalizeItem) and does NOT derive an `id` (the store owns id via
|
||||
* addTrend→trendId). `publishedAt`/`summary` are carried only when present (key omitted
|
||||
* otherwise), mirroring the store's conditional-spread idiom.
|
||||
* addTrend→trendId). `publishedAt`/`summary`/`score` are carried only when present (key
|
||||
* omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned
|
||||
* into the persisted envelope here (judgment → composite, via scoreEnvelope). On the capture
|
||||
* path the dims are pre-validated by normalizeItem, so scoreEnvelope→composite cannot throw;
|
||||
* called DIRECTLY with bad dims it throws by contract (defense-in-depth — SC2).
|
||||
*/
|
||||
export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
|
||||
return {
|
||||
|
|
@ -135,6 +189,7 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
|
|||
topics: [...item.topics],
|
||||
...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
|
||||
...(item.summary !== undefined ? { summary: item.summary } : {}),
|
||||
...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,38 @@ export function band(composite: number): Band {
|
|||
return toBand(BANDS[BANDS.length - 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The persist-ready relevance envelope (RE-R3a): the agent's judgment (mode + the five
|
||||
* dimension scores) plus the deterministically-derived composite + priority band. Lives
|
||||
* in score.ts (the score domain owns it); types.ts imports it (one-way — score.ts imports
|
||||
* nothing internal, so no cycle).
|
||||
*/
|
||||
export interface TrendScore {
|
||||
mode: ScoreMode;
|
||||
dimensions: DimensionScores;
|
||||
composite: number;
|
||||
priority: Priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode's five dimension keys, in SSOT weight-literal order. `normalizeItem` consumes
|
||||
* this as a membership set; score.test pins the order so a silent SSOT reorder fails.
|
||||
*/
|
||||
export function requiredDimensions(mode: ScoreMode): string[] {
|
||||
return Object.keys(WEIGHTS[mode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the persist-ready envelope from the agent's judgment: the composite is
|
||||
* `composite(dimensions, mode)` and the priority is `band(composite).priority` — the
|
||||
* existing pure functions stay the single owners (no new arithmetic). Throws via
|
||||
* `composite` on an out-of-range dimension (its contract).
|
||||
*/
|
||||
export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore {
|
||||
const c = composite(dimensions, mode);
|
||||
return { mode, dimensions, composite: c, priority: band(c).priority };
|
||||
}
|
||||
|
||||
export interface TriageOptions {
|
||||
mode: ScoreMode;
|
||||
threshold: number;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { createHash } from "node:crypto";
|
|||
|
||||
import { SCHEMA_VERSION } from "./types.js";
|
||||
import type { TrendStore, TrendRecord, TrendQueryHit } from "./types.js";
|
||||
import type { TrendScore } from "./score.js";
|
||||
|
||||
export { SCHEMA_VERSION } from "./types.js";
|
||||
|
||||
|
|
@ -32,6 +33,8 @@ export interface TrendInput {
|
|||
publishedAt?: string;
|
||||
topics: string[];
|
||||
summary?: string;
|
||||
/** The persisted relevance envelope (RE-R3a), if the caller computed one. First-sight, never updated on re-capture. */
|
||||
score?: TrendScore;
|
||||
}
|
||||
|
||||
export interface AddResult {
|
||||
|
|
@ -76,10 +79,11 @@ export function emptyStore(): TrendStore {
|
|||
export function loadStore(path: string): TrendStore {
|
||||
if (!existsSync(path)) return emptyStore();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TrendStore>;
|
||||
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2 is
|
||||
// purely additive-optional (an old record is already a valid v2 record that simply
|
||||
// lacks the optional publishedAt), so the migration is the version stamp alone —
|
||||
// records pass through untouched (lossless + idempotent for any well-formed store).
|
||||
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2→v3 are
|
||||
// all purely additive-optional (an old record is already a valid v3 record that simply
|
||||
// lacks the optional publishedAt [v2] / score [v3]), so the migration is the version
|
||||
// stamp alone — records pass through untouched (lossless + idempotent for any
|
||||
// well-formed store; a new optional field survives JSON.stringify on resave).
|
||||
// A string / NaN / absent version coerces to the current version (never crashes); the
|
||||
// non-array `trends` coercion below is unchanged and out of the losslessness claim.
|
||||
const onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION;
|
||||
|
|
@ -134,6 +138,7 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
|
|||
...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {}),
|
||||
topics: [...input.topics],
|
||||
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
||||
...(input.score !== undefined ? { score: input.score } : {}),
|
||||
};
|
||||
store.trends.push(trend);
|
||||
return { store, added: true, merged: false };
|
||||
|
|
|
|||
|
|
@ -19,10 +19,13 @@
|
|||
* a typed store in the per-user data dir (`${LINKEDIN_STUDIO_DATA}`), so the
|
||||
* trend history survives plugin upgrades/reinstalls via the M0 data-path seam.
|
||||
* The minimal core here (title, url, source, capturedAt, topics, optional
|
||||
* summary) can gain fields (relevance score, first-mover timing, status) in a
|
||||
* later slice without breaking the shape.
|
||||
* summary) can gain fields (first-mover timing, status) in a later slice without
|
||||
* breaking the shape — the relevance `score` field (RE-R3a) is the first such
|
||||
* realized addition.
|
||||
*/
|
||||
|
||||
import type { TrendScore } from "./score.js";
|
||||
|
||||
export interface TrendRecord {
|
||||
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
|
||||
id: string;
|
||||
|
|
@ -45,6 +48,14 @@ export interface TrendRecord {
|
|||
topics: string[];
|
||||
/** Optional short summary of the trend, stored VERBATIM. */
|
||||
summary?: string;
|
||||
/**
|
||||
* The persisted relevance assessment (RE-R3a): the agent's judgment (mode + the
|
||||
* five 1–10 dimension scores) plus the deterministically-derived composite + band,
|
||||
* computed once at first sight by the store's single scorer owner. First-sight,
|
||||
* never updated on re-capture (re-score pairs with the R3b status slice). Absent on
|
||||
* pre-R3a records and on the score-free `add` manual path (key omitted).
|
||||
*/
|
||||
score?: TrendScore;
|
||||
}
|
||||
|
||||
export interface TrendStore {
|
||||
|
|
@ -59,4 +70,4 @@ export interface TrendQueryHit {
|
|||
topicOverlap: number;
|
||||
}
|
||||
|
||||
export const SCHEMA_VERSION = 2;
|
||||
export const SCHEMA_VERSION = 3;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,24 @@ import type { TrendRecord, TrendStore } from "../src/types.js";
|
|||
|
||||
const TODAY = "2026-06-24";
|
||||
|
||||
type TestScore = {
|
||||
mode: "kortform" | "long-form";
|
||||
dimensions: Record<string, number>;
|
||||
composite: number;
|
||||
priority: "Immediate" | "High" | "Medium" | "Low" | "Skip";
|
||||
};
|
||||
|
||||
function mkTrend(
|
||||
p: { title: string; url: string; topics: string[]; capturedAt: string; publishedAt?: string; source?: string; summary?: string },
|
||||
p: {
|
||||
title: string;
|
||||
url: string;
|
||||
topics: string[];
|
||||
capturedAt: string;
|
||||
publishedAt?: string;
|
||||
source?: string;
|
||||
summary?: string;
|
||||
score?: TestScore;
|
||||
},
|
||||
): TrendRecord {
|
||||
return {
|
||||
id: p.title + "|" + p.url,
|
||||
|
|
@ -25,8 +41,14 @@ function mkTrend(
|
|||
...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}),
|
||||
topics: p.topics,
|
||||
...(p.summary !== undefined ? { summary: p.summary } : {}),
|
||||
...(p.score !== undefined ? { score: p.score } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A composite-bearing score for the rank tests (mode/priority kept consistent for render asserts). */
|
||||
function mkScore(composite: number, priority: TestScore["priority"], mode: TestScore["mode"] = "kortform"): TestScore {
|
||||
return { mode, dimensions: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }, composite, priority };
|
||||
}
|
||||
function mkStore(trends: TrendRecord[]): TrendStore {
|
||||
return { schemaVersion: 2, trends };
|
||||
}
|
||||
|
|
@ -159,6 +181,161 @@ describe("renderBrief + briefSummary (SC3)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("rankForBrief — composite primary within bucket (RE-R3a / SC5)", () => {
|
||||
const pillars = ["a", "b"];
|
||||
|
||||
test("higher composite sorts first at the same overlap + freshness", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Low", url: "https://e/low", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
|
||||
mkTrend({ title: "High", url: "https://e/high", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["High", "Low"], "composite 9 before composite 6");
|
||||
});
|
||||
|
||||
test("an unscored record sorts after every scored record in its bucket (sentinel -1)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Unscored", url: "https://e/u", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
mkTrend({ title: "Scored low", url: "https://e/s", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(2.0, "Low") }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Scored low", "Unscored"], "any scored beats unscored");
|
||||
});
|
||||
|
||||
test("both-unscored same-title/diff-url pair falls back to url asc (total order intact)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
|
||||
});
|
||||
|
||||
test("composite overrides effectiveDate within the bucket (composite is the leading key)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Fresher lower", url: "https://e/fl", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(5.0, "Medium") }),
|
||||
mkTrend({ title: "Older higher", url: "https://e/oh", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Older higher", "Fresher lower"]);
|
||||
});
|
||||
|
||||
test("deterministic: identical scored input -> identical ranking order", () => {
|
||||
const trends = [
|
||||
mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
|
||||
];
|
||||
const r1 = rankForBrief(mkStore(trends), pillars, TODAY);
|
||||
const r2 = rankForBrief(mkStore(trends), pillars, TODAY);
|
||||
assert.deepEqual(r1.topMatches.map((e) => e.trend.title), r2.topMatches.map((e) => e.trend.title));
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
|
||||
const pillars = ["AI", "gov"];
|
||||
|
||||
test("scored top-entry meta line is the full pinned shape (· <priority> (<mode>) between age and Pillarer)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Immediate (kortform) · Pillarer: AI, gov"),
|
||||
"scored top-entry meta line must carry · <priority> (<mode>) between (<age>d) and · Pillarer",
|
||||
);
|
||||
});
|
||||
|
||||
test("unscored top-entry meta line is UNCHANGED (no token)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Pillarer: AI, gov"),
|
||||
"unscored top-entry meta line must be unchanged",
|
||||
);
|
||||
});
|
||||
|
||||
test("scored bullet (single match) is the full pinned shape (· <priority> (<mode>) before · 🔗)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · High (kortform) · 🔗 https://e/b"),
|
||||
"scored bullet must carry · <priority> (<mode>) before · 🔗",
|
||||
);
|
||||
});
|
||||
|
||||
test("unscored bullet (single match) is UNCHANGED (no token)", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(
|
||||
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · 🔗 https://e/b"),
|
||||
"unscored bullet must be unchanged",
|
||||
);
|
||||
});
|
||||
|
||||
test("briefSummary names the band (no mode) on a scored top", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 2d)."), `summary should carry the band: ${s}`);
|
||||
assert.ok(!s.includes("kortform"), "summary must not carry the mode");
|
||||
});
|
||||
|
||||
test("briefSummary omits the band token on an unscored top", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(s.includes("Topp: «Alpha» (AI · 2d)."), `unscored summary should omit the band: ${s}`);
|
||||
});
|
||||
|
||||
test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "«Quoted» take", url: "https://e/q", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||||
assert.ok(!s.includes('"'), "summary must not contain a double-quote");
|
||||
assert.ok(!s.includes("\n"), "summary must be a single line");
|
||||
assert.ok(s.includes("Immediate"), "summary still carries the band");
|
||||
});
|
||||
|
||||
test("single-pillar unscored top -> summary renders with no · <priority> token, one line", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||||
]);
|
||||
const s = briefSummary(rankForBrief(store, ["AI"], TODAY));
|
||||
assert.ok(!s.includes("· ·"), "no empty priority slot");
|
||||
assert.ok(s.includes("Topp: «Solo» (AI · 2d)."), `single-pillar unscored summary: ${s}`);
|
||||
assert.ok(!s.includes("\n"));
|
||||
});
|
||||
|
||||
test("ranking: descriptor equals the exact pinned string", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
]);
|
||||
const md = renderBrief(rankForBrief(store, pillars, TODAY, { freshDays: 7 }));
|
||||
assert.ok(
|
||||
md.includes("ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays 7"),
|
||||
"the ranking descriptor must match the pinned RE-R3a string verbatim",
|
||||
);
|
||||
});
|
||||
|
||||
test("deterministic: identical scored input -> identical bytes", () => {
|
||||
const store = mkStore([
|
||||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
|
||||
]);
|
||||
const r = rankForBrief(store, pillars, TODAY);
|
||||
assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultBriefDir", () => {
|
||||
test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => {
|
||||
const prev = process.env.LINKEDIN_STUDIO_DATA;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "no
|
|||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { SCHEMA_VERSION } from "../src/types.js";
|
||||
|
||||
// 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));
|
||||
|
|
@ -97,7 +99,7 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
|
|||
"tally must sum to the input size",
|
||||
);
|
||||
const persisted = JSON.parse(readFileSync(store, "utf8"));
|
||||
assert.equal(persisted.schemaVersion, 2);
|
||||
assert.equal(persisted.schemaVersion, SCHEMA_VERSION);
|
||||
assert.equal(persisted.trends.length, 1);
|
||||
assert.equal(persisted.trends[0].publishedAt, "2026-06-20");
|
||||
assert.match(persisted.trends[0].capturedAt, /^\d{4}-\d{2}-\d{2}$/);
|
||||
|
|
@ -157,6 +159,73 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
|
|||
const { status } = run(["capture"], "");
|
||||
assert.equal(status, 2);
|
||||
});
|
||||
|
||||
// ── RE-R3a: capture persists the computed relevance score (SC7) ──
|
||||
test("a valid per-item score -> record carries the computed composite/priority (read back via list --json)", () => {
|
||||
const store = tmpStore();
|
||||
try {
|
||||
const batch = JSON.stringify([
|
||||
{
|
||||
source: "tavily",
|
||||
title: "Scored capture",
|
||||
url: "https://example.com/sc",
|
||||
topics: ["ai"],
|
||||
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 } },
|
||||
},
|
||||
]);
|
||||
const cap = run(["capture", "--store", store, "--json"], batch);
|
||||
assert.equal(cap.status, 0);
|
||||
const summary = JSON.parse(cap.stdout);
|
||||
assert.equal(summary.added, 1);
|
||||
assert.equal(summary.errors.length, 0);
|
||||
|
||||
const ls = run(["list", "--store", store, "--json"], "");
|
||||
assert.equal(ls.status, 0);
|
||||
const rows = JSON.parse(ls.stdout);
|
||||
assert.equal(rows.length, 1);
|
||||
// 9*.30 + 8*.25 + 9*.20 + 7*.15 + 6*.10 = 8.15 -> round1 8.1 (8.15*10 = 81.4999… in IEEE-754) -> Immediate
|
||||
assert.equal(rows[0].score.composite, 8.1);
|
||||
assert.equal(rows[0].score.priority, "Immediate");
|
||||
assert.equal(rows[0].score.mode, "kortform");
|
||||
assert.deepEqual(rows[0].score.dimensions, { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 });
|
||||
} finally {
|
||||
rmSync(join(store, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a batch with one bad score (timing:99) -> that item in errors[], the valid one added, exit 0", () => {
|
||||
const store = tmpStore();
|
||||
try {
|
||||
const batch = JSON.stringify([
|
||||
{
|
||||
source: "tavily",
|
||||
title: "Good scored",
|
||||
url: "https://example.com/good",
|
||||
topics: ["ai"],
|
||||
score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 } },
|
||||
},
|
||||
{
|
||||
source: "tavily",
|
||||
title: "Bad scored",
|
||||
url: "https://example.com/bad",
|
||||
topics: ["ai"],
|
||||
score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 } },
|
||||
},
|
||||
]);
|
||||
const { status, stdout } = run(["capture", "--store", store, "--json"], batch);
|
||||
assert.equal(status, 0, "a bad score must not fail the run");
|
||||
const summary = JSON.parse(stdout);
|
||||
assert.equal(summary.added, 1, "the valid scored item is added");
|
||||
assert.equal(summary.errors.length, 1, "the bad-score item lands in errors[]");
|
||||
assert.equal(
|
||||
summary.added + summary.merged + summary.duplicates + summary.errors.length,
|
||||
2,
|
||||
"tally must sum to the input size",
|
||||
);
|
||||
} finally {
|
||||
rmSync(join(store, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { describe, test } from "node:test";
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js";
|
||||
import type { TrendItem } from "../src/item.js";
|
||||
import { normalizeField } from "../src/store.js";
|
||||
import { scoreEnvelope } from "../src/score.js";
|
||||
|
||||
describe("trends item normalizer (RE-R1 / B1)", () => {
|
||||
describe("normalizeItem — well-formed", () => {
|
||||
|
|
@ -224,4 +226,115 @@ describe("trends item normalizer (RE-R1 / B1)", () => {
|
|||
assert.equal(input.capturedAt, "2026-06-24");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeItem — score validation (RE-R3a / SC2)", () => {
|
||||
const base = {
|
||||
source: "tavily",
|
||||
title: "Scored item",
|
||||
url: "https://example.com/s",
|
||||
topics: ["ai"],
|
||||
};
|
||||
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
|
||||
|
||||
test("a valid kortform score -> carried with the validated dims", () => {
|
||||
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: validDims } });
|
||||
assert.equal(res.ok, true);
|
||||
if (!res.ok) return;
|
||||
assert.deepEqual(res.item.score, { mode: "kortform", dimensions: validDims });
|
||||
});
|
||||
|
||||
test("a valid long-form score -> carried with its five dims", () => {
|
||||
const longDims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
|
||||
const res = normalizeItem({ ...base, score: { mode: "long-form", dimensions: longDims } });
|
||||
assert.equal(res.ok, true);
|
||||
if (!res.ok) return;
|
||||
assert.deepEqual(res.item.score, { mode: "long-form", dimensions: longDims });
|
||||
});
|
||||
|
||||
test("absent score -> key omitted", () => {
|
||||
const res = normalizeItem(base);
|
||||
assert.equal(res.ok, true);
|
||||
if (!res.ok) return;
|
||||
assert.equal("score" in res.item, false);
|
||||
});
|
||||
|
||||
test("a bad mode -> structured error (no throw)", () => {
|
||||
const res = normalizeItem({ ...base, score: { mode: "bogus", dimensions: validDims } });
|
||||
assert.equal(res.ok, false);
|
||||
if (res.ok) return;
|
||||
assert.ok(res.errors.some((e) => e.includes("invalid score")), res.errors.join("; "));
|
||||
});
|
||||
|
||||
test("a missing dimension -> structured error (no throw)", () => {
|
||||
const { authority, ...missing } = validDims;
|
||||
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: missing } });
|
||||
assert.equal(res.ok, false);
|
||||
if (res.ok) return;
|
||||
assert.ok(res.errors.some((e) => e.includes("invalid score")));
|
||||
});
|
||||
|
||||
test("a dimension out of [1,10] (0 or 11) -> structured error (no throw)", () => {
|
||||
const lo = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 0 } } });
|
||||
assert.equal(lo.ok, false);
|
||||
const hi = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 11 } } });
|
||||
assert.equal(hi.ok, false);
|
||||
});
|
||||
|
||||
test("a non-object score -> structured error (no throw)", () => {
|
||||
const res = normalizeItem({ ...base, score: "high" });
|
||||
assert.equal(res.ok, false);
|
||||
if (res.ok) return;
|
||||
assert.ok(res.errors.some((e) => e.includes("invalid score")));
|
||||
});
|
||||
|
||||
test("an array dimensions -> structured error (no throw)", () => {
|
||||
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: [9, 8, 9, 7, 6] } });
|
||||
assert.equal(res.ok, false);
|
||||
if (res.ok) return;
|
||||
assert.ok(res.errors.some((e) => e.includes("invalid score")));
|
||||
});
|
||||
|
||||
test("an array score -> structured error (no throw)", () => {
|
||||
const res = normalizeItem({ ...base, score: [] });
|
||||
assert.equal(res.ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("itemToInput — score bridge + the throw contract (RE-R3a / SC2)", () => {
|
||||
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
|
||||
|
||||
test("a scored item -> input.score equals scoreEnvelope(mode, dimensions)", () => {
|
||||
const item: TrendItem = {
|
||||
source: "tavily",
|
||||
title: "T",
|
||||
url: "https://example.com/t",
|
||||
topics: ["ai"],
|
||||
score: { mode: "kortform", dimensions: validDims },
|
||||
};
|
||||
const input = itemToInput(item, "2026-06-24");
|
||||
assert.deepEqual(input.score, scoreEnvelope("kortform", validDims));
|
||||
});
|
||||
|
||||
test("an unscored item -> no score key", () => {
|
||||
const item: TrendItem = {
|
||||
source: "tavily",
|
||||
title: "T",
|
||||
url: "https://example.com/t",
|
||||
topics: ["ai"],
|
||||
};
|
||||
const input = itemToInput(item, "2026-06-24") as Record<string, unknown>;
|
||||
assert.equal("score" in input, false);
|
||||
});
|
||||
|
||||
test("called directly with an out-of-range dim -> throws by contract (defense-in-depth)", () => {
|
||||
const item: TrendItem = {
|
||||
source: "tavily",
|
||||
title: "T",
|
||||
url: "https://example.com/t",
|
||||
topics: ["ai"],
|
||||
score: { mode: "kortform", dimensions: { ...validDims, timing: 99 } },
|
||||
};
|
||||
assert.throws(() => itemToInput(item, "2026-06-24"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { KORTFORM_WEIGHTS, LONG_FORM_WEIGHTS, composite, band, triage } from "../src/score.js";
|
||||
import {
|
||||
KORTFORM_WEIGHTS,
|
||||
LONG_FORM_WEIGHTS,
|
||||
composite,
|
||||
band,
|
||||
triage,
|
||||
requiredDimensions,
|
||||
scoreEnvelope,
|
||||
} from "../src/score.js";
|
||||
|
||||
const r1 = (x: number) => Math.round(x * 10) / 10;
|
||||
const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0);
|
||||
|
|
@ -142,4 +150,42 @@ describe("trends scorer (RE-R1 / B2)", () => {
|
|||
assert.deepEqual(dropped, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiredDimensions (RE-R3a / SC1)", () => {
|
||||
test("kortform -> the five keys in SSOT weight order", () => {
|
||||
assert.deepEqual(requiredDimensions("kortform"), ["pillar", "audience", "timing", "angle", "authority"]);
|
||||
});
|
||||
|
||||
test("long-form -> the five keys in SSOT weight order", () => {
|
||||
assert.deepEqual(requiredDimensions("long-form"), ["pillar", "depth", "angle", "authority", "currency"]);
|
||||
});
|
||||
|
||||
test("order is pinned to the SSOT weight literals (a silent reorder fails)", () => {
|
||||
assert.deepEqual(requiredDimensions("kortform"), Object.keys(KORTFORM_WEIGHTS));
|
||||
assert.deepEqual(requiredDimensions("long-form"), Object.keys(LONG_FORM_WEIGHTS));
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreEnvelope (RE-R3a / SC1)", () => {
|
||||
test("composes composite()+band() — composite/priority equal the existing functions (one owner)", () => {
|
||||
const dims = { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 };
|
||||
const env = scoreEnvelope("kortform", dims);
|
||||
assert.equal(env.mode, "kortform");
|
||||
assert.deepEqual(env.dimensions, dims);
|
||||
assert.equal(env.composite, composite(dims, "kortform"));
|
||||
assert.equal(env.priority, band(composite(dims, "kortform")).priority);
|
||||
});
|
||||
|
||||
test("long-form envelope composes the long-form composite/band", () => {
|
||||
const dims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
|
||||
const env = scoreEnvelope("long-form", dims);
|
||||
assert.equal(env.composite, composite(dims, "long-form"));
|
||||
assert.equal(env.priority, band(composite(dims, "long-form")).priority);
|
||||
});
|
||||
|
||||
test("a bad dimension makes scoreEnvelope throw (via composite — defense-in-depth contract)", () => {
|
||||
const dims = { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 };
|
||||
assert.throws(() => scoreEnvelope("kortform", dims));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -275,6 +275,67 @@ describe("trends store", () => {
|
|||
assert.equal(res2.merged, true, "topic union still reported");
|
||||
assert.equal("publishedAt" in res2.store.trends[0], false, "no back-fill of first-sight provenance");
|
||||
});
|
||||
|
||||
// ── RE-R3a: relevance score first-sight persistence (SC3) ──
|
||||
const kortScore = {
|
||||
mode: "kortform" as const,
|
||||
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
|
||||
composite: 8.1,
|
||||
priority: "Immediate" as const,
|
||||
};
|
||||
|
||||
test("RED: persists score on a new record when present (first-sight)", () => {
|
||||
const res = addTrend(emptyStore(), {
|
||||
title: "Scored trend",
|
||||
url: "https://example.com/sc",
|
||||
source: "tavily",
|
||||
capturedAt: "2026-06-24",
|
||||
topics: ["ai"],
|
||||
score: kortScore,
|
||||
});
|
||||
assert.deepEqual(res.store.trends[0].score, kortScore);
|
||||
});
|
||||
|
||||
test("regression guard: omits score when absent (no undefined-valued key)", () => {
|
||||
const res = addTrend(emptyStore(), {
|
||||
title: "Unscored trend",
|
||||
url: "https://example.com/us",
|
||||
source: "tavily",
|
||||
capturedAt: "2026-06-24",
|
||||
topics: ["ai"],
|
||||
});
|
||||
assert.equal("score" in res.store.trends[0], false);
|
||||
});
|
||||
|
||||
test("RED: re-capture keeps the first sighting's score (no overwrite), unions topics", () => {
|
||||
let store = emptyStore();
|
||||
store = addTrend(store, {
|
||||
title: "Same scored trend",
|
||||
url: "https://example.com/ssc",
|
||||
source: "tavily",
|
||||
capturedAt: "2026-06-01",
|
||||
topics: ["a"],
|
||||
score: kortScore,
|
||||
}).store;
|
||||
const lowerScore = {
|
||||
mode: "kortform" as const,
|
||||
dimensions: { pillar: 6, audience: 5, timing: 6, angle: 5, authority: 5 },
|
||||
composite: 5.6,
|
||||
priority: "Medium" as const,
|
||||
};
|
||||
const res2 = addTrend(store, {
|
||||
title: "Same scored trend",
|
||||
url: "https://example.com/ssc",
|
||||
source: "gemini",
|
||||
capturedAt: "2026-06-20",
|
||||
topics: ["a", "b"],
|
||||
score: lowerScore,
|
||||
});
|
||||
assert.equal(res2.added, false);
|
||||
assert.equal(res2.merged, true);
|
||||
assert.deepEqual(res2.store.trends[0].score, kortScore, "first-sight score kept (D3)");
|
||||
assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryByTopic", () => {
|
||||
|
|
@ -429,7 +490,7 @@ describe("trends store", () => {
|
|||
});
|
||||
withFixture(v1, (path) => {
|
||||
const s = loadStore(path);
|
||||
assert.equal(s.schemaVersion, 2, "v1 store must migrate to v2");
|
||||
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v1 store must migrate to the current version");
|
||||
assert.equal(s.trends.length, 1);
|
||||
assert.equal(s.trends[0].title, "Old trend");
|
||||
assert.equal(s.trends[0].capturedAt, "2026-05-01");
|
||||
|
|
@ -438,22 +499,22 @@ describe("trends store", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("RED: round-trip loadStore→saveStore writes schemaVersion:2 to disk", () => {
|
||||
test("RED: round-trip loadStore→saveStore writes the current schemaVersion to disk", () => {
|
||||
withFixture(JSON.stringify({ schemaVersion: 1, trends: [] }), (path) => {
|
||||
saveStore(path, loadStore(path));
|
||||
const onDisk = JSON.parse(readFileSync(path, "utf8"));
|
||||
assert.equal(onDisk.schemaVersion, 2);
|
||||
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
test("RED: a non-numeric schemaVersion is coerced to v2 (old code passes the string through)", () => {
|
||||
test("RED: a non-numeric schemaVersion is coerced to the current version (old code passes the string through)", () => {
|
||||
withFixture(JSON.stringify({ schemaVersion: "weird", trends: [] }), (path) => {
|
||||
assert.equal(loadStore(path).schemaVersion, 2);
|
||||
assert.equal(loadStore(path).schemaVersion, SCHEMA_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GREEN-only regression guards: old code already returns the current version ──
|
||||
test("regression guard: a v2 store loads as v2, idempotent (records + publishedAt intact)", () => {
|
||||
test("regression guard: a v2 store loads idempotent (records + publishedAt intact)", () => {
|
||||
const v2 = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
trends: [
|
||||
|
|
@ -470,7 +531,7 @@ describe("trends store", () => {
|
|||
});
|
||||
withFixture(v2, (path) => {
|
||||
const s = loadStore(path);
|
||||
assert.equal(s.schemaVersion, 2);
|
||||
assert.equal(s.schemaVersion, SCHEMA_VERSION);
|
||||
assert.equal(s.trends[0].publishedAt, "2026-05-30");
|
||||
});
|
||||
});
|
||||
|
|
@ -493,4 +554,114 @@ describe("trends store", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema migration (RE-R3a / score v2→v3)", () => {
|
||||
const withFixture = (contents: string, fn: (path: string) => void) => {
|
||||
const dir = tmp();
|
||||
const path = join(dir, "trends.json");
|
||||
try {
|
||||
writeFileSync(path, contents, "utf8");
|
||||
fn(path);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// ── genuinely RED: pre-bump SCHEMA_VERSION=2 → loadStore(v2).schemaVersion===2 ≠ 3 ──
|
||||
test("RED: a v2 store (no score) loads stamped as v3, records intact, no score invented", () => {
|
||||
const v2 = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
trends: [
|
||||
{
|
||||
id: "abc123",
|
||||
title: "Old dated trend",
|
||||
url: "https://example.com/o",
|
||||
source: "tavily",
|
||||
capturedAt: "2026-05-01",
|
||||
topics: ["ai"],
|
||||
publishedAt: "2026-04-30",
|
||||
},
|
||||
],
|
||||
});
|
||||
withFixture(v2, (path) => {
|
||||
const s = loadStore(path);
|
||||
assert.equal(s.schemaVersion, 3, "v2 store must migrate to v3");
|
||||
assert.equal(s.trends.length, 1);
|
||||
assert.equal(s.trends[0].title, "Old dated trend");
|
||||
assert.equal(s.trends[0].capturedAt, "2026-05-01");
|
||||
assert.equal(s.trends[0].publishedAt, "2026-04-30");
|
||||
assert.deepEqual(s.trends[0].topics, ["ai"]);
|
||||
assert.equal("score" in s.trends[0], false, "migration must not invent a score");
|
||||
});
|
||||
});
|
||||
|
||||
test("RED: round-trip loadStore→saveStore writes schemaVersion:3 to disk", () => {
|
||||
withFixture(JSON.stringify({ schemaVersion: 2, trends: [] }), (path) => {
|
||||
saveStore(path, loadStore(path));
|
||||
const onDisk = JSON.parse(readFileSync(path, "utf8"));
|
||||
assert.equal(onDisk.schemaVersion, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test("RED: a v3 store with score on records loads idempotent", () => {
|
||||
const v3 = JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
trends: [
|
||||
{
|
||||
id: "x",
|
||||
title: "Scored",
|
||||
url: "https://example.com/s",
|
||||
source: "tavily",
|
||||
capturedAt: "2026-06-01",
|
||||
topics: ["ai"],
|
||||
score: {
|
||||
mode: "kortform",
|
||||
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
|
||||
composite: 8.1,
|
||||
priority: "Immediate",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
withFixture(v3, (path) => {
|
||||
const s = loadStore(path);
|
||||
assert.equal(s.schemaVersion, 3);
|
||||
assert.deepEqual(s.trends[0].score, {
|
||||
mode: "kortform",
|
||||
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
|
||||
composite: 8.1,
|
||||
priority: "Immediate",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("RED: a v3 store's score survives load → save → load (field preservation)", () => {
|
||||
const score = {
|
||||
mode: "kortform",
|
||||
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
|
||||
composite: 8.1,
|
||||
priority: "Immediate",
|
||||
};
|
||||
const v3 = JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
trends: [
|
||||
{
|
||||
id: "x",
|
||||
title: "Survivor",
|
||||
url: "https://example.com/sv",
|
||||
source: "tavily",
|
||||
capturedAt: "2026-06-01",
|
||||
topics: ["ai"],
|
||||
score,
|
||||
},
|
||||
],
|
||||
});
|
||||
withFixture(v3, (path) => {
|
||||
const first = loadStore(path);
|
||||
saveStore(path, first);
|
||||
const second = loadStore(path);
|
||||
assert.deepEqual(second.trends[0].score, score, "score must survive a load+resave (no field stripping)");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue