diff --git a/docs/api/NewsService.openapi.json b/docs/api/NewsService.openapi.json index 9b5f6c0a4..d4754b483 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"},"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"},"ListFeedDigestRequest":{"properties":{"lang":{"description":"ISO 639-1 language code (en, fr, ar, etc.)","type":"string"},"variant":{"description":"Site variant: full, tech, finance, happy","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 (ok/error/timeout)","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":{"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"},"source":{"description":"Source feed name.","minLength":1,"type":"string"},"threat":{"$ref":"#/components/schemas/ThreatClassification"},"title":{"description":"Article headline.","minLength":1,"type":"string"}},"required":["source","title"],"type":"object"},"SummarizeArticleRequest":{"description":"SummarizeArticleRequest specifies parameters for LLM article summarization.","properties":{"geoContext":{"description":"Geographic signal context to include in the prompt.","type":"string"},"headlines":{"items":{"description":"Headlines to summarize (max 8 used).","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"},"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\", \"ml\", or \"llm\".","type":"string"}},"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"}}},"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":"Site variant: full, tech, finance, happy","in":"query","name":"variant","required":false,"schema":{"type":"string"}},{"description":"ISO 639-1 language code (en, fr, ar, etc.)","in":"query","name":"lang","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListFeedDigestResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"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","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SummarizeArticleRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SummarizeArticleResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"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().","in":"query","name":"cache_key","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SummarizeArticleResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetSummarizeArticleCache","tags":["NewsService"]}}}} \ 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"},"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"},"ListFeedDigestRequest":{"properties":{"lang":{"description":"ISO 639-1 language code (en, fr, ar, etc.)","type":"string"},"variant":{"description":"Site variant: full, tech, finance, happy","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).","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":{"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"},"source":{"description":"Source feed name.","minLength":1,"type":"string"},"threat":{"$ref":"#/components/schemas/ThreatClassification"},"title":{"description":"Article headline.","minLength":1,"type":"string"}},"required":["source","title"],"type":"object"},"SummarizeArticleRequest":{"description":"SummarizeArticleRequest specifies parameters for LLM article summarization.","properties":{"geoContext":{"description":"Geographic signal context to include in the prompt.","type":"string"},"headlines":{"items":{"description":"Headlines to summarize (max 8 used).","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"},"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\", \"ml\", or \"llm\".","type":"string"}},"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"}}},"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":"Site variant: full, tech, finance, happy","in":"query","name":"variant","required":false,"schema":{"type":"string"}},{"description":"ISO 639-1 language code (en, fr, ar, etc.)","in":"query","name":"lang","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListFeedDigestResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"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","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SummarizeArticleRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SummarizeArticleResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"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().","in":"query","name":"cache_key","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SummarizeArticleResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetSummarizeArticleCache","tags":["NewsService"]}}}} \ No newline at end of file diff --git a/docs/api/NewsService.openapi.yaml b/docs/api/NewsService.openapi.yaml index 66ad03ed4..8eef63123 100644 --- a/docs/api/NewsService.openapi.yaml +++ b/docs/api/NewsService.openapi.yaml @@ -235,7 +235,9 @@ components: type: object additionalProperties: type: string - description: Per-feed status (ok/error/timeout) + description: |- + Per-feed status — only non-ok states emitted; absent key implies ok. + Values: empty (feed returned 0 items), timeout (timed out during fetch). generatedAt: type: string description: ISO 8601 timestamp of when this digest was generated diff --git a/proto/worldmonitor/news/v1/list_feed_digest.proto b/proto/worldmonitor/news/v1/list_feed_digest.proto index a290d5434..76892a408 100644 --- a/proto/worldmonitor/news/v1/list_feed_digest.proto +++ b/proto/worldmonitor/news/v1/list_feed_digest.proto @@ -15,7 +15,8 @@ message ListFeedDigestRequest { message ListFeedDigestResponse { // Per-category buckets — keys match category names from feed config map categories = 1; - // Per-feed status (ok/error/timeout) + // Per-feed status — only non-ok states emitted; absent key implies ok. + // Values: empty (feed returned 0 items), timeout (timed out during fetch). map feed_statuses = 2; // ISO 8601 timestamp of when this digest was generated string generated_at = 3; diff --git a/server/gateway.ts b/server/gateway.ts index 666f497f6..0c55e4f82 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -24,12 +24,13 @@ export const serverOptions: ServerOptions = { onError: mapErrorToResponse }; // NOTE: This map is shared across all domain bundles (~3KB). Kept centralised for // single-source-of-truth maintainability; the size is negligible vs handler code. -type CacheTier = 'fast' | 'medium' | 'slow' | 'static' | 'daily' | 'no-store'; +type CacheTier = 'fast' | 'medium' | 'slow' | 'slow-browser' | 'static' | 'daily' | 'no-store'; const TIER_HEADERS: Record = { fast: 'public, s-maxage=300, stale-while-revalidate=60, stale-if-error=600', medium: 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900', slow: 'public, s-maxage=1800, stale-while-revalidate=300, stale-if-error=3600', + 'slow-browser': 'public, max-age=300, s-maxage=900, stale-while-revalidate=60, stale-if-error=1800', static: 'public, s-maxage=7200, stale-while-revalidate=600, stale-if-error=14400', daily: 'public, s-maxage=86400, stale-while-revalidate=7200, stale-if-error=172800', 'no-store': 'no-store', @@ -41,6 +42,7 @@ const TIER_CDN_CACHE: Record = { fast: 'public, s-maxage=600, stale-while-revalidate=300, stale-if-error=1200', medium: 'public, s-maxage=1200, stale-while-revalidate=600, stale-if-error=1800', slow: 'public, s-maxage=3600, stale-while-revalidate=900, stale-if-error=7200', + 'slow-browser': 'public, s-maxage=900, stale-while-revalidate=60, stale-if-error=1800', static: 'public, s-maxage=14400, stale-while-revalidate=3600, stale-if-error=28800', daily: 'public, s-maxage=86400, stale-while-revalidate=14400, stale-if-error=172800', 'no-store': null, @@ -127,7 +129,7 @@ const RPC_CACHE_TIER: Record = { '/api/prediction/v1/list-prediction-markets': 'medium', '/api/forecast/v1/get-forecasts': 'medium', '/api/supply-chain/v1/get-chokepoint-status': 'medium', - '/api/news/v1/list-feed-digest': 'slow', + '/api/news/v1/list-feed-digest': 'slow-browser', '/api/intelligence/v1/classify-event': 'static', '/api/intelligence/v1/get-country-facts': 'daily', '/api/intelligence/v1/list-security-advisories': 'slow', diff --git a/server/worldmonitor/news/v1/list-feed-digest.ts b/server/worldmonitor/news/v1/list-feed-digest.ts index 3eb1057b2..30e6308af 100644 --- a/server/worldmonitor/news/v1/list-feed-digest.ts +++ b/server/worldmonitor/news/v1/list-feed-digest.ts @@ -6,7 +6,8 @@ import type { NewsItem as ProtoNewsItem, ThreatLevel as ProtoThreatLevel, } from '../../../../src/generated/server/worldmonitor/news/v1/service_server'; -import { cachedFetchJson, getCachedJsonBatch } from '../../../_shared/redis'; +import { cachedFetchJson, getCachedJson, setCachedJson, getCachedJsonBatch } from '../../../_shared/redis'; +import { markNoCacheResponse } from '../../../_shared/response-headers'; import { sha256Hex } from '../../../_shared/hash'; import { CHROME_UA } from '../../../_shared/constants'; import { VARIANT_FEEDS, INTEL_SOURCES, type ServerFeed } from './_feeds'; @@ -106,10 +107,10 @@ async function fetchAndParseRss( variant: string, signal: AbortSignal, ): Promise { - const cacheKey = `rss:feed:v1:${feed.url}`; + const cacheKey = `rss:feed:v1:${variant}:${feed.url}`; try { - const cached = await cachedFetchJson(cacheKey, 600, async () => { + const cached = await cachedFetchJson(cacheKey, 3600, async () => { // Try direct fetch first let text = await fetchRssText(feed.url, signal).catch(() => null); @@ -271,26 +272,44 @@ function toProtoItem(item: ParsedItem): ProtoNewsItem { } export async function listFeedDigest( - _ctx: ServerContext, + ctx: ServerContext, req: ListFeedDigestRequest, ): Promise { const variant = VALID_VARIANTS.has(req.variant) ? req.variant : 'full'; const lang = req.lang || 'en'; const digestCacheKey = `news:digest:v1:${variant}:${lang}`; - const fallbackKey = `${variant}:${lang}`; + + const empty = (): ListFeedDigestResponse => ({ categories: {}, feedStatuses: {}, generatedAt: new Date().toISOString() }); + try { - const cached = await cachedFetchJson(digestCacheKey, 900, async () => { - return buildDigest(variant, lang); - }); + // Check Redis first — warm path returns immediately + const cached = await getCachedJson(digestCacheKey) as ListFeedDigestResponse | null; if (cached) { if (fallbackDigestCache.size > 50) fallbackDigestCache.clear(); fallbackDigestCache.set(fallbackKey, { data: cached, ts: Date.now() }); + return cached; } - return cached ?? fallbackDigestCache.get(fallbackKey)?.data ?? { categories: {}, feedStatuses: {}, generatedAt: new Date().toISOString() }; + + // Cold path — build fresh digest + const fresh = await buildDigest(variant, lang); + const totalItems = Object.values(fresh.categories).reduce((sum, b) => sum + b.items.length, 0); + + if (totalItems > 0) { + // Good response: cache in Redis and update in-memory fallback + await setCachedJson(digestCacheKey, fresh, 900); + if (fallbackDigestCache.size > 50) fallbackDigestCache.clear(); + fallbackDigestCache.set(fallbackKey, { data: fresh, ts: Date.now() }); + } else { + // Degraded response: skip Redis write; prevent gateway/CDN from caching + markNoCacheResponse(ctx.request); + } + + return fresh; } catch { - return fallbackDigestCache.get(fallbackKey)?.data ?? { categories: {}, feedStatuses: {}, generatedAt: new Date().toISOString() }; + markNoCacheResponse(ctx.request); + return fallbackDigestCache.get(fallbackKey)?.data ?? empty(); } } @@ -328,7 +347,7 @@ async function buildDigest(variant: string, lang: string): Promise { const items = await fetchAndParseRss(feed, variant, deadlineController.signal); - feedStatuses[feed.name] = items.length > 0 ? 'ok' : 'empty'; + if (items.length === 0) feedStatuses[feed.name] = 'empty'; return { category, items }; }), ); diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 5295e6f84..d80e5c5d5 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -275,7 +275,7 @@ export class DataLoaderManager implements AppModule { try { const resp = await fetch( toApiUrl(`/api/news/v1/list-feed-digest?variant=${SITE_VARIANT}&lang=${getCurrentLanguage()}`), - { signal: AbortSignal.timeout(this.digestRequestTimeoutMs) }, + { cache: 'no-cache', signal: AbortSignal.timeout(this.digestRequestTimeoutMs) }, ); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json() as ListFeedDigestResponse; diff --git a/tests/digest-no-reclassify.test.mjs b/tests/digest-no-reclassify.test.mjs index 3205e70af..7c8ccae62 100644 --- a/tests/digest-no-reclassify.test.mjs +++ b/tests/digest-no-reclassify.test.mjs @@ -14,6 +14,10 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const src = readFileSync(resolve(__dirname, '..', 'src', 'app', 'data-loader.ts'), 'utf-8'); +const serverSrc = readFileSync( + resolve(__dirname, '..', 'server', 'worldmonitor', 'news', 'v1', 'list-feed-digest.ts'), + 'utf-8', +); describe('Digest branch must not reclassify with AI', () => { const digestBranchStart = src.indexOf("// Digest branch: server already aggregated feeds"); @@ -50,3 +54,12 @@ describe('Digest branch must not reclassify with AI', () => { 'canQueueAiClassification should not be imported (no call sites remain)'); }); }); + +describe('feedStatuses must not emit ok entries', () => { + it('buildDigest does not write ok to feedStatuses', () => { + assert.ok( + !serverSrc.includes("feedStatuses[feed.name] = items.length > 0 ? 'ok' : 'empty'"), + "feedStatuses must not write 'ok' entries — wastes payload on every response", + ); + }); +}); diff --git a/tests/route-cache-tier.test.mjs b/tests/route-cache-tier.test.mjs index 84d7c90cf..19a033715 100644 --- a/tests/route-cache-tier.test.mjs +++ b/tests/route-cache-tier.test.mjs @@ -40,7 +40,7 @@ function extractGetRoutes() { function extractCacheTierKeys() { const gatewayPath = join(root, 'server', 'gateway.ts'); const src = readFileSync(gatewayPath, 'utf-8'); - const re = /'\/(api\/[^']+)':\s*'(fast|medium|slow|static|daily|no-store)'/g; + const re = /'\/(api\/[^']+)':\s*'(fast|medium|slow|slow-browser|static|daily|no-store)'/g; const entries = {}; let m; while ((m = re.exec(src)) !== null) { @@ -84,4 +84,11 @@ describe('RPC_CACHE_TIER route parity', () => { 'Gateway still has medium default fallback — ensure all routes are explicit', ); }); + + it('slow-browser tier includes max-age, slow tier does not', () => { + const gatewaySrc = readFileSync(join(root, 'server', 'gateway.ts'), 'utf-8'); + assert.match(gatewaySrc, /slow-browser.*max-age/s, 'slow-browser tier must include max-age'); + const slowLine = gatewaySrc.match(/^\s+slow: 'public.*'/m)?.[0] ?? ''; + assert.ok(!slowLine.includes('max-age'), 'slow tier must NOT include max-age'); + }); });