diff --git a/api/bootstrap.js b/api/bootstrap.js index cf6bf505c..4e8b70da6 100644 --- a/api/bootstrap.js +++ b/api/bootstrap.js @@ -26,12 +26,13 @@ const BOOTSTRAP_CACHE_KEYS = { positiveGeoEvents: 'positive-events:geo-bootstrap:v1', theaterPosture: 'theater-posture:sebuf:stale:v1', riskScores: 'risk:scores:sebuf:stale:v1', + naturalEvents: 'natural:events:v1', }; const SLOW_KEYS = new Set([ 'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving', 'sectors', 'etfFlows', 'shippingRates', 'wildfires', 'climateAnomalies', - 'cyberThreats', 'techReadiness', 'theaterPosture', 'riskScores', + 'cyberThreats', 'techReadiness', 'theaterPosture', 'riskScores', 'naturalEvents', ]); const FAST_KEYS = new Set([ 'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints', diff --git a/api/natural/v1/[rpc].ts b/api/natural/v1/[rpc].ts new file mode 100644 index 000000000..e4ef829f2 --- /dev/null +++ b/api/natural/v1/[rpc].ts @@ -0,0 +1,9 @@ +export const config = { runtime: 'edge' }; + +import { createDomainGateway, serverOptions } from '../../../server/gateway'; +import { createNaturalServiceRoutes } from '../../../src/generated/server/worldmonitor/natural/v1/service_server'; +import { naturalHandler } from '../../../server/worldmonitor/natural/v1/handler'; + +export default createDomainGateway( + createNaturalServiceRoutes(naturalHandler, serverOptions), +); diff --git a/docs/api/NaturalService.openapi.json b/docs/api/NaturalService.openapi.json new file mode 100644 index 000000000..6abe501b6 --- /dev/null +++ b/docs/api/NaturalService.openapi.json @@ -0,0 +1 @@ +{"components":{"schemas":{"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"},"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"},"ListNaturalEventsRequest":{"properties":{"days":{"format":"int32","type":"integer"}},"type":"object"},"ListNaturalEventsResponse":{"properties":{"events":{"items":{"$ref":"#/components/schemas/NaturalEvent"},"type":"array"}},"type":"object"},"NaturalEvent":{"properties":{"category":{"type":"string"},"categoryTitle":{"type":"string"},"closed":{"type":"boolean"},"date":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"description":{"type":"string"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"magnitude":{"format":"double","type":"number"},"magnitudeUnit":{"type":"string"},"sourceName":{"type":"string"},"sourceUrl":{"type":"string"},"title":{"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":"NaturalService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/natural/v1/list-natural-events":{"get":{"operationId":"ListNaturalEvents","parameters":[{"in":"query","name":"days","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListNaturalEventsResponse"}}},"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":"ListNaturalEvents","tags":["NaturalService"]}}}} \ No newline at end of file diff --git a/docs/api/NaturalService.openapi.yaml b/docs/api/NaturalService.openapi.yaml new file mode 100644 index 000000000..712216ef9 --- /dev/null +++ b/docs/api/NaturalService.openapi.yaml @@ -0,0 +1,117 @@ +openapi: 3.1.0 +info: + title: NaturalService API + version: 1.0.0 +paths: + /api/natural/v1/list-natural-events: + get: + tags: + - NaturalService + summary: ListNaturalEvents + operationId: ListNaturalEvents + parameters: + - name: days + in: query + required: false + schema: + type: integer + format: int32 + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/ListNaturalEventsResponse' + "400": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + default: + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Error: + type: object + properties: + message: + type: string + description: Error message (e.g., 'user not found', 'database connection failed') + description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize. + FieldViolation: + type: object + properties: + field: + type: string + 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') + description: + type: string + description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing') + required: + - field + - description + description: FieldViolation describes a single validation error for a specific field. + ValidationError: + type: object + properties: + violations: + type: array + items: + $ref: '#/components/schemas/FieldViolation' + description: List of validation violations + required: + - violations + description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong. + ListNaturalEventsRequest: + type: object + properties: + days: + type: integer + format: int32 + ListNaturalEventsResponse: + type: object + properties: + events: + type: array + items: + $ref: '#/components/schemas/NaturalEvent' + NaturalEvent: + type: object + properties: + id: + type: string + title: + type: string + description: + type: string + category: + type: string + categoryTitle: + type: string + lat: + type: number + format: double + lon: + type: number + format: double + date: + type: integer + format: int64 + description: 'Warning: Values > 2^53 may lose precision in JavaScript' + magnitude: + type: number + format: double + magnitudeUnit: + type: string + sourceUrl: + type: string + sourceName: + type: string + closed: + type: boolean diff --git a/docs/api/NewsService.openapi.json b/docs/api/NewsService.openapi.json index 7f54794a1..9b5f6c0a4 100644 --- a/docs/api/NewsService.openapi.json +++ b/docs/api/NewsService.openapi.json @@ -1 +1 @@ -{"openapi": "3.1.0", "info": {"title": "NewsService API", "version": "1.0.0"}, "paths": {"/api/news/v1/summarize-article": {"post": {"tags": ["NewsService"], "summary": "SummarizeArticle", "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": {"description": "Successful response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SummarizeArticleResponse"}}}}, "400": {"description": "Validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ValidationError"}}}}, "default": {"description": "Error response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/news/v1/summarize-article-cache": {"get": {"tags": ["NewsService"], "summary": "GetSummarizeArticleCache", "description": "GetSummarizeArticleCache looks up a cached summary by deterministic key (CDN-cacheable GET).", "operationId": "GetSummarizeArticleCache", "parameters": [{"name": "cache_key", "in": "query", "description": "Deterministic cache key computed by buildSummaryCacheKey().", "required": false, "schema": {"type": "string"}}], "responses": {"200": {"description": "Successful response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SummarizeArticleResponse"}}}}, "400": {"description": "Validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ValidationError"}}}}, "default": {"description": "Error response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/news/v1/list-feed-digest": {"get": {"tags": ["NewsService"], "summary": "ListFeedDigest", "description": "ListFeedDigest returns a pre-aggregated digest of all RSS feeds for a site variant.", "operationId": "ListFeedDigest", "parameters": [{"name": "variant", "in": "query", "description": "Site variant: full, tech, finance, happy", "required": false, "schema": {"type": "string"}}, {"name": "lang", "in": "query", "description": "ISO 639-1 language code (en, fr, ar, etc.)", "required": false, "schema": {"type": "string"}}], "responses": {"200": {"description": "Successful response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListFeedDigestResponse"}}}}, "400": {"description": "Validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ValidationError"}}}}, "default": {"description": "Error response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}}, "components": {"schemas": {"Error": {"type": "object", "properties": {"message": {"type": "string", "description": "Error message (e.g., 'user not found', 'database connection failed')"}}, "description": "Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize."}, "FieldViolation": {"type": "object", "properties": {"field": {"type": "string", "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')"}, "description": {"type": "string", "description": "Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')"}}, "required": ["field", "description"], "description": "FieldViolation describes a single validation error for a specific field."}, "ValidationError": {"type": "object", "properties": {"violations": {"type": "array", "items": {"$ref": "#/components/schemas/FieldViolation"}, "description": "List of validation violations"}}, "required": ["violations"], "description": "ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong."}, "SummarizeArticleRequest": {"type": "object", "properties": {"provider": {"type": "string", "minLength": 1, "description": "LLM provider: \"ollama\", \"groq\", \"openrouter\""}, "headlines": {"type": "array", "items": {"type": "string", "minItems": 1, "description": "Headlines to summarize (max 8 used)."}, "minItems": 1}, "mode": {"type": "string", "description": "Summarization mode: \"brief\", \"analysis\", \"translate\", \"\" (default)."}, "geoContext": {"type": "string", "description": "Geographic signal context to include in the prompt."}, "variant": {"type": "string", "description": "Variant: \"full\", \"tech\", or target language for translate mode."}, "lang": {"type": "string", "description": "Output language code, default \"en\"."}}, "required": ["provider"], "description": "SummarizeArticleRequest specifies parameters for LLM article summarization."}, "SummarizeStatus": {"type": "string", "enum": ["SUMMARIZE_STATUS_UNSPECIFIED", "SUMMARIZE_STATUS_SUCCESS", "SUMMARIZE_STATUS_CACHED", "SUMMARIZE_STATUS_SKIPPED", "SUMMARIZE_STATUS_ERROR"], "description": "SummarizeStatus indicates the outcome of a summarization request."}, "SummarizeArticleResponse": {"type": "object", "properties": {"summary": {"type": "string", "description": "The generated summary text."}, "model": {"type": "string", "description": "Model identifier used for generation."}, "provider": {"type": "string", "description": "Provider that produced the result (or \"cache\")."}, "tokens": {"type": "integer", "format": "int32", "description": "Token count from the LLM response."}, "fallback": {"type": "boolean", "description": "Whether the client should try the next provider in the fallback chain."}, "error": {"type": "string", "description": "Error message if the request failed."}, "errorType": {"type": "string", "description": "Error type/name (e.g. \"TypeError\")."}, "status": {"$ref": "#/components/schemas/SummarizeStatus"}, "statusDetail": {"type": "string", "description": "Human-readable detail for non-success statuses (error message, skip reason, etc.)."}}, "description": "SummarizeArticleResponse contains the LLM summarization result."}, "GetSummarizeArticleCacheRequest": {"type": "object", "properties": {"cacheKey": {"type": "string", "description": "Deterministic cache key computed by buildSummaryCacheKey()."}}, "description": "GetSummarizeArticleCacheRequest looks up a pre-computed summary by cache key."}, "ListFeedDigestRequest": {"type": "object", "properties": {"variant": {"type": "string", "description": "Site variant: full, tech, finance, happy"}, "lang": {"type": "string", "description": "ISO 639-1 language code (en, fr, ar, etc.)"}}}, "ListFeedDigestResponse": {"type": "object", "properties": {"categories": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/CategoryBucket"}, "description": "Per-category buckets — keys match category names from feed config"}, "feedStatuses": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Per-feed status (ok/error/timeout)"}, "generatedAt": {"type": "string", "description": "ISO 8601 timestamp of when this digest was generated"}}}, "CategoriesEntry": {"type": "object", "properties": {"key": {"type": "string"}, "value": {"$ref": "#/components/schemas/CategoryBucket"}}}, "FeedStatusesEntry": {"type": "object", "properties": {"key": {"type": "string"}, "value": {"type": "string"}}}, "CategoryBucket": {"type": "object", "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/NewsItem"}}}}, "NewsItem": {"type": "object", "properties": {"source": {"type": "string", "minLength": 1, "description": "Source feed name."}, "title": {"type": "string", "minLength": 1, "description": "Article headline."}, "link": {"type": "string", "description": "Article URL."}, "publishedAt": {"type": "integer", "format": "int64", "description": "Publication time, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript"}, "isAlert": {"type": "boolean", "description": "Whether this article triggered an alert condition."}, "threat": {"$ref": "#/components/schemas/ThreatClassification"}, "location": {"$ref": "#/components/schemas/GeoCoordinates"}, "locationName": {"type": "string", "description": "Human-readable location name."}}, "required": ["source", "title"], "description": "NewsItem represents a single news article from RSS feed aggregation."}, "ThreatClassification": {"type": "object", "properties": {"level": {"type": "string", "enum": ["THREAT_LEVEL_UNSPECIFIED", "THREAT_LEVEL_LOW", "THREAT_LEVEL_MEDIUM", "THREAT_LEVEL_HIGH", "THREAT_LEVEL_CRITICAL"], "description": "ThreatLevel represents the assessed threat level of a news event."}, "category": {"type": "string", "description": "Event category."}, "confidence": {"type": "number", "maximum": 1, "minimum": 0, "format": "double", "description": "Confidence score (0.0 to 1.0)."}, "source": {"type": "string", "description": "Classification source — \"keyword\", \"ml\", or \"llm\"."}}, "description": "ThreatClassification represents an AI-assessed threat level for a news item."}, "GeoCoordinates": {"type": "object", "properties": {"latitude": {"type": "number", "maximum": 90, "minimum": -90, "format": "double", "description": "Latitude in decimal degrees (-90 to 90)."}, "longitude": {"type": "number", "maximum": 180, "minimum": -180, "format": "double", "description": "Longitude in decimal degrees (-180 to 180)."}}, "description": "GeoCoordinates represents a geographic location using WGS84 coordinates."}}}} \ 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 (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 diff --git a/docs/api/NewsService.openapi.yaml b/docs/api/NewsService.openapi.yaml index 2b86aab01..66ad03ed4 100644 --- a/docs/api/NewsService.openapi.yaml +++ b/docs/api/NewsService.openapi.yaml @@ -169,15 +169,6 @@ components: required: - provider description: SummarizeArticleRequest specifies parameters for LLM article summarization. - SummarizeStatus: - type: string - enum: - - SUMMARIZE_STATUS_UNSPECIFIED - - SUMMARIZE_STATUS_SUCCESS - - SUMMARIZE_STATUS_CACHED - - SUMMARIZE_STATUS_SKIPPED - - SUMMARIZE_STATUS_ERROR - description: SummarizeStatus indicates the outcome of a summarization request. SummarizeArticleResponse: type: object properties: @@ -204,10 +195,17 @@ components: type: string description: Error type/name (e.g. "TypeError"). status: - $ref: '#/components/schemas/SummarizeStatus' + type: string + enum: + - SUMMARIZE_STATUS_UNSPECIFIED + - SUMMARIZE_STATUS_SUCCESS + - SUMMARIZE_STATUS_CACHED + - SUMMARIZE_STATUS_SKIPPED + - SUMMARIZE_STATUS_ERROR + description: SummarizeStatus indicates the outcome of a summarization request. statusDetail: type: string - description: Human-readable detail for non-success statuses (error message, skip reason, etc.). + description: Human-readable detail for non-success statuses (skip reason, etc.). description: SummarizeArticleResponse contains the LLM summarization result. GetSummarizeArticleCacheRequest: type: object diff --git a/proto/worldmonitor/natural/v1/list_natural_events.proto b/proto/worldmonitor/natural/v1/list_natural_events.proto new file mode 100644 index 000000000..36a30b684 --- /dev/null +++ b/proto/worldmonitor/natural/v1/list_natural_events.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package worldmonitor.natural.v1; + +import "sebuf/http/annotations.proto"; + +message NaturalEvent { + string id = 1; + string title = 2; + string description = 3; + string category = 4; + string category_title = 5; + double lat = 6; + double lon = 7; + int64 date = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + double magnitude = 9; + string magnitude_unit = 10; + string source_url = 11; + string source_name = 12; + bool closed = 13; +} + +message ListNaturalEventsRequest { + int32 days = 1 [(sebuf.http.query) = { name: "days" }]; +} + +message ListNaturalEventsResponse { + repeated NaturalEvent events = 1; +} diff --git a/proto/worldmonitor/natural/v1/service.proto b/proto/worldmonitor/natural/v1/service.proto new file mode 100644 index 000000000..0a1ad1efa --- /dev/null +++ b/proto/worldmonitor/natural/v1/service.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package worldmonitor.natural.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/natural/v1/list_natural_events.proto"; + +service NaturalService { + option (sebuf.http.service_config) = {base_path: "/api/natural/v1"}; + + rpc ListNaturalEvents(ListNaturalEventsRequest) returns (ListNaturalEventsResponse) { + option (sebuf.http.config) = {path: "/list-natural-events", method: HTTP_METHOD_GET}; + } +} diff --git a/server/gateway.ts b/server/gateway.ts index 198818770..ca09df345 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -56,6 +56,7 @@ const RPC_CACHE_TIER: Record = { '/api/aviation/v1/list-airport-delays': 'static', '/api/market/v1/get-country-stock-index': 'slow', + '/api/natural/v1/list-natural-events': 'slow', '/api/wildfire/v1/list-fire-detections': 'static', '/api/maritime/v1/list-navigational-warnings': 'static', '/api/supply-chain/v1/get-shipping-rates': 'static', diff --git a/server/worldmonitor/natural/v1/handler.ts b/server/worldmonitor/natural/v1/handler.ts new file mode 100644 index 000000000..78824b02a --- /dev/null +++ b/server/worldmonitor/natural/v1/handler.ts @@ -0,0 +1,7 @@ +import type { NaturalServiceHandler } from '../../../../src/generated/server/worldmonitor/natural/v1/service_server'; + +import { listNaturalEvents } from './list-natural-events'; + +export const naturalHandler: NaturalServiceHandler = { + listNaturalEvents, +}; diff --git a/server/worldmonitor/natural/v1/list-natural-events.ts b/server/worldmonitor/natural/v1/list-natural-events.ts new file mode 100644 index 000000000..1b069704f --- /dev/null +++ b/server/worldmonitor/natural/v1/list-natural-events.ts @@ -0,0 +1,177 @@ +import type { + NaturalServiceHandler, + ServerContext, + ListNaturalEventsRequest, + ListNaturalEventsResponse, + NaturalEvent, +} from '../../../../src/generated/server/worldmonitor/natural/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'natural:events:v1'; +const REDIS_CACHE_TTL = 1800; // 30 min + +const EONET_API_URL = 'https://eonet.gsfc.nasa.gov/api/v3/events'; +const GDACS_API = 'https://www.gdacs.org/gdacsapi/api/events/geteventlist/MAP'; + +const DAYS = 30; +const WILDFIRE_MAX_AGE_MS = 48 * 60 * 60 * 1000; + +const GDACS_TO_CATEGORY: Record = { + EQ: 'earthquakes', + FL: 'floods', + TC: 'severeStorms', + VO: 'volcanoes', + WF: 'wildfires', + DR: 'drought', +}; + +const EVENT_TYPE_NAMES: Record = { + EQ: 'Earthquake', + FL: 'Flood', + TC: 'Tropical Cyclone', + VO: 'Volcano', + WF: 'Wildfire', + DR: 'Drought', +}; + +async function fetchEonet(days: number): Promise { + const url = `${EONET_API_URL}?status=open&days=${days}`; + const res = await fetch(url, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`EONET ${res.status}`); + + const data: any = await res.json(); + const events: NaturalEvent[] = []; + const now = Date.now(); + + for (const event of data.events || []) { + const category = event.categories?.[0]; + if (!category) continue; + if (category.id === 'earthquakes') continue; + + const latestGeo = event.geometry?.[event.geometry.length - 1]; + if (!latestGeo || latestGeo.type !== 'Point') continue; + + const eventDate = new Date(latestGeo.date); + const [lon, lat] = latestGeo.coordinates; + + if (category.id === 'wildfires' && now - eventDate.getTime() > WILDFIRE_MAX_AGE_MS) continue; + + const source = event.sources?.[0]; + events.push({ + id: event.id || '', + title: event.title || '', + description: event.description || '', + category: category.id || '', + categoryTitle: category.title || '', + lat, + lon, + date: eventDate.getTime(), + magnitude: latestGeo.magnitudeValue ?? 0, + magnitudeUnit: latestGeo.magnitudeUnit || '', + sourceUrl: source?.url || '', + sourceName: source?.id || '', + closed: event.closed !== null, + }); + } + + return events; +} + +async function fetchGdacs(): Promise { + const res = await fetch(GDACS_API, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`GDACS ${res.status}`); + + const data: any = await res.json(); + const features: any[] = data.features || []; + const seen = new Set(); + const events: NaturalEvent[] = []; + + for (const f of features) { + if (!f.geometry || f.geometry.type !== 'Point') continue; + const props = f.properties; + const key = `${props.eventtype}-${props.eventid}`; + if (seen.has(key)) continue; + seen.add(key); + + if (props.alertlevel === 'Green') continue; + + const category = GDACS_TO_CATEGORY[props.eventtype] || 'manmade'; + const alertPrefix = props.alertlevel === 'Red' ? '🔴 ' : props.alertlevel === 'Orange' ? '🟠 ' : ''; + const description = props.description || EVENT_TYPE_NAMES[props.eventtype] || props.eventtype; + const severity = props.severitydata?.severitytext || ''; + + events.push({ + id: `gdacs-${props.eventtype}-${props.eventid}`, + title: `${alertPrefix}${props.name || ''}`, + description: `${description}${severity ? ` - ${severity}` : ''}`, + category, + categoryTitle: description, + lat: f.geometry.coordinates[1] ?? 0, + lon: f.geometry.coordinates[0] ?? 0, + date: new Date(props.fromdate || 0).getTime(), + magnitude: 0, + magnitudeUnit: '', + sourceUrl: props.url?.report || '', + sourceName: 'GDACS', + closed: false, + }); + } + + return events.slice(0, 100); +} + +export const listNaturalEvents: NaturalServiceHandler['listNaturalEvents'] = async ( + _ctx: ServerContext, + _req: ListNaturalEventsRequest, +): Promise => { + + try { + const result = await cachedFetchJson( + REDIS_CACHE_KEY, + REDIS_CACHE_TTL, + async () => { + const [eonetResult, gdacsResult] = await Promise.allSettled([ + fetchEonet(DAYS), + fetchGdacs(), + ]); + + const eonetEvents = eonetResult.status === 'fulfilled' ? eonetResult.value : []; + const gdacsEvents = gdacsResult.status === 'fulfilled' ? gdacsResult.value : []; + + if (eonetResult.status === 'rejected') console.error('[EONET]', eonetResult.reason?.message); + if (gdacsResult.status === 'rejected') console.error('[GDACS]', gdacsResult.reason?.message); + + const seenLocations = new Set(); + const merged: NaturalEvent[] = []; + + for (const event of gdacsEvents) { + const k = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`; + if (!seenLocations.has(k)) { + seenLocations.add(k); + merged.push(event); + } + } + for (const event of eonetEvents) { + const k = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`; + if (!seenLocations.has(k)) { + seenLocations.add(k); + merged.push(event); + } + } + + return merged.length > 0 ? { events: merged } : null; + }, + ); + return result || { events: [] }; + } catch { + return { events: [] }; + } +}; diff --git a/src/generated/client/worldmonitor/displacement/v1/service_client.ts b/src/generated/client/worldmonitor/displacement/v1/service_client.ts index fe9e65e5a..86159ab57 100644 --- a/src/generated/client/worldmonitor/displacement/v1/service_client.ts +++ b/src/generated/client/worldmonitor/displacement/v1/service_client.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: worldmonitor/displacement/v1/service.proto diff --git a/src/generated/client/worldmonitor/giving/v1/service_client.ts b/src/generated/client/worldmonitor/giving/v1/service_client.ts index 3dc639496..9d8a9f1d7 100644 --- a/src/generated/client/worldmonitor/giving/v1/service_client.ts +++ b/src/generated/client/worldmonitor/giving/v1/service_client.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: worldmonitor/giving/v1/service.proto diff --git a/src/generated/client/worldmonitor/intelligence/v1/service_client.ts b/src/generated/client/worldmonitor/intelligence/v1/service_client.ts index c6c0c68d7..c76133c11 100644 --- a/src/generated/client/worldmonitor/intelligence/v1/service_client.ts +++ b/src/generated/client/worldmonitor/intelligence/v1/service_client.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: worldmonitor/intelligence/v1/service.proto diff --git a/src/generated/client/worldmonitor/maritime/v1/service_client.ts b/src/generated/client/worldmonitor/maritime/v1/service_client.ts index e61bf2f63..6ecdea60c 100644 --- a/src/generated/client/worldmonitor/maritime/v1/service_client.ts +++ b/src/generated/client/worldmonitor/maritime/v1/service_client.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: worldmonitor/maritime/v1/service.proto diff --git a/src/generated/client/worldmonitor/natural/v1/service_client.ts b/src/generated/client/worldmonitor/natural/v1/service_client.ts new file mode 100644 index 000000000..ffef38a91 --- /dev/null +++ b/src/generated/client/worldmonitor/natural/v1/service_client.ts @@ -0,0 +1,117 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: worldmonitor/natural/v1/service.proto + +export interface ListNaturalEventsRequest { + days: number; +} + +export interface ListNaturalEventsResponse { + events: NaturalEvent[]; +} + +export interface NaturalEvent { + id: string; + title: string; + description: string; + category: string; + categoryTitle: string; + lat: number; + lon: number; + date: number; + magnitude: number; + magnitudeUnit: string; + sourceUrl: string; + sourceName: string; + closed: boolean; +} + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +export interface NaturalServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface NaturalServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class NaturalServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: NaturalServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async listNaturalEvents(req: ListNaturalEventsRequest, options?: NaturalServiceCallOptions): Promise { + let path = "/api/natural/v1/list-natural-events"; + const params = new URLSearchParams(); + if (req.days != null && req.days !== 0) params.set("days", String(req.days)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as ListNaturalEventsResponse; + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} + diff --git a/src/generated/client/worldmonitor/news/v1/service_client.ts b/src/generated/client/worldmonitor/news/v1/service_client.ts index 4c33c6ac6..161c9e889 100644 --- a/src/generated/client/worldmonitor/news/v1/service_client.ts +++ b/src/generated/client/worldmonitor/news/v1/service_client.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: worldmonitor/news/v1/service.proto @@ -10,8 +11,6 @@ export interface SummarizeArticleRequest { lang: string; } -export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR"; - export interface SummarizeArticleResponse { summary: string; model: string; @@ -66,6 +65,8 @@ export interface GeoCoordinates { longitude: number; } +export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR"; + export type ThreatLevel = "THREAT_LEVEL_UNSPECIFIED" | "THREAT_LEVEL_LOW" | "THREAT_LEVEL_MEDIUM" | "THREAT_LEVEL_HIGH" | "THREAT_LEVEL_CRITICAL"; export interface FieldViolation { diff --git a/src/generated/client/worldmonitor/prediction/v1/service_client.ts b/src/generated/client/worldmonitor/prediction/v1/service_client.ts index 35717856e..1382ca245 100644 --- a/src/generated/client/worldmonitor/prediction/v1/service_client.ts +++ b/src/generated/client/worldmonitor/prediction/v1/service_client.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: worldmonitor/prediction/v1/service.proto diff --git a/src/generated/client/worldmonitor/research/v1/service_client.ts b/src/generated/client/worldmonitor/research/v1/service_client.ts index c573a14ba..c37279a6d 100644 --- a/src/generated/client/worldmonitor/research/v1/service_client.ts +++ b/src/generated/client/worldmonitor/research/v1/service_client.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: worldmonitor/research/v1/service.proto diff --git a/src/generated/client/worldmonitor/seismology/v1/service_client.ts b/src/generated/client/worldmonitor/seismology/v1/service_client.ts index cc712d5d8..9078cdaf4 100644 --- a/src/generated/client/worldmonitor/seismology/v1/service_client.ts +++ b/src/generated/client/worldmonitor/seismology/v1/service_client.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: worldmonitor/seismology/v1/service.proto diff --git a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts index cf70f1ffa..4c0e8bdf1 100644 --- a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts +++ b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts @@ -207,3 +207,4 @@ export class SupplyChainServiceClient { throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); } } + diff --git a/src/generated/client/worldmonitor/wildfire/v1/service_client.ts b/src/generated/client/worldmonitor/wildfire/v1/service_client.ts index b0f669f18..6377a1688 100644 --- a/src/generated/client/worldmonitor/wildfire/v1/service_client.ts +++ b/src/generated/client/worldmonitor/wildfire/v1/service_client.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-client. DO NOT EDIT. // source: worldmonitor/wildfire/v1/service.proto diff --git a/src/generated/server/worldmonitor/conflict/v1/service_server.ts b/src/generated/server/worldmonitor/conflict/v1/service_server.ts index 1d0ff9f17..6a420d7bc 100644 --- a/src/generated/server/worldmonitor/conflict/v1/service_server.ts +++ b/src/generated/server/worldmonitor/conflict/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/conflict/v1/service.proto diff --git a/src/generated/server/worldmonitor/displacement/v1/service_server.ts b/src/generated/server/worldmonitor/displacement/v1/service_server.ts index 99db57c56..e5045f598 100644 --- a/src/generated/server/worldmonitor/displacement/v1/service_server.ts +++ b/src/generated/server/worldmonitor/displacement/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/displacement/v1/service.proto diff --git a/src/generated/server/worldmonitor/giving/v1/service_server.ts b/src/generated/server/worldmonitor/giving/v1/service_server.ts index 5d6c97869..d5ca73a52 100644 --- a/src/generated/server/worldmonitor/giving/v1/service_server.ts +++ b/src/generated/server/worldmonitor/giving/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/giving/v1/service.proto diff --git a/src/generated/server/worldmonitor/intelligence/v1/service_server.ts b/src/generated/server/worldmonitor/intelligence/v1/service_server.ts index 695cfcc65..ca3fbf609 100644 --- a/src/generated/server/worldmonitor/intelligence/v1/service_server.ts +++ b/src/generated/server/worldmonitor/intelligence/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/intelligence/v1/service.proto diff --git a/src/generated/server/worldmonitor/maritime/v1/service_server.ts b/src/generated/server/worldmonitor/maritime/v1/service_server.ts index ecf0b31e7..9887cca52 100644 --- a/src/generated/server/worldmonitor/maritime/v1/service_server.ts +++ b/src/generated/server/worldmonitor/maritime/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/maritime/v1/service.proto diff --git a/src/generated/server/worldmonitor/natural/v1/service_server.ts b/src/generated/server/worldmonitor/natural/v1/service_server.ts new file mode 100644 index 000000000..24a474324 --- /dev/null +++ b/src/generated/server/worldmonitor/natural/v1/service_server.ts @@ -0,0 +1,131 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: worldmonitor/natural/v1/service.proto + +export interface ListNaturalEventsRequest { + days: number; +} + +export interface ListNaturalEventsResponse { + events: NaturalEvent[]; +} + +export interface NaturalEvent { + id: string; + title: string; + description: string; + category: string; + categoryTitle: string; + lat: number; + lon: number; + date: number; + magnitude: number; + magnitudeUnit: string; + sourceUrl: string; + sourceName: string; + closed: boolean; +} + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +export interface ServerContext { + request: Request; + pathParams: Record; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface NaturalServiceHandler { + listNaturalEvents(ctx: ServerContext, req: ListNaturalEventsRequest): Promise; +} + +export function createNaturalServiceRoutes( + handler: NaturalServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "GET", + path: "/api/natural/v1/list-natural-events", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: ListNaturalEventsRequest = { + days: Number(params.get("days") ?? "0"), + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("listNaturalEvents", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.listNaturalEvents(ctx, body); + return new Response(JSON.stringify(result as ListNaturalEventsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + ]; +} + diff --git a/src/generated/server/worldmonitor/news/v1/service_server.ts b/src/generated/server/worldmonitor/news/v1/service_server.ts index 5c41a8bf9..45ddec997 100644 --- a/src/generated/server/worldmonitor/news/v1/service_server.ts +++ b/src/generated/server/worldmonitor/news/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/news/v1/service.proto @@ -10,8 +11,6 @@ export interface SummarizeArticleRequest { lang: string; } -export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR"; - export interface SummarizeArticleResponse { summary: string; model: string; @@ -66,6 +65,8 @@ export interface GeoCoordinates { longitude: number; } +export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR"; + export type ThreatLevel = "THREAT_LEVEL_UNSPECIFIED" | "THREAT_LEVEL_LOW" | "THREAT_LEVEL_MEDIUM" | "THREAT_LEVEL_HIGH" | "THREAT_LEVEL_CRITICAL"; export interface FieldViolation { diff --git a/src/generated/server/worldmonitor/positive_events/v1/service_server.ts b/src/generated/server/worldmonitor/positive_events/v1/service_server.ts index a6218b060..9b3160184 100644 --- a/src/generated/server/worldmonitor/positive_events/v1/service_server.ts +++ b/src/generated/server/worldmonitor/positive_events/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/positive_events/v1/service.proto diff --git a/src/generated/server/worldmonitor/prediction/v1/service_server.ts b/src/generated/server/worldmonitor/prediction/v1/service_server.ts index b6a88211c..0bf462c34 100644 --- a/src/generated/server/worldmonitor/prediction/v1/service_server.ts +++ b/src/generated/server/worldmonitor/prediction/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/prediction/v1/service.proto diff --git a/src/generated/server/worldmonitor/research/v1/service_server.ts b/src/generated/server/worldmonitor/research/v1/service_server.ts index 18966dfbc..65148f1b1 100644 --- a/src/generated/server/worldmonitor/research/v1/service_server.ts +++ b/src/generated/server/worldmonitor/research/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/research/v1/service.proto diff --git a/src/generated/server/worldmonitor/seismology/v1/service_server.ts b/src/generated/server/worldmonitor/seismology/v1/service_server.ts index 9b3914ba7..3e03fb617 100644 --- a/src/generated/server/worldmonitor/seismology/v1/service_server.ts +++ b/src/generated/server/worldmonitor/seismology/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/seismology/v1/service.proto diff --git a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts index d613151b0..4e8ca8bdc 100644 --- a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts +++ b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/supply_chain/v1/service.proto diff --git a/src/generated/server/worldmonitor/wildfire/v1/service_server.ts b/src/generated/server/worldmonitor/wildfire/v1/service_server.ts index 539add899..9fb07896d 100644 --- a/src/generated/server/worldmonitor/wildfire/v1/service_server.ts +++ b/src/generated/server/worldmonitor/wildfire/v1/service_server.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Code generated by protoc-gen-ts-server. DO NOT EDIT. // source: worldmonitor/wildfire/v1/service.proto diff --git a/src/services/eonet.ts b/src/services/eonet.ts index afd2c4e27..b04c949e9 100644 --- a/src/services/eonet.ts +++ b/src/services/eonet.ts @@ -1,40 +1,10 @@ import type { NaturalEvent, NaturalEventCategory } from '@/types'; -import { fetchGDACSEvents, type GDACSEvent } from './gdacs'; - -interface EonetGeometry { - magnitudeValue?: number; - magnitudeUnit?: string; - date: string; - type: string; - coordinates: [number, number]; -} - -interface EonetSource { - id: string; - url: string; -} - -interface EonetCategory { - id: string; - title: string; -} - -interface EonetEvent { - id: string; - title: string; - description: string | null; - closed: string | null; - categories: EonetCategory[]; - sources: EonetSource[]; - geometry: EonetGeometry[]; -} - -interface EonetResponse { - title: string; - events: EonetEvent[]; -} - -const EONET_API_URL = 'https://eonet.gsfc.nasa.gov/api/v3/events'; +import { + NaturalServiceClient, + type ListNaturalEventsResponse, +} from '@/generated/client/worldmonitor/natural/v1/service_client'; +import { createCircuitBreaker } from '@/utils'; +import { getHydratedData } from '@/services/bootstrap'; const CATEGORY_ICONS: Record = { severeStorms: '🌀', @@ -56,117 +26,34 @@ export function getNaturalEventIcon(category: NaturalEventCategory): string { return CATEGORY_ICONS[category] || '⚠️'; } -// Wildfires older than 48 hours are filtered out (stale data) -const WILDFIRE_MAX_AGE_MS = 48 * 60 * 60 * 1000; +const client = new NaturalServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); +const breaker = createCircuitBreaker({ name: 'NaturalEvents', cacheTtlMs: 30 * 60 * 1000, persistCache: true }); -const GDACS_TO_CATEGORY: Record = { - EQ: 'earthquakes', - FL: 'floods', - TC: 'severeStorms', - VO: 'volcanoes', - WF: 'wildfires', - DR: 'drought', -}; +const emptyFallback: ListNaturalEventsResponse = { events: [] }; -function convertGDACSToNaturalEvent(gdacs: GDACSEvent): NaturalEvent { - const category = GDACS_TO_CATEGORY[gdacs.eventType] || 'manmade'; +function toNaturalEvent(e: ListNaturalEventsResponse['events'][number]): NaturalEvent { return { - id: gdacs.id, - title: `${gdacs.alertLevel === 'Red' ? '🔴 ' : gdacs.alertLevel === 'Orange' ? '🟠 ' : ''}${gdacs.name}`, - description: `${gdacs.description}${gdacs.severity ? ` - ${gdacs.severity}` : ''}`, - category, - categoryTitle: gdacs.description, - lat: gdacs.coordinates[1], - lon: gdacs.coordinates[0], - date: gdacs.fromDate, - sourceUrl: gdacs.url, - sourceName: 'GDACS', - closed: false, + id: e.id, + title: e.title, + description: e.description || undefined, + category: (e.category || 'manmade') as NaturalEventCategory, + categoryTitle: e.categoryTitle, + lat: e.lat, + lon: e.lon, + date: new Date(e.date), + magnitude: e.magnitude ?? undefined, + magnitudeUnit: e.magnitudeUnit ?? undefined, + sourceUrl: e.sourceUrl || undefined, + sourceName: e.sourceName || undefined, + closed: e.closed, }; } -export async function fetchNaturalEvents(days = 30): Promise { - const [eonetEvents, gdacsEvents] = await Promise.all([ - fetchEonetEvents(days), - fetchGDACSEvents(), - ]); +export async function fetchNaturalEvents(_days = 30): Promise { + const hydrated = getHydratedData('naturalEvents') as ListNaturalEventsResponse | undefined; + const response = hydrated ?? await breaker.execute(async () => { + return client.listNaturalEvents({ days: 30 }); + }, emptyFallback); - const gdacsConverted = gdacsEvents.map(convertGDACSToNaturalEvent); - const seenLocations = new Set(); - const merged: NaturalEvent[] = []; - - for (const event of gdacsConverted) { - const key = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`; - if (!seenLocations.has(key)) { - seenLocations.add(key); - merged.push(event); - } - } - - for (const event of eonetEvents) { - const key = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`; - if (!seenLocations.has(key)) { - seenLocations.add(key); - merged.push(event); - } - } - - return merged; -} - -async function fetchEonetEvents(days: number): Promise { - try { - const url = `${EONET_API_URL}?status=open&days=${days}`; - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`EONET API error: ${response.status}`); - } - - const data: EonetResponse = await response.json(); - const events: NaturalEvent[] = []; - const now = Date.now(); - - for (const event of data.events) { - const category = event.categories[0]; - if (!category) continue; - - // Skip earthquakes - USGS provides better data for seismic events - if (category.id === 'earthquakes') continue; - - // Get most recent geometry point - const latestGeo = event.geometry[event.geometry.length - 1]; - if (!latestGeo || latestGeo.type !== 'Point') continue; - - const eventDate = new Date(latestGeo.date); - const [lon, lat] = latestGeo.coordinates; - const source = event.sources[0]; - - // Filter out wildfires older than 48 hours - if (category.id === 'wildfires' && now - eventDate.getTime() > WILDFIRE_MAX_AGE_MS) { - continue; - } - - events.push({ - id: event.id, - title: event.title, - description: event.description || undefined, - category: category.id as NaturalEventCategory, - categoryTitle: category.title, - lat, - lon, - date: eventDate, - magnitude: latestGeo.magnitudeValue, - magnitudeUnit: latestGeo.magnitudeUnit, - sourceUrl: source?.url, - sourceName: source?.id, - closed: event.closed !== null, - }); - } - - return events; - } catch (error) { - console.error('[EONET] Failed to fetch natural events:', error); - return []; - } + return (response.events || []).map(toNaturalEvent); } diff --git a/src/services/gdacs.ts b/src/services/gdacs.ts deleted file mode 100644 index 4677f8f24..000000000 --- a/src/services/gdacs.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { createCircuitBreaker } from '@/utils'; - -export interface GDACSEvent { - id: string; - eventType: 'EQ' | 'FL' | 'TC' | 'VO' | 'WF' | 'DR'; - name: string; - description: string; - alertLevel: 'Green' | 'Orange' | 'Red'; - country: string; - coordinates: [number, number]; - fromDate: Date; - severity: string; - url: string; -} - -interface GDACSFeature { - geometry: { - type: string; - coordinates: [number, number]; - }; - properties: { - eventtype: string; - eventid: number; - name: string; - description: string; - alertlevel: string; - country: string; - fromdate: string; - severitydata?: { - severity: number; - severitytext: string; - severityunit: string; - }; - url: { - report: string; - }; - }; -} - -interface GDACSResponse { - features: GDACSFeature[]; -} - -const GDACS_API = 'https://www.gdacs.org/gdacsapi/api/events/geteventlist/MAP'; -const breaker = createCircuitBreaker({ name: 'GDACS', cacheTtlMs: 10 * 60 * 1000, persistCache: true }); - -const EVENT_TYPE_NAMES: Record = { - EQ: 'Earthquake', - FL: 'Flood', - TC: 'Tropical Cyclone', - VO: 'Volcano', - WF: 'Wildfire', - DR: 'Drought', -}; - -export async function fetchGDACSEvents(): Promise { - return breaker.execute(async () => { - const response = await fetch(GDACS_API, { - headers: { 'Accept': 'application/json' } - }); - - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const data: GDACSResponse = await response.json(); - - const seen = new Set(); - return data.features - .filter(f => { - if (!f.geometry || f.geometry.type !== 'Point') return false; - const key = `${f.properties.eventtype}-${f.properties.eventid}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }) - .filter(f => f.properties.alertlevel !== 'Green') - .slice(0, 100) - .map(f => ({ - id: `gdacs-${f.properties.eventtype}-${f.properties.eventid}`, - eventType: f.properties.eventtype as GDACSEvent['eventType'], - name: f.properties.name, - description: f.properties.description || EVENT_TYPE_NAMES[f.properties.eventtype] || f.properties.eventtype, - alertLevel: f.properties.alertlevel as GDACSEvent['alertLevel'], - country: f.properties.country, - coordinates: f.geometry.coordinates, - fromDate: new Date(f.properties.fromdate), - severity: f.properties.severitydata?.severitytext || '', - url: f.properties.url?.report || '', - })); - }, []); -} - -export function getGDACSStatus(): string { - return breaker.getStatus(); -} - -export function getEventTypeIcon(type: GDACSEvent['eventType']): string { - switch (type) { - case 'EQ': return '🌍'; - case 'FL': return '🌊'; - case 'TC': return '🌀'; - case 'VO': return '🌋'; - case 'WF': return '🔥'; - case 'DR': return '☀️'; - default: return '⚠️'; - } -} - -export function getAlertColor(level: GDACSEvent['alertLevel']): [number, number, number, number] { - switch (level) { - case 'Red': return [255, 0, 0, 200]; - case 'Orange': return [255, 140, 0, 180]; - default: return [255, 200, 0, 160]; - } -} diff --git a/vite.config.ts b/vite.config.ts index de68222de..e77b36c41 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -196,6 +196,7 @@ function sebufApiPlugin(): Plugin { givingServerMod, givingHandlerMod, tradeServerMod, tradeHandlerMod, supplyChainServerMod, supplyChainHandlerMod, + naturalServerMod, naturalHandlerMod, ] = await Promise.all([ import('./server/router'), import('./server/cors'), @@ -242,6 +243,8 @@ function sebufApiPlugin(): Plugin { import('./server/worldmonitor/trade/v1/handler'), import('./src/generated/server/worldmonitor/supply_chain/v1/service_server'), import('./server/worldmonitor/supply-chain/v1/handler'), + import('./src/generated/server/worldmonitor/natural/v1/service_server'), + import('./server/worldmonitor/natural/v1/handler'), ]); const serverOptions = { onError: errorMod.mapErrorToResponse }; @@ -267,6 +270,7 @@ function sebufApiPlugin(): Plugin { ...givingServerMod.createGivingServiceRoutes(givingHandlerMod.givingHandler, serverOptions), ...tradeServerMod.createTradeServiceRoutes(tradeHandlerMod.tradeHandler, serverOptions), ...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions), + ...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions), ]; cachedCorsMod = corsMod; return routerMod.createRouter(allRoutes);