Skip to content

fix(brief): restore email multi-section format + de-vapidify lead prompt#3411

Merged
koala73 merged 3 commits into
mainfrom
fix/brief-email-multi-section-format
Apr 25, 2026
Merged

fix(brief): restore email multi-section format + de-vapidify lead prompt#3411
koala73 merged 3 commits into
mainfrom
fix/brief-email-multi-section-format

Conversation

@koala73

@koala73 koala73 commented Apr 25, 2026

Copy link
Copy Markdown
Owner

Summary

Two regressions from the canonical-synthesis refactor (PR #3396) shipped in the 2026-04-25 evening tick. Reader noticed the change immediately:

"It's just concerning cause it feels a different structure"

Old (afternoon, pre-refactor):

Good afternoon. The most impactful development today is the escalating rhetoric and actions surrounding Iran, with Pentagon chief Hegseth declaring a "global" US blockade and former President Trump ordering the Navy to shoot Iranian boats…

  • The US blockade on Iran, now described as "going global"…
  • The firing of Navy Secretary John Phelan amid tensions…
  • 36 military officers facing arraignment for an alleged coup plot…
    Signals to watch: Any direct engagement between US and Iranian forces; further details on the Nigerian coup plot.

New (evening, post-refactor):

Good evening. The global stage is buzzing with significant developments tonight, as geopolitical tensions continue to simmer and economic strategies shift. We're seeing a renewed focus on regional stability and diplomatic maneuvering, which could reshape alliances and trade flows in the coming months. For you, as a discerning reader, understanding these intricate shifts is key to navigating the evolving international landscape.

Two distinct problems shipping together:

  1. Email body shape regressed from 5 paragraphs to 1. injectEmailSummary only consumed .lead from the structured synthesis. Threads + signals existed in the envelope but never made it to email.
  2. Lead prompt produced editorial filler instead of named events. "Addresses the reader directly" invited the model toward "buzzing" / "navigating" / "discerning reader" instead of "Pentagon chief Hegseth declared…".

Fixes

scripts/lib/email-summary-html.mjs (new)

  • Extracted from seed-digest-notifications.mjs so the HTML assembly can be unit-tested without the cron's env-checking side effects.
  • Accepts a structured {lead, threads, signals} object (or a string for legacy / L3 stub).
  • Renders all three sections in the email body — restoring the pre-refactor multi-paragraph richness:
    • Lead paragraph at top
    • Threads as <b>Tag</b> — teaser lines
    • "Signals to watch:" header + bulleted signals
  • All thread/signal text HTML-escaped (defense-in-depth on hostile or quoted phrasing).

scripts/seed-digest-notifications.mjs

  • Imports injectEmailSummary from the new lib module; removed inline definition.
  • Send loop captures the FULL synthesis (briefSynthesis) in addition to the string projection (briefLead). Email gets the full structured object; non-email channels (Telegram/Slack/Discord/webhook) keep the single-string lead since their formats favour brevity.
  • Removed unused markdownToEmailHtml import (now in the helper).

scripts/lib/brief-llm.mjs — prompt rewrite

  • Lead instruction: "FIRST sentence MUST name the single most impactful development by its specific actor and event" with positive example "Pentagon chief Hegseth declared the US blockade on Iran is going global". Concrete naming is now load-bearing.
  • Threads + signals each carry a "name SPECIFIC event/actor" instruction with positive examples.
  • BANNED phrasing list — the literal phrases the model produced last evening:

    "the global stage", "buzzing with developments", "intricate shifts", "evolving landscape", "navigating", "discerning reader", "continues to simmer", "shape the coming months", "strategic importance"

  • Cache key bumped brief:llm:digest:v3v4. v3 rows still in TTL would otherwise serve stale vapid leads for 4h post-deploy. Same precedent as the v2→v3 bump.

Test plan

  • tests/email-summary-html.test.mjs: 12/12 new (null/empty handling, string fallback, structured rendering, malformed-thread skip, HTML-escape, malformed-signal skip)
  • tests/brief-llm.test.mjs: 77/77 (cache prefix references updated to v4)
  • npm run test:data: 7121/7121
  • npm run typecheck + npm run typecheck:api: clean
  • node -c scripts/seed-digest-notifications.mjs: parses

Post-deploy validation

Tomorrow morning's brief (first tick after deploy + cache TTL expires within 4h):

  • Email exec block should contain 3 sections: lead paragraph + threads + "Signals to watch" bullets (5 paragraphs total, matching the old format)
  • Lead's first sentence should name a specific actor + event (e.g. "Pentagon chief…", "Navy Secretary…"), NOT abstract framing
  • Magazine digest.lead is unchanged (still the pull-quote)

Failure signals:

  • If lead still uses any banned phrase → prompt instruction not landing; investigate Gemini's adherence
  • If threads/signals missing from email → check briefSynthesis is being passed (not briefLead) to injectEmailSummary

Rollback: revert this PR. Branch is one commit; revert is mechanical. Cache key v4 means v3 rows will resurface on revert — acceptable cost (4h TTL for stale-on-revert rows).

Companion

Parent: PR #3396 (canonical synthesis brain), PR #3401 (Greptile P1+P4). This is the third follow-up — the user-visible regression patch.

Two regressions from the canonical-synthesis refactor (PR #3396)
shipped in the 2026-04-25 evening tick:

1. Email Executive Summary became a 1-paragraph pull-quote.
   Pre-refactor, generateAISummary returned a 5-paragraph blob
   (lead + 3-5 bullets + "Signals to watch:") that filled the
   email's editorial slot. Post-refactor, the canonical synthesis
   returns structured fields ({lead, threads, signals}) and
   `injectEmailSummary` only consumed `.lead` — leaving threads
   and signals visible in the magazine but absent from email.
   Reader saw "1 paragraph instead of 5".

2. The lead paragraph itself was vapid editorial filler.
   The new system prompt's "addresses the reader directly"
   instruction invited gemini-2.5-flash to produce phrases like
   "the global stage is buzzing with developments" and
   "navigating the evolving landscape" instead of naming actors
   and events. Pre-refactor Brain B prompt's "Lead with the single
   most impactful development for this user" forced concrete
   naming — that semantic was lost.

Fixes:

scripts/lib/email-summary-html.mjs (new):
- Extracts injectEmailSummary so it can be unit-tested without
  the cron's env-checking side effects.
- Now accepts a structured {lead, threads, signals} object (or a
  string for legacy / L3 stub) and renders all three sections in
  the email body — restoring the pre-refactor 5-paragraph richness:
  * Lead paragraph (top of block)
  * Threads as "<b>Tag</b> — teaser" lines
  * "Signals to watch:" header + bulleted signals
- All thread/signal text HTML-escaped (defense-in-depth).

scripts/seed-digest-notifications.mjs:
- Imports injectEmailSummary from the new lib module; removed
  inline definition.
- Send loop captures the FULL synthesis (briefSynthesis) in
  addition to the string projection (briefLead). Email gets the
  full structured object; non-email channels (Telegram, Slack,
  Discord, webhook) keep the single-string lead since their
  formats favour brevity.
- Removed unused markdownToEmailHtml import (now in the helper).

scripts/lib/brief-llm.mjs:
- DIGEST_PROSE_SYSTEM_BASE prompt rewritten:
  * Lead instruction: "FIRST sentence MUST name the single most
    impactful development by its specific actor and event"
    (e.g. "Pentagon chief Hegseth declared..."). Concrete naming
    is now load-bearing, not optional editorial flourish.
  * Threads + signals each carry a "name SPECIFIC event/actor"
    instruction with positive examples.
  * BANNED phrasing list: "the global stage", "buzzing with
    developments", "intricate shifts", "evolving landscape",
    "navigating", "discerning reader", "continues to simmer",
    "shape the coming months", "strategic importance" — these
    were the exact phrases the model produced last evening.
- Cache key bumped brief:llm:digest:v3 → v4. v3 rows still in
  TTL would otherwise serve stale vapid leads for 4h post-deploy.
  Same precedent as the v2→v3 bump.

Tests added (tests/email-summary-html.test.mjs, +12):
- null/undefined/empty/empty-lead → slot stripped
- string input → lead-only render (legacy + L3 stub path)
- structured input → lead + threads + signals all rendered
- malformed thread entries skipped without rejecting the block
- HTML-escapes hostile thread tags / teasers / signals
- malformed signal entries skipped
- empty-html input passthrough

Tests updated (tests/brief-llm.test.mjs):
- Cache key prefix references bumped v3 → v4.

Test results:
- tests/email-summary-html.test.mjs: 12/12 (new file)
- tests/brief-llm.test.mjs: 77/77
- Full data suite: 7121/7121
- typecheck + typecheck:api: clean

User-facing impact: tomorrow morning's brief will again show the
specific named events ("Pentagon chief Hegseth declared...",
"Navy Secretary John Phelan fired...") in 5 paragraphs instead
of one paragraph of editorial filler.
@vercel

vercel Bot commented Apr 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview Apr 25, 2026 6:41pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two regressions from PR #3396: the email body was truncated to only the lead paragraph (threads and signals were never injected), and the LLM prompt invited vapid editorial framing instead of named actors and events. The fix extracts a new injectEmailSummary helper that maps the full {lead, threads, signals} synthesis into a multi-section HTML block, bumps the cache key to v4 to evict stale L3 responses, and rewrites the lead prompt with an explicit actor/event requirement and a banned-phrasing list.

Confidence Score: 4/5

Safe to merge; only P2 findings remain and the core regression fixes are correct.

All findings are P2. The logic is sound: briefSynthesis is correctly captured and passed to the new helper, markdownToEmailHtml HTML-escapes the lead before markdown processing, threads and signals escape their content, and cache-key versioning is properly documented. The one actionable issue—an orphaned "Signals to watch:" header when all signal entries are malformed—is a defensive-coding gap unlikely to fire against real LLM output.

scripts/lib/email-summary-html.mjs — signals block header emission logic; tests/email-summary-html.test.mjs — missing all-malformed-signals test case.

Important Files Changed

Filename Overview
scripts/lib/email-summary-html.mjs New module correctly maps structured synthesis to multi-section HTML email; minor issue with the "Signals to watch:" header being emitted when all signal entries are malformed/empty.
scripts/lib/brief-llm.mjs Cache key bumped to v4 and prompt rewritten with concrete actor/event requirement plus banned-phrasing list; changes are well-scoped and correctly commented.
scripts/seed-digest-notifications.mjs Correctly captures full synthesis object into briefSynthesis and passes it to injectEmailSummary while keeping briefLead string for non-email channels; dead import removed cleanly.
tests/email-summary-html.test.mjs Good coverage of null/empty/string/structured paths and HTML-escaping; missing an all-malformed-signals test case that would catch the orphaned-header bug.
tests/brief-llm.test.mjs Cache prefix references correctly updated from v3 to v4, with an added assertion that no v3 keys are written; minimal, correct change.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[runSynthesisWithFallback] --> B{synthesis ok?}
    B -- yes --> C[briefSynthesis = full object]
    B -- no --> D[briefSynthesis = null]
    C --> E[briefLead = synthesis.lead string]
    D --> F[briefLead = null]

    E --> G[buildChannelBodies\nTelegram / Slack / Discord\nuses string lead only]
    C --> H[injectEmailSummary\nhtmlRaw + briefSynthesis]
    D --> H

    H --> I{summary type?}
    I -- null or no lead --> J[strip slot placeholder]
    I -- string legacy --> K[lead block only]
    I -- full object --> L[lead + threads + signals\nmulti-section HTML]

    L --> M[Email: Executive Summary\n+ Thread lines\n+ Signals to watch]
Loading

Reviews (1): Last reviewed commit: "fix(brief): restore email multi-section ..." | Re-trigger Greptile

Comment thread scripts/lib/email-summary-html.mjs Outdated
Comment on lines +80 to +87
const signalsHtml = payload.signals.length > 0
? `<div style="font-size:13px;line-height:1.7;color:#ccc;margin:14px 0 0 0;"><b style="color:#f2ede4;">Signals to watch:</b></div>` +
payload.signals.map((s) => {
const text = escape(typeof s === 'string' ? s : '');
if (!text) return '';
return `<div style="font-size:13px;line-height:1.7;color:#ccc;margin:4px 0 0 0;">• ${text}</div>`;
}).filter(Boolean).join('')
: '';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Orphaned "Signals to watch:" header when all entries are malformed

The "Signals to watch:" header <div> is unconditionally included in the ternary's true branch. If payload.signals is non-empty but every entry is a non-string or empty string, the .filter(Boolean).join('') step produces '', so the rendered block contains the header with no bullets beneath it.

This is a live edge — the same code explicitly guards against non-string entries (typeof s === 'string' ? s : ''), and the matching test only covers the case where at least one valid signal exists. Extracting the filtered items first makes the header conditional on there being something to show:

const signalsHtml = (() => {
  if (payload.signals.length === 0) return '';
  const items = payload.signals
    .map((s) => {
      const text = escape(typeof s === 'string' ? s : '');
      if (!text) return '';
      return `<div style="font-size:13px;line-height:1.7;color:#ccc;margin:4px 0 0 0;">• ${text}</div>`;
    })
    .filter(Boolean);
  if (items.length === 0) return '';
  return `<div style="font-size:13px;line-height:1.7;color:#ccc;margin:14px 0 0 0;"><b style="color:#f2ede4;">Signals to watch:</b></div>` +
    items.join('');
})();

Comment on lines +142 to +154
const out = injectEmailSummary(TEMPLATE, {
lead: 'A long-enough lead text for the validator floor.',
threads: [],
signals: ['Valid signal.', '', 42, null],
});
assert.ok(out.includes('• Valid signal'));
// Header still emitted because at least one signal is valid.
assert.ok(out.includes('Signals to watch:'));
// Only one bullet — the malformed entries are skipped.
const bullets = (out.match(/• /g) ?? []).length;
assert.equal(bullets, 1);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test gap: all-malformed signals array not covered

The skips empty / non-string signal entries test always has at least one valid signal, so the case where payload.signals is non-empty but every entry is invalid (e.g., [null, 42, '']) is untested. That edge is the exact condition that triggers the orphaned-header issue in email-summary-html.mjs. A one-liner addition covers it:

it('emits no signals block when all signal entries are non-string or empty', () => {
  const out = injectEmailSummary(TEMPLATE, {
    lead: 'A long-enough lead text for the validator floor.',
    threads: [],
    signals: [null, 42, ''],
  });
  assert.ok(!out.includes('Signals to watch:'));
});

@koala73
koala73 merged commit 8d5598e into main Apr 25, 2026
10 checks passed
@koala73
koala73 deleted the fix/brief-email-multi-section-format branch April 25, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant