mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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.
121 lines
4.3 KiB
TypeScript
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;
|
|
}
|