mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(brief): single canonical synthesis brain — eliminate email/brief lead divergence (#3396)
* feat(brief-llm): canonical synthesis prompt + v3 cache key
Extends generateDigestProse to be the single source of truth for
brief executive-summary synthesis (canonicalises what was previously
split between brief-llm's generateDigestProse and seed-digest-
notifications.mjs's generateAISummary). Ports Brain B's prompt
features into buildDigestPrompt:
- ctx={profile, greeting, isPublic} parameter (back-compat: 4-arg
callers behave like today)
- per-story severity uppercased + short-hash prefix [h:XXXX] so the
model can emit rankedStoryHashes for stable re-ranking
- profile lines + greeting opener appear only when ctx.isPublic !== true
validateDigestProseShape gains optional rankedStoryHashes (≥4-char
strings, capped to MAX_STORIES_PER_USER × 2). v2-shaped rows still
pass — field defaults to [].
hashDigestInput v3:
- material includes profile-SHA, greeting bucket, isPublic flag,
per-story hash
- isPublic=true substitutes literal 'public' for userId in the cache
key so all share-URL readers of the same (date, sensitivity, pool)
hit ONE cache row (no PII in public cache key)
Adds generateDigestProsePublic(stories, sensitivity, deps) wrapper —
no userId param by design — for the share-URL surface.
Cache prefix bumped brief:llm:digest:v2 → v3. v2 rows expire on TTL.
Per the v1→v2 precedent (see hashDigestInput comment), one-tick cost
on rollout is acceptable for cache-key correctness.
Tests: 72/72 passing in tests/brief-llm.test.mjs (8 new for the v3
behaviors), full data suite 6952/6952.
Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Step 1, Codex-approved (5 rounds).
* feat(brief): envelope v3 — adds digest.publicLead for share-URL surface
Bumps BRIEF_ENVELOPE_VERSION 2 → 3. Adds optional
BriefDigest.publicLead — non-personalised executive lead generated
by generateDigestProsePublic (already in this branch from the
previous commit) for the public share-URL surface. Personalised
`lead` is the canonical synthesis for authenticated channels;
publicLead is its profile-stripped sibling so api/brief/public/*
never serves user-specific content (watched assets/regions).
SUPPORTED_ENVELOPE_VERSIONS = [1, 2, 3] keeps v1 + v2 envelopes
in the 7-day TTL window readable through the rollout — the
composer only ever writes the current version, but readers must
tolerate older shapes that haven't expired yet. Same rollout
pattern used at the v1 → v2 bump.
Renderer changes (server/_shared/brief-render.js):
- ALLOWED_DIGEST_KEYS gains 'publicLead' (closed-key-set still
enforced; v2 envelopes pass because publicLead === undefined is
the v2 shape).
- assertBriefEnvelope: new isNonEmptyString check on publicLead
when present. Type contract enforced; absence is OK.
Tests (tests/brief-magazine-render.test.mjs):
- New describe block "v3 publicLead field": v3 envelope renders;
malformed publicLead rejected; v2 envelope still passes; ad-hoc
digest keys (e.g. synthesisLevel) still rejected — confirming
the closed-key-set defense holds for the cron-local-only fields
the orchestrator must NOT persist.
- BRIEF_ENVELOPE_VERSION pin updated 2 → 3 with rollout-rationale
comment.
Test results: 182 brief-related tests pass; full data suite
6956/6956.
Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Step 2, Codex Round-3 Medium #2.
* feat(brief): synthesis splice + rankedStoryHashes pre-cap re-order
Plumbs the canonical synthesis output (lead, threads, signals,
publicLead, rankedStoryHashes from generateDigestProse) through the
pure composer so the orchestration layer can hand pre-resolved data
into envelope.digest. Composer stays sync / no I/O — Codex Round-2
High #2 honored.
Changes:
scripts/lib/brief-compose.mjs:
- digestStoryToUpstreamTopStory now emits `hash` (the digest story's
stable identifier, falls back to titleHash when absent). Without
this, rankedStoryHashes from the LLM has nothing to match against.
- composeBriefFromDigestStories accepts opts.synthesis = {lead,
threads, signals, rankedStoryHashes?, publicLead?}. When passed,
splices into envelope.digest after the stub is built. Partial
synthesis (e.g. only `lead` populated) keeps stub defaults for the
other fields — graceful degradation when L2 fallback fires.
shared/brief-filter.js:
- filterTopStories accepts optional rankedStoryHashes. New helper
applyRankedOrder re-orders stories by short-hash prefix match
BEFORE the cap is applied, so the model's editorial judgment of
importance survives MAX_STORIES_PER_USER. Stable for ties; stories
not in the ranking come after in original order. Empty/missing
ranking is a no-op (legacy callers unchanged).
shared/brief-filter.d.ts:
- filterTopStories signature gains rankedStoryHashes?: string[].
- UpstreamTopStory gains hash?: unknown (carried through from
digestStoryToUpstreamTopStory).
Tests added (tests/brief-from-digest-stories.test.mjs):
- synthesis substitutes lead/threads/signals/publicLead.
- legacy 4-arg callers (no synthesis) keep stub lead.
- partial synthesis (only lead) keeps stub threads/signals.
- rankedStoryHashes re-orders pool before cap.
- short-hash prefix match (model emits 8 chars; story carries full).
- unranked stories go after in original order.
Test results: 33/33 in brief-from-digest-stories; 182/182 across all
brief tests; full data suite 6956/6956.
Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Step 3, Codex Round-2 Low + Round-2 High #2.
* feat(brief): single canonical synthesis per user; rewire all channels
Restructures the digest cron's per-user compose + send loops to
produce ONE canonical synthesis per user per issueSlot — the lead
text every channel (email HTML, plain-text, Telegram, Slack,
Discord, webhook) and the magazine show is byte-identical. This
eliminates the "two-brain" divergence that was producing different
exec summaries on different surfaces (observed 2026-04-25 0802).
Architecture:
composeBriefsForRun (orchestration):
- Pre-annotates every eligible rule with lastSentAt + isDue once,
before the per-user pass. Same getLastSentAt helper the send loop
uses so compose + send agree on lastSentAt for every rule.
composeAndStoreBriefForUser (per-user):
- Two-pass winner walk: try DUE rules first (sortedDue), fall back
to ALL eligible rules (sortedAll) for compose-only ticks.
Preserves today's dashboard refresh contract for weekly /
twice_daily users on non-due ticks (Codex Round-4 High #1).
- Within each pass, walk by compareRules priority and pick the
FIRST candidate with a non-empty pool — mirrors today's behavior
at scripts/seed-digest-notifications.mjs:1044 and prevents the
"highest-priority but empty pool" edge case (Codex Round-4
Medium #2).
- Three-level synthesis fallback chain:
L1: generateDigestProse(fullPool, ctx={profile,greeting,!public})
L2: generateDigestProse(envelope-sized slice, ctx={})
L3: stub from assembleStubbedBriefEnvelope
Distinct log lines per fallback level so ops can quantify
failure-mode distribution.
- Generates publicLead in parallel via generateDigestProsePublic
(no userId param; cache-shared across all share-URL readers).
- Splices synthesis into envelope via composer's optional
`synthesis` arg (Step 3); rankedStoryHashes re-orders the pool
BEFORE the cap so editorial importance survives MAX_STORIES.
- synthesisLevel stored in the cron-local briefByUser entry — NOT
persisted in the envelope (renderer's assertNoExtraKeys would
reject; Codex Round-2 Medium #5).
Send loop:
- Reads lastSentAt via shared getLastSentAt helper (single source
of truth with compose flow).
- briefLead = brief?.envelope?.data?.digest?.lead — the canonical
lead. Passed to buildChannelBodies (text/Telegram/Slack/Discord),
injectEmailSummary (HTML email), and sendWebhook (webhook
payload's `summary` field). All-channel parity (Codex Round-1
Medium #6).
- Subject ternary reads cron-local synthesisLevel: 1 or 2 →
"Intelligence Brief", 3 → "Digest" (preserves today's UX for
fallback paths; Codex Round-1 Missing #5).
Removed:
- generateAISummary() — the second LLM call that produced the
divergent email lead. ~85 lines.
- AI_SUMMARY_CACHE_TTL constant — no longer referenced. The
digest:ai-summary:v1:* cache rows expire on their existing 1h
TTL (no cleanup pass).
Helpers added:
- getLastSentAt(rule) — extracted Upstash GET for digest:last-sent
so compose + send both call one source of truth.
- buildSynthesisCtx(rule, nowMs) — formats profile + greeting for
the canonical synthesis call. Preserves all today's prefs-fetch
failure-mode behavior.
Composer:
- compareRules now exported from scripts/lib/brief-compose.mjs so
the cron can sort each pass identically to groupEligibleRulesByUser.
Test results: full data suite 6962/6962 (was 6956 pre-Step 4; +6
new compose-synthesis tests from Step 3).
Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Steps 4 + 4b. Codex-approved (5 rounds).
* fix(brief-render): public-share lead fail-safe — never leak personalised lead
Public-share render path (api/brief/public/[hash].ts → renderer
publicMode=true) MUST NEVER serve the personalised digest.lead
because that string can carry profile context — watched assets,
saved-region names, etc. — written by generateDigestProse with
ctx.profile populated.
Previously: redactForPublic redacted user.name and stories.whyMatters
but passed digest.lead through unchanged. Codex Round-2 High
(security finding).
Now (v3 envelope contract):
- redactForPublic substitutes digest.lead = digest.publicLead when
the v3 envelope carries one (generated by generateDigestProsePublic
with profile=null, cache-shared across all public readers).
- When publicLead is absent (v2 envelope still in TTL window OR v3
envelope where publicLead generation failed), redactForPublic sets
digest.lead to empty string.
- renderDigestGreeting: when lead is empty, OMIT the <blockquote>
pull-quote entirely. Page still renders complete (greeting +
horizontal rule), just without the italic lead block.
- NEVER falls back to the original personalised lead.
assertBriefEnvelope still validates publicLead's contract (when
present, must be a non-empty string) BEFORE redactForPublic runs,
so a malformed publicLead throws before any leak risk.
Tests added (tests/brief-magazine-render.test.mjs):
- v3 envelope renders publicLead in pull-quote, personalised lead
text never appears.
- v2 envelope (no publicLead) omits pull-quote; rest of page
intact.
- empty-string publicLead rejected by validator (defensive).
- private render still uses personalised lead.
Test results: 68 brief-magazine-render tests pass; full data suite
remains green from prior commit.
Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Step 5, Codex Round-2 High (security).
* feat(digest): brief lead parity log + extra acceptance tests
Adds the parity-contract observability line and supplementary
acceptance tests for the canonical synthesis path.
Parity log (per send, after successful delivery):
[digest] brief lead parity user=<id> rule=<v>:<s>:<lang>
synthesis_level=<1|2|3> exec_len=<n> brief_lead_len=<n>
channels_equal=<bool> public_lead_len=<n>
When channels_equal=false an extra WARN line fires —
"PARITY REGRESSION user=… — email lead != envelope lead." Sentry's
existing console-breadcrumb hook lifts this without an explicit
captureMessage call. Plan acceptance criterion A5.
Tests added (tests/brief-llm.test.mjs, +9):
- generateDigestProsePublic: two distinct callers with identical
(sensitivity, story-pool) hit the SAME cache row (per Codex
Round-2 Medium #4 — "no PII in public cache key").
- public + private writes never collide on cache key (defensive).
- greeting bucket change re-keys the personalised cache (Brain B
parity).
- profile change re-keys the personalised cache.
- v3 cache prefix used (no v2 writes).
Test results: 77/77 in brief-llm; full data suite 6971/6971
(was 6962 pre-Step-7; +9 new public-cache tests).
Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Steps 6 (partial) + 7. Acceptance A5, A6.g, A6.f.
* test(digest): backfill A6.h/i/l/m acceptance tests via helper extraction
* fix(brief): close two correctness regressions on multi-rule + public surface
Two findings from human review of the canonical-synthesis PR:
1. Public-share redaction leaked personalised signals + threads.
The new prompt explicitly personalises both `lead` and `signals`
("personalise lead and signals"), but redactForPublic only
substituted `lead` — leaving `signals` and `threads` intact.
Public renderer's hasSignals gate would emit the signals page
whenever `digest.signals.length > 0`, exposing watched-asset /
region phrasing to anonymous readers. Same privacy bug class
the original PR was meant to close, just on different fields.
2. Multi-rule users got cross-pool lead/storyList mismatch.
composeAndStoreBriefForUser picks ONE winning rule for the
canonical envelope. The send loop then injected that ONE
`briefLead` into every due rule's channel body — even though
each rule's storyList came from its own (per-rule) digest pool.
Multi-rule users (e.g. `full` + `finance`) ended up with email
bodies leading on geopolitics while listing finance stories.
Cross-rule editorial mismatch reintroduced after the cross-
surface fix.
Fix 1 — public signals + threads:
- Envelope shape: BriefDigest gains `publicSignals?: string[]` +
`publicThreads?: BriefThread[]` (sibling fields to publicLead).
Renderer's ALLOWED_DIGEST_KEYS extended; assertBriefEnvelope
validates them when present.
- generateDigestProsePublic already returned a full prose object
(lead + signals + threads) — orchestration now captures all
three instead of just `.lead`. Composer splices each into its
envelope slot.
- redactForPublic substitutes:
digest.lead ← publicLead (or empty → omits pull-quote)
digest.signals ← publicSignals (or empty → omits signals page)
digest.threads ← publicThreads (or category-derived stub via
new derivePublicThreadsStub helper — never
falls back to the personalised threads)
- New tests cover all three substitutions + their fail-safes.
Fix 2 — per-rule synthesis in send loop:
- Each due rule independently calls runSynthesisWithFallback over
ITS OWN pool + ctx. Channel body lead is internally consistent
with the storyList (both from the same pool).
- Cache absorbs the cost: when this is the winner rule, the
synthesis hits the cache row written during the compose pass
(same userId/sensitivity/pool/ctx) — no extra LLM call. Only
multi-rule users with non-overlapping pools incur additional
LLM calls.
- magazineUrl still points at the winner's envelope (single brief
per user per slot — `(userId, issueSlot)` URL contract). Channel
lead vs magazine lead may differ for non-winner rule sends;
documented as acceptable trade-off (URL/key shape change to
support per-rule magazines is out of scope for this PR).
- Parity log refined: adds `winner_match=<bool>` field. The
PARITY REGRESSION warning now fires only when winner_match=true
AND the channel lead differs from the envelope lead (the actual
contract regression). Non-winner sends with legitimately
different leads no longer spam the alert.
Test results:
- tests/brief-magazine-render.test.mjs: 75/75 (+7 new for public
signals/threads + validator + private-mode-ignores-public-fields)
- Full data suite: 6995/6995 (was 6988; +7 net)
- typecheck + typecheck:api: clean
Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Addresses 2 review findings on PR #3396 not anticipated in the
5-round Codex review.
* fix(brief): unify compose+send window, fall through filter-rejection
Address two residual risks in PR #3396 (single-canonical-brain refactor):
Risk 1 — canonical lead synthesized from a fixed 24h pool while the
send loop ships stories from `lastSentAt ?? 24h`. For weekly users
that meant a 24h-pool lead bolted onto a 7d email body — the same
cross-surface divergence the refactor was meant to eliminate, just in
a different shape. Twice-daily users hit a 12h-vs-24h variant.
Fix: extract the window formula to `digestWindowStartMs(lastSentAt,
nowMs, defaultLookbackMs)` in digest-orchestration-helpers.mjs and
call it from BOTH the compose path's digestFor closure AND the send
loop. The compose path now derives windowStart per-candidate from
`cand.lastSentAt`, identical to what the send loop will use for that
rule. Removed the now-unused BRIEF_STORY_WINDOW_MS constant.
Side-effect: digestFor now receives the full annotated candidate
(`cand`) instead of just the rule, so it can reach `cand.lastSentAt`.
Backwards-compatible at the helper level — pickWinningCandidateWithPool
forwards `cand` instead of `cand.rule`.
Cache memo hit rate drops since lastSentAt varies per-rule, but
correctness > a few extra Upstash GETs.
Risk 2 — pickWinningCandidateWithPool returned the first candidate
with a non-empty raw pool as winner. If composeBriefFromDigestStories
then dropped every story (URL/headline/shape filters), the caller
bailed without trying lower-priority candidates. Pre-PR behaviour was
to keep walking. This regressed multi-rule users whose top-priority
rule's pool happens to be entirely filter-rejected.
Fix: optional `tryCompose(cand, stories)` callback on
pickWinningCandidateWithPool. When provided, the helper calls it after
the non-empty pool check; falsy return → log filter-rejected and walk
to the next candidate; truthy → returns `{winner, stories,
composeResult}` so the caller can reuse the result. Without the
callback, legacy semantics preserved (existing tests + callers
unaffected).
Caller composeAndStoreBriefForUser passes a no-synthesis compose call
as tryCompose — cheap pure-JS, no I/O. Synthesis only runs once after
the winner is locked in, so the perf cost is one extra compose per
filter-rejected candidate, no extra LLM round-trips.
Tests:
- 10 new cases in tests/digest-orchestration-helpers.test.mjs
covering: digestFor receiving full candidate; tryCompose
fall-through to lower-priority; all-rejected returns null;
composeResult forwarded; legacy semantics without tryCompose;
digestWindowStartMs lastSentAt-vs-default branches; weekly +
twice-daily window parity assertions; epoch-zero ?? guard.
- Updated tests/digest-cache-key-sensitivity.test.mjs static-shape
regex to match the new `cand.rule.sensitivity` cache-key shape
(intent unchanged: cache key MUST include sensitivity).
Stacked on PR #3396 — targets feat/brief-two-brain-divergence.
This commit is contained in:
@@ -380,3 +380,138 @@ describe('composeBriefFromDigestStories — continued', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── synthesis splice (Codex Round-3 plan, Step 3) ─────────────────────────
|
||||
|
||||
describe('composeBriefFromDigestStories — synthesis splice', () => {
|
||||
it('substitutes envelope.digest.lead/threads/signals/publicLead from synthesis', () => {
|
||||
const env = composeBriefFromDigestStories(
|
||||
rule(),
|
||||
[digestStory({ hash: 'h1', title: 'Story 1' }), digestStory({ hash: 'h2', title: 'Story 2' })],
|
||||
{ clusters: 12, multiSource: 3 },
|
||||
{
|
||||
nowMs: NOW,
|
||||
synthesis: {
|
||||
lead: 'A canonical executive lead from the orchestration layer that exceeds the 40-char floor.',
|
||||
threads: [{ tag: 'Energy', teaser: 'Hormuz tensions resurface today.' }],
|
||||
signals: ['Watch for naval redeployment in the Gulf.'],
|
||||
publicLead: 'A non-personalised lead suitable for the share-URL surface.',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(env);
|
||||
assert.match(env.data.digest.lead, /A canonical executive lead/);
|
||||
assert.equal(env.data.digest.threads.length, 1);
|
||||
assert.equal(env.data.digest.threads[0].tag, 'Energy');
|
||||
assert.deepEqual(env.data.digest.signals, ['Watch for naval redeployment in the Gulf.']);
|
||||
assert.match(env.data.digest.publicLead, /share-URL surface/);
|
||||
});
|
||||
|
||||
it('falls back to stub lead when synthesis is omitted (legacy callers)', () => {
|
||||
const env = composeBriefFromDigestStories(
|
||||
rule(),
|
||||
[digestStory({ hash: 'h1' })],
|
||||
{ clusters: 0, multiSource: 0 },
|
||||
{ nowMs: NOW }, // no synthesis arg
|
||||
);
|
||||
assert.ok(env);
|
||||
// Stub lead from assembleStubbedBriefEnvelope: "Today's brief surfaces N threads…"
|
||||
assert.match(env.data.digest.lead, /Today's brief surfaces/);
|
||||
// publicLead absent on the stub path — the renderer's public-mode
|
||||
// fail-safe omits the pull-quote rather than leaking personalised lead.
|
||||
assert.equal(env.data.digest.publicLead, undefined);
|
||||
});
|
||||
|
||||
it('partial synthesis (only lead) does not clobber threads/signals stubs', () => {
|
||||
const env = composeBriefFromDigestStories(
|
||||
rule(),
|
||||
[digestStory({ hash: 'h1', title: 'X', sources: ['Reuters'] })],
|
||||
{ clusters: 0, multiSource: 0 },
|
||||
{
|
||||
nowMs: NOW,
|
||||
synthesis: {
|
||||
lead: 'Custom lead at least forty characters long for validator pass-through.',
|
||||
// threads + signals omitted — must keep the stub defaults.
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(env);
|
||||
assert.match(env.data.digest.lead, /Custom lead/);
|
||||
// Threads default from deriveThreadsFromStories (stub path).
|
||||
assert.ok(env.data.digest.threads.length >= 1);
|
||||
});
|
||||
|
||||
it('rankedStoryHashes re-orders the surfaced pool BEFORE the cap is applied', () => {
|
||||
const stories = [
|
||||
digestStory({ hash: 'aaaa1111', title: 'First by digest order' }),
|
||||
digestStory({ hash: 'bbbb2222', title: 'Second by digest order' }),
|
||||
digestStory({ hash: 'cccc3333', title: 'Third by digest order' }),
|
||||
];
|
||||
const env = composeBriefFromDigestStories(
|
||||
rule(),
|
||||
stories,
|
||||
{ clusters: 0, multiSource: 0 },
|
||||
{
|
||||
nowMs: NOW,
|
||||
synthesis: {
|
||||
lead: 'Editorial lead at least forty characters long for validator pass-through.',
|
||||
// Re-rank: third story should lead, then first, then second.
|
||||
rankedStoryHashes: ['cccc3333', 'aaaa1111', 'bbbb2222'],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(env);
|
||||
assert.equal(env.data.stories[0].headline, 'Third by digest order');
|
||||
assert.equal(env.data.stories[1].headline, 'First by digest order');
|
||||
assert.equal(env.data.stories[2].headline, 'Second by digest order');
|
||||
});
|
||||
|
||||
it('rankedStoryHashes matches by short-hash prefix (model emits 8-char prefixes)', () => {
|
||||
const stories = [
|
||||
digestStory({ hash: 'longhash1234567890abc', title: 'First' }),
|
||||
digestStory({ hash: 'otherhashfullsuffix', title: 'Second' }),
|
||||
];
|
||||
const env = composeBriefFromDigestStories(
|
||||
rule(),
|
||||
stories,
|
||||
{ clusters: 0, multiSource: 0 },
|
||||
{
|
||||
nowMs: NOW,
|
||||
synthesis: {
|
||||
lead: 'Editorial lead at least forty characters long for validator pass-through.',
|
||||
// Model emits 8-char prefixes; helper must prefix-match the
|
||||
// story's full hash.
|
||||
rankedStoryHashes: ['otherhash', 'longhash'],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(env);
|
||||
assert.equal(env.data.stories[0].headline, 'Second');
|
||||
assert.equal(env.data.stories[1].headline, 'First');
|
||||
});
|
||||
|
||||
it('stories not present in rankedStoryHashes go after, in original order', () => {
|
||||
const stories = [
|
||||
digestStory({ hash: 'unranked-A', title: 'Unranked A' }),
|
||||
digestStory({ hash: 'ranked-B', title: 'Ranked B' }),
|
||||
digestStory({ hash: 'unranked-C', title: 'Unranked C' }),
|
||||
];
|
||||
const env = composeBriefFromDigestStories(
|
||||
rule(),
|
||||
stories,
|
||||
{ clusters: 0, multiSource: 0 },
|
||||
{
|
||||
nowMs: NOW,
|
||||
synthesis: {
|
||||
lead: 'Editorial lead at least forty characters long for validator pass-through.',
|
||||
rankedStoryHashes: ['ranked-B'],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(env);
|
||||
assert.equal(env.data.stories[0].headline, 'Ranked B');
|
||||
// A and C keep their original relative order (A then C).
|
||||
assert.equal(env.data.stories[1].headline, 'Unranked A');
|
||||
assert.equal(env.data.stories[2].headline, 'Unranked C');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
parseDigestProse,
|
||||
validateDigestProseShape,
|
||||
generateDigestProse,
|
||||
generateDigestProsePublic,
|
||||
enrichBriefEnvelopeWithLLM,
|
||||
buildStoryDescriptionPrompt,
|
||||
parseStoryDescription,
|
||||
@@ -47,7 +48,7 @@ function story(overrides = {}) {
|
||||
|
||||
function envelope(overrides = {}) {
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
issuedAt: 1_745_000_000_000,
|
||||
data: {
|
||||
user: { name: 'Reader', tz: 'UTC' },
|
||||
@@ -249,8 +250,12 @@ describe('buildDigestPrompt', () => {
|
||||
const { system, user } = buildDigestPrompt([story(), story({ headline: 'Second', country: 'PS' })], 'critical');
|
||||
assert.match(system, /chief editor of WorldMonitor Brief/);
|
||||
assert.match(user, /Reader sensitivity level: critical/);
|
||||
assert.match(user, /01\. \[critical\] Iran threatens/);
|
||||
assert.match(user, /02\. \[critical\] Second/);
|
||||
// v3 prompt format: "01. [h:XXXX] [SEVERITY] Headline" — includes
|
||||
// a short hash prefix for ranking and uppercases severity to
|
||||
// emphasise editorial importance to the model. Hash falls back
|
||||
// to "p<NN>" position when story.hash is absent (test fixtures).
|
||||
assert.match(user, /01\. \[h:p?[a-z0-9]+\] \[CRITICAL\] Iran threatens/);
|
||||
assert.match(user, /02\. \[h:p?[a-z0-9]+\] \[CRITICAL\] Second/);
|
||||
});
|
||||
|
||||
it('caps at 12 stories', () => {
|
||||
@@ -259,6 +264,42 @@ describe('buildDigestPrompt', () => {
|
||||
const lines = user.split('\n').filter((l) => /^\d{2}\. /.test(l));
|
||||
assert.equal(lines.length, 12);
|
||||
});
|
||||
|
||||
it('opens lead with greeting when ctx.greeting set and not public', () => {
|
||||
const { user } = buildDigestPrompt([story()], 'critical', { greeting: 'Good morning', isPublic: false });
|
||||
assert.match(user, /Open the lead with: "Good morning\."/);
|
||||
});
|
||||
|
||||
it('omits greeting and profile when ctx.isPublic=true', () => {
|
||||
const { user } = buildDigestPrompt([story()], 'critical', {
|
||||
profile: 'Watching: oil futures, Strait of Hormuz',
|
||||
greeting: 'Good morning',
|
||||
isPublic: true,
|
||||
});
|
||||
assert.doesNotMatch(user, /Good morning/);
|
||||
assert.doesNotMatch(user, /Watching:/);
|
||||
});
|
||||
|
||||
it('includes profile lines when ctx.profile set and not public', () => {
|
||||
const { user } = buildDigestPrompt([story()], 'critical', {
|
||||
profile: 'Watching: oil futures',
|
||||
isPublic: false,
|
||||
});
|
||||
assert.match(user, /Reader profile/);
|
||||
assert.match(user, /Watching: oil futures/);
|
||||
});
|
||||
|
||||
it('emits stable [h:XXXX] short-hash prefix derived from story.hash', () => {
|
||||
const s = story({ hash: 'abc12345xyz9876' });
|
||||
const { user } = buildDigestPrompt([s], 'critical');
|
||||
// Short hash is first 8 chars of the digest story hash.
|
||||
assert.match(user, /\[h:abc12345\]/);
|
||||
});
|
||||
|
||||
it('asks model to emit rankedStoryHashes in JSON output (system prompt)', () => {
|
||||
const { system } = buildDigestPrompt([story()], 'critical');
|
||||
assert.match(system, /rankedStoryHashes/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseDigestProse ───────────────────────────────────────────────────────
|
||||
@@ -426,8 +467,11 @@ describe('generateDigestProse', () => {
|
||||
// `threads`, which the renderer's assertBriefEnvelope requires.
|
||||
const llm1 = makeLLM(validJson);
|
||||
await generateDigestProse('user_a', stories, 'all', { ...cache, callLLM: llm1.callLLM });
|
||||
// Corrupt the stored row in place
|
||||
const badKey = [...cache.store.keys()].find((k) => k.startsWith('brief:llm:digest:v2:'));
|
||||
// Corrupt the stored row in place. Cache key prefix bumped to v3
|
||||
// (2026-04-25) when the digest hash gained ctx (profile, greeting,
|
||||
// isPublic) and per-story `hash` fields. v2 rows are ignored on
|
||||
// rollout; v3 is the active prefix.
|
||||
const badKey = [...cache.store.keys()].find((k) => k.startsWith('brief:llm:digest:v3:'));
|
||||
assert.ok(badKey, 'expected a digest prose cache entry');
|
||||
cache.store.set(badKey, { lead: 'short', /* missing threads + signals */ });
|
||||
const llm2 = makeLLM(validJson);
|
||||
@@ -452,6 +496,10 @@ describe('validateDigestProseShape', () => {
|
||||
assert.ok(out);
|
||||
assert.notEqual(out, good, 'must not return the caller object by reference');
|
||||
assert.equal(out.threads.length, 1);
|
||||
// v3: rankedStoryHashes is always present in the normalised
|
||||
// output (defaults to [] when source lacks the field — keeps the
|
||||
// shape stable for downstream consumers).
|
||||
assert.ok(Array.isArray(out.rankedStoryHashes));
|
||||
});
|
||||
|
||||
it('rejects missing threads', () => {
|
||||
@@ -469,6 +517,128 @@ describe('validateDigestProseShape', () => {
|
||||
assert.equal(validateDigestProseShape([good]), null);
|
||||
assert.equal(validateDigestProseShape('string'), null);
|
||||
});
|
||||
|
||||
it('preserves rankedStoryHashes when present (v3 path)', () => {
|
||||
const out = validateDigestProseShape({
|
||||
...good,
|
||||
rankedStoryHashes: ['abc12345', 'def67890', 'short', 'ok'],
|
||||
});
|
||||
assert.ok(out);
|
||||
// 'short' (5 chars) keeps; 'ok' (2 chars) drops below the ≥4-char floor.
|
||||
assert.deepEqual(out.rankedStoryHashes, ['abc12345', 'def67890', 'short']);
|
||||
});
|
||||
|
||||
it('drops malformed rankedStoryHashes entries without rejecting the payload', () => {
|
||||
const out = validateDigestProseShape({
|
||||
...good,
|
||||
rankedStoryHashes: ['valid_hash', null, 42, '', ' ', 'bb'],
|
||||
});
|
||||
assert.ok(out, 'malformed ranking entries do not invalidate the whole object');
|
||||
assert.deepEqual(out.rankedStoryHashes, ['valid_hash']);
|
||||
});
|
||||
|
||||
it('returns empty rankedStoryHashes when field absent (v2-shaped row passes)', () => {
|
||||
const out = validateDigestProseShape(good);
|
||||
assert.deepEqual(out.rankedStoryHashes, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateDigestProsePublic + cache-key independence (Codex Round-2 #4) ──
|
||||
|
||||
describe('generateDigestProsePublic — public cache shared across users', () => {
|
||||
const stories = [story(), story({ headline: 'Second', country: 'PS' })];
|
||||
const validJson = JSON.stringify({
|
||||
lead: 'A non-personalised editorial lead generated for the share-URL surface, free of profile context.',
|
||||
threads: [{ tag: 'Energy', teaser: 'Hormuz tensions resurface today.' }],
|
||||
signals: ['Watch for naval redeployment in the Gulf.'],
|
||||
});
|
||||
|
||||
it('two distinct callers with identical (sensitivity, story-pool) hit the SAME cache row', async () => {
|
||||
// The whole point of generateDigestProsePublic: when the share
|
||||
// URL is opened by 1000 different anonymous readers, only the
|
||||
// first call hits the LLM. Every subsequent call serves the
|
||||
// same cached output. (Internally: hashDigestInput substitutes
|
||||
// 'public' for userId when ctx.isPublic === true.)
|
||||
const cache = makeCache();
|
||||
const llm1 = makeLLM(validJson);
|
||||
await generateDigestProsePublic(stories, 'critical', { ...cache, callLLM: llm1.callLLM });
|
||||
assert.equal(llm1.calls.length, 1);
|
||||
|
||||
// Second call — different "user" context (the wrapper takes no
|
||||
// userId, so this is just a second invocation), same pool.
|
||||
// Should hit cache, NOT re-LLM.
|
||||
const llm2 = makeLLM(() => { throw new Error('would not be called'); });
|
||||
const out = await generateDigestProsePublic(stories, 'critical', { ...cache, callLLM: llm2.callLLM });
|
||||
assert.ok(out);
|
||||
assert.equal(llm2.calls.length, 0, 'public cache shared across calls — no per-user inflation');
|
||||
});
|
||||
|
||||
it('does NOT collide with the personalised cache for the same story pool', async () => {
|
||||
// Defensive: a private call (with profile/greeting/userId) and a
|
||||
// public call must produce DIFFERENT cache keys. Otherwise a
|
||||
// private call could poison the public cache row (or vice versa).
|
||||
const cache = makeCache();
|
||||
const llm = makeLLM(validJson);
|
||||
|
||||
await generateDigestProsePublic(stories, 'critical', { ...cache, callLLM: llm.callLLM });
|
||||
const publicKeys = [...cache.store.keys()];
|
||||
|
||||
await generateDigestProse('user_xyz', stories, 'critical',
|
||||
{ ...cache, callLLM: llm.callLLM },
|
||||
{ profile: 'Watching: oil', greeting: 'Good morning', isPublic: false },
|
||||
);
|
||||
const privateKeys = [...cache.store.keys()].filter((k) => !publicKeys.includes(k));
|
||||
|
||||
assert.equal(publicKeys.length, 1, 'one public cache row');
|
||||
assert.equal(privateKeys.length, 1, 'private call writes its own row');
|
||||
assert.notEqual(publicKeys[0], privateKeys[0], 'public + private rows must use distinct keys');
|
||||
// Public key contains literal "public:" segment — userId substitution
|
||||
assert.match(publicKeys[0], /:public:/);
|
||||
// Private key contains the userId
|
||||
assert.match(privateKeys[0], /:user_xyz:/);
|
||||
});
|
||||
|
||||
it('greeting changes invalidate the personalised cache (per Brain B parity)', async () => {
|
||||
// Brain B's old cache (digest:ai-summary:v1) included greeting in
|
||||
// the key — morning prose differed from afternoon prose. The
|
||||
// canonical synthesis preserves that semantic via greetingBucket.
|
||||
const cache = makeCache();
|
||||
const llm1 = makeLLM(validJson);
|
||||
await generateDigestProse('user_a', stories, 'all',
|
||||
{ ...cache, callLLM: llm1.callLLM },
|
||||
{ greeting: 'Good morning', isPublic: false },
|
||||
);
|
||||
const llm2 = makeLLM(validJson);
|
||||
await generateDigestProse('user_a', stories, 'all',
|
||||
{ ...cache, callLLM: llm2.callLLM },
|
||||
{ greeting: 'Good evening', isPublic: false },
|
||||
);
|
||||
assert.equal(llm2.calls.length, 1, 'greeting bucket change re-keys the cache');
|
||||
});
|
||||
|
||||
it('profile changes invalidate the personalised cache', async () => {
|
||||
const cache = makeCache();
|
||||
const llm1 = makeLLM(validJson);
|
||||
await generateDigestProse('user_a', stories, 'all',
|
||||
{ ...cache, callLLM: llm1.callLLM },
|
||||
{ profile: 'Watching: oil', isPublic: false },
|
||||
);
|
||||
const llm2 = makeLLM(validJson);
|
||||
await generateDigestProse('user_a', stories, 'all',
|
||||
{ ...cache, callLLM: llm2.callLLM },
|
||||
{ profile: 'Watching: gas', isPublic: false },
|
||||
);
|
||||
assert.equal(llm2.calls.length, 1, 'profile change re-keys the cache');
|
||||
});
|
||||
|
||||
it('writes to cache under brief:llm:digest:v3 prefix (not v2)', async () => {
|
||||
const cache = makeCache();
|
||||
const llm = makeLLM(validJson);
|
||||
await generateDigestProse('user_a', stories, 'all', { ...cache, callLLM: llm.callLLM });
|
||||
const keys = [...cache.store.keys()];
|
||||
assert.ok(keys.some((k) => k.startsWith('brief:llm:digest:v3:')), 'v3 prefix used');
|
||||
assert.ok(!keys.some((k) => k.startsWith('brief:llm:digest:v2:')), 'no v2 writes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStoryDescriptionPrompt', () => {
|
||||
|
||||
@@ -414,8 +414,56 @@ describe('renderBriefMagazine — envelope validation', () => {
|
||||
});
|
||||
|
||||
describe('BRIEF_ENVELOPE_VERSION', () => {
|
||||
it('is the literal 2 (bump requires cross-producer coordination)', () => {
|
||||
assert.equal(BRIEF_ENVELOPE_VERSION, 2);
|
||||
it('is the literal 3 (bump requires cross-producer coordination)', () => {
|
||||
// Bumped 2 → 3 (2026-04-25) when BriefDigest gained the optional
|
||||
// `publicLead` field for the share-URL surface. v2 envelopes still
|
||||
// in the 7-day TTL window remain readable — see
|
||||
// SUPPORTED_ENVELOPE_VERSIONS = [1, 2, 3]. Test below covers v1
|
||||
// back-compat; v2 back-compat is exercised by the missing-publicLead
|
||||
// path in the BriefDigest validator (publicLead === undefined is OK).
|
||||
assert.equal(BRIEF_ENVELOPE_VERSION, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderBriefMagazine — v3 publicLead field (Codex Round-3 Medium #2)', () => {
|
||||
it('accepts a v3 envelope with publicLead', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.publicLead = 'A non-personalised editorial lead for share-URL surface readers.';
|
||||
// Should NOT throw — publicLead is now an allowed digest key.
|
||||
const html = renderBriefMagazine(env);
|
||||
assert.ok(typeof html === 'string' && html.length > 0);
|
||||
});
|
||||
|
||||
it('rejects a publicLead that is not a non-empty string', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.publicLead = 42;
|
||||
assert.throws(
|
||||
() => renderBriefMagazine(env),
|
||||
/envelope\.data\.digest\.publicLead, when present, must be a non-empty string/,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a v2 envelope still in TTL window without publicLead (back-compat)', () => {
|
||||
// v2 envelopes already in Redis at v3 rollout MUST keep rendering
|
||||
// — SUPPORTED_ENVELOPE_VERSIONS = [1, 2, 3]. publicLead is
|
||||
// optional; absence is the v2 shape.
|
||||
const env = envelope();
|
||||
env.version = 2;
|
||||
delete env.data.digest.publicLead;
|
||||
const html = renderBriefMagazine(env);
|
||||
assert.ok(typeof html === 'string' && html.length > 0);
|
||||
});
|
||||
|
||||
it('rejects an envelope with an unknown digest key (closed-key-set still enforced)', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.synthesisLevel = 1; // would-be ad-hoc metadata
|
||||
assert.throws(
|
||||
() => renderBriefMagazine(env),
|
||||
/envelope\.data\.digest has unexpected key "synthesisLevel"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -699,6 +747,175 @@ describe('renderBriefMagazine — publicMode', () => {
|
||||
const b = renderBriefMagazine(env, {});
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
// ── Public-share lead fail-safe (Codex Round-2 High security) ──────
|
||||
//
|
||||
// Personalised `digest.lead` carries profile context (watched assets,
|
||||
// saved regions, etc.). On the public-share surface we MUST render
|
||||
// `publicLead` (a non-personalised parallel synthesis) instead, OR
|
||||
// omit the pull-quote entirely. NEVER fall back to the personalised
|
||||
// lead.
|
||||
|
||||
it('renders publicLead in the pull-quote when v3 envelope carries it', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.lead = 'Personal lead with watched-asset details that must NOT leak.';
|
||||
env.data.digest.publicLead = 'A non-personalised editorial lead suitable for share readers.';
|
||||
const html = renderBriefMagazine(env, { publicMode: true });
|
||||
assert.ok(
|
||||
html.includes('non-personalised editorial lead'),
|
||||
'pull-quote must render the publicLead text',
|
||||
);
|
||||
assert.ok(
|
||||
!html.includes('watched-asset details'),
|
||||
'personalised lead text must NEVER appear on the public surface',
|
||||
);
|
||||
});
|
||||
|
||||
it('OMITS the pull-quote when publicLead is absent (v2 envelope back-compat)', () => {
|
||||
// v2 envelopes still in TTL window have no publicLead. Public-mode
|
||||
// render MUST omit the blockquote rather than render the
|
||||
// personalised lead.
|
||||
const env = envelope();
|
||||
env.version = 2;
|
||||
env.data.digest.lead = 'Personal lead with watched-asset details that must NOT leak.';
|
||||
delete env.data.digest.publicLead;
|
||||
const html = renderBriefMagazine(env, { publicMode: true });
|
||||
assert.ok(
|
||||
!html.includes('watched-asset details'),
|
||||
'personalised lead text must NEVER appear on the public surface',
|
||||
);
|
||||
// Sanity: the rest of the page (greeting + greeting block) is
|
||||
// still rendered — only the blockquote is omitted.
|
||||
assert.ok(html.includes('At The Top Of The Hour'));
|
||||
});
|
||||
|
||||
it('OMITS the pull-quote when publicLead is empty string (defensive)', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.lead = 'Personal lead that must NOT leak.';
|
||||
// Defensive: publicLead set to empty string by a buggy producer.
|
||||
// The render path treats empty as absent, omitting the pull-quote.
|
||||
// (assertBriefEnvelope rejects publicLead='' as a non-empty-string
|
||||
// violation, so this only matters if a future code path bypasses
|
||||
// validation — belt-and-braces.)
|
||||
env.data.digest.publicLead = '';
|
||||
// Validator rejects empty publicLead first, so render throws —
|
||||
// proves the contract is enforced before redactForPublic runs.
|
||||
assert.throws(
|
||||
() => renderBriefMagazine(env, { publicMode: true }),
|
||||
/publicLead, when present, must be a non-empty string/,
|
||||
);
|
||||
});
|
||||
|
||||
it('private (non-public) render still uses the personalised lead', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.lead = 'Personal lead for the authenticated reader.';
|
||||
env.data.digest.publicLead = 'Generic public lead.';
|
||||
const html = renderBriefMagazine(env); // private path
|
||||
assert.ok(html.includes('Personal lead for the authenticated reader'));
|
||||
assert.ok(!html.includes('Generic public lead'), 'publicLead is share-only');
|
||||
});
|
||||
|
||||
// ── Public signals + threads fail-safe (extends Codex Round-2 High security) ──
|
||||
|
||||
it('substitutes publicSignals when present — personalised signals never reach the public surface', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.lead = 'Personal lead.';
|
||||
env.data.digest.publicLead = 'Generic public lead.';
|
||||
// Personalised signals can echo a user's watched assets ("your
|
||||
// Saudi exposure"). Anonymous public readers must never see this.
|
||||
env.data.digest.signals = ['Watch Saudi crude exposure on your watchlist for OPEC moves'];
|
||||
env.data.digest.publicSignals = ['Watch OPEC for production-quota signals'];
|
||||
const html = renderBriefMagazine(env, { publicMode: true });
|
||||
assert.ok(html.includes('OPEC for production-quota'), 'publicSignals must render');
|
||||
assert.ok(!html.includes('your watchlist'), 'personalised signals must NEVER appear on public');
|
||||
assert.ok(!html.includes('Saudi crude exposure'), 'personalised signal phrase must NEVER appear on public');
|
||||
});
|
||||
|
||||
it('OMITS the signals page when publicSignals is absent (fail-safe — never serves personalised signals)', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.lead = 'Personal lead.';
|
||||
env.data.digest.publicLead = 'Generic public lead.';
|
||||
env.data.digest.signals = ['Watch your private watchlist for OPEC moves'];
|
||||
delete env.data.digest.publicSignals;
|
||||
const html = renderBriefMagazine(env, { publicMode: true });
|
||||
// Renderer's hasSignals gate hides the signals page when the
|
||||
// array is empty. Personalised signal phrase must NOT appear.
|
||||
assert.ok(!html.includes('your private watchlist'), 'personalised signals must NEVER appear on public');
|
||||
assert.ok(!html.includes('Digest / 04'), 'signals page section must be omitted');
|
||||
});
|
||||
|
||||
it('substitutes publicThreads when present — personalised thread teasers never reach public', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.lead = 'Personal lead.';
|
||||
env.data.digest.publicLead = 'Generic public lead.';
|
||||
env.data.digest.threads = [
|
||||
{ tag: 'Energy', teaser: 'Saudi exposure on your portfolio is at risk this week' },
|
||||
];
|
||||
env.data.digest.publicThreads = [
|
||||
{ tag: 'Energy', teaser: 'OPEC production quota debate intensifies' },
|
||||
];
|
||||
const html = renderBriefMagazine(env, { publicMode: true });
|
||||
assert.ok(html.includes('OPEC production quota'), 'publicThreads must render');
|
||||
assert.ok(!html.includes('your portfolio'), 'personalised thread teaser must NEVER appear on public');
|
||||
});
|
||||
|
||||
it('falls back to category-derived threads stub when publicThreads absent', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.lead = 'Personal lead.';
|
||||
env.data.digest.publicLead = 'Generic public lead.';
|
||||
env.data.digest.threads = [
|
||||
{ tag: 'Energy', teaser: 'Saudi exposure on your portfolio is at risk this week' },
|
||||
];
|
||||
delete env.data.digest.publicThreads;
|
||||
const html = renderBriefMagazine(env, { publicMode: true });
|
||||
assert.ok(!html.includes('your portfolio'), 'personalised thread must NEVER appear on public');
|
||||
// Stub teaser pattern — generic phrasing derived from story
|
||||
// categories. Renderer still produces a threads page.
|
||||
assert.ok(
|
||||
html.includes('thread on the desk today') || html.includes('threads on the desk today'),
|
||||
'category-derived threads stub renders',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed publicSignals (validator contract)', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.publicSignals = ['ok signal', 42]; // 42 is not a string
|
||||
assert.throws(
|
||||
() => renderBriefMagazine(env, { publicMode: true }),
|
||||
/publicSignals\[1\] must be a non-empty string/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed publicThreads (validator contract)', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.publicThreads = [{ tag: 'Energy' }]; // missing teaser
|
||||
assert.throws(
|
||||
() => renderBriefMagazine(env, { publicMode: true }),
|
||||
/publicThreads\[0\]\.teaser must be a non-empty string/,
|
||||
);
|
||||
});
|
||||
|
||||
it('private render ignores publicSignals + publicThreads — uses personalised', () => {
|
||||
const env = envelope();
|
||||
env.version = 3;
|
||||
env.data.digest.signals = ['Personalised signal for authenticated reader'];
|
||||
env.data.digest.publicSignals = ['Generic public signal'];
|
||||
env.data.digest.threads = [{ tag: 'Energy', teaser: 'Personalised teaser' }];
|
||||
env.data.digest.publicThreads = [{ tag: 'Energy', teaser: 'Generic public teaser' }];
|
||||
const html = renderBriefMagazine(env);
|
||||
assert.ok(html.includes('Personalised signal'), 'private render uses personalised signals');
|
||||
assert.ok(!html.includes('Generic public signal'), 'public siblings ignored on private path');
|
||||
assert.ok(html.includes('Personalised teaser'), 'private render uses personalised threads');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regression: cover greeting follows envelope.data.digest.greeting ─────────
|
||||
|
||||
@@ -32,13 +32,16 @@ const src = readFileSync(
|
||||
);
|
||||
|
||||
describe('digestFor cache key includes sensitivity', () => {
|
||||
it('memoization key interpolates candidate.sensitivity', () => {
|
||||
it('memoization key interpolates cand.rule.sensitivity', () => {
|
||||
// The key must include sensitivity alongside variant+lang+windowStart
|
||||
// so stricter users do not inherit a looser populator's pool.
|
||||
// Post-canonical-window-fix: digestFor receives the annotated candidate
|
||||
// (`cand`) instead of just the rule, and reaches sensitivity via
|
||||
// cand.rule.sensitivity.
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+key\s*=\s*`\$\{candidate\.variant[^`]*?\$\{candidate\.sensitivity[^`]*?\$\{windowStart\}`/,
|
||||
'digestFor cache key must interpolate candidate.sensitivity',
|
||||
/const\s+key\s*=\s*`\$\{cand\.rule\.variant[^`]*?\$\{cand\.rule\.sensitivity[^`]*?\$\{windowStart\}`/,
|
||||
'digestFor cache key must interpolate cand.rule.sensitivity',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,11 +53,11 @@ describe('digestFor cache key includes sensitivity', () => {
|
||||
// something else).
|
||||
//
|
||||
// Anchor the match to the cache-key template-literal context so it
|
||||
// cannot be satisfied by an unrelated `chosenCandidate.sensitivity
|
||||
// ?? 'high'` elsewhere in the file (e.g. the new operator log line).
|
||||
// cannot be satisfied by an unrelated `cand.rule.sensitivity ?? 'high'`
|
||||
// elsewhere in the file (e.g. the new operator log line).
|
||||
assert.match(
|
||||
src,
|
||||
/\$\{candidate\.sensitivity\s*\?\?\s*'high'\}\s*:\s*\$\{windowStart\}/,
|
||||
/\$\{cand\.rule\.sensitivity\s*\?\?\s*'high'\}\s*:\s*\$\{windowStart\}/,
|
||||
'cache key default for sensitivity must be "high" to align with buildDigest default, anchored inside the cache-key template literal',
|
||||
);
|
||||
});
|
||||
@@ -63,12 +66,12 @@ describe('digestFor cache key includes sensitivity', () => {
|
||||
// Sanity: ensure the key construction is not pulled out into a
|
||||
// separate helper whose shape this test can no longer see.
|
||||
const digestForBlock = src.match(
|
||||
/async\s+function\s+digestFor\s*\(candidate\)\s*\{[\s\S]*?\n\s*\}/,
|
||||
/async\s+function\s+digestFor\s*\(cand\)\s*\{[\s\S]*?\n\s*\}/,
|
||||
);
|
||||
assert.ok(digestForBlock, 'digestFor function block should exist');
|
||||
assert.match(
|
||||
digestForBlock[0],
|
||||
/candidate\.sensitivity/,
|
||||
/cand\.rule\.sensitivity/,
|
||||
'sensitivity must be referenced inside digestFor',
|
||||
);
|
||||
});
|
||||
|
||||
466
tests/digest-orchestration-helpers.test.mjs
Normal file
466
tests/digest-orchestration-helpers.test.mjs
Normal file
@@ -0,0 +1,466 @@
|
||||
// Pure-function unit tests for the canonical-synthesis orchestration
|
||||
// helpers extracted from scripts/seed-digest-notifications.mjs.
|
||||
//
|
||||
// Covers plan acceptance criteria:
|
||||
// A6.h — three-level synthesis fallback chain
|
||||
// A6.i — subject-line correctness ("Intelligence Brief" vs "Digest")
|
||||
// A6.l — compose-only tick still works for weekly user (sortedAll fallback)
|
||||
// A6.m — winner walks past empty-pool top-priority candidate
|
||||
//
|
||||
// Acceptance criteria A6.a-d (multi-rule, twice_daily, weekly window
|
||||
// parity, all-channel reads) require a full mock of the cron's main()
|
||||
// loop with Upstash + Convex stubs — out of scope for this PR's
|
||||
// pure-function coverage. They are exercised via the parity log line
|
||||
// (A5) in production observability instead.
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
digestWindowStartMs,
|
||||
pickWinningCandidateWithPool,
|
||||
runSynthesisWithFallback,
|
||||
subjectForBrief,
|
||||
} from '../scripts/lib/digest-orchestration-helpers.mjs';
|
||||
|
||||
// ── subjectForBrief — A6.i ────────────────────────────────────────────────
|
||||
|
||||
describe('subjectForBrief — synthesis-level → email subject', () => {
|
||||
it('synthesis level 1 + non-empty briefLead → Intelligence Brief', () => {
|
||||
assert.equal(
|
||||
subjectForBrief({ briefLead: 'A real lead', synthesisLevel: 1, shortDate: 'Apr 25' }),
|
||||
'WorldMonitor Intelligence Brief — Apr 25',
|
||||
);
|
||||
});
|
||||
|
||||
it('synthesis level 2 + non-empty briefLead → Intelligence Brief (L2 still editorial)', () => {
|
||||
assert.equal(
|
||||
subjectForBrief({ briefLead: 'A degraded lead', synthesisLevel: 2, shortDate: 'Apr 25' }),
|
||||
'WorldMonitor Intelligence Brief — Apr 25',
|
||||
);
|
||||
});
|
||||
|
||||
it('synthesis level 3 → Digest (stub fallback ships less editorial subject)', () => {
|
||||
assert.equal(
|
||||
subjectForBrief({ briefLead: 'a stub', synthesisLevel: 3, shortDate: 'Apr 25' }),
|
||||
'WorldMonitor Digest — Apr 25',
|
||||
);
|
||||
});
|
||||
|
||||
it('null briefLead → Digest regardless of level (no signal for editorial subject)', () => {
|
||||
assert.equal(
|
||||
subjectForBrief({ briefLead: null, synthesisLevel: 1, shortDate: 'Apr 25' }),
|
||||
'WorldMonitor Digest — Apr 25',
|
||||
);
|
||||
});
|
||||
|
||||
it('empty-string briefLead → Digest', () => {
|
||||
assert.equal(
|
||||
subjectForBrief({ briefLead: '', synthesisLevel: 1, shortDate: 'Apr 25' }),
|
||||
'WorldMonitor Digest — Apr 25',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── pickWinningCandidateWithPool — A6.l + A6.m ────────────────────────────
|
||||
|
||||
function rule(overrides) {
|
||||
return {
|
||||
userId: 'u1',
|
||||
variant: 'full',
|
||||
sensitivity: 'all',
|
||||
aiDigestEnabled: true,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function annotated(rule, due, lastSentAt = null) {
|
||||
return { rule, lastSentAt, due };
|
||||
}
|
||||
|
||||
describe('pickWinningCandidateWithPool — winner walk', () => {
|
||||
it('A6.l — picks ANY eligible rule when none are due (compose-only tick)', async () => {
|
||||
// Weekly user on a non-due tick: no rules due, but the dashboard
|
||||
// contract says we still compose a brief from the user's
|
||||
// preferred rule. sortedAll fallback covers this.
|
||||
const weeklyRule = rule({ variant: 'full', digestMode: 'weekly' });
|
||||
const annotatedList = [annotated(weeklyRule, false)];
|
||||
const digestFor = async () => [{ hash: 'h1', title: 'A story' }];
|
||||
const lines = [];
|
||||
const result = await pickWinningCandidateWithPool(
|
||||
annotatedList,
|
||||
digestFor,
|
||||
(l) => lines.push(l),
|
||||
'u1',
|
||||
);
|
||||
assert.ok(result, 'compose-only tick must still pick a winner');
|
||||
assert.equal(result.winner.rule, weeklyRule);
|
||||
assert.equal(result.winner.due, false);
|
||||
assert.equal(result.stories.length, 1);
|
||||
});
|
||||
|
||||
it('A6.m — walks past empty-pool top-priority due rule to lower-priority due rule with stories', async () => {
|
||||
// A user with two due rules: full:critical (top priority by
|
||||
// compareRules) has empty pool; regional:high (lower priority)
|
||||
// has stories. Winner must be regional:high — not null.
|
||||
const fullCritical = rule({ variant: 'full', sensitivity: 'critical', updatedAt: 100 });
|
||||
const regionalHigh = rule({ variant: 'regional', sensitivity: 'high', updatedAt: 50 });
|
||||
const annotatedList = [annotated(fullCritical, true), annotated(regionalHigh, true)];
|
||||
|
||||
const digestFor = async (c) => {
|
||||
if (c.rule === fullCritical) return []; // empty pool
|
||||
if (c.rule === regionalHigh) return [{ hash: 'h2', title: 'Story from regional' }];
|
||||
return [];
|
||||
};
|
||||
const lines = [];
|
||||
const result = await pickWinningCandidateWithPool(
|
||||
annotatedList,
|
||||
digestFor,
|
||||
(l) => lines.push(l),
|
||||
'u1',
|
||||
);
|
||||
assert.ok(result, 'lower-priority candidate with stories must still win');
|
||||
assert.equal(result.winner.rule, regionalHigh);
|
||||
// Empty-pool log emitted for the skipped top-priority candidate
|
||||
assert.ok(
|
||||
lines.some((l) => l.includes('outcome=empty-pool') && l.includes('variant=full')),
|
||||
'empty-pool line must be logged for the skipped candidate',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers DUE rules over not-due rules even when not-due is higher priority', async () => {
|
||||
// Higher-priority rule isn't due; lower-priority rule IS due.
|
||||
// Plan rule: pick from due candidates first. Codex Round-3 High #1.
|
||||
const higherPriorityNotDue = rule({ variant: 'full', sensitivity: 'critical', updatedAt: 100 });
|
||||
const lowerPriorityDue = rule({ variant: 'regional', sensitivity: 'high', updatedAt: 50 });
|
||||
const annotatedList = [
|
||||
annotated(higherPriorityNotDue, false), // higher priority, NOT due
|
||||
annotated(lowerPriorityDue, true), // lower priority, DUE
|
||||
];
|
||||
const digestFor = async () => [{ hash: 'h', title: 'X' }];
|
||||
const result = await pickWinningCandidateWithPool(
|
||||
annotatedList,
|
||||
digestFor,
|
||||
() => {},
|
||||
'u1',
|
||||
);
|
||||
assert.ok(result);
|
||||
assert.equal(result.winner.rule, lowerPriorityDue, 'due rule wins over higher-priority not-due');
|
||||
});
|
||||
|
||||
it('returns null when EVERY candidate has an empty pool', async () => {
|
||||
const annotatedList = [annotated(rule({ variant: 'a' }), true), annotated(rule({ variant: 'b' }), false)];
|
||||
const digestFor = async () => [];
|
||||
const result = await pickWinningCandidateWithPool(
|
||||
annotatedList,
|
||||
digestFor,
|
||||
() => {},
|
||||
'u1',
|
||||
);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it('returns null on empty annotated list (no rules for user)', async () => {
|
||||
const result = await pickWinningCandidateWithPool([], async () => [{ hash: 'h' }], () => {}, 'u1');
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it('does not call digestFor twice for the same rule (dedup across passes)', async () => {
|
||||
// A rule that's due appears in BOTH sortedDue and sortedAll —
|
||||
// walk must dedupe so digestFor (Upstash GET) only fires once.
|
||||
const dueRule = rule({ variant: 'full' });
|
||||
const annotatedList = [annotated(dueRule, true)];
|
||||
let calls = 0;
|
||||
const digestFor = async () => { calls++; return [{ hash: 'h' }]; };
|
||||
await pickWinningCandidateWithPool(annotatedList, digestFor, () => {}, 'u1');
|
||||
assert.equal(calls, 1, 'same rule must not be tried twice');
|
||||
});
|
||||
|
||||
it('passes the FULL annotated candidate to digestFor (not just the rule) so callers can derive a per-candidate window from cand.lastSentAt', async () => {
|
||||
// Regression guard for the canonical-vs-send window divergence.
|
||||
// digestFor needs lastSentAt to compute its windowStart via
|
||||
// digestWindowStartMs; passing only the rule strips that signal
|
||||
// and forces a fixed-24h fallback that the email/Slack body
|
||||
// doesn't honour.
|
||||
const dueRule = rule({ variant: 'full' });
|
||||
const passedArgs = [];
|
||||
const digestFor = async (cand) => { passedArgs.push(cand); return [{ hash: 'h' }]; };
|
||||
await pickWinningCandidateWithPool(
|
||||
[annotated(dueRule, true, 1_700_000_000_000)],
|
||||
digestFor,
|
||||
() => {},
|
||||
'u1',
|
||||
);
|
||||
assert.equal(passedArgs.length, 1);
|
||||
assert.equal(passedArgs[0].rule, dueRule);
|
||||
assert.equal(passedArgs[0].lastSentAt, 1_700_000_000_000);
|
||||
assert.equal(passedArgs[0].due, true);
|
||||
});
|
||||
|
||||
it('walks past a filter-rejected top-priority candidate to a lower-priority candidate that composes successfully (Risk 2 regression guard)', async () => {
|
||||
// Pre-fix behaviour: helper returned the first NON-EMPTY pool as
|
||||
// winner. If composer then dropped every story (URL/headline/shape
|
||||
// filters), the caller bailed without trying lower-priority rules.
|
||||
// Fix: tryCompose callback lets the helper continue walking when
|
||||
// a candidate's pool survives buildDigest but compose returns null.
|
||||
const fullCritical = rule({ variant: 'full', sensitivity: 'critical', updatedAt: 100 });
|
||||
const regionalHigh = rule({ variant: 'regional', sensitivity: 'high', updatedAt: 50 });
|
||||
const annotatedList = [annotated(fullCritical, true), annotated(regionalHigh, true)];
|
||||
const digestFor = async () => [{ hash: 'h', title: 'pool member' }];
|
||||
// tryCompose: top candidate gets filtered to nothing (returns null);
|
||||
// lower-priority survives.
|
||||
const tryCompose = (cand) => {
|
||||
if (cand.rule === fullCritical) return null; // simulate URL/headline filter dropping all
|
||||
if (cand.rule === regionalHigh) return { envelope: 'ok' };
|
||||
return null;
|
||||
};
|
||||
const lines = [];
|
||||
const result = await pickWinningCandidateWithPool(
|
||||
annotatedList,
|
||||
digestFor,
|
||||
(l) => lines.push(l),
|
||||
'u1',
|
||||
tryCompose,
|
||||
);
|
||||
assert.ok(result, 'lower-priority candidate must still win after top-priority filter-rejection');
|
||||
assert.equal(result.winner.rule, regionalHigh);
|
||||
assert.deepEqual(result.composeResult, { envelope: 'ok' });
|
||||
assert.ok(
|
||||
lines.some((l) => l.includes('outcome=filter-rejected') && l.includes('variant=full')),
|
||||
'filter-rejected line must be logged for the skipped top candidate',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when EVERY candidate is rejected by tryCompose (no fallthrough has a survivor)', async () => {
|
||||
const a = rule({ variant: 'a' });
|
||||
const b = rule({ variant: 'b' });
|
||||
const annotatedList = [annotated(a, true), annotated(b, true)];
|
||||
const digestFor = async () => [{ hash: 'h' }];
|
||||
const tryCompose = () => null; // nothing ever composes
|
||||
const result = await pickWinningCandidateWithPool(
|
||||
annotatedList,
|
||||
digestFor,
|
||||
() => {},
|
||||
'u1',
|
||||
tryCompose,
|
||||
);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it('forwards tryCompose return value as composeResult on success (lets caller skip a redundant compose call)', async () => {
|
||||
const r = rule({ variant: 'full' });
|
||||
const composedEnvelope = { data: { stories: [{ hash: 'h' }] } };
|
||||
const result = await pickWinningCandidateWithPool(
|
||||
[annotated(r, true)],
|
||||
async () => [{ hash: 'h' }],
|
||||
() => {},
|
||||
'u1',
|
||||
() => composedEnvelope,
|
||||
);
|
||||
assert.ok(result);
|
||||
assert.equal(result.composeResult, composedEnvelope);
|
||||
});
|
||||
|
||||
it('without tryCompose, preserves legacy "first non-empty pool wins" semantics (existing callers/tests unaffected)', async () => {
|
||||
const r = rule({ variant: 'full' });
|
||||
const result = await pickWinningCandidateWithPool(
|
||||
[annotated(r, true)],
|
||||
async () => [{ hash: 'h' }],
|
||||
() => {},
|
||||
'u1',
|
||||
// no tryCompose
|
||||
);
|
||||
assert.ok(result);
|
||||
assert.equal(result.winner.rule, r);
|
||||
assert.equal(result.composeResult, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ── digestWindowStartMs — Risk 1 (canonical vs send window parity) ────────
|
||||
|
||||
describe('digestWindowStartMs — single source of truth for compose + send window', () => {
|
||||
it('returns lastSentAt verbatim when present (rule has shipped before)', () => {
|
||||
const lastSentAt = 1_700_000_000_000;
|
||||
assert.equal(digestWindowStartMs(lastSentAt, 1_700_086_400_000, 24 * 60 * 60 * 1000), lastSentAt);
|
||||
});
|
||||
|
||||
it('falls back to nowMs - defaultLookbackMs when lastSentAt is null (first send)', () => {
|
||||
const nowMs = 1_700_086_400_000;
|
||||
const lookback = 24 * 60 * 60 * 1000;
|
||||
assert.equal(digestWindowStartMs(null, nowMs, lookback), nowMs - lookback);
|
||||
});
|
||||
|
||||
it('falls back when lastSentAt is undefined', () => {
|
||||
const nowMs = 1_700_086_400_000;
|
||||
const lookback = 24 * 60 * 60 * 1000;
|
||||
assert.equal(digestWindowStartMs(undefined, nowMs, lookback), nowMs - lookback);
|
||||
});
|
||||
|
||||
it('weekly user (lastSentAt = 7d ago) → window covers exactly the prior 7d', () => {
|
||||
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const nowMs = 2_000_000_000_000;
|
||||
const lastSentAt = nowMs - sevenDaysMs;
|
||||
const windowStart = digestWindowStartMs(lastSentAt, nowMs, 24 * 60 * 60 * 1000);
|
||||
// The compose-path brief lead and the send-loop email body both
|
||||
// call buildDigest(rule, windowStart) with this same value, so a
|
||||
// weekly user's lead now summarizes the same 7-day pool that
|
||||
// ships in the email body. Pre-fix, the lead came from a 24h pool
|
||||
// while the email shipped 7d.
|
||||
assert.equal(windowStart, lastSentAt);
|
||||
assert.equal(nowMs - windowStart, sevenDaysMs);
|
||||
});
|
||||
|
||||
it('twice-daily user (lastSentAt = 12h ago) → 12h window matches what ships', () => {
|
||||
const twelveHoursMs = 12 * 60 * 60 * 1000;
|
||||
const nowMs = 2_000_000_000_000;
|
||||
const lastSentAt = nowMs - twelveHoursMs;
|
||||
const windowStart = digestWindowStartMs(lastSentAt, nowMs, 24 * 60 * 60 * 1000);
|
||||
assert.equal(windowStart, lastSentAt);
|
||||
assert.equal(nowMs - windowStart, twelveHoursMs);
|
||||
});
|
||||
|
||||
it('zero is a valid lastSentAt (epoch — exotic but legal); does not fall through to default', () => {
|
||||
// ?? operator is explicit about this; guards against regressions
|
||||
// toward `||` which would treat 0 as missing.
|
||||
const nowMs = 1_700_000_000_000;
|
||||
assert.equal(digestWindowStartMs(0, nowMs, 24 * 60 * 60 * 1000), 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── runSynthesisWithFallback — A6.h ───────────────────────────────────────
|
||||
|
||||
const validProse = {
|
||||
lead: 'A long-enough executive lead about Hormuz and the Gaza humanitarian crisis, written in editorial tone.',
|
||||
threads: [{ tag: 'Energy', teaser: 'Hormuz tensions resurface today.' }],
|
||||
signals: ['Watch for naval redeployment.'],
|
||||
};
|
||||
|
||||
function makeDeps(callLLM) {
|
||||
const cache = new Map();
|
||||
return {
|
||||
callLLM,
|
||||
cacheGet: async (k) => cache.has(k) ? cache.get(k) : null,
|
||||
cacheSet: async (k, v) => { cache.set(k, v); },
|
||||
};
|
||||
}
|
||||
|
||||
describe('runSynthesisWithFallback — three-level chain', () => {
|
||||
it('L1 success — canonical synthesis returned, level=1', async () => {
|
||||
const deps = makeDeps(async () => JSON.stringify(validProse));
|
||||
const trace = [];
|
||||
const result = await runSynthesisWithFallback(
|
||||
'u1',
|
||||
[{ hash: 'h1', headline: 'Story 1', threatLevel: 'critical' }],
|
||||
'all',
|
||||
{ profile: 'Watching: oil', greeting: 'Good morning' },
|
||||
deps,
|
||||
(level, kind) => trace.push({ level, kind }),
|
||||
);
|
||||
assert.ok(result.synthesis);
|
||||
assert.equal(result.level, 1);
|
||||
assert.match(result.synthesis.lead, /editorial tone/);
|
||||
assert.deepEqual(trace, [{ level: 1, kind: 'success' }]);
|
||||
});
|
||||
|
||||
it('L1 LLM down → L2 succeeds, level=2', async () => {
|
||||
// Note: generateDigestProse internally absorbs callLLM throws and
|
||||
// returns null (its return-null-on-failure contract). So
|
||||
// runSynthesisWithFallback sees the L1 attempt as a "fall" event,
|
||||
// not a "throw". Test verifies the BEHAVIOR (L2 wins) rather than
|
||||
// the trace event kind.
|
||||
let firstCall = true;
|
||||
const deps = makeDeps(async () => {
|
||||
if (firstCall) { firstCall = false; throw new Error('L1 LLM down'); }
|
||||
return JSON.stringify(validProse);
|
||||
});
|
||||
const trace = [];
|
||||
const result = await runSynthesisWithFallback(
|
||||
'u1',
|
||||
[{ hash: 'h1', headline: 'Story 1', threatLevel: 'critical' }],
|
||||
'all',
|
||||
{ profile: 'Watching: oil', greeting: 'Good morning' },
|
||||
deps,
|
||||
(level, kind) => trace.push({ level, kind }),
|
||||
);
|
||||
assert.ok(result.synthesis);
|
||||
assert.equal(result.level, 2);
|
||||
// Trace: L1 fell (callLLM throw absorbed → null), L2 succeeded.
|
||||
assert.equal(trace[0].level, 1);
|
||||
assert.equal(trace[0].kind, 'fall');
|
||||
assert.equal(trace[1].level, 2);
|
||||
assert.equal(trace[1].kind, 'success');
|
||||
});
|
||||
|
||||
it('L1 returns null + L2 returns null → L3 stub, level=3', async () => {
|
||||
const deps = makeDeps(async () => null); // both calls return null
|
||||
const trace = [];
|
||||
const result = await runSynthesisWithFallback(
|
||||
'u1',
|
||||
[{ hash: 'h1', headline: 'Story 1', threatLevel: 'critical' }],
|
||||
'all',
|
||||
{ profile: null, greeting: null },
|
||||
deps,
|
||||
(level, kind) => trace.push({ level, kind }),
|
||||
);
|
||||
assert.equal(result.synthesis, null);
|
||||
assert.equal(result.level, 3);
|
||||
// Trace shows L1 fell, L2 fell, L3 success (synthesis=null is the
|
||||
// stub path's contract).
|
||||
assert.deepEqual(trace.map((t) => `${t.level}:${t.kind}`), [
|
||||
'1:fall',
|
||||
'2:fall',
|
||||
'3:success',
|
||||
]);
|
||||
});
|
||||
|
||||
it('cache.cacheGet throws — generateDigestProse swallows it, L1 still succeeds via LLM call', async () => {
|
||||
// generateDigestProse's cache try/catch catches ALL throws (not
|
||||
// just misses), so a cache-layer outage falls through to a fresh
|
||||
// LLM call and returns successfully. Documented contract: cache
|
||||
// is best-effort. This test locks the contract — if a future
|
||||
// refactor narrows the catch, fallback semantics change.
|
||||
const deps = {
|
||||
callLLM: async () => JSON.stringify(validProse),
|
||||
cacheGet: async () => { throw new Error('cache outage'); },
|
||||
cacheSet: async () => {},
|
||||
};
|
||||
const result = await runSynthesisWithFallback(
|
||||
'u1',
|
||||
[{ hash: 'h1', headline: 'Story 1', threatLevel: 'critical' }],
|
||||
'all',
|
||||
{ profile: null, greeting: null },
|
||||
deps,
|
||||
);
|
||||
assert.ok(result.synthesis);
|
||||
assert.equal(result.level, 1);
|
||||
});
|
||||
|
||||
it('callLLM down on every call → L3 stub, no exception escapes', async () => {
|
||||
const deps = makeDeps(async () => { throw new Error('LLM totally down'); });
|
||||
const result = await runSynthesisWithFallback(
|
||||
'u1',
|
||||
[{ hash: 'h1', headline: 'Story 1', threatLevel: 'critical' }],
|
||||
'all',
|
||||
{ profile: null, greeting: null },
|
||||
deps,
|
||||
);
|
||||
// generateDigestProse absorbs each callLLM throw → returns null;
|
||||
// fallback chain reaches L3 stub. The brief still ships.
|
||||
assert.equal(result.synthesis, null);
|
||||
assert.equal(result.level, 3);
|
||||
});
|
||||
|
||||
it('omits trace callback safely (defensive — production callers may not pass one)', async () => {
|
||||
const deps = makeDeps(async () => JSON.stringify(validProse));
|
||||
// No trace argument
|
||||
const result = await runSynthesisWithFallback(
|
||||
'u1',
|
||||
[{ hash: 'h1', headline: 'Story 1', threatLevel: 'critical' }],
|
||||
'all',
|
||||
{ profile: null, greeting: null },
|
||||
deps,
|
||||
);
|
||||
assert.equal(result.level, 1);
|
||||
assert.ok(result.synthesis);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user