Files
worldmonitor/shared/brief-envelope.d.ts
Elie Habib 81536cb395 feat(brief): source links, LLM descriptions, strip suffix (envelope v2) (#3181)
* feat(brief): source links, LLM descriptions, strip publisher suffix (envelope v2)

Three coordinated fixes to the magazine content pipeline.

1. Headlines were ending with " - AP News" / " | Reuters" etc. because
   the composer passed RSS titles through verbatim. Added
   stripHeadlineSuffix() in brief-compose.mjs, conservative case-
   insensitive match only when the trailing token equals primarySource,
   so a real subtitle that happens to contain a dash still survives.

2. Story descriptions were the headline verbatim. Added
   generateStoryDescription to brief-llm.mjs, plumbed into
   enrichBriefEnvelopeWithLLM: one additional LLM call per story,
   cached 24h on a v1 key covering headline, source, severity,
   category, country. Cache hits are revalidated via
   parseStoryDescription so a bad row cannot flow to the envelope.
   Falls through to the cleaned headline on any failure.

3. Source attribution was plain text, no outgoing link. Bumped
   BRIEF_ENVELOPE_VERSION to 2, added BriefStory.sourceUrl. The
   composer now plumbs story:track:v1.link through
   digestStoryToUpstreamTopStory, UpstreamTopStory.primaryLink,
   filterTopStories, BriefStory.sourceUrl. The renderer wraps the
   Source line in an anchor with target=_blank, rel=noopener
   noreferrer, and UTM params (utm_source=worldmonitor,
   utm_medium=brief, utm_campaign=<issueDate>, utm_content=story-
   <rank>). UTM appending is idempotent, publisher-attributed URLs
   keep their own utm_source.

Envelope validation gains a validateSourceUrl step (https/http only,
no userinfo credentials, parseable absolute URL). Stories without a
valid upstream link are dropped by filterTopStories rather than
shipping with an unlinked source.

Tests: 30 renderer tests to 38; new assertions cover UTM presence on
every anchor, HTML-escaping of ampersands in hrefs, pre-existing UTM
preservation, and all four validator rejection modes. New composer
tests cover suffix stripping, link plumb-through, and v2 drop-on-no-
link behaviour. New LLM tests for generateStoryDescription cover
cache hit/miss, revalidation of bad rows, 24h TTL, and null-on-
failure.

* fix(brief): v1 back-compat window on renderer + consolidate story hash helper

Two P1/P2 review findings on #3181.

P1 (v1 back-compat). Bumping BRIEF_ENVELOPE_VERSION 1 to 2 made every
v1 envelope still resident in Redis under the 7-day TTL fail
assertBriefEnvelope. The hosted /api/brief route would 404 "expired"
and the /api/latest-brief preview would downgrade to "composing",
breaking already-issued links from the preceding week.

Fix: renderer now accepts SUPPORTED_ENVELOPE_VERSIONS = Set([1, 2])
on READ. BRIEF_ENVELOPE_VERSION stays at 2 and is the only version
the composer ever writes. BriefStory.sourceUrl is required when
version === 2 and absent on v1; when rendering a v1 story the source
line degrades to plain text (no anchor), matching pre-v2 appearance.
When the TTL window passes the set can shrink to [2] in a follow-up.

P2 (hash dedup). hashStoryDescription was byte-identical to hashStory,
inviting silent drift if one prompt gains a field the other forgets.
Consolidated into hashBriefStory. Cache key separation remains via
the distinct prefixes (brief:llm:whymatters:v2:/brief:llm:description:v1:).

Tests: adds 3 v1 back-compat assertions (plain source line, field
validation still runs, defensive sourceUrl check), updates the
version-mismatch assertion to match the new supported-set message.
161/161 pass (was 158). Full test:data 5706/5706.
2026-04-18 21:49:17 +04:00

121 lines
4.3 KiB
TypeScript

// Type declarations for shared/brief-envelope.js.
//
// The envelope is the integration boundary between the per-user brief
// composer (Railway worker, future Phase 3) and every consumer surface:
// the hosted magazine edge route, the dashboard panel preview RPC, the
// email teaser renderer, the carousel renderer, and the Tauri in-app
// reader. All consumers read the same brief:{userId}:{issueDate} Redis
// key and bind to this contract.
//
// Intentionally NOT wrapped in the seed-envelope `_seed` frame. A brief
// is 1 producer -> 1 user -> 1 read (7-day TTL), not a global public
// seed; reusing `_seed` here invites code that mis-applies seed
// invariants (staleness gating, api/health.js SEED_META pairing, etc.)
// to per-user keys. The version constant lives on the envelope root.
//
// Forbidden fields: importanceScore, primaryLink, pubDate, and any AI
// model / provider / cache timestamp strings must NOT appear in
// BriefEnvelope.data. They exist upstream in news:insights:v1 but are
// stripped at compose time. See PR #3143 for the notify-endpoint fix
// that established this rule.
export const BRIEF_ENVELOPE_VERSION: 2;
/**
* Versions the renderer accepts from Redis on READ. Always contains
* the current BRIEF_ENVELOPE_VERSION plus any versions still live in
* the 7-day TTL window. Composer writes ONLY the current version —
* this is a read-side compatibility shim.
*/
export const SUPPORTED_ENVELOPE_VERSIONS: ReadonlySet<number>;
/**
* Severity ladder. Four values, no synonyms. `critical` and `high`
* render with the highlight treatment; `medium` and `low` render
* plain. See HIGHLIGHTED_LEVELS in the renderer.
*/
export type BriefThreatLevel = 'critical' | 'high' | 'medium' | 'low';
export interface BriefUser {
/** Display name used in the greeting and back-cover chrome. */
name: string;
/** IANA timezone string, e.g. "UTC", "Europe/Paris". */
tz: string;
}
export interface BriefNumbers {
/** Total story clusters ingested globally in the last 24h. */
clusters: number;
/** Multi-source confirmed events globally in the last 24h. */
multiSource: number;
/** Stories surfaced in THIS user's brief. Must equal stories.length. */
surfaced: number;
}
export interface BriefThread {
/** Short editorial label, e.g. "Energy", "Diplomacy". */
tag: string;
/** One-sentence teaser, no trailing period required. */
teaser: string;
}
export interface BriefDigest {
/** e.g. "Good evening." — time-of-day aware in user.tz. */
greeting: string;
/** Executive summary paragraph — italic pull-quote in the magazine. */
lead: string;
numbers: BriefNumbers;
/** Threads to watch today. Renderer splits into 03a/03b when > 6. */
threads: BriefThread[];
/** Signals-to-watch. The "04 · Signals" page is omitted when empty. */
signals: string[];
}
export interface BriefStory {
/** Editorial category label. */
category: string;
/** ISO-2 country code (or composite like "IL / LB"). */
country: string;
threatLevel: BriefThreatLevel;
headline: string;
description: string;
/** Publication/wire attribution (rendered as the anchor text). */
source: string;
/**
* Outgoing link to the original article. Required on v2 envelopes
* and must parse as an absolute https/http URL. Absent on v1
* envelopes still living in the 7-day TTL window; the renderer
* degrades to a plain (unlinked) source line for those. No
* importanceScore / pubDate / briefModel — those upstream fields
* remain banned in `data`.
*/
sourceUrl?: string;
/** Per-user LLM-generated rationale. */
whyMatters: string;
}
export interface BriefData {
user: BriefUser;
/** Short issue code, e.g. "17.04". */
issue: string;
/** ISO date "YYYY-MM-DD" in user.tz. */
date: string;
/** Long-form human date, e.g. "17 April 2026". */
dateLong: string;
digest: BriefDigest;
stories: BriefStory[];
}
/**
* Canonical envelope stored at brief:{userId}:{issueDate} in Redis.
* Renderer + future composer + future consumers must all pin to
* `version === BRIEF_ENVELOPE_VERSION` at runtime — see the consumer
* drift incident (PR #3139) for why.
*/
export interface BriefEnvelope {
version: typeof BRIEF_ENVELOPE_VERSION;
/** Unix ms when the envelope was composed. Informational only. */
issuedAt: number;
data: BriefData;
}