diff --git a/.github/workflows/feed-validation.yml b/.github/workflows/feed-validation.yml index 9caca24c9f..0638761f53 100644 --- a/.github/workflows/feed-validation.yml +++ b/.github/workflows/feed-validation.yml @@ -24,7 +24,10 @@ on: branches: [main] paths: - 'src/config/feeds.ts' + - 'server/worldmonitor/news/v1/_feeds.ts' - 'scripts/validate-rss-feeds.mjs' + - 'scripts/_feed-health.mjs' + - 'scripts/seed-recall-benchmark.mjs' - 'api/_rss-allowed-domain-match.js' - 'api/_rss-allowed-domains.js' - 'shared/rss-allowed-domains.json' @@ -43,7 +46,7 @@ jobs: # nominal, but a hung dependency-install or network stall could otherwise # occupy a runner for the default 6h timeout. 15min gives plenty of # headroom while avoiding stuck-runner alerts on real outages. - timeout-minutes: 15 + timeout-minutes: 25 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -51,4 +54,27 @@ jobs: node-version: '24' cache: 'npm' - run: npm ci + # #4920: with Upstash secrets configured, the run also publishes + # per-feed health + silent-zero streaks (news:feed-health:v1) and the + # GDELT recall benchmark (news:recall-benchmark:v1). Without secrets + # both publishers skip silently — validation output is unchanged. + # #4927 review P1: a silent skip must still be VISIBLE on the run — + # otherwise green scheduled runs mask never-activated health + # publishing (the health endpoints activation-gate these keys, but an + # operator scanning workflow runs should see the gap too). + - name: Warn when health publishing is not activated + env: + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + run: | + if [ -z "$UPSTASH_REDIS_REST_URL" ] || [ -z "$UPSTASH_REDIS_REST_TOKEN" ]; then + echo "::warning title=feed-health publishing skipped::UPSTASH_REDIS_REST_URL/TOKEN are not configured as repo secrets — news:feed-health:v1 and news:recall-benchmark:v1 will not be published (health endpoints show these as pending-activation)." + fi - run: npm run test:feeds:ci + env: + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + - run: node scripts/seed-recall-benchmark.mjs + env: + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} diff --git a/api/health.js b/api/health.js index 74b5955480..69c6a89aa6 100644 --- a/api/health.js +++ b/api/health.js @@ -108,6 +108,10 @@ const BOOTSTRAP_KEYS = { }; const STANDALONE_KEYS = { + // #4920 completeness measurement (daily GH Actions publishers) — ops + // keys: health-monitored but NOT bootstrap-hydrated into page loads. + newsFeedHealth: 'news:feed-health:v1', + newsRecallBenchmark: 'news:recall-benchmark:v1', serviceStatuses: 'infra:service-statuses:v1', macroSignals: 'economic:macro-signals:v1', bisPolicy: 'economic:bis:policy:v1', @@ -260,6 +264,9 @@ const SEED_META = { notamClosures: { key: 'seed-meta:aviation:notam', maxStaleMin: 240 }, // 2h interval; 240min = 2x interval predictionMarkets: { key: 'seed-meta:prediction:markets', maxStaleMin: 90 }, newsInsights: { key: 'seed-meta:news:insights', maxStaleMin: 30 }, + // #4920: daily GH Actions cadence; 2880 = 2x — one fully missed day alarms + newsFeedHealth: { key: 'seed-meta:news:feed-health', maxStaleMin: 2880 }, + newsRecallBenchmark: { key: 'seed-meta:news:recall-benchmark', maxStaleMin: 2880 }, marketQuotes: { key: 'seed-meta:market:stocks', maxStaleMin: 30 }, commodityQuotes: { key: 'seed-meta:market:commodities', maxStaleMin: 30 }, goldExtended: { key: 'seed-meta:market:gold-extended', maxStaleMin: 30 }, @@ -486,6 +493,14 @@ const ON_DEMAND_KEYS = new Set([ // chronic LLM-provider failures must surface as CRIT. 'simulationPackageLatest', // written by writeSimulationPackage after deep forecast runs; only present after first successful deep run 'simulationOutcomeLatest', // written by writeSimulationOutcome after simulation runs; only present after first successful simulation + // #4927 review P1: activation-gated on the operator adding UPSTASH_* as GH + // Actions secrets — the publishers skip silently without them, so a + // never-activated key must read as soft EMPTY_ON_DEMAND, not EMPTY/CRIT, + // while the workflow stays green. On-demand softening is REVOKED once the + // durable activation marker exists (see ACTIVATION_MARKERS): after the + // first publish, missing data/meta is EMPTY/STALE_SEED like any other key. + 'newsFeedHealth', + 'newsRecallBenchmark', 'newsThreatSummary', // relay classify loop — only written when mergedByCountry has entries; absent on quiet news periods 'resilienceRanking', // on-demand RPC cache populated after ranking requests; missing before first Pro use is expected 'recoveryFiscalSpace', 'recoveryReserveAdequacy', 'recoveryExternalDebt', @@ -537,6 +552,17 @@ const ON_DEMAND_KEYS = new Set([ // even when the data key has strlen=0. For producer-specific cases where the // payload must exist but recordCount=0 is valid, add to ZERO_RECORD_DATA_OK_KEYS // only. +// #4927 re-review P1: "has ever published" must survive the 7d seed-meta +// TTL — inferring activation from meta existence meant a publisher that ran +// once and died read as healthy pending-activation again after the meta +// expired. Publishers SET these markers with NO TTL on every successful +// publish; when the marker exists the key leaves ON_DEMAND softening and +// normal EMPTY/STALE_SEED rules apply. +const ACTIVATION_MARKERS = { + newsFeedHealth: 'seed-activated:news:feed-health', + newsRecallBenchmark: 'seed-activated:news:recall-benchmark', +}; + const EMPTY_DATA_OK_KEYS = new Set([ 'notamClosures', 'faaDelays', 'intlDelays', 'gpsjam', 'positiveGeoEvents', 'weatherAlerts', 'earningsCalendar', 'econCalendar', 'cotPositioning', @@ -675,7 +701,8 @@ function isCascadeCovered(name, hasData, keyStrens, keyErrors) { function classifyKey(name, redisKey, opts, ctx) { const { keyStrens, keyErrors, keyMetaValues, keyMetaErrors, now } = ctx; const seedCfg = SEED_META[name]; - const isOnDemand = !!opts.allowOnDemand && ON_DEMAND_KEYS.has(name); + const isOnDemand = !!opts.allowOnDemand && ON_DEMAND_KEYS.has(name) + && !(ctx.activatedNames && ctx.activatedNames.has(name)); const meta = readSeedMeta(seedCfg, keyMetaValues, keyMetaErrors, now); @@ -850,6 +877,7 @@ export default async function handler(req, ctx) { ...Object.values(STANDALONE_KEYS), ]; const allMetaKeys = Object.values(SEED_META).map(s => s.key); + const activationEntries = Object.entries(ACTIVATION_MARKERS); // STRLEN for data keys avoids loading large blobs into memory (OOM prevention). // NEG_SENTINEL ('__WM_NEG__') is 10 bytes — strlenIsData() rejects exactly @@ -859,6 +887,7 @@ export default async function handler(req, ctx) { const commands = [ ...allDataKeys.map(k => ['STRLEN', k]), ...allMetaKeys.map(k => ['GET', k]), + ...activationEntries.map(([, marker]) => ['EXISTS', marker]), ]; if (!getRedisCredentials()) throw new Error('Redis not configured'); results = await redisPipeline(commands, 8_000); @@ -895,8 +924,16 @@ export default async function handler(req, ctx) { if (r?.error) keyMetaErrors.set(allMetaKeys[i], r.error); keyMetaValues.set(allMetaKeys[i], r?.result ?? null); } + // activatedNames: keys whose durable activation marker exists — these + // leave ON_DEMAND softening permanently (#4927 re-review P1). A marker + // read error degrades to not-activated (soft), never to a false alarm. + const activatedNames = new Set(); + for (let i = 0; i < activationEntries.length; i++) { + const r = results[allDataKeys.length + allMetaKeys.length + i]; + if (!r?.error && Number(r?.result) === 1) activatedNames.add(activationEntries[i][0]); + } - const classifyCtx = { keyStrens, keyErrors, keyMetaValues, keyMetaErrors, now }; + const classifyCtx = { keyStrens, keyErrors, keyMetaValues, keyMetaErrors, activatedNames, now }; const checks = {}; const counts = { ok: 0, warn: 0, onDemandWarn: 0, staleContent: 0, crit: 0 }; let totalChecks = 0; @@ -1042,6 +1079,7 @@ export default async function handler(req, ctx) { export const __testing__ = { readSeedMeta, classifyKey, + ACTIVATION_MARKERS, STATUS_COUNTS, // U7 (Tier 3 parity test): exposed for tests/mcp-bootstrap-parity.test.mjs // to walk the canonical seeded-data inventory. Both consts are unexported diff --git a/api/seed-health.js b/api/seed-health.js index ca1ca45570..51f5d51bdc 100644 --- a/api/seed-health.js +++ b/api/seed-health.js @@ -27,6 +27,17 @@ const SEED_DOMAINS = { 'climate:co2-monitoring': { key: 'seed-meta:climate:co2-monitoring', intervalMin: 1440 }, // daily cron; health.js maxStaleMin:4320 (3x) is intentionally higher — it's an alarm threshold, not the cron cadence 'climate:ocean-ice': { key: 'seed-meta:climate:ocean-ice', intervalMin: 1440 }, // daily cron; health.js maxStaleMin:2880 (2x) tolerates one missed run 'climate:news-intelligence': { key: 'seed-meta:climate:news-intelligence', intervalMin: 30 }, + // #4920 completeness measurement — both run in the daily feed-validation + // GitHub Actions workflow (00:00 UTC), not Railway. 1440-min cadence; + // classifier stales at intervalMin*2 = one fully missed day. + // activationKey (#4927 review P1 + re-review): published from GH Actions + // only when the operator has added the UPSTASH secrets. 'missing' reads + // as pending-activation (healthy) ONLY while the durable activation + // marker is absent — publishers SET it with no TTL on first success, so + // "has ever published" survives the 7d seed-meta TTL and a dead + // publisher alarms as missing/stale instead of reverting to pending. + 'news:feed-health': { key: 'seed-meta:news:feed-health', intervalMin: 1440, activationKey: 'seed-activated:news:feed-health' }, + 'news:recall-benchmark': { key: 'seed-meta:news:recall-benchmark', intervalMin: 1440, activationKey: 'seed-activated:news:recall-benchmark' }, // Phase 2 — Parameterized endpoints 'unrest:events': { key: 'seed-meta:unrest:events', intervalMin: 15 }, 'cyber:threats': { key: 'seed-meta:cyber:threats', intervalMin: 240 }, @@ -262,6 +273,7 @@ async function getSeedBatch(entries) { const commands = []; const metaSlots = []; const probeSlots = []; + const activationSlots = []; for (const [domain, cfg] of entries) { metaSlots.push({ domain, key: cfg.key, index: commands.length }); commands.push(['GET', cfg.key]); @@ -269,6 +281,10 @@ async function getSeedBatch(entries) { probeSlots.push({ domain, index: commands.length }); commands.push(['GET', cfg.dataProbe.key]); } + if (cfg.activationKey) { + activationSlots.push({ domain, index: commands.length }); + commands.push(['EXISTS', cfg.activationKey]); + } } const data = await redisPipeline(commands, 3000); @@ -286,7 +302,11 @@ async function getSeedBatch(entries) { for (const slot of probeSlots) { probeMap.set(slot.domain, data[slot.index]?.result ?? null); } - return { metaMap, probeMap }; + const activatedMap = new Map(); + for (const slot of activationSlots) { + activatedMap.set(slot.domain, Number(data[slot.index]?.result) === 1); + } + return { metaMap, probeMap, activatedMap }; } export default async function handler(req) { @@ -305,9 +325,10 @@ export default async function handler(req) { const entries = Object.entries(SEED_DOMAINS); let metaMap; + let activatedMap = new Map(); let probeMap; try { - ({ metaMap, probeMap } = await getSeedBatch(entries)); + ({ metaMap, probeMap, activatedMap } = await getSeedBatch(entries)); } catch { return jsonResponse({ error: 'Redis unavailable' }, 503, cors); } @@ -321,6 +342,15 @@ export default async function handler(req) { const maxStalenessMs = cfg.intervalMin * 2 * 60 * 1000; if (!meta) { + if (cfg.activationKey && !activatedMap.get(domain)) { + // Never seeded (durable marker absent) AND operator-activation- + // gated: healthy pending state, not an alarm (#4927 review P1). + // Once the marker exists, missing meta falls through to + // 'missing' — a publisher that ran once and died must alarm + // (#4927 re-review P1). + seeds[domain] = { status: 'pending-activation', fetchedAt: null, recordCount: null, stale: false }; + continue; + } seeds[domain] = { status: 'missing', fetchedAt: null, recordCount: null, stale: true }; if (cfg.minRecordCount != null) seeds[domain].minRecordCount = cfg.minRecordCount; missingCount++; diff --git a/docs/api/NewsService.openapi.json b/docs/api/NewsService.openapi.json index 12d12493a1..692d1d70df 100644 --- a/docs/api/NewsService.openapi.json +++ b/docs/api/NewsService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"CategoriesEntry":{"properties":{"key":{"type":"string"},"value":{"$ref":"#/components/schemas/CategoryBucket"}},"type":"object"},"CategoryBucket":{"properties":{"items":{"items":{"$ref":"#/components/schemas/NewsItem"},"type":"array"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FeedStatusesEntry":{"properties":{"key":{"type":"string"},"value":{"type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"ForbiddenError":{"description":"Returned when a PRO-gated endpoint denies access because the caller has no resolved authenticated user, entitlements cannot be verified, or the caller lacks the required entitlement tier.","properties":{"currentTier":{"description":"Caller entitlement tier when known.","format":"int32","type":"integer"},"error":{"description":"Human-readable entitlement failure reason.","type":"string"},"planKey":{"description":"Caller plan key when known.","type":"string"},"requiredTier":{"description":"Minimum entitlement tier required for this endpoint.","format":"int32","type":"integer"}},"required":["error"],"type":"object"},"GatewayError":{"description":"Returned by gateway infrastructure errors before an RPC handler runs, such as origin, routing, method, authentication, or quota checks.","properties":{"error":{"description":"Gateway error reason or structured gateway failure details.","oneOf":[{"type":"string"},{"additionalProperties":true,"type":"object"}]}},"required":["error"],"type":"object"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"GetSummarizeArticleCacheRequest":{"description":"GetSummarizeArticleCacheRequest looks up a pre-computed summary by cache key.","properties":{"cacheKey":{"description":"Deterministic cache key computed by buildSummaryCacheKey().","type":"string"}},"type":"object"},"InvalidRequestBodyError":{"description":"Returned when a JSON POST request body is empty or malformed.","properties":{"message":{"description":"Invalid request body","type":"string"}},"required":["message"],"type":"object"},"JmespathProjectionError":{"description":"Returned when a REST jmespath projection is invalid or exceeds the expression/output byte limits.","properties":{"_jmespath_error":{"description":"Projection error discriminator and details.","type":"string"},"original_keys":{"description":"Top-level keys or shape of the unprojected response.","items":{"type":"string"},"type":"array"}},"required":["_jmespath_error","original_keys"],"type":"object"},"ListFeedDigestRequest":{"properties":{"lang":{"description":"ISO 639-1 language code (en, fr, ar, etc.)","type":"string"},"variant":{"description":"Digest variant: full, tech, finance, happy, commodity. Unsupported site variants, including energy, currently fall back to full.","type":"string"}},"type":"object"},"ListFeedDigestResponse":{"properties":{"categories":{"additionalProperties":{"$ref":"#/components/schemas/CategoryBucket"},"description":"Per-category buckets — keys match category names from feed config","type":"object"},"feedStatuses":{"additionalProperties":{"type":"string"},"description":"Per-feed status — only non-ok states emitted; absent key implies ok.\n Values: empty (feed returned 0 items), timeout (timed out during fetch),\n all-undated (every parsed item lacked a usable date), partial-undated\n (some parsed items lacked a usable date).","type":"object"},"generatedAt":{"description":"ISO 8601 timestamp of when this digest was generated","type":"string"}},"type":"object"},"NewsItem":{"description":"NewsItem represents a single news article from RSS feed aggregation.","properties":{"corroborationCount":{"description":"Number of distinct sources that reported the same story in this digest\n cycle. \"Same story\" is determined by edit-tolerant story-identity\n clustering (headlines describing one event with different wording,\n ordering, source suffixes, truncation, or morphology corroborate each\n other) — not exact-title matching. See shared/story-identity.js.","format":"int32","type":"integer"},"importanceScore":{"description":"Composite importance score. The base 0-100 score uses severity × 55% +\n source tier × 20% + corroboration × 15% + recency × 10%, then may add an\n 18-point diplomacy/flashpoint boost and 4 points per entity-level\n corroborating source, capped at five sources. The final score can exceed\n 100; with current boosts it is approximately capped at 138.","format":"int32","type":"integer"},"isAlert":{"description":"Whether this article triggered an alert condition.","type":"boolean"},"link":{"description":"Article URL.","type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"locationName":{"description":"Human-readable location name.","type":"string"},"publishedAt":{"description":"Publication time, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"snippet":{"description":"Cleaned article description from the RSS/Atom \u003cdescription\u003e /\n \u003ccontent:encoded\u003e / \u003csummary\u003e / \u003ccontent\u003e tag: HTML-stripped,\n entity-decoded, whitespace-normalised, clipped to 400 chars. Empty string\n when unavailable or indistinguishable from the headline — consumers must\n fall back to the headline for display/LLM grounding in that case.","type":"string"},"source":{"description":"Source feed name.","minLength":1,"type":"string"},"storyMeta":{"$ref":"#/components/schemas/StoryMeta"},"threat":{"$ref":"#/components/schemas/ThreatClassification"},"title":{"description":"Article headline.","minLength":1,"type":"string"}},"required":["source","title"],"type":"object"},"RateLimitError":{"description":"Returned when a gateway or handler rate limit rejects the request.","properties":{"error":{"description":"Human-readable rate-limit failure reason.","type":"string"}},"required":["error"],"type":"object"},"StoryMeta":{"description":"StoryMeta carries cross-cycle persistence data attached to each news item.","properties":{"firstSeen":{"description":"Epoch ms when the story first appeared in any digest cycle.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"mentionCount":{"description":"Total number of digest cycles in which this story appeared.","format":"int32","type":"integer"},"phase":{"description":"StoryPhase represents the lifecycle stage of a tracked news story.","enum":["STORY_PHASE_UNSPECIFIED","STORY_PHASE_BREAKING","STORY_PHASE_DEVELOPING","STORY_PHASE_SUSTAINED","STORY_PHASE_FADING"],"type":"string"},"sourceCount":{"description":"Number of unique sources that reported this story (cached from Redis Set).","format":"int32","type":"integer"}},"type":"object"},"SummarizeArticleRequest":{"description":"SummarizeArticleRequest specifies parameters for LLM article summarization.","properties":{"bodies":{"items":{"description":"Optional article bodies paired 1:1 with `headlines`. When bodies[i] is\n non-empty, the prompt interleaves it as grounding context under\n headlines[i]; when empty, behavior is identical to headline-only today.\n Callers may supply a shorter array; missing entries are treated as empty.\n Each body is subject to the same sanitisation as headlines before reaching\n the LLM prompt.","type":"string"},"type":"array"},"geoContext":{"description":"Geographic signal context to include in the prompt.","type":"string"},"headlines":{"items":{"description":"Headlines to summarize. Up to 10 raw headlines are used for request\n identity/cache matching; the LLM prompt uses up to 5 unique, non-empty\n sanitized headline/body pairs.","minItems":1,"type":"string"},"minItems":1,"type":"array"},"lang":{"description":"Output language code, default \"en\".","type":"string"},"mode":{"description":"Summarization mode: \"brief\", \"analysis\", \"translate\", \"\" (default).","type":"string"},"provider":{"description":"LLM provider: \"ollama\", \"groq\", \"openrouter\"","minLength":1,"type":"string"},"systemAppend":{"description":"Optional system prompt append for analytical framework instructions.","type":"string"},"variant":{"description":"Variant: \"full\", \"tech\", or target language for translate mode.","type":"string"}},"required":["provider"],"type":"object"},"SummarizeArticleResponse":{"description":"SummarizeArticleResponse contains the LLM summarization result.","properties":{"error":{"description":"Error message if the request failed.","type":"string"},"errorType":{"description":"Error type/name (e.g. \"TypeError\").","type":"string"},"fallback":{"description":"Whether the client should try the next provider in the fallback chain.","type":"boolean"},"model":{"description":"Model identifier used for generation.","type":"string"},"provider":{"description":"Provider that produced the result (or \"cache\").","type":"string"},"status":{"description":"SummarizeStatus indicates the outcome of a summarization request.","enum":["SUMMARIZE_STATUS_UNSPECIFIED","SUMMARIZE_STATUS_SUCCESS","SUMMARIZE_STATUS_CACHED","SUMMARIZE_STATUS_SKIPPED","SUMMARIZE_STATUS_ERROR"],"type":"string"},"statusDetail":{"description":"Human-readable detail for non-success statuses (skip reason, etc.).","type":"string"},"summary":{"description":"The generated summary text.","type":"string"},"tokens":{"description":"Token count from the LLM response.","format":"int32","type":"integer"}},"type":"object"},"ThreatClassification":{"description":"ThreatClassification represents an AI-assessed threat level for a news item.","properties":{"category":{"description":"Event category.","type":"string"},"confidence":{"description":"Confidence score (0.0 to 1.0).","format":"double","maximum":1,"minimum":0,"type":"number"},"level":{"description":"ThreatLevel represents the assessed threat level of a news event.","enum":["THREAT_LEVEL_UNSPECIFIED","THREAT_LEVEL_LOW","THREAT_LEVEL_MEDIUM","THREAT_LEVEL_HIGH","THREAT_LEVEL_CRITICAL"],"type":"string"},"source":{"description":"Classification source — \"keyword\", \"keyword-historical-downgrade\", or\n \"llm\".","type":"string"}},"type":"object"},"UnauthorizedError":{"description":"Returned when the API key is missing, malformed, or lacks current API access.","properties":{"error":{"description":"Human-readable error message.","type":"string"}},"required":["error"],"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}},"securitySchemes":{"ApiKeyHeader":{"description":"Alias header for the WorldMonitor API key (X-WorldMonitor-Key).","in":"header","name":"X-Api-Key","type":"apiKey"},"WorldMonitorKey":{"description":"User-issued WorldMonitor API key.","in":"header","name":"X-WorldMonitor-Key","type":"apiKey"}}},"info":{"title":"NewsService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/news/v1/list-feed-digest":{"get":{"description":"ListFeedDigest returns a pre-aggregated digest of all RSS feeds for a site variant.","operationId":"ListFeedDigest","parameters":[{"description":"Digest variant: full, tech, finance, happy, commodity. Unsupported site variants, including energy, currently fall back to full.","example":"example","in":"query","name":"variant","required":false,"schema":{"type":"string"}},{"description":"ISO 639-1 language code (en, fr, ar, etc.)","example":"en","in":"query","name":"lang","required":false,"schema":{"type":"string"}},{"description":"Optional JMESPath expression applied server-side to project or reduce the JSON response before it is returned (mirrors the MCP jmespath argument). Invalid expressions, expressions larger than 1024 UTF-8 bytes, or projections that exceed the 256 KB output cap return HTTP 400 with a {_jmespath_error, original_keys} envelope. Grammar and worked examples: https://www.worldmonitor.app/docs/mcp-jmespath.","example":"keys(@)","in":"query","name":"jmespath","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"example":{"categories":{"exampleKey":{"items":[{"corroborationCount":42,"importanceScore":42,"isAlert":true,"link":"https://example.com/worldmonitor","location":{"latitude":40.7128,"longitude":-74.006},"source":"example","title":"example"}]}},"feedStatuses":{"exampleKey":"example"},"generatedAt":"2026-01-15T12:00:00Z"},"schema":{"$ref":"#/components/schemas/ListFeedDigestResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/JmespathProjectionError"}]}}},"description":"Validation error"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedError"}}},"description":"Missing or invalid API key."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenError"}}},"description":"API access requires an active subscription (the API key's subscription is inactive or expired)."},"429":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/RateLimitError"}]}}},"description":"Rate limit exceeded.","headers":{"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"string"}},"X-RateLimit-Limit":{"description":"Maximum requests allowed in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Remaining":{"description":"Requests remaining in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Reset":{"description":"Unix epoch milliseconds when the active rate-limit window resets.","schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/GatewayError"}]}}},"description":"Gateway or handler error response."}},"summary":"ListFeedDigest","tags":["NewsService"]}},"/api/news/v1/summarize-article":{"post":{"description":"SummarizeArticle generates an LLM summary with provider selection and fallback support.","operationId":"SummarizeArticle","parameters":[{"description":"Optional client-generated idempotency key. Retrying a POST with the same key and an identical request body replays the original response (only the status, body, and Content-Type are reproduced) instead of re-executing; reusing the key with a different body is rejected with 422. For mutations this avoids duplicating the side effect, while for batch-read POSTs it replays a cached snapshot that can be up to 24 hours stale. Keys are scoped per authenticated caller (falling back to the source IP for unauthenticated endpoints) and retained for 24 hours.","example":"4f8b9c2e-1a3d-4b6f-8e0a-2c5d7f9b1e34","in":"header","name":"Idempotency-Key","required":false,"schema":{"maxLength":255,"minLength":1,"pattern":"^[\\x21-\\x7E]{1,255}$","type":"string"}}],"requestBody":{"content":{"application/json":{"example":{"bodies":["example"],"geoContext":"example","headlines":["example"],"lang":"en","mode":"brief","provider":"openrouter"},"schema":{"$ref":"#/components/schemas/SummarizeArticleRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"example":{"error":"example","errorType":"all","fallback":true,"model":"example","provider":"openrouter"},"schema":{"$ref":"#/components/schemas/SummarizeArticleResponse"}}},"description":"Successful response","headers":{"Idempotency-Key":{"description":"The idempotency key echoed from the request. Present only when the client opted into idempotency.","schema":{"type":"string"}},"Idempotent-Replayed":{"description":"true when this response was replayed from an earlier request with the same key, false on the first (original) request. Present only when the client opted into idempotency.","schema":{"type":"boolean"}}}},"400":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"],"type":"object"},{"$ref":"#/components/schemas/InvalidRequestBodyError"}]}}},"description":"Validation error, invalid Idempotency-Key header, or malformed JSON request body"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedError"}}},"description":"Missing or invalid API key."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenError"}}},"description":"API access requires an active subscription (the API key's subscription is inactive or expired)."},"409":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"],"type":"object"}}},"description":"A request with this Idempotency-Key is still being processed","headers":{"Idempotency-Key":{"description":"The idempotency key supplied by the client.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying the in-flight request.","schema":{"type":"string"}}}},"422":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"],"type":"object"}}},"description":"The Idempotency-Key was already used with a different request body","headers":{"Idempotency-Key":{"description":"The idempotency key supplied by the client.","schema":{"type":"string"}}}},"429":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/RateLimitError"}]}}},"description":"Rate limit exceeded.","headers":{"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"string"}},"X-RateLimit-Limit":{"description":"Maximum requests allowed in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Remaining":{"description":"Requests remaining in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Reset":{"description":"Unix epoch milliseconds when the active rate-limit window resets.","schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/GatewayError"}]}}},"description":"Gateway or handler error response."}},"summary":"SummarizeArticle","tags":["NewsService"]}},"/api/news/v1/summarize-article-cache":{"get":{"description":"GetSummarizeArticleCache looks up a cached summary by deterministic key (CDN-cacheable GET).","operationId":"GetSummarizeArticleCache","parameters":[{"description":"Deterministic cache key computed by buildSummaryCacheKey().","example":"summary:v1:example-cache","in":"query","name":"cache_key","required":false,"schema":{"pattern":"^summary:v\\d+:[a-z0-9:_-]{3,120}$","type":"string"}},{"description":"Optional JMESPath expression applied server-side to project or reduce the JSON response before it is returned (mirrors the MCP jmespath argument). Invalid expressions, expressions larger than 1024 UTF-8 bytes, or projections that exceed the 256 KB output cap return HTTP 400 with a {_jmespath_error, original_keys} envelope. Grammar and worked examples: https://www.worldmonitor.app/docs/mcp-jmespath.","example":"keys(@)","in":"query","name":"jmespath","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"example":{"error":"example","errorType":"all","fallback":true,"model":"example","provider":"openrouter"},"schema":{"$ref":"#/components/schemas/SummarizeArticleResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/JmespathProjectionError"}]}}},"description":"Validation error"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedError"}}},"description":"Missing or invalid API key."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenError"}}},"description":"API access requires an active subscription (the API key's subscription is inactive or expired)."},"429":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/RateLimitError"}]}}},"description":"Rate limit exceeded.","headers":{"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"string"}},"X-RateLimit-Limit":{"description":"Maximum requests allowed in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Remaining":{"description":"Requests remaining in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Reset":{"description":"Unix epoch milliseconds when the active rate-limit window resets.","schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/GatewayError"}]}}},"description":"Gateway or handler error response."}},"summary":"GetSummarizeArticleCache","tags":["NewsService"]}}},"security":[{"WorldMonitorKey":[]},{"ApiKeyHeader":[]}],"servers":[{"url":"https://api.worldmonitor.app"}]} \ No newline at end of file +{"components":{"schemas":{"CategoriesEntry":{"properties":{"key":{"type":"string"},"value":{"$ref":"#/components/schemas/CategoryBucket"}},"type":"object"},"CategoryBucket":{"properties":{"items":{"items":{"$ref":"#/components/schemas/NewsItem"},"type":"array"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FeedStatusesEntry":{"properties":{"key":{"type":"string"},"value":{"type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"ForbiddenError":{"description":"Returned when a PRO-gated endpoint denies access because the caller has no resolved authenticated user, entitlements cannot be verified, or the caller lacks the required entitlement tier.","properties":{"currentTier":{"description":"Caller entitlement tier when known.","format":"int32","type":"integer"},"error":{"description":"Human-readable entitlement failure reason.","type":"string"},"planKey":{"description":"Caller plan key when known.","type":"string"},"requiredTier":{"description":"Minimum entitlement tier required for this endpoint.","format":"int32","type":"integer"}},"required":["error"],"type":"object"},"GatewayError":{"description":"Returned by gateway infrastructure errors before an RPC handler runs, such as origin, routing, method, authentication, or quota checks.","properties":{"error":{"description":"Gateway error reason or structured gateway failure details.","oneOf":[{"type":"string"},{"additionalProperties":true,"type":"object"}]}},"required":["error"],"type":"object"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"GetSummarizeArticleCacheRequest":{"description":"GetSummarizeArticleCacheRequest looks up a pre-computed summary by cache key.","properties":{"cacheKey":{"description":"Deterministic cache key computed by buildSummaryCacheKey().","type":"string"}},"type":"object"},"InvalidRequestBodyError":{"description":"Returned when a JSON POST request body is empty or malformed.","properties":{"message":{"description":"Invalid request body","type":"string"}},"required":["message"],"type":"object"},"JmespathProjectionError":{"description":"Returned when a REST jmespath projection is invalid or exceeds the expression/output byte limits.","properties":{"_jmespath_error":{"description":"Projection error discriminator and details.","type":"string"},"original_keys":{"description":"Top-level keys or shape of the unprojected response.","items":{"type":"string"},"type":"array"}},"required":["_jmespath_error","original_keys"],"type":"object"},"ListFeedDigestRequest":{"properties":{"lang":{"description":"ISO 639-1 language code (en, fr, ar, etc.)","type":"string"},"variant":{"description":"Digest variant: full, tech, finance, happy, commodity. Unsupported site variants, including energy, currently fall back to full.","type":"string"}},"type":"object"},"ListFeedDigestResponse":{"properties":{"categories":{"additionalProperties":{"$ref":"#/components/schemas/CategoryBucket"},"description":"Per-category buckets — keys match category names from feed config","type":"object"},"feedStatuses":{"additionalProperties":{"type":"string"},"description":"Per-feed status — only non-ok states emitted; absent key implies ok.\n Values: empty (feed returned 0 items), timeout (timed out during fetch),\n all-undated (every parsed item lacked a usable date), partial-undated\n (some parsed items lacked a usable date).","type":"object"},"generatedAt":{"description":"ISO 8601 timestamp of when this digest was generated","type":"string"}},"type":"object"},"NewsItem":{"description":"NewsItem represents a single news article from RSS feed aggregation.","properties":{"corroborationCount":{"description":"Number of distinct sources that reported the same story in this digest\n cycle. \"Same story\" is determined by edit-tolerant story-identity\n clustering (headlines describing one event with different wording,\n ordering, source suffixes, truncation, or morphology corroborate each\n other) — not exact-title matching. See shared/story-identity.js.","format":"int32","type":"integer"},"importanceScore":{"description":"Composite importance score. The base 0-100 score uses severity × 55% +\n source tier × 20% + corroboration × 15% + recency × 10%, then may add an\n 18-point diplomacy/flashpoint boost and 4 points per entity-level\n corroborating source, capped at five sources. The final score can exceed\n 100; with current boosts it is approximately capped at 138.","format":"int32","type":"integer"},"isAlert":{"description":"Whether this article triggered an alert condition.","type":"boolean"},"link":{"description":"Article URL.","type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"locationName":{"description":"Human-readable location name.","type":"string"},"publishedAt":{"description":"Publication time, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"snippet":{"description":"Cleaned article description from the RSS/Atom \u003cdescription\u003e /\n \u003ccontent:encoded\u003e / \u003csummary\u003e / \u003ccontent\u003e tag: HTML-stripped,\n entity-decoded, whitespace-normalised, clipped to 400 chars. Empty string\n when unavailable or indistinguishable from the headline — consumers must\n fall back to the headline for display/LLM grounding in that case.","type":"string"},"source":{"description":"Source feed name.","minLength":1,"type":"string"},"storyMeta":{"$ref":"#/components/schemas/StoryMeta"},"threat":{"$ref":"#/components/schemas/ThreatClassification"},"title":{"description":"Article headline.","minLength":1,"type":"string"}},"required":["source","title"],"type":"object"},"RateLimitError":{"description":"Returned when a gateway or handler rate limit rejects the request.","properties":{"error":{"description":"Human-readable rate-limit failure reason.","type":"string"}},"required":["error"],"type":"object"},"StoryMeta":{"description":"StoryMeta carries cross-cycle persistence data attached to each news item.","properties":{"firstSeen":{"description":"Epoch ms when the story first appeared in any digest cycle.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"mentionCount":{"description":"Total number of digest cycles in which this story appeared.","format":"int32","type":"integer"},"phase":{"description":"StoryPhase represents the lifecycle stage of a tracked news story.","enum":["STORY_PHASE_UNSPECIFIED","STORY_PHASE_BREAKING","STORY_PHASE_DEVELOPING","STORY_PHASE_SUSTAINED","STORY_PHASE_FADING"],"type":"string"},"sourceCount":{"description":"Number of unique sources reporting this story in the CURRENT digest\n cycle (batch corroboration from fuzzy story clustering, #4919;\n defaults to 1 for unclustered items). The cross-cycle Redis source\n set (story:sources:v1) feeds importance scoring, not this field.","format":"int32","type":"integer"}},"type":"object"},"SummarizeArticleRequest":{"description":"SummarizeArticleRequest specifies parameters for LLM article summarization.","properties":{"bodies":{"items":{"description":"Optional article bodies paired 1:1 with `headlines`. When bodies[i] is\n non-empty, the prompt interleaves it as grounding context under\n headlines[i]; when empty, behavior is identical to headline-only today.\n Callers may supply a shorter array; missing entries are treated as empty.\n Each body is subject to the same sanitisation as headlines before reaching\n the LLM prompt.","type":"string"},"type":"array"},"geoContext":{"description":"Geographic signal context to include in the prompt.","type":"string"},"headlines":{"items":{"description":"Headlines to summarize. Up to 10 raw headlines are used for request\n identity/cache matching; the LLM prompt uses up to 5 unique, non-empty\n sanitized headline/body pairs.","minItems":1,"type":"string"},"minItems":1,"type":"array"},"lang":{"description":"Output language code, default \"en\".","type":"string"},"mode":{"description":"Summarization mode: \"brief\", \"analysis\", \"translate\", \"\" (default).","type":"string"},"provider":{"description":"LLM provider: \"ollama\", \"groq\", \"openrouter\"","minLength":1,"type":"string"},"systemAppend":{"description":"Optional system prompt append for analytical framework instructions.","type":"string"},"variant":{"description":"Variant: \"full\", \"tech\", or target language for translate mode.","type":"string"}},"required":["provider"],"type":"object"},"SummarizeArticleResponse":{"description":"SummarizeArticleResponse contains the LLM summarization result.","properties":{"error":{"description":"Error message if the request failed.","type":"string"},"errorType":{"description":"Error type/name (e.g. \"TypeError\").","type":"string"},"fallback":{"description":"Whether the client should try the next provider in the fallback chain.","type":"boolean"},"model":{"description":"Model identifier used for generation.","type":"string"},"provider":{"description":"Provider that produced the result (or \"cache\").","type":"string"},"status":{"description":"SummarizeStatus indicates the outcome of a summarization request.","enum":["SUMMARIZE_STATUS_UNSPECIFIED","SUMMARIZE_STATUS_SUCCESS","SUMMARIZE_STATUS_CACHED","SUMMARIZE_STATUS_SKIPPED","SUMMARIZE_STATUS_ERROR"],"type":"string"},"statusDetail":{"description":"Human-readable detail for non-success statuses (skip reason, etc.).","type":"string"},"summary":{"description":"The generated summary text.","type":"string"},"tokens":{"description":"Token count from the LLM response.","format":"int32","type":"integer"}},"type":"object"},"ThreatClassification":{"description":"ThreatClassification represents an AI-assessed threat level for a news item.","properties":{"category":{"description":"Event category.","type":"string"},"confidence":{"description":"Confidence score (0.0 to 1.0).","format":"double","maximum":1,"minimum":0,"type":"number"},"level":{"description":"ThreatLevel represents the assessed threat level of a news event.","enum":["THREAT_LEVEL_UNSPECIFIED","THREAT_LEVEL_LOW","THREAT_LEVEL_MEDIUM","THREAT_LEVEL_HIGH","THREAT_LEVEL_CRITICAL"],"type":"string"},"source":{"description":"Classification source — \"keyword\", \"keyword-historical-downgrade\", or\n \"llm\".","type":"string"}},"type":"object"},"UnauthorizedError":{"description":"Returned when the API key is missing, malformed, or lacks current API access.","properties":{"error":{"description":"Human-readable error message.","type":"string"}},"required":["error"],"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}},"securitySchemes":{"ApiKeyHeader":{"description":"Alias header for the WorldMonitor API key (X-WorldMonitor-Key).","in":"header","name":"X-Api-Key","type":"apiKey"},"WorldMonitorKey":{"description":"User-issued WorldMonitor API key.","in":"header","name":"X-WorldMonitor-Key","type":"apiKey"}}},"info":{"title":"NewsService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/news/v1/list-feed-digest":{"get":{"description":"ListFeedDigest returns a pre-aggregated digest of all RSS feeds for a site variant.","operationId":"ListFeedDigest","parameters":[{"description":"Digest variant: full, tech, finance, happy, commodity. Unsupported site variants, including energy, currently fall back to full.","example":"example","in":"query","name":"variant","required":false,"schema":{"type":"string"}},{"description":"ISO 639-1 language code (en, fr, ar, etc.)","example":"en","in":"query","name":"lang","required":false,"schema":{"type":"string"}},{"description":"Optional JMESPath expression applied server-side to project or reduce the JSON response before it is returned (mirrors the MCP jmespath argument). Invalid expressions, expressions larger than 1024 UTF-8 bytes, or projections that exceed the 256 KB output cap return HTTP 400 with a {_jmespath_error, original_keys} envelope. Grammar and worked examples: https://www.worldmonitor.app/docs/mcp-jmespath.","example":"keys(@)","in":"query","name":"jmespath","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"example":{"categories":{"exampleKey":{"items":[{"corroborationCount":42,"importanceScore":42,"isAlert":true,"link":"https://example.com/worldmonitor","location":{"latitude":40.7128,"longitude":-74.006},"source":"example","title":"example"}]}},"feedStatuses":{"exampleKey":"example"},"generatedAt":"2026-01-15T12:00:00Z"},"schema":{"$ref":"#/components/schemas/ListFeedDigestResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/JmespathProjectionError"}]}}},"description":"Validation error"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedError"}}},"description":"Missing or invalid API key."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenError"}}},"description":"API access requires an active subscription (the API key's subscription is inactive or expired)."},"429":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/RateLimitError"}]}}},"description":"Rate limit exceeded.","headers":{"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"string"}},"X-RateLimit-Limit":{"description":"Maximum requests allowed in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Remaining":{"description":"Requests remaining in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Reset":{"description":"Unix epoch milliseconds when the active rate-limit window resets.","schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/GatewayError"}]}}},"description":"Gateway or handler error response."}},"summary":"ListFeedDigest","tags":["NewsService"]}},"/api/news/v1/summarize-article":{"post":{"description":"SummarizeArticle generates an LLM summary with provider selection and fallback support.","operationId":"SummarizeArticle","parameters":[{"description":"Optional client-generated idempotency key. Retrying a POST with the same key and an identical request body replays the original response (only the status, body, and Content-Type are reproduced) instead of re-executing; reusing the key with a different body is rejected with 422. For mutations this avoids duplicating the side effect, while for batch-read POSTs it replays a cached snapshot that can be up to 24 hours stale. Keys are scoped per authenticated caller (falling back to the source IP for unauthenticated endpoints) and retained for 24 hours.","example":"4f8b9c2e-1a3d-4b6f-8e0a-2c5d7f9b1e34","in":"header","name":"Idempotency-Key","required":false,"schema":{"maxLength":255,"minLength":1,"pattern":"^[\\x21-\\x7E]{1,255}$","type":"string"}}],"requestBody":{"content":{"application/json":{"example":{"bodies":["example"],"geoContext":"example","headlines":["example"],"lang":"en","mode":"brief","provider":"openrouter"},"schema":{"$ref":"#/components/schemas/SummarizeArticleRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"example":{"error":"example","errorType":"all","fallback":true,"model":"example","provider":"openrouter"},"schema":{"$ref":"#/components/schemas/SummarizeArticleResponse"}}},"description":"Successful response","headers":{"Idempotency-Key":{"description":"The idempotency key echoed from the request. Present only when the client opted into idempotency.","schema":{"type":"string"}},"Idempotent-Replayed":{"description":"true when this response was replayed from an earlier request with the same key, false on the first (original) request. Present only when the client opted into idempotency.","schema":{"type":"boolean"}}}},"400":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"],"type":"object"},{"$ref":"#/components/schemas/InvalidRequestBodyError"}]}}},"description":"Validation error, invalid Idempotency-Key header, or malformed JSON request body"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedError"}}},"description":"Missing or invalid API key."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenError"}}},"description":"API access requires an active subscription (the API key's subscription is inactive or expired)."},"409":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"],"type":"object"}}},"description":"A request with this Idempotency-Key is still being processed","headers":{"Idempotency-Key":{"description":"The idempotency key supplied by the client.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying the in-flight request.","schema":{"type":"string"}}}},"422":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"],"type":"object"}}},"description":"The Idempotency-Key was already used with a different request body","headers":{"Idempotency-Key":{"description":"The idempotency key supplied by the client.","schema":{"type":"string"}}}},"429":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/RateLimitError"}]}}},"description":"Rate limit exceeded.","headers":{"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"string"}},"X-RateLimit-Limit":{"description":"Maximum requests allowed in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Remaining":{"description":"Requests remaining in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Reset":{"description":"Unix epoch milliseconds when the active rate-limit window resets.","schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/GatewayError"}]}}},"description":"Gateway or handler error response."}},"summary":"SummarizeArticle","tags":["NewsService"]}},"/api/news/v1/summarize-article-cache":{"get":{"description":"GetSummarizeArticleCache looks up a cached summary by deterministic key (CDN-cacheable GET).","operationId":"GetSummarizeArticleCache","parameters":[{"description":"Deterministic cache key computed by buildSummaryCacheKey().","example":"summary:v1:example-cache","in":"query","name":"cache_key","required":false,"schema":{"pattern":"^summary:v\\d+:[a-z0-9:_-]{3,120}$","type":"string"}},{"description":"Optional JMESPath expression applied server-side to project or reduce the JSON response before it is returned (mirrors the MCP jmespath argument). Invalid expressions, expressions larger than 1024 UTF-8 bytes, or projections that exceed the 256 KB output cap return HTTP 400 with a {_jmespath_error, original_keys} envelope. Grammar and worked examples: https://www.worldmonitor.app/docs/mcp-jmespath.","example":"keys(@)","in":"query","name":"jmespath","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"example":{"error":"example","errorType":"all","fallback":true,"model":"example","provider":"openrouter"},"schema":{"$ref":"#/components/schemas/SummarizeArticleResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ValidationError"},{"$ref":"#/components/schemas/JmespathProjectionError"}]}}},"description":"Validation error"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedError"}}},"description":"Missing or invalid API key."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenError"}}},"description":"API access requires an active subscription (the API key's subscription is inactive or expired)."},"429":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/RateLimitError"}]}}},"description":"Rate limit exceeded.","headers":{"Retry-After":{"description":"Seconds to wait before retrying the request.","schema":{"type":"string"}},"X-RateLimit-Limit":{"description":"Maximum requests allowed in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Remaining":{"description":"Requests remaining in the active rate-limit window.","schema":{"type":"string"}},"X-RateLimit-Reset":{"description":"Unix epoch milliseconds when the active rate-limit window resets.","schema":{"type":"string"}}}},"default":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/Error"},{"$ref":"#/components/schemas/GatewayError"}]}}},"description":"Gateway or handler error response."}},"summary":"GetSummarizeArticleCache","tags":["NewsService"]}}},"security":[{"WorldMonitorKey":[]},{"ApiKeyHeader":[]}],"servers":[{"url":"https://api.worldmonitor.app"}]} \ No newline at end of file diff --git a/docs/api/NewsService.openapi.yaml b/docs/api/NewsService.openapi.yaml index 18dd32484c..a0bed9e723 100644 --- a/docs/api/NewsService.openapi.yaml +++ b/docs/api/NewsService.openapi.yaml @@ -739,7 +739,11 @@ components: sourceCount: type: integer format: int32 - description: Number of unique sources that reported this story (cached from Redis Set). + description: |- + Number of unique sources reporting this story in the CURRENT digest + cycle (batch corroboration from fuzzy story clustering, #4919; + defaults to 1 for unclustered items). The cross-cycle Redis source + set (story:sources:v1) feeds importance scoring, not this field. phase: type: string enum: diff --git a/docs/api/worldmonitor.openapi.yaml b/docs/api/worldmonitor.openapi.yaml index e48e5f9e3c..7e877141a6 100644 --- a/docs/api/worldmonitor.openapi.yaml +++ b/docs/api/worldmonitor.openapi.yaml @@ -32212,7 +32212,11 @@ components: sourceCount: type: integer format: int32 - description: Number of unique sources that reported this story (cached from Redis Set). + description: |- + Number of unique sources reporting this story in the CURRENT digest + cycle (batch corroboration from fuzzy story clustering, #4919; + defaults to 1 for unclustered items). The cross-cycle Redis source + set (story:sources:v1) feeds importance scoring, not this field. phase: type: string enum: diff --git a/proto/worldmonitor/news/v1/news_item.proto b/proto/worldmonitor/news/v1/news_item.proto index 19dfb378dc..786cd5f176 100644 --- a/proto/worldmonitor/news/v1/news_item.proto +++ b/proto/worldmonitor/news/v1/news_item.proto @@ -58,7 +58,10 @@ message StoryMeta { int64 first_seen = 1 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; // Total number of digest cycles in which this story appeared. int32 mention_count = 2; - // Number of unique sources that reported this story (cached from Redis Set). + // Number of unique sources reporting this story in the CURRENT digest + // cycle (batch corroboration from fuzzy story clustering, #4919; + // defaults to 1 for unclustered items). The cross-cycle Redis source + // set (story:sources:v1) feeds importance scoring, not this field. int32 source_count = 3; // Story lifecycle phase derived from persistence data. StoryPhase phase = 4; diff --git a/scripts/_clustering.mjs b/scripts/_clustering.mjs index 0ac9ee48ae..62e233bef9 100644 --- a/scripts/_clustering.mjs +++ b/scripts/_clustering.mjs @@ -304,29 +304,60 @@ export function computeEntityCorroboration(clusters, nowMs = Date.now()) { // Note: velocity filter omitted (vs frontend selectTopStories) because digest // items lack velocity data. Phase B may add velocity when RPC provides it. -export function selectTopStories(clusters, maxCount = 8) { +/** + * @param {object[]} clusters + * @param {number} [maxCount] + * @param {{ considered?: number; admissibilityDropped?: number; sourceCapDropped?: number; overflowDropped?: number }} [stats] + * #4920 coverage ledger: when provided, populated with how many clusters + * each gate dropped — previously all three gates were silent. + */ +export function selectTopStories(clusters, maxCount = 8, stats) { const nowMs = Date.now(); computeEntityCorroboration(clusters, nowMs); - const scored = clusters - .map(c => { - const score = scoreImportance(c); - return { cluster: c, score, effectiveScore: score * recencyWeight(c, nowMs) }; - }) - .filter(({ cluster: c, score }) => isTopStoriesAdmissible(c, score)) - .sort((a, b) => b.effectiveScore - a.effectiveScore || b.score - a.score); + const admissible = []; + let admissibilityDropped = 0; + for (const c of clusters) { + const score = scoreImportance(c); + if (isTopStoriesAdmissible(c, score)) { + admissible.push({ cluster: c, score, effectiveScore: score * recencyWeight(c, nowMs) }); + } else { + admissibilityDropped++; + } + } + admissible.sort((a, b) => b.effectiveScore - a.effectiveScore || b.score - a.score); const selected = []; const sourceCount = new Map(); const MAX_PER_SOURCE = 3; - - for (const { cluster, score, effectiveScore } of scored) { + let sourceCapDropped = 0; + let overflowDropped = 0; + + // #4927 review P2: classify EVERY admissible candidate — breaking at + // maxCount lumped later same-source candidates into overflow arithmetic + // even though the source cap would have rejected them regardless of + // room. Cap-first attribution: a candidate whose source already hit the + // per-source cap is a sourceCap drop; only genuinely rankable candidates + // count as overflow. + for (const { cluster, score, effectiveScore } of admissible) { const source = cluster.primarySource; const count = sourceCount.get(source) || 0; - if (count < MAX_PER_SOURCE) { - selected.push({ ...cluster, importanceScore: score, effectiveImportanceScore: effectiveScore }); - sourceCount.set(source, count + 1); + if (count >= MAX_PER_SOURCE) { + sourceCapDropped++; + continue; } - if (selected.length >= maxCount) break; + if (selected.length >= maxCount) { + overflowDropped++; + continue; + } + selected.push({ ...cluster, importanceScore: score, effectiveImportanceScore: effectiveScore }); + sourceCount.set(source, count + 1); + } + + if (stats && typeof stats === 'object') { + stats.considered = clusters.length; + stats.admissibilityDropped = admissibilityDropped; + stats.sourceCapDropped = sourceCapDropped; + stats.overflowDropped = overflowDropped; } return selected; diff --git a/scripts/_feed-health.mjs b/scripts/_feed-health.mjs new file mode 100644 index 0000000000..7bfa698181 --- /dev/null +++ b/scripts/_feed-health.mjs @@ -0,0 +1,83 @@ +/** + * #4920 (a): pure feed-health payload builder. + * + * Consumed by scripts/validate-rss-feeds.mjs after its validation run to + * turn per-feed results into the published `news:feed-health:v1` payload, + * carrying cross-run state (consecutive-empty streaks) so "silent zero" + * Google News wrappers — feeds that respond 200 with zero items when + * Google's ranking shifts — become visible instead of rotting quietly. + * + * Pure module: no I/O. The caller reads the previous payload from Redis + * and passes it in. + */ + +/** A Google News search wrapper — the class of feed that silently zeroes. */ +export function isGoogleNewsWrapper(url) { + return typeof url === 'string' && url.includes('news.google.com/rss/'); +} + +/** + * Consecutive EMPTY runs (this run included) before a wrapper feed is + * flagged as a silent zero. One empty run can be Google jitter; two daily + * runs of zero items on a search wrapper is a real coverage hole. + */ +export const SILENT_ZERO_THRESHOLD = 2; + +/** + * @param {Array<{ name: string; url: string; status: 'OK'|'STALE'|'DEAD'|'EMPTY'|'SKIP'; detail?: string; catalog?: string }>} results + * @param {{ feeds?: Record } | null} previousPayload + * @param {number} nowMs + */ +export function buildFeedHealthPayload(results, previousPayload, nowMs) { + const prevFeeds = previousPayload && typeof previousPayload === 'object' && previousPayload.feeds + ? previousPayload.feeds + : {}; + + const feeds = {}; + const summary = { ok: 0, stale: 0, dead: 0, empty: 0, skipped: 0 }; + const silentZeros = []; + + for (const result of results) { + const key = result.url; + const status = result.status; + if (status === 'OK') summary.ok++; + else if (status === 'STALE') summary.stale++; + else if (status === 'DEAD') summary.dead++; + else if (status === 'EMPTY') summary.empty++; + else summary.skipped++; + + const wrapper = isGoogleNewsWrapper(result.url); + const prevStreak = Number.isFinite(prevFeeds[key]?.consecutiveEmpty) + ? prevFeeds[key].consecutiveEmpty + : 0; + // DEAD counts toward the streak too — a wrapper that flips between + // "200 with zero items" and timeouts is still delivering nothing. + const deliveredNothing = status === 'EMPTY' || status === 'DEAD'; + const consecutiveEmpty = deliveredNothing ? prevStreak + 1 : 0; + + const entry = { + name: result.name, + status, + catalog: result.catalog ?? 'client', + wrapper, + consecutiveEmpty, + }; + if (result.detail) entry.detail = String(result.detail).slice(0, 120); + feeds[key] = entry; + + if (wrapper && consecutiveEmpty >= SILENT_ZERO_THRESHOLD) { + silentZeros.push({ name: result.name, url: result.url, consecutiveEmpty }); + } + } + + silentZeros.sort((a, b) => b.consecutiveEmpty - a.consecutiveEmpty || a.name.localeCompare(b.name)); + + return { + v: 1, + checkedAt: nowMs, + summary, + feedCount: results.length, + silentZeros, + feeds, + }; +} diff --git a/scripts/_recall-benchmark-core.mjs b/scripts/_recall-benchmark-core.mjs new file mode 100644 index 0000000000..804cc71f4f --- /dev/null +++ b/scripts/_recall-benchmark-core.mjs @@ -0,0 +1,74 @@ +/** + * #4920 (c): pure recall computation for the external-coverage benchmark. + * + * Given headlines from an external reference corpus (GDELT top articles) + * and the titles the digest actually ingested, compute what fraction of + * the external stories our pipeline carries — the first number that can + * honestly answer "did we miss a story?". + * + * Matching delegates to shared/story-identity (#4919): the same + * edit-tolerant similarity the pipeline itself uses for corroboration, + * so "we have this story" means the same thing here as it does there. + * + * Pure module: no I/O. + */ + +import { + storyVector, + cosineSimilarity, + STORY_SIMILARITY_THRESHOLD, +} from './shared/story-identity.js'; + +/** + * @param {Array<{ title: string; url?: string }>} externalItems + * @param {string[]} digestTitles + * @param {{ threshold?: number; maxMissedReported?: number }} [opts] + */ +export function computeRecall(externalItems, digestTitles, opts = {}) { + const threshold = typeof opts.threshold === 'number' ? opts.threshold : STORY_SIMILARITY_THRESHOLD; + const maxMissedReported = opts.maxMissedReported ?? 15; + + const digestVectors = digestTitles + .map((title) => ({ title, vec: storyVector(title) })) + .filter((entry) => entry.vec !== null); + + let matched = 0; + const missed = []; + let unvectorizable = 0; + + for (const item of externalItems) { + const vec = storyVector(item.title || ''); + if (!vec) { + // Contentless external titles can't be matched either way; exclude + // from the denominator rather than counting them as misses. + unvectorizable++; + continue; + } + let best = 0; + let bestTitle = ''; + for (const candidate of digestVectors) { + const sim = cosineSimilarity(vec, candidate.vec); + if (sim > best) { + best = sim; + bestTitle = candidate.title; + } + } + if (best >= threshold) { + matched++; + } else { + missed.push({ title: item.title, url: item.url, bestScore: Number(best.toFixed(3)), closest: bestTitle }); + } + } + + const total = matched + missed.length; + missed.sort((a, b) => a.bestScore - b.bestScore); + + return { + recallPct: total > 0 ? Number(((matched / total) * 100).toFixed(1)) : null, + matched, + total, + unvectorizable, + missed: missed.slice(0, maxMissedReported), + threshold, + }; +} diff --git a/scripts/_upstash-rest.mjs b/scripts/_upstash-rest.mjs new file mode 100644 index 0000000000..1feb8737ea --- /dev/null +++ b/scripts/_upstash-rest.mjs @@ -0,0 +1,34 @@ +/** + * #4920: minimal Upstash REST helper shared by the GitHub-Actions-hosted + * completeness publishers (validate-rss-feeds feed-health, recall + * benchmark). Deliberately NOT _seed-utils.mjs: that module's credential + * getter hard-exits when env is missing, while these publishers must + * skip silently on runs without secrets (local, PRs). + */ + +/** @returns {{ restUrl: string; token: string } | null} */ +export function getOptionalUpstashCreds() { + const restUrl = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!restUrl || !token) return null; + return { restUrl, token }; +} + +/** + * @param {{ restUrl: string; token: string }} creds + * @param {Array} command Redis command array, e.g. ['GET', 'key'] + */ +export async function upstashCommand(creds, command) { + const resp = await fetch(creds.restUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${creds.token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'worldmonitor-ops/1.0 (+https://worldmonitor.app)', + }, + body: JSON.stringify(command), + signal: AbortSignal.timeout(15_000), + }); + if (!resp.ok) throw new Error(`Upstash HTTP ${resp.status}`); + return resp.json(); +} diff --git a/scripts/seed-insights.mjs b/scripts/seed-insights.mjs index 372d637f58..de4b7ea2c2 100644 --- a/scripts/seed-insights.mjs +++ b/scripts/seed-insights.mjs @@ -397,7 +397,9 @@ async function fetchInsights() { const clusters = clusterItems(normalizedItems); console.log(` Clusters: ${clusters.length}`); - const topStories = selectTopStories(clusters, 8); + // #4920 coverage ledger: capture what the selection gates dropped. + const selectionStats = {}; + const topStories = selectTopStories(clusters, 8, selectionStats); console.log(` Top stories: ${topStories.length}`); const observability = buildImportanceObservability(clusters, topStories); console.log( @@ -507,6 +509,23 @@ async function fetchInsights() { }; }); + // #4920: user-facing provenance — "compiled from N stories across M + // sources" — plus the selection-gate drop counts. Read by + // insights-loader/InsightsPanel; no proto involved (plain Redis JSON). + const provenance = { + storiesConsidered: normalizedItems.length, + sourcesConsidered: new Set(normalizedItems.map(item => item.source).filter(Boolean)).size, + selectionDrops: { + admissibility: selectionStats.admissibilityDropped ?? 0, + sourceCap: selectionStats.sourceCapDropped ?? 0, + overflow: selectionStats.overflowDropped ?? 0, + }, + }; + console.log( + ` Provenance: ${provenance.storiesConsidered} stories / ${provenance.sourcesConsidered} sources; ` + + `drops adm=${provenance.selectionDrops.admissibility} srcCap=${provenance.selectionDrops.sourceCap} overflow=${provenance.selectionDrops.overflow}`, + ); + const payload = { worldBrief, worldBriefSources, @@ -519,6 +538,7 @@ async function fetchInsights() { multiSourceCount, fastMovingCount, importanceSignals: observability, + provenance, }; // LKG preservation: don't overwrite "ok" with "degraded" diff --git a/scripts/seed-recall-benchmark.mjs b/scripts/seed-recall-benchmark.mjs new file mode 100644 index 0000000000..21eb9522ae --- /dev/null +++ b/scripts/seed-recall-benchmark.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * #4920 (c): external recall benchmark. + * + * Daily job (runs in the feed-validation GitHub Actions workflow, after + * validation — no Railway slot): pulls the current top articles from + * GDELT's public DOC API across broad news verticals, checks whether each + * appears anywhere in the digest we actually ingested + * (news:digest:v1:full:en), and publishes the recall percentage + the + * missed headlines to news:recall-benchmark:v1. + * + * This is the pipeline's first ground-truth completeness number: "of the + * stories a neutral external index considers top news today, what + * fraction does WorldMonitor carry?" Misses are listed by lowest + * similarity so coverage holes are actionable (add a feed / fix a + * wrapper), not just a percentage. + * + * Exit policy: analysis job — any upstream failure logs and exits 0 + * (the workflow must not redden on GDELT jitter). Missing Redis creds + * skip silently (local runs). + */ + +import { fetchGdeltJson } from './_gdelt-fetch.mjs'; +import { computeRecall } from './_recall-benchmark-core.mjs'; +import { pathToFileURL } from 'node:url'; +import { getOptionalUpstashCreds, upstashCommand } from './_upstash-rest.mjs'; + +const RECALL_KEY = 'news:recall-benchmark:v1'; +const META_KEY = 'seed-meta:news:recall-benchmark'; +const DIGEST_KEY = 'news:digest:v1:full:en'; + +// Broad verticals, not niche topics — the benchmark asks "are we carrying +// the big stories", so the reference set must be what a general reader +// would consider top news. maxrecords kept modest: 25×4 ≈ 100 external +// stories/day is plenty of signal without hammering GDELT. +const REFERENCE_QUERIES = [ + { label: 'conflict', q: '(conflict OR military OR war OR strike)' }, + { label: 'economy', q: '(economy OR markets OR inflation OR "central bank")' }, + { label: 'disaster', q: '(earthquake OR flood OR hurricane OR wildfire OR disaster)' }, + { label: 'diplomacy', q: '(summit OR sanctions OR treaty OR election)' }, +]; + +export function gdeltUrl(query) { + const params = new URLSearchParams({ + query: `${query} sourcelang:eng`, + mode: 'ArtList', + format: 'json', + sort: 'HybridRel', + timespan: '24h', + maxrecords: '25', + }); + return `https://api.gdeltproject.org/api/v2/doc/doc?${params.toString()}`; +} + +export function unwrapEnvelope(parsed) { + // Canonical keys may be stored as { data, fetchedAt, ... } envelopes or + // bare payloads — same tolerance as scripts/seed-insights.mjs. + if (parsed && typeof parsed === 'object' && parsed.data && typeof parsed.data === 'object') { + return parsed.data; + } + return parsed; +} + +async function main() { + const creds = getOptionalUpstashCreds(); + if (!creds) { + console.log('recall-benchmark skipped (no UPSTASH_REDIS_REST_URL/TOKEN in env)'); + return; + } + + // 1. Digest titles — what we actually ingested. + const got = await upstashCommand(creds, ['GET', DIGEST_KEY]); + if (typeof got?.result !== 'string' || got.result.length === 0) { + console.warn(`WARN: ${DIGEST_KEY} missing/empty — cannot benchmark, skipping`); + return; + } + const digest = unwrapEnvelope(JSON.parse(got.result)); + const digestTitles = Object.values(digest?.categories ?? {}) + .flatMap((bucket) => (Array.isArray(bucket?.items) ? bucket.items : [])) + .map((item) => item?.title) + .filter((title) => typeof title === 'string' && title.length > 0); + if (digestTitles.length === 0) { + console.warn('WARN: digest carried zero titles — skipping'); + return; + } + + // 2. External reference set from GDELT. + const seenUrls = new Set(); + const externalItems = []; + for (const { label, q } of REFERENCE_QUERIES) { + try { + const json = await fetchGdeltJson(gdeltUrl(q), { label: `recall-${label}` }); + const articles = Array.isArray(json?.articles) ? json.articles : []; + for (const article of articles) { + const title = article?.title; + const url = article?.url; + if (typeof title !== 'string' || title.length < 10) continue; + if (url && seenUrls.has(url)) continue; + if (url) seenUrls.add(url); + externalItems.push({ title, url, vertical: label }); + } + console.log(` [gdelt:${label}] ${articles.length} articles`); + } catch (err) { + console.warn(` [gdelt:${label}] failed: ${err.message} — continuing with remaining verticals`); + } + } + if (externalItems.length < 20) { + console.warn(`WARN: only ${externalItems.length} external articles — too thin to publish, skipping`); + return; + } + + // 3. Recall. + const result = computeRecall(externalItems, digestTitles); + console.log( + `recall: ${result.recallPct}% (${result.matched}/${result.total} external stories present; ` + + `${digestTitles.length} digest titles; ${result.unvectorizable} unvectorizable)`, + ); + for (const miss of result.missed) { + console.log(` MISSED (best ${miss.bestScore}): ${miss.title}`); + } + + // 4. Publish. + const payload = { + v: 1, + checkedAt: Date.now(), + recallPct: result.recallPct, + matched: result.matched, + total: result.total, + digestTitleCount: digestTitles.length, + threshold: result.threshold, + // Untrusted external titles: clamp length so a hostile/broken GDELT + // response cannot balloon the published payload. + missed: result.missed.map((m) => ({ + ...m, + title: String(m.title).slice(0, 200), + // Only http(s) URLs, clamped — GDELT fields are untrusted input. + url: typeof m.url === 'string' && /^https?:\/\//.test(m.url) ? m.url.slice(0, 300) : undefined, + })), + }; + await upstashCommand(creds, ['SET', RECALL_KEY, JSON.stringify(payload), 'EX', String(3 * 86400)]); + await upstashCommand(creds, ['SET', META_KEY, JSON.stringify({ + fetchedAt: payload.checkedAt, + recordCount: result.total, + sourceVersion: 'recall-benchmark-v1', + }), 'EX', String(7 * 86400)]); + // Durable activation marker — NO TTL by design (#4927 re-review P1); see + // validate-rss-feeds.mjs for the rationale. + await upstashCommand(creds, ['SET', 'seed-activated:news:recall-benchmark', '1']); + console.log(`published ${RECALL_KEY}`); +} + +// Importable for tests; only run when executed directly. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + // Analysis job: never redden the workflow on upstream jitter. + console.warn(`recall-benchmark failed (non-fatal): ${err.message}`); + }); +} diff --git a/scripts/translate-locales.mjs b/scripts/translate-locales.mjs index 8d578fb48a..20f1b0a5ea 100644 --- a/scripts/translate-locales.mjs +++ b/scripts/translate-locales.mjs @@ -29,10 +29,10 @@ const onlyArg = [...args].find(a => a.startsWith('--only=')); const onlyLocales = onlyArg ? onlyArg.slice('--only='.length).split(',') : null; const ROOT = proTest ? 'pro-test/src/locales' : 'src/locales'; -const LOCALES = ['ar', 'bg', 'cs', 'de', 'el', 'es', 'fr', 'hi', 'hr', 'hu', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ro', 'ru', 'sv', 'th', 'tr', 'vi', 'zh']; +const LOCALES = ['ar', 'bg', 'cs', 'de', 'el', 'es', 'fa', 'fr', 'hi', 'hr', 'hu', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ro', 'ru', 'sv', 'th', 'tr', 'vi', 'zh']; const LANG_NAMES = { ar: 'Arabic', bg: 'Bulgarian', cs: 'Czech', de: 'German', el: 'Greek', - es: 'Spanish', fr: 'French', hi: 'Hindi', hr: 'Croatian', hu: 'Hungarian', it: 'Italian', ja: 'Japanese', + es: 'Spanish', fa: 'Persian (Farsi)', fr: 'French', hi: 'Hindi', hr: 'Croatian', hu: 'Hungarian', it: 'Italian', ja: 'Japanese', ko: 'Korean', nl: 'Dutch', pl: 'Polish', pt: 'Portuguese (Brazil)', ro: 'Romanian', ru: 'Russian', sv: 'Swedish', th: 'Thai', tr: 'Turkish', vi: 'Vietnamese', zh: 'Simplified Chinese', diff --git a/scripts/validate-rss-feeds.mjs b/scripts/validate-rss-feeds.mjs index 4e9a9a06c6..93b71a605b 100644 --- a/scripts/validate-rss-feeds.mjs +++ b/scripts/validate-rss-feeds.mjs @@ -8,6 +8,10 @@ import { isAllowedDomain } from '../api/_rss-allowed-domain-match.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const FEEDS_PATH = join(__dirname, '..', 'src', 'config', 'feeds.ts'); +// #4920: the SERVER digest catalog is a separate universe from the client +// config — buildDigest ingests from _feeds.ts, so completeness must be +// measured against it too, not just the client list. +const SERVER_FEEDS_PATH = join(__dirname, '..', 'server', 'worldmonitor', 'news', 'v1', '_feeds.ts'); const CHROME_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; const FETCH_TIMEOUT = 15_000; @@ -102,6 +106,44 @@ function extractFeeds() { return feeds; } +/** + * #4920: text-extract the SERVER digest catalog (_feeds.ts). Entries are + * `{ name: '…', url: '…' | gn('…') | gnLocale('…', hl, gl, ceid) }` — the + * gn()/gnLocale() helpers are replicated here so extracted URLs match what + * the digest fetches at runtime. Same no-import text-extraction pattern as + * extractFeeds() above (this script must not execute app code). + */ +export function extractServerFeeds() { + let src; + try { + src = readFileSync(SERVER_FEEDS_PATH, 'utf8'); + } catch { + return []; + } + const gn = (q) => + `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=en-US&gl=US&ceid=US:en`; + const gnLocale = (q, hl, gl, ceid) => + `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=${hl}&gl=${gl}&ceid=${ceid}`; + + const feeds = []; + const seen = new Set(); + // Names may be single- OR double-quoted ("Tom's Hardware"). + const entryRe = /name:\s*(?:'((?:[^'\\]|\\.)*)'|"([^"]+)")\s*,\s*url:\s*(?:'([^']+)'|gn\(\s*'((?:[^'\\]|\\.)*)'\s*\)|gnLocale\(\s*'((?:[^'\\]|\\.)*)'\s*,\s*'([^']+)'\s*,\s*'([^']+)'\s*,\s*'([^']+)'\s*\))/g; + let m; + while ((m = entryRe.exec(src)) !== null) { + const name = (m[1] ?? m[2]).replace(/\\'/g, "'"); + let url; + if (m[3]) url = m[3]; + else if (m[4] !== undefined) url = gn(m[4].replace(/\\'/g, "'")); + else url = gnLocale(m[5].replace(/\\'/g, "'"), m[6], m[7], m[8]); + if (!seen.has(url)) { + seen.add(url); + feeds.push({ name, url, catalog: 'server' }); + } + } + return feeds; +} + function assertCiAllowed(rawUrl) { let parsed; try { @@ -264,9 +306,21 @@ function pad(str, len) { } async function main() { - const feeds = extractFeeds(); + const clientFeeds = extractFeeds().map((f) => ({ ...f, catalog: 'client' })); + const serverFeeds = extractServerFeeds(); + // Merge on URL: a feed present in both catalogs is validated once and + // labeled 'both'. The server catalog is what the digest ingests — its + // health is the completeness signal (#4920). + const byUrl = new Map(); + for (const feed of clientFeeds) byUrl.set(feed.url, feed); + for (const feed of serverFeeds) { + const existing = byUrl.get(feed.url); + if (existing) existing.catalog = 'both'; + else byUrl.set(feed.url, feed); + } + const feeds = [...byUrl.values()]; const mode = CI_MODE ? 'CI (https-only + allowlist + per-hop redirect re-check)' : 'standard'; - console.log(`Validating ${feeds.length} RSS feeds [${mode}] (${CONCURRENCY} concurrent, ${FETCH_TIMEOUT / 1000}s timeout)...\n`); + console.log(`Validating ${feeds.length} RSS feeds (${clientFeeds.length} client, ${serverFeeds.length} server) [${mode}] (${CONCURRENCY} concurrent, ${FETCH_TIMEOUT / 1000}s timeout)...\n`); const results = await runBatch(feeds, validateFeed, CONCURRENCY); @@ -337,6 +391,16 @@ async function main() { process.exit(1); } + // #4920: publish per-feed health + silent-zero streaks to Redis when + // credentials are present (the daily GitHub Actions run passes them as + // secrets; local/PR runs without creds skip silently). Best-effort: a + // Redis outage must not fail feed validation. Deliberately AFTER the + // config-drift hard-fail (#4927 review P3): a guardrail-failing run must + // not refresh health metadata first and mask the failure as fresh/OK. + await publishFeedHealth(results).catch((err) => + console.warn(`WARN: feed-health publish failed: ${err.message}`), + ); + if (stale.length || thirdPartyDead.length || empty.length) { console.warn( `\nWARN: ${thirdPartyDead.length} third-party dead, ${stale.length} stale, ` + @@ -346,7 +410,61 @@ async function main() { } } -main().catch(err => { - console.error('Fatal:', err); - process.exit(2); -}); +export async function publishFeedHealth(results) { + const { getOptionalUpstashCreds, upstashCommand } = await import('./_upstash-rest.mjs'); + const creds = getOptionalUpstashCreds(); + if (!creds) { + console.log('feed-health publish skipped (no UPSTASH_REDIS_REST_URL/TOKEN in env)'); + return { published: false, reason: 'no-creds' }; + } + const { buildFeedHealthPayload } = await import('./_feed-health.mjs'); + const redis = (command) => upstashCommand(creds, command); + + // Streak continuity (#4927 external review): distinguish "key absent" + // (first run — fresh streaks are correct) from "read FAILED" (transient + // Redis/network error — publishing would silently reset every + // consecutive-empty streak and hide silent-zero continuity). On a + // failed read, skip this run's publish entirely; tomorrow's run + // continues the streaks. + let previous = null; + try { + const got = await redis(['GET', 'news:feed-health:v1']); + if (typeof got?.result === 'string') previous = JSON.parse(got.result); + } catch (err) { + console.warn(`feed-health publish skipped: previous-state read failed (${err.message}) — preserving streaks`); + return { published: false, reason: 'previous-read-failed' }; + } + + const payload = buildFeedHealthPayload(results, previous, Date.now()); + await redis(['SET', 'news:feed-health:v1', JSON.stringify(payload), 'EX', String(3 * 86400)]); + await redis(['SET', 'seed-meta:news:feed-health', JSON.stringify({ + fetchedAt: payload.checkedAt, + recordCount: payload.summary.ok, + sourceVersion: 'feed-health-v1', + }), 'EX', String(7 * 86400)]); + // Durable activation marker — NO TTL by design (#4927 re-review P1): + // health endpoints soften missing data only while this key is absent; + // once we have ever published, a dead publisher must alarm as stale, + // not revert to pending-activation when the 7d meta expires. + await redis(['SET', 'seed-activated:news:feed-health', '1']); + + if (payload.silentZeros.length) { + console.warn(`\nSILENT ZEROS (${payload.silentZeros.length} Google News wrappers delivering nothing across runs):`); + for (const feed of payload.silentZeros) { + console.warn(` ${feed.name} — ${feed.consecutiveEmpty} consecutive empty runs — ${feed.url}`); + } + } + console.log(`feed-health published: ${payload.summary.ok}/${payload.feedCount} OK, ${payload.silentZeros.length} silent zeros`); + return { published: true, payload }; +} + +// Importable for tests (#4920): only run when executed directly. +// pathToFileURL handles Windows drive letters and percent-encoding that a +// hand-built file:// string gets wrong. +const { pathToFileURL } = await import('node:url'); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(err => { + console.error('Fatal:', err); + process.exit(2); + }); +} diff --git a/server/worldmonitor/news/v1/list-feed-digest.ts b/server/worldmonitor/news/v1/list-feed-digest.ts index ce973dd0b6..6d5719046a 100644 --- a/server/worldmonitor/news/v1/list-feed-digest.ts +++ b/server/worldmonitor/news/v1/list-feed-digest.ts @@ -375,6 +375,7 @@ interface ParseResult { items: ParsedItem[]; parsedTotal: number; // count of / blocks attempted droppedUndated: number; // count dropped because every recognized date tag was empty/unparseable/future + droppedFeedCap?: number; // #4920: items beyond ITEMS_PER_FEED, previously uncounted } // Cache TTLs: a successful parse (parsedTotal > 0) caches for an hour to @@ -405,7 +406,9 @@ async function fetchAndParseRss( // (Same class of cache-prefix bump as v2→v3 and v3→v4, which this codebase // already established as the correct cutover pattern for parsed-cache // shape changes.) - const cacheKey = `rss:feed:v5:${variant}:${feed.url}`; + // v5→v6 (#4920 review): ParseResult gained droppedFeedCap; warm v5 rows + // lack it and would undercount the coverage ledger for their whole TTL. + const cacheKey = `rss:feed:v6:${variant}:${feed.url}`; try { // Read cache unconditionally — the v5 prefix guarantees pre-fix @@ -522,6 +525,10 @@ function parseRssXml(xml: string, feed: ServerFeed, variant: string): ParseResul const isAtom = matches.length === 0; if (isAtom) matches = [...xml.matchAll(entryRegex)]; + // #4920 coverage ledger: items beyond the per-feed cap were previously + // dropped with no counter anywhere — fully invisible. + const droppedFeedCap = Math.max(0, matches.length - ITEMS_PER_FEED); + for (const match of matches.slice(0, ITEMS_PER_FEED)) { const block = match[1]!; @@ -619,7 +626,7 @@ function parseRssXml(xml: string, feed: ServerFeed, variant: string): ParseResul // (default 120s) — the feed retries quickly instead of being pinned // empty for the full 3600s TTL. if (parsedTotal === 0) return null; - return { items, parsedTotal, droppedUndated }; + return { items, parsedTotal, droppedUndated, droppedFeedCap }; } /** @@ -1200,6 +1207,9 @@ async function writeStoryTracking(items: ParsedItem[], variant: string, lang: st async function buildDigest(variant: string, lang: string): Promise { const feedsByCategory = VARIANT_FEEDS[variant] ?? {}; const feedStatuses: Record = {}; + // #4920 coverage ledger: count every silent drop gate so "how much did + // we NOT show" is a queryable number instead of a feeling. + const ledgerDrops = { perFeedCap: 0, undated: 0, freshnessFloor: 0, perCategoryCap: 0 }; const categories: Record = {}; const deadlineController = new AbortController(); @@ -1247,16 +1257,23 @@ async function buildDigest(variant: string, lang: string): Promise 0) { feedStatuses[feed.name] = 'partial-undated'; } - return { category, items: result.items }; + return { + category, + items: result.items, + droppedUndated: result.droppedUndated, + droppedFeedCap: result.droppedFeedCap ?? 0, + }; }), ); for (const result of settled) { if (result.status === 'fulfilled') { - const { category, items } = result.value; + const { category, items, droppedUndated, droppedFeedCap } = result.value; const existing = results.get(category) ?? []; existing.push(...items); results.set(category, existing); + ledgerDrops.undated += droppedUndated; + ledgerDrops.perFeedCap += droppedFeedCap; } } } @@ -1280,6 +1297,7 @@ async function buildDigest(variant: string, lang: string): Promise 0) { console.warn( `[digest] freshness floor dropped ${droppedStaleTotal} stale items ` + @@ -1410,6 +1428,7 @@ async function buildDigest(variant: string, lang: string): Promise b.importanceScore - a.importanceScore || b.publishedAt - a.publishedAt, ); + ledgerDrops.perCategoryCap += Math.max(0, items.length - MAX_ITEMS_PER_CATEGORY); slicedByCategory.set(category, items.slice(0, MAX_ITEMS_PER_CATEGORY)); } @@ -1460,6 +1479,32 @@ async function buildDigest(variant: string, lang: string): Promise item.source)).size; + const ledger = { + v: 1, + generatedAt: Date.now(), + variant, + lang, + itemsIngested: allItems.length, + itemsServed: allSliced.length, + distinctSources, + drops: { ...ledgerDrops }, + }; + // Key-cardinality clamp: variant/lang are request-supplied — only write + // ledgers for known variants and well-formed 2-letter langs so a caller + // spraying arbitrary values cannot inflate the keyspace. + if (VARIANT_FEEDS[variant] && /^[a-z]{2}$/.test(lang)) { + // #4927 review P2: awaited — a fire-and-forget write can be killed + // when the response finishes before the side write lands. + await setCachedJson(`news:coverage-ledger:v1:${variant}:${lang}`, ledger, 7200).catch((err: unknown) => + console.warn('[digest] coverage-ledger write failed:', err), + ); + } + return { categories, feedStatuses, diff --git a/src/components/InsightsPanel.ts b/src/components/InsightsPanel.ts index 0dd644afdf..377b263014 100644 --- a/src/components/InsightsPanel.ts +++ b/src/components/InsightsPanel.ts @@ -523,6 +523,7 @@ export class InsightsPanel extends Panel { const sentimentOverview = this.renderSentimentOverview(sentiments); const storiesHtml = this.renderServerStories(insights.topStories, sentiments); const statsHtml = this.renderServerStats(insights); + const provenanceHtml = this.renderProvenance(insights); const missedHtml = this.renderMissedStories(); this.setSafeContent(unsafeRawHtml(` @@ -531,6 +532,7 @@ export class InsightsPanel extends Panel { ${convergenceHtml} ${sentimentOverview} ${statsHtml} + ${provenanceHtml}
${t('components.insights.breakingConfirmed')}
${storiesHtml} @@ -578,6 +580,20 @@ export class InsightsPanel extends Panel { }).join(''); } + private renderProvenance(insights: ServerInsights): string { + // #4920: the completeness stamp. Absent on pre-rollout payloads. + const prov = insights.provenance; + if (!prov || !prov.storiesConsidered) return ''; + return ` +
+ ${t('components.insights.compiledFrom', { + stories: String(prov.storiesConsidered), + sources: String(prov.sourcesConsidered), + })} +
+ `; + } + private renderServerStats(insights: ServerInsights): string { return `
diff --git a/src/locales/ar.json b/src/locales/ar.json index ce17be1c86..42cc7a67ce 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -1263,7 +1263,9 @@ "sources_zero": "{{count}} مصادر", "sources_two": "{{count}} مصدران", "sources_few": "{{count}} مصادر", - "sources_many": "{{count}} مصدر" + "sources_many": "{{count}} مصدر", + "provenanceTitle": "أصل التغطية: عدد القصص المأخوذة والمصادر المميزة التي تم اختيار هذا الملخص منها", + "compiledFrom": "مجمّع من {{stories}} قصة عبر {{sources}} مصادر" }, "cascade": { "filters": { diff --git a/src/locales/bg.json b/src/locales/bg.json index 741db9e34b..0b6b1000f8 100644 --- a/src/locales/bg.json +++ b/src/locales/bg.json @@ -1302,7 +1302,9 @@ "tonePositive": "Положителен", "overall": "Обобщено: {{tone}}", "signalTypesEvents": "{{types}} типа сигнали • {{events}} събития", - "newsSignals": "{{news}} новини • {{signals}} сигнали" + "newsSignals": "{{news}} новини • {{signals}} сигнали", + "provenanceTitle": "Произход на обхвата: колко включени истории и отделни източници е избрано от този преглед", + "compiledFrom": "Събрано от {{stories}} истории в {{sources}} източници" }, "settings": { "exportSettings": "Експорт на настройки", diff --git a/src/locales/cs.json b/src/locales/cs.json index 574d98b40c..337e345c90 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -1292,7 +1292,9 @@ "signalTypesEvents": "{{types}} typů signálů • {{events}} událostí", "newsSignals": "{{news}} zpráv • {{signals}} signálů", "sources_few": "{{count}} zdroje", - "sources_many": "{{count}} zdrojů" + "sources_many": "{{count}} zdrojů", + "provenanceTitle": "Původ pokrytí: počet příjatých příběhů a odlišných zdrojů, ze kterých byl tento přehled vybrán", + "compiledFrom": "Sestaveno z {{stories}} příběhů z {{sources}} zdrojů" }, "cascade": { "filters": { diff --git a/src/locales/de.json b/src/locales/de.json index 132ec3afd8..3f3971e5b5 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1602,7 +1602,9 @@ "tonePositive": "Positiv", "overall": "Gesamt: {{tone}}", "signalTypesEvents": "{{types}} Signaltypen • {{events}} Ereignisse", - "newsSignals": "{{news}} Nachrichten • {{signals}} Signale" + "newsSignals": "{{news}} Nachrichten • {{signals}} Signale", + "provenanceTitle": "Abdeckungsherkunft: Anzahl der erfassten Stories und unterschiedlichen Quellen, aus denen dieser Brief ausgewählt wurde", + "compiledFrom": "Zusammengestellt aus {{stories}} Geschichten über {{sources}} Quellen" }, "etfFlows": { "unavailable": "ETF-Daten vorübergehend nicht verfügbar", diff --git a/src/locales/el.json b/src/locales/el.json index 59185a6eca..8a65b8edc8 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -1302,7 +1302,9 @@ "tonePositive": "Θετικό", "overall": "Συνολικά: {{tone}}", "signalTypesEvents": "{{types}} τύποι σήματος • {{events}} συμβάντα", - "newsSignals": "{{news}} ειδήσεις • {{signals}} σήματα" + "newsSignals": "{{news}} ειδήσεις • {{signals}} σήματα", + "provenanceTitle": "Προέλευση κάλυψης: πόσες καταχωρημένες ιστορίες και διακριτές πηγές επιλέχθησαν για αυτή τη σύνοψη", + "compiledFrom": "Συντάχθηκε από {{stories}} ιστορίες σε {{sources}} πηγές" }, "settings": { "exportSettings": "Εξαγωγή ρυθμίσεων", diff --git a/src/locales/en.json b/src/locales/en.json index 8604538195..65e3c6e45f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1442,7 +1442,9 @@ "tonePositive": "Positive", "overall": "Overall: {{tone}}", "signalTypesEvents": "{{types}} signal types • {{events}} events", - "newsSignals": "{{news}} news • {{signals}} signals" + "newsSignals": "{{news}} news • {{signals}} signals", + "compiledFrom": "Compiled from {{stories}} stories across {{sources}} sources", + "provenanceTitle": "Coverage provenance: how many ingested stories and distinct sources this brief was selected from" }, "settings": { "exportSettings": "Export Settings", diff --git a/src/locales/es.json b/src/locales/es.json index f3a2b6870e..f1b0d13055 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1603,7 +1603,9 @@ "overall": "General: {{tone}}", "signalTypesEvents": "{{types}} tipos de señal • {{events}} eventos", "newsSignals": "{{news}} noticias • {{signals}} señales", - "sources_many": "{{count}} fuentes" + "sources_many": "{{count}} fuentes", + "provenanceTitle": "Procedencia de cobertura: cuántas historias ingeridas y fuentes distintas se seleccionaron para este resumen", + "compiledFrom": "Compilado de {{stories}} historias en {{sources}} fuentes" }, "etfFlows": { "unavailable": "Datos ETF temporalmente no disponibles", diff --git a/src/locales/fa.json b/src/locales/fa.json index 8604538195..cfe0dc68e3 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1442,7 +1442,9 @@ "tonePositive": "Positive", "overall": "Overall: {{tone}}", "signalTypesEvents": "{{types}} signal types • {{events}} events", - "newsSignals": "{{news}} news • {{signals}} signals" + "newsSignals": "{{news}} news • {{signals}} signals", + "compiledFrom": "گردآوری‌شده از {{stories}} خبر از {{sources}} منبع", + "provenanceTitle": "شفافیت پوشش: این گزیده از چه تعداد خبر دریافتی و چند منبع مجزا انتخاب شده است" }, "settings": { "exportSettings": "Export Settings", diff --git a/src/locales/fr.json b/src/locales/fr.json index 69b6b9bb01..aa3f8575bf 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1260,7 +1260,9 @@ "overall": "Général : {{tone}}", "signalTypesEvents": "{{types}} types de signaux • {{events}} événements", "newsSignals": "{{news}} actualités • {{signals}} signaux", - "sources_many": "{{count}} sources" + "sources_many": "{{count}} sources", + "provenanceTitle": "Provenance de la couverture : nombre d'articles ingérés et de sources distinctes à partir desquels ce résumé a été sélectionné", + "compiledFrom": "Compilé à partir de {{stories}} articles provenant de {{sources}} sources" }, "settings": { "exportSettings": "Exporter les paramètres", diff --git a/src/locales/hi.json b/src/locales/hi.json index 942588a474..f4fdadebf3 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -1442,7 +1442,9 @@ "tonePositive": "सकारात्मक", "overall": "कुल मिलाकर: {{tone}}", "signalTypesEvents": "{{types}} संकेत प्रकार • {{events}} घटनाएं", - "newsSignals": "{{news}} खबरें • {{signals}} संकेत" + "newsSignals": "{{news}} खबरें • {{signals}} संकेत", + "provenanceTitle": "कवरेज प्रोवेनेंस: इस ब्रीफ को कितनी अंतर्ग्रहीत कहानियों और विशिष्ट स्रोतों से चुना गया था", + "compiledFrom": "{{stories}} कहानियों और {{sources}} स्रोतों से संकलित" }, "settings": { "exportSettings": "सेटिंग्स निर्यात करें", diff --git a/src/locales/hr.json b/src/locales/hr.json index ee3b28e3f4..bea39806ae 100644 --- a/src/locales/hr.json +++ b/src/locales/hr.json @@ -1577,7 +1577,9 @@ "overall": "Ukupno: {{tone}}", "signalTypesEvents": "{{types}} tipova signala • {{events}} događaja", "newsSignals": "{{news}} vijesti • {{signals}} signala", - "sources_few": "{{count}} izvora" + "sources_few": "{{count}} izvora", + "provenanceTitle": "Pokrivanje izvora: koliko je unesenih članaka i različitih izvora odabrano za ovaj sažetak", + "compiledFrom": "Sastavljeno od {{stories}} priča iz {{sources}} izvora" }, "settings": { "dataManagementLabel": "Upravljanje podacima", diff --git a/src/locales/hu.json b/src/locales/hu.json index 6300c06727..0eb23caaeb 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1442,7 +1442,9 @@ "tonePositive": "Pozitív", "overall": "Összes: {{tone}}", "signalTypesEvents": "{{types}} szignáltípus • {{events}} esemény", - "newsSignals": "{{news}} hír • {{signals}} szignál" + "newsSignals": "{{news}} hír • {{signals}} szignál", + "provenanceTitle": "Lefedettség eredete: hány feldolgozott történetből és különálló forrásból választotta ki ezt az összefoglalót", + "compiledFrom": "{{stories}} történetből és {{sources}} forrásból összeállítva" }, "settings": { "exportSettings": "Beállítások exportálása", diff --git a/src/locales/it.json b/src/locales/it.json index 6986276480..ceb0ca3c3a 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1603,7 +1603,9 @@ "overall": "Complessivo: {{tone}}", "signalTypesEvents": "{{types}} tipi di segnale • {{events}} eventi", "newsSignals": "{{news}} notizie • {{signals}} segnali", - "sources_many": "{{count}} fonti" + "sources_many": "{{count}} fonti", + "provenanceTitle": "Provenienza della copertura: quante storie acquisite e quante fonti distinte sono state utilizzate per selezionare questo briefing", + "compiledFrom": "Compilato da {{stories}} storie provenienti da {{sources}} fonti" }, "etfFlows": { "unavailable": "Dati ETF temporaneamente non disponibili", diff --git a/src/locales/ja.json b/src/locales/ja.json index b1a80c9e57..c127468e10 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1302,7 +1302,9 @@ "tonePositive": "ポジティブ", "overall": "全体: {{tone}}", "signalTypesEvents": "{{types}} シグナルタイプ • {{events}} イベント", - "newsSignals": "{{news}} ニュース • {{signals}} シグナル" + "newsSignals": "{{news}} ニュース • {{signals}} シグナル", + "provenanceTitle": "カバレッジプロヴェナンス: このブリーフが選択された取り込まれたストーリーと個別ソースの数", + "compiledFrom": "{{stories}}件のストーリーから{{sources}}個のソースをコンパイル" }, "settings": { "exportSettings": "設定をエクスポート", diff --git a/src/locales/ko.json b/src/locales/ko.json index d7bb90cab9..5fe8055445 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -1302,7 +1302,9 @@ "tonePositive": "긍정적", "overall": "전체: {{tone}}", "signalTypesEvents": "{{types}}개 신호 유형 • {{events}}개 이벤트", - "newsSignals": "{{news}}개 뉴스 • {{signals}}개 신호" + "newsSignals": "{{news}}개 뉴스 • {{signals}}개 신호", + "provenanceTitle": "커버리지 출처: 이 브리프가 선택된 수집 스토리 및 고유 소스의 개수", + "compiledFrom": "{{stories}}개의 스토리와 {{sources}}개의 소스에서 컴파일됨" }, "settings": { "exportSettings": "설정 내보내기", diff --git a/src/locales/nl.json b/src/locales/nl.json index fb20de03f6..b62959ecd5 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -1274,7 +1274,9 @@ "tonePositive": "Positief", "overall": "Algemeen: {{tone}}", "signalTypesEvents": "{{types}} signaaltypen • {{events}} gebeurtenissen", - "newsSignals": "{{news}} nieuws • {{signals}} signalen" + "newsSignals": "{{news}} nieuws • {{signals}} signalen", + "provenanceTitle": "Dekking herkomst: hoeveel opgenomen verhalen en verschillende bronnen deze samenvatting is geselecteerd uit", + "compiledFrom": "Samengesteld uit {{stories}} verhalen uit {{sources}} bronnen" }, "techHubs": { "infoTooltip": "Tech Hub-activiteit
Toont tech-hubs met de meeste nieuwsactiviteit.

Activiteitsniveaus:
Hoog — Breaking news of 50+ score
Verhoogd — Score 20-49
Laag — Score lager dan 20

Klik op een hub om naar de locatie ervan te zoomen." diff --git a/src/locales/pl.json b/src/locales/pl.json index 2129930985..6e1f63b596 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -1540,7 +1540,9 @@ "signalTypesEvents": "{{types}} typy sygnałów • {{events}} zdarzenia", "newsSignals": "{{news}} wiadomości • {{signals}} sygnały", "sources_few": "{{count}} źródła", - "sources_many": "{{count}} źródeł" + "sources_many": "{{count}} źródeł", + "provenanceTitle": "Pochodzenie zasięgu: ile pozyskanych artykułów i odrębnych źródeł wybrano dla tego briefu", + "compiledFrom": "Zebrane z {{stories}} artykułów z {{sources}} źródeł" }, "etfFlows": { "unavailable": "Dane ETF tymczasowo niedostępne", diff --git a/src/locales/pt.json b/src/locales/pt.json index c5a8cb27f3..9018f5dfa6 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -1275,7 +1275,9 @@ "overall": "Geral: {{tone}}", "signalTypesEvents": "{{types}} tipos de sinal • {{events}} eventos", "newsSignals": "{{news}} notícias • {{signals}} sinais", - "sources_many": "{{count}} fontes" + "sources_many": "{{count}} fontes", + "provenanceTitle": "Proveniência de cobertura: quantas histórias ingeridas e fontes distintas foram selecionadas para este resumo", + "compiledFrom": "Compilado de {{stories}} histórias em {{sources}} fontes" }, "techHubs": { "infoTooltip": "Atividade do Tech Hub
Mostra os hubs de tecnologia com mais atividade de notícias.

Níveis de atividade:
Alto — Notícias de última hora ou pontuação acima de 50
Elevado — Pontuação 20-49
Baixo — Pontuação abaixo de 20

Clique em um hub para ampliar sua localização." diff --git a/src/locales/ro.json b/src/locales/ro.json index 0fc7f0cef9..5668412d63 100644 --- a/src/locales/ro.json +++ b/src/locales/ro.json @@ -1303,7 +1303,9 @@ "overall": "Overall: {{tone}}", "signalTypesEvents": "{{types}} tipuri de semnale • {{events}} evenimente", "newsSignals": "{{news}} știri • {{signals}} semnale", - "sources_few": "{{count}} surse" + "sources_few": "{{count}} surse", + "provenanceTitle": "Proveniență acoperire: câte articole ingerate și surse distincte din care a fost selectat acest rezumat", + "compiledFrom": "Compilat din {{stories}} povești din {{sources}} surse" }, "settings": { "exportSettings": "Exportare setări", diff --git a/src/locales/ru.json b/src/locales/ru.json index 831b46a8c2..2cba74dd16 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1304,7 +1304,9 @@ "signalTypesEvents": "{{types}} типов сигналов • {{events}} событий", "newsSignals": "{{news}} новостей • {{signals}} сигналов", "sources_few": "{{count}} источника", - "sources_many": "{{count}} источников" + "sources_many": "{{count}} источников", + "provenanceTitle": "Происхождение охвата: количество полученных историй и отдельных источников, из которых была выбрана эта сводка", + "compiledFrom": "Собрано из {{stories}} историй из {{sources}} источников" }, "settings": { "exportSettings": "Экспорт настроек", diff --git a/src/locales/sv.json b/src/locales/sv.json index 1a2b51c420..fb9d4677fe 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -1274,7 +1274,9 @@ "tonePositive": "Positiv", "overall": "Totalt: {{tone}}", "signalTypesEvents": "{{types}} signaltyper • {{events}} händelser", - "newsSignals": "{{news}} nyheter • {{signals}} signaler" + "newsSignals": "{{news}} nyheter • {{signals}} signaler", + "provenanceTitle": "Täckningshärkomst: hur många inmatade historier och unika källor denna kortfattade sammanfattning valdes från", + "compiledFrom": "Kompilerat från {{stories}} berättelser från {{sources}} källor" }, "techHubs": { "infoTooltip": "Teknisk hubbaktivitet
Visar tekniska nav med mest nyhetsaktivitet.

Aktivitetsnivåer:
Hög — Nyheter från 50+ PH_• 50+ stil: {{elevatedColor}}\">Höjd — Poäng 20-49
Låg — Poäng under 20

Klicka på ett nav för att zooma till dess plats." diff --git a/src/locales/th.json b/src/locales/th.json index 6da5027e60..2d216cbc78 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -1302,7 +1302,9 @@ "tonePositive": "บวก", "overall": "โดยรวม: {{tone}}", "signalTypesEvents": "{{types}} ประเภทสัญญาณ • {{events}} เหตุการณ์", - "newsSignals": "{{news}} ข่าว • {{signals}} สัญญาณ" + "newsSignals": "{{news}} ข่าว • {{signals}} สัญญาณ", + "provenanceTitle": "ความมาของการครอบคลุม: จำนวนเรื่องข่าวที่นำเข้าและแหล่งข่าวที่แตกต่างกันที่ใช้สำหรับสรุปนี้", + "compiledFrom": "รวบรวมจาก {{stories}} เรื่องราวจาก {{sources}} แหล่งที่มา" }, "settings": { "exportSettings": "ส่งออกการตั้งค่า", diff --git a/src/locales/tr.json b/src/locales/tr.json index 715963027d..1e586d01d0 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -1302,7 +1302,9 @@ "tonePositive": "Pozitif", "overall": "Genel: {{tone}}", "signalTypesEvents": "{{types}} sinyal türü • {{events}} olay", - "newsSignals": "{{news}} haber • {{signals}} sinyal" + "newsSignals": "{{news}} haber • {{signals}} sinyal", + "compiledFrom": "{{stories}} hikaye ve {{sources}} kaynaktan derlenmiştir", + "provenanceTitle": "Kapsam kaynağı: bu özet için kaç alınan hikaye ve farklı kaynaktan seçildiği" }, "settings": { "exportSettings": "Ayarları Dışa Aktar", diff --git a/src/locales/vi.json b/src/locales/vi.json index a1329089bd..8fce2e3d09 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1302,7 +1302,9 @@ "tonePositive": "Tích cực", "overall": "Tổng thể: {{tone}}", "signalTypesEvents": "{{types}} loại tín hiệu • {{events}} sự kiện", - "newsSignals": "{{news}} tin tức • {{signals}} tín hiệu" + "newsSignals": "{{news}} tin tức • {{signals}} tín hiệu", + "provenanceTitle": "Nguồn gốc bài viết: số lượng bài viết được nhập và các nguồn riêng biệt mà bài viết này được chọn từ đó", + "compiledFrom": "Được biên soạn từ {{stories}} câu chuyện trên {{sources}} nguồn" }, "settings": { "exportSettings": "Xuất cài đặt", diff --git a/src/locales/zh.json b/src/locales/zh.json index dc5f40f285..e5d0c8d7c9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1302,7 +1302,9 @@ "tonePositive": "正面", "overall": "整体:{{tone}}", "signalTypesEvents": "{{types}} 种信号类型 • {{events}} 个事件", - "newsSignals": "{{news}} 条新闻 • {{signals}} 个信号" + "newsSignals": "{{news}} 条新闻 • {{signals}} 个信号", + "compiledFrom": "编译自 {{stories}} 条故事和 {{sources}} 个来源", + "provenanceTitle": "覆盖来源:此摘要选自多少条摄取的故事和不同的来源" }, "settings": { "exportSettings": "导出设置", diff --git a/src/services/insights-loader.ts b/src/services/insights-loader.ts index 81c3ec543e..99b9537bd8 100644 --- a/src/services/insights-loader.ts +++ b/src/services/insights-loader.ts @@ -32,6 +32,13 @@ export interface ServerInsights { clusterCount: number; multiSourceCount: number; fastMovingCount: number; + /** #4920 coverage provenance — present on payloads seeded after the + * completeness-measurement rollout; absent on older cached payloads. */ + provenance?: { + storiesConsidered: number; + sourcesConsidered: number; + selectionDrops?: { admissibility: number; sourceCap: number; overflow: number }; + }; } let cached: ServerInsights | null = null; diff --git a/src/styles/main.css b/src/styles/main.css index c5392ca73b..a1610d044b 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -17948,6 +17948,15 @@ a.prediction-link:hover { flex: 1; } +/* #4920 coverage provenance stamp under the insights stats */ +.insights-provenance { + font-size: 10px; + color: var(--text-dim); + margin: -6px 0 12px; + padding: 0 8px; + text-align: right; +} + .insight-stat-value { display: block; font-size: 18px; diff --git a/tests/completeness-measurement.test.mjs b/tests/completeness-measurement.test.mjs new file mode 100644 index 0000000000..887558ae4c --- /dev/null +++ b/tests/completeness-measurement.test.mjs @@ -0,0 +1,362 @@ +// #4920: completeness measurement — feed-health payload/silent-zeros, +// recall benchmark math, selection-gate drop stats, coverage-ledger and +// provenance wiring. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + buildFeedHealthPayload, + isGoogleNewsWrapper, + SILENT_ZERO_THRESHOLD, +} from '../scripts/_feed-health.mjs'; +import { computeRecall } from '../scripts/_recall-benchmark-core.mjs'; +import { selectTopStories } from '../scripts/_clustering.mjs'; +import { extractServerFeeds } from '../scripts/validate-rss-feeds.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const readSrc = (rel) => readFileSync(resolve(root, rel), 'utf-8'); + +const GN = 'https://news.google.com/rss/search?q=site%3Areuters.com&hl=en-US&gl=US&ceid=US:en'; + +describe('feed-health payload (#4920a)', () => { + it('classifies wrappers and counts statuses', () => { + const payload = buildFeedHealthPayload([ + { name: 'BBC', url: 'https://feeds.bbci.co.uk/news/world/rss.xml', status: 'OK', catalog: 'both' }, + { name: 'Reuters GN', url: GN, status: 'EMPTY', catalog: 'server' }, + { name: 'Dead Feed', url: 'https://example.com/rss', status: 'DEAD', detail: 'Timeout (15s)' }, + ], null, 1_000); + assert.equal(payload.summary.ok, 1); + assert.equal(payload.summary.empty, 1); + assert.equal(payload.summary.dead, 1); + assert.equal(payload.feeds[GN].wrapper, true); + assert.equal(payload.feeds[GN].consecutiveEmpty, 1); + assert.deepEqual(payload.silentZeros, [], 'one empty run is not yet a silent zero'); + }); + + it('silent zero fires for a wrapper after consecutive empty runs, and resets on recovery', () => { + const run1 = buildFeedHealthPayload([{ name: 'R', url: GN, status: 'EMPTY' }], null, 1); + const run2 = buildFeedHealthPayload([{ name: 'R', url: GN, status: 'EMPTY' }], run1, 2); + assert.equal(run2.feeds[GN].consecutiveEmpty, SILENT_ZERO_THRESHOLD); + assert.equal(run2.silentZeros.length, 1, 'wrapper empty across runs = silent zero'); + + const run3 = buildFeedHealthPayload([{ name: 'R', url: GN, status: 'OK' }], run2, 3); + assert.equal(run3.feeds[GN].consecutiveEmpty, 0, 'recovery resets the streak'); + assert.deepEqual(run3.silentZeros, []); + }); + + it('non-wrapper feeds never appear in silentZeros regardless of streak', () => { + const url = 'https://example.com/rss'; + let prev = null; + for (let i = 0; i < 4; i++) { + prev = buildFeedHealthPayload([{ name: 'X', url, status: 'EMPTY' }], prev, i); + } + assert.equal(prev.feeds[url].consecutiveEmpty, 4); + assert.deepEqual(prev.silentZeros, [], 'silent-zero is a wrapper-specific signal'); + }); + + it('isGoogleNewsWrapper matches search wrappers only', () => { + assert.equal(isGoogleNewsWrapper(GN), true); + assert.equal(isGoogleNewsWrapper('https://feeds.bbci.co.uk/news/world/rss.xml'), false); + }); +}); + +describe('recall benchmark math (#4920c)', () => { + const digest = [ + 'Iran threatens to close Strait of Hormuz if US blockade continues', + 'Turkey hikes interest rates to 50% in surprise move', + 'Magnitude 6.8 earthquake strikes northern Chile', + ]; + + it('matches edit-variants of carried stories and reports misses with evidence', () => { + const external = [ + { title: 'Iran threatens to close Strait of Hormuz — live updates', url: 'https://a' }, + { title: 'Turkey hikes rates to 50% in surprise move', url: 'https://b' }, + { title: 'Nigeria fuel subsidy protests spread to Lagos', url: 'https://c' }, + ]; + const result = computeRecall(external, digest); + assert.equal(result.matched, 2); + assert.equal(result.total, 3); + assert.equal(result.recallPct, 66.7); + assert.equal(result.missed.length, 1); + assert.match(result.missed[0].title, /Nigeria/); + assert.ok(result.missed[0].bestScore < result.threshold); + }); + + it('excludes unvectorizable external titles from the denominator', () => { + const result = computeRecall( + [{ title: '!!!' }, { title: 'Turkey hikes rates to 50% in surprise move' }], + digest, + ); + assert.equal(result.total, 1); + assert.equal(result.unvectorizable, 1); + assert.equal(result.recallPct, 100); + }); + + it('empty external set yields null recall, never NaN', () => { + const result = computeRecall([], digest); + assert.equal(result.recallPct, null); + }); +}); + +describe('selectTopStories drop stats (#4920b)', () => { + const mkCluster = (title, source, sources = 1, score = 150) => ({ + primaryTitle: title, + primarySource: source, + primaryLink: 'https://x', + pubDate: new Date().toISOString(), + sources: Array.from({ length: sources }, (_, i) => `${source}-${i}`), + // High tier + alert to clear admissibility deterministically + isAlert: score > 100, + tier: 1, + }); + + it('populates considered/admissibility/sourceCap counters', () => { + const clusters = [ + mkCluster('Iran threatens Hormuz closure blockade', 'Reuters', 3), + mkCluster('Turkey hikes interest rates surprise move', 'Reuters', 2), + mkCluster('Chile earthquake magnitude strikes north', 'Reuters', 2), + mkCluster('Kenya protests spread across Nairobi city', 'Reuters', 2), + mkCluster('Totally inadmissible single-source story here', 'BlogX', 1, 10), + ]; + // Make the last one inadmissible: single source, no alert, low score + clusters[4].isAlert = false; + const stats = {}; + const selected = selectTopStories(clusters, 8, stats); + assert.equal(stats.considered, 5); + assert.ok(stats.admissibilityDropped >= 1, 'single-source low-score cluster dropped'); + assert.ok(stats.sourceCapDropped >= 1, 'fourth same-source cluster hits MAX_PER_SOURCE=3'); + assert.ok(selected.length <= 8); + }); + + it('stats argument is optional (call sites without it keep working)', () => { + assert.doesNotThrow(() => selectTopStories([], 8)); + }); +}); + +describe('server catalog extraction (#4920a)', () => { + it('extracts the digest feed catalog with rebuilt Google News URLs', () => { + const feeds = extractServerFeeds(); + assert.ok(feeds.length > 250, `expected 250+ server feeds, got ${feeds.length}`); + const wrapper = feeds.find((f) => f.url.includes('news.google.com')); + assert.ok(wrapper, 'gn() URLs must be rebuilt'); + assert.match(wrapper.url, /^https:\/\/news\.google\.com\/rss\/search\?q=.+&hl=/); + assert.ok(feeds.every((f) => f.catalog === 'server')); + }); + + it('extracts double-quoted names (Tom\'s Hardware class — #4927 cross-model)', () => { + const feeds = extractServerFeeds(); + assert.ok(feeds.some((f) => f.name === "Tom's Hardware"), 'double-quoted names must not be skipped'); + }); +}); + +describe('coverage-ledger and provenance wiring (source-textual)', () => { + it('digest counts every drop gate and publishes the ledger', () => { + const src = readSrc('server/worldmonitor/news/v1/list-feed-digest.ts'); + assert.match(src, /droppedFeedCap = Math\.max\(0, matches\.length - ITEMS_PER_FEED\)/); + assert.match(src, /ledgerDrops\.perCategoryCap \+= Math\.max\(0, items\.length - MAX_ITEMS_PER_CATEGORY\)/); + assert.match(src, /ledgerDrops\.freshnessFloor = droppedStaleTotal/); + assert.match(src, /news:coverage-ledger:v1/); + }); + + it('insights payload carries provenance and the panel renders it', () => { + const seedSrc = readSrc('scripts/seed-insights.mjs'); + assert.match(seedSrc, /storiesConsidered: normalizedItems\.length/); + assert.match(seedSrc, /selectTopStories\(clusters, 8, selectionStats\)/); + const panelSrc = readSrc('src/components/InsightsPanel.ts'); + assert.match(panelSrc, /components\.insights\.compiledFrom/); + const en = JSON.parse(readSrc('src/locales/en.json')); + assert.match(en.components.insights.compiledFrom, /\{\{stories\}\}.*\{\{sources\}\}/); + }); + + it('both completeness keys are registered in health surfaces', () => { + const seedHealth = readSrc('api/seed-health.js'); + assert.match(seedHealth, /'news:feed-health'/); + assert.match(seedHealth, /'news:recall-benchmark'/); + const health = readSrc('api/health.js'); + assert.match(health, /news:feed-health:v1/); + assert.match(health, /seed-meta:news:recall-benchmark/); + }); + + it('workflow passes Upstash secrets to both publishers', () => { + const wf = readSrc('.github/workflows/feed-validation.yml'); + assert.match(wf, /UPSTASH_REDIS_REST_URL/); + assert.match(wf, /seed-recall-benchmark\.mjs/); + }); +}); + +// ── #4927 review-round additions ─────────────────────────────────────────── + +import { unwrapEnvelope, gdeltUrl } from '../scripts/seed-recall-benchmark.mjs'; +import { publishFeedHealth } from '../scripts/validate-rss-feeds.mjs'; +import { getOptionalUpstashCreds } from '../scripts/_upstash-rest.mjs'; + +describe('gn()/gnLocale() replica drift guard (#4927 review)', () => { + it('extractServerFeeds URL builders textually match the _feeds.ts source', () => { + const feedsSrc = readSrc('server/worldmonitor/news/v1/_feeds.ts'); + const validatorSrc = readSrc('scripts/validate-rss-feeds.mjs'); + // The load-bearing template expressions must appear byte-identical in + // both files — a change to gn()'s URL shape in _feeds.ts without the + // replica following makes the health report validate URLs the digest + // never fetches. + const gnTemplate = 'https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=en-US&gl=US&ceid=US:en'; + const gnLocaleTemplate = 'https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=${hl}&gl=${gl}&ceid=${ceid}'; + for (const template of [gnTemplate, gnLocaleTemplate]) { + assert.ok(feedsSrc.includes(template), `_feeds.ts must contain: ${template}`); + assert.ok(validatorSrc.includes(template), `validate-rss-feeds replica must contain: ${template}`); + } + }); +}); + +describe('selectTopStories overflow stat (#4927 review)', () => { + it('counts admissible clusters that never fit under maxCount', () => { + const clusters = Array.from({ length: 12 }, (_, i) => ({ + primaryTitle: `Distinct breaking story number ${i} about topic ${i}`, + primarySource: `Source${i}`, + primaryLink: `https://x/${i}`, + pubDate: new Date().toISOString(), + sources: [`Source${i}`, `Other${i}`], + isAlert: true, + tier: 1, + })); + const stats = {}; + const selected = selectTopStories(clusters, 8, stats); + assert.equal(selected.length, 8); + assert.equal(stats.overflowDropped, 4, '12 admissible distinct-source clusters, 8 slots → 4 overflow'); + assert.equal(stats.sourceCapDropped, 0); + }); +}); + +describe('publisher orchestration seams (#4927 review)', () => { + it('publishFeedHealth skips cleanly without credentials', async () => { + const prevUrl = process.env.UPSTASH_REDIS_REST_URL; + const prevToken = process.env.UPSTASH_REDIS_REST_TOKEN; + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.UPSTASH_REDIS_REST_TOKEN; + try { + const out = await publishFeedHealth([{ name: 'X', url: 'https://x/rss', status: 'OK' }]); + assert.deepEqual(out, { published: false, reason: 'no-creds' }); + assert.equal(getOptionalUpstashCreds(), null); + } finally { + if (prevUrl !== undefined) process.env.UPSTASH_REDIS_REST_URL = prevUrl; + if (prevToken !== undefined) process.env.UPSTASH_REDIS_REST_TOKEN = prevToken; + } + }); + + it('gdeltUrl builds a bounded, English, 24h ArtList query', () => { + const url = gdeltUrl('(economy OR markets)'); + assert.match(url, /^https:\/\/api\.gdeltproject\.org\/api\/v2\/doc\/doc\?/); + assert.match(url, /sourcelang%3Aeng/); + assert.match(url, /maxrecords=25/); + assert.match(url, /timespan=24h/); + }); + + it('unwrapEnvelope tolerates enveloped and bare payloads', () => { + assert.deepEqual(unwrapEnvelope({ data: { a: 1 }, fetchedAt: 5 }), { a: 1 }); + assert.deepEqual(unwrapEnvelope({ categories: {} }), { categories: {} }); + assert.equal(unwrapEnvelope(null), null); + }); +}); + +describe('locale integrity for provenance keys (#4927 review)', () => { + it('every parity-tested locale carries compiledFrom with double-brace tokens', () => { + const fs = readdirSyncLocales(); + for (const file of fs) { + const d = JSON.parse(readSrc(`src/locales/${file}`)); + const val = d?.components?.insights?.compiledFrom; + assert.ok(typeof val === 'string' && val.length > 0, `${file} missing compiledFrom`); + assert.ok(val.includes('{{stories}}') && val.includes('{{sources}}'), + `${file} compiledFrom must keep {{stories}}/{{sources}} tokens, got: ${val}`); + } + }); +}); + +import { readdirSync } from 'node:fs'; +function readdirSyncLocales() { + return readdirSync(resolve(root, 'src/locales')) + .filter((f) => f.endsWith('.json') && f !== 'en.shell.json'); +} + +describe('feed-health streak continuity (#4927 external review)', () => { + it('a failed previous-state read skips publish instead of resetting streaks', async () => { + process.env.UPSTASH_REDIS_REST_URL = 'https://stub.upstash.example'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'stub-token'; + const realFetch = globalThis.fetch; + globalThis.fetch = async () => { throw new Error('ECONNRESET'); }; + try { + const out = await publishFeedHealth([{ name: 'X', url: 'https://x/rss', status: 'EMPTY' }]); + assert.deepEqual(out, { published: false, reason: 'previous-read-failed' }); + } finally { + globalThis.fetch = realFetch; + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.UPSTASH_REDIS_REST_TOKEN; + } + }); +}); + +describe('selection attribution: same-source overflow (#4927 re-review)', () => { + it('REGRESSION: candidates past maxCount are cap-attributed when their source already hit the cap', () => { + const mk = (title, source) => ({ + primaryTitle: title, primarySource: source, primaryLink: `https://x/${title}`, + pubDate: new Date().toISOString(), sources: [source, 'Wire'], isAlert: true, tier: 1, + }); + // 3 selected from SameSource (hits MAX_PER_SOURCE), then 2 distinct fill + // maxCount=5; then: 1 more SameSource (cap drop even though selection is + // full) + 2 distinct (genuine overflow). + const clusters = [ + mk('s1', 'SameSource'), mk('s2', 'SameSource'), mk('s3', 'SameSource'), + mk('d1', 'A'), mk('d2', 'B'), + mk('s4', 'SameSource'), + mk('d3', 'C'), mk('d4', 'D'), + ]; + const stats = {}; + const selected = selectTopStories(clusters, 5, stats); + assert.equal(selected.length, 5); + assert.equal(stats.sourceCapDropped, 1, 's4 is a source-cap drop, not overflow'); + assert.equal(stats.overflowDropped, 2, 'only genuinely rankable candidates count as overflow'); + assert.equal(stats.admissibilityDropped + stats.sourceCapDropped + stats.overflowDropped + selected.length, clusters.length, + 'every considered cluster is attributed exactly once'); + }); +}); + +describe('durable activation lifecycle (#4927 re-review P1)', () => { + it('classifyKey: on-demand softening is revoked once the activation marker exists', async () => { + const { __testing__ } = await import('../api/health.js'); + const { classifyKey, ACTIVATION_MARKERS } = __testing__; + assert.ok(ACTIVATION_MARKERS.newsFeedHealth.startsWith('seed-activated:'), 'marker namespace pinned'); + + const baseCtx = { + keyStrens: new Map([['news:feed-health:v1', 0]]), + keyErrors: new Map(), + keyMetaValues: new Map(), + keyMetaErrors: new Map(), + now: Date.now(), + }; + // Never activated: missing data reads soft EMPTY_ON_DEMAND. + const pending = classifyKey('newsFeedHealth', 'news:feed-health:v1', { allowOnDemand: true }, + { ...baseCtx, activatedNames: new Set() }); + assert.equal(pending.status, 'EMPTY_ON_DEMAND'); + // Activated then died (marker present, data+meta expired): must alarm. + const dead = classifyKey('newsFeedHealth', 'news:feed-health:v1', { allowOnDemand: true }, + { ...baseCtx, activatedNames: new Set(['newsFeedHealth']) }); + assert.equal(dead.status, 'EMPTY', 'post-activation missing data must be EMPTY, not softened'); + }); + + it('publishers persist the durable marker with NO TTL alongside every publish', () => { + const feedSrc = readSrc('scripts/validate-rss-feeds.mjs'); + assert.match(feedSrc, /\['SET', 'seed-activated:news:feed-health', '1'\]/, + 'feed-health publisher must SET the marker'); + assert.doesNotMatch(feedSrc, /'seed-activated:news:feed-health', '1', 'EX'/, + 'marker must be durable — no TTL'); + const recallSrc = readSrc('scripts/seed-recall-benchmark.mjs'); + assert.match(recallSrc, /\['SET', 'seed-activated:news:recall-benchmark', '1'\]/); + const seedHealthSrc = readSrc('api/seed-health.js'); + assert.match(seedHealthSrc, /activationKey: 'seed-activated:news:feed-health'/, + 'seed-health gates pending-activation on the marker'); + assert.match(seedHealthSrc, /cfg\.activationKey && !activatedMap\.get\(domain\)/, + 'missing meta with marker present must fall through to missing/stale'); + }); +}); diff --git a/tests/mcp-bootstrap-parity.test.mjs b/tests/mcp-bootstrap-parity.test.mjs index 41b3ad6cdc..89490a405c 100644 --- a/tests/mcp-bootstrap-parity.test.mjs +++ b/tests/mcp-bootstrap-parity.test.mjs @@ -39,6 +39,14 @@ const { TOOL_REGISTRY } = mcpTesting; // ----------------------------------------------------------------------------- const EXCLUDED_FROM_MCP = new Map([ + // =========================================================================== + // #4920 completeness-measurement ops keys (pipeline health, not content) + // =========================================================================== + ['news:feed-health:v1', + 'ops surface: per-feed validation status + silent-zero streaks published by the daily feed-validation workflow; consumed by api/health.js + operators, not a queryable news slice (#4920).'], + ['news:recall-benchmark:v1', + 'ops surface: daily GDELT recall percentage + missed headlines for coverage monitoring; consumed by api/health.js + operators, not a queryable news slice (#4920).'], + // =========================================================================== // Intermediate / pipeline keys (data surfaces through a sibling tool) // =========================================================================== diff --git a/tests/news-story-track-description-persistence.test.mts b/tests/news-story-track-description-persistence.test.mts index 8794c44b8b..f5a56a45c4 100644 --- a/tests/news-story-track-description-persistence.test.mts +++ b/tests/news-story-track-description-persistence.test.mts @@ -346,7 +346,7 @@ describe('buildStoryTrackHsetFields — story:track:v1 HSET contract', () => { }); describe('fetchAndParseRss — cache prefix invalidation contract', () => { - it('rss:feed cache prefix is v5 (post-isEphemeralLiveCoverage), not v4', () => { + it('rss:feed cache prefix is v6 (post-droppedFeedCap, #4920), not v4/v5', () => { // Pre-PR ParsedItems cached at rss:feed:v4 lack the // isEphemeralLiveCoverage field. If a cache hit returned one of those, // the falsy-coerce in @@ -363,13 +363,15 @@ describe('fetchAndParseRss — cache prefix invalidation contract', () => { resolve(__dirname, '..', 'server', 'worldmonitor', 'news', 'v1', 'list-feed-digest.ts'), 'utf-8', ); + // v5→v6 (#4920 review): ParseResult gained droppedFeedCap; warm v5 + // rows lack it and would undercount the coverage ledger for their TTL. assert.ok( - src.includes("`rss:feed:v5:${variant}:${feed.url}`"), - 'rss:feed cache key must use v5 prefix — see comment above the cacheKey assignment in fetchAndParseRss', + src.includes("`rss:feed:v6:${variant}:${feed.url}`"), + 'rss:feed cache key must use v6 prefix — see comment above the cacheKey assignment in fetchAndParseRss', ); assert.ok( - !src.includes("`rss:feed:v4:${variant}:${feed.url}`"), - 'must NOT leave a residual v4 cacheKey assignment — would silently revert the cutover', + !src.includes("`rss:feed:v5:${variant}:${feed.url}`") && !src.includes("`rss:feed:v4:${variant}:${feed.url}`"), + 'must NOT leave a residual v4/v5 cacheKey assignment — would silently revert the cutover', ); }); }); diff --git a/tests/seed-health-portwatch-port-activity.test.mjs b/tests/seed-health-portwatch-port-activity.test.mjs index ef01d203e6..b0d80d5a17 100644 --- a/tests/seed-health-portwatch-port-activity.test.mjs +++ b/tests/seed-health-portwatch-port-activity.test.mjs @@ -43,6 +43,12 @@ function installSeedHealthPipelineMock(portwatchRecordCount, { missingPortwatchM const commands = JSON.parse(init.body); const results = commands.map((command) => { const [op, key] = command; + // #4927: activation-gated entries add EXISTS probes on their + // seed-activated:* markers; absent in this harness. + if (op === 'EXISTS') { + assert.match(String(key), /^seed-activated:/, 'EXISTS is only used for activation markers'); + return { result: 0 }; + } assert.equal(op, 'GET'); if (key === PORTWATCH_META_KEY) { if (missingPortwatchMeta) return { result: null }; diff --git a/tests/seed-health-resilience-intervals.test.mjs b/tests/seed-health-resilience-intervals.test.mjs index 82d81bb13b..817f87ee07 100644 --- a/tests/seed-health-resilience-intervals.test.mjs +++ b/tests/seed-health-resilience-intervals.test.mjs @@ -62,6 +62,12 @@ function installPipelineMock(values) { const commands = JSON.parse(init.body); const results = commands.map((command) => { const [op, key] = command; + // #4927: activation-gated entries add EXISTS probes on their + // seed-activated:* markers; absent unless a test seeds them. + if (op === 'EXISTS') { + assert.match(String(key), /^seed-activated:/, 'EXISTS is only used for activation markers'); + return { result: values.has(key) ? 1 : 0 }; + } assert.equal(op, 'GET'); const value = values.has(key) ? values.get(key)