openpondai/agents/news-intelligence-roundup

OpenTool app

1Branch0Tags
OP
OpenPond Syncsync: merge local template master into production
typescript
import { createAIClient, ensureTextContent } from "opentool/ai"; import type { IncludePredictionMarkets, NewsIntelligenceRoundupConfig, RoundupMode, SourceFamily, } from "./config"; export type SearchCurrentArticle = { articleId: string; title: string; summary?: string | null; sourceId: string; sourceName: string; sourceFamily: SourceFamily; trustTier?: string | null; canonicalUrl?: string | null; publishedAt?: string | null; articleConfidence?: number | null; eventId?: string | null; eventKey?: string | null; candidateEventId?: string | null; catalystType?: string | null; matchedBy?: string | null; whyMatched?: string | null; dataAgeMs?: number | null; }; type SearchCurrentResponse = { query?: string | null; eventKey?: string | null; eventId?: string | null; taxonomy?: string | null; dataAgeMs?: number | null; articles?: SearchCurrentArticle[]; matchedEvents?: Array<{ eventId: string; eventKey: string; title: string; taxonomy?: string | null; state?: string | null; stateConfidence?: number | null; }>; predictionMarketContext?: unknown; }; type LiveFeedItem = { articleId: string; title: string; summary?: string | null; sourceId: string; sourceName: string; sourceFamily: SourceFamily; canonicalUrl?: string | null; publishedAt?: string | null; dataAgeMs?: number | null; mapping?: { eventId?: string | null; eventKey?: string | null; eventTitle?: string | null; taxonomy?: string | null; eventState?: string | null; whyMatched?: string | null; } | null; }; type LiveFeedResponse = { items?: LiveFeedItem[]; dataAgeMs?: number | null; }; type TopEventSourceSummary = { sourceId: string; sourceName: string; title: string; canonicalUrl?: string | null; publishedAt?: string | null; }; type TopEventResponse = { events?: Array<{ eventId: string; eventKey: string; title: string; taxonomy?: string | null; eventState?: string | null; eventConfidence?: number | null; latestEvidenceAt?: string | null; sourceSummary?: TopEventSourceSummary[]; }>; }; export type RoundupArticle = { articleId: string; title: string; sourceId: string; sourceName: string; sourceFamily: SourceFamily; canonicalUrl: string | null; publishedAt: string | null; summary: string | null; whyMatched: string | null; articleConfidence: number | null; dataAgeMs: number | null; windows: number[]; }; export type WindowRoundupResult = { windowHours: number; articleCount: number; dataAgeMs: number | null; matchedEvents: Array<{ eventId: string; eventKey: string; title: string; taxonomy: string | null; state: string | null; stateConfidence: number | null; }>; articles: RoundupArticle[]; }; export type RoundupSummary = { title: string; overallSummary: string; windowSummaries: Array<{ windowHours: number; summary: string; topArticles: Array<{ articleId: string; title: string; sourceName: string; canonicalUrl: string | null; publishedAt: string | null; reason: string; }>; }>; topArticles: Array<{ articleId: string; title: string; sourceName: string; canonicalUrl: string | null; publishedAt: string | null; reason: string; }>; }; export type RoundupResult = { ok: true; title: string; mode: RoundupMode; query: string | null; generatedAt: string; windows: number[]; sourceIds?: string[]; sourceFamilies?: SourceFamily[]; includePredictionMarkets: IncludePredictionMarkets; ingestOnRequest: boolean; asOf?: string; summary: RoundupSummary; windowResults: WindowRoundupResult[]; allArticles: RoundupArticle[]; }; function resolveGatewayBase() { return process.env.OPENPOND_GATEWAY_URL?.replace(/\/$/, "") ?? null; } function articleIdentity(article: { articleId: string; canonicalUrl?: string | null; sourceId: string; title: string; }) { return ( article.articleId || article.canonicalUrl || `${article.sourceId}:${article.title}`.toLowerCase() ); } function withWindowTag( article: { articleId: string; title: string; sourceId: string; sourceName: string; sourceFamily: SourceFamily; canonicalUrl?: string | null; publishedAt?: string | null; summary?: string | null; whyMatched?: string | null; articleConfidence?: number | null; dataAgeMs?: number | null; }, windowHours: number ): RoundupArticle { return { articleId: article.articleId, title: article.title, sourceId: article.sourceId, sourceName: article.sourceName, sourceFamily: article.sourceFamily, canonicalUrl: article.canonicalUrl ?? null, publishedAt: article.publishedAt ?? null, summary: article.summary ?? null, whyMatched: article.whyMatched ?? null, articleConfidence: article.articleConfidence ?? null, dataAgeMs: article.dataAgeMs ?? null, windows: [windowHours], }; } function normalizeModeLabel(mode: RoundupMode) { if (mode === "recent_feed") return "recent feed"; if (mode === "top_events") return "top events"; return "query search"; } function resolveEditorialFocus(config: NewsIntelligenceRoundupConfig) { return ( config.query?.trim() || "global markets, macro, geopolitics, oil, rates, inflation, and crypto policy" ); } function mergeArticles(windowResults: WindowRoundupResult[]) { const deduped = new Map<string, RoundupArticle>(); for (const windowResult of windowResults) { for (const article of windowResult.articles) { const key = article.articleId || article.canonicalUrl || `${article.sourceId}:${article.title}`.toLowerCase(); const existing = deduped.get(key); if (!existing) { deduped.set(key, { ...article, windows: [...article.windows] }); continue; } existing.windows = Array.from( new Set([...existing.windows, ...article.windows]), ).sort((left, right) => left - right); if (!existing.summary && article.summary) existing.summary = article.summary; if (!existing.whyMatched && article.whyMatched) { existing.whyMatched = article.whyMatched; } if (!existing.publishedAt && article.publishedAt) { existing.publishedAt = article.publishedAt; } if (!existing.canonicalUrl && article.canonicalUrl) { existing.canonicalUrl = article.canonicalUrl; } if ( existing.articleConfidence == null && article.articleConfidence != null ) { existing.articleConfidence = article.articleConfidence; } if (existing.dataAgeMs == null && article.dataAgeMs != null) { existing.dataAgeMs = article.dataAgeMs; } } } return Array.from(deduped.values()).sort((left, right) => { const leftTs = left.publishedAt ? Date.parse(left.publishedAt) : 0; const rightTs = right.publishedAt ? Date.parse(right.publishedAt) : 0; return rightTs - leftTs; }); } function buildArticleReason(article: RoundupArticle) { const reason = article.whyMatched?.trim() || article.summary?.trim() || "Material to current market sentiment."; return reason.length > 140 ? `${reason.slice(0, 137).trimEnd()}...` : reason; } function listHeadlines(articles: RoundupArticle[], count: number) { return articles .slice(0, count) .map((article) => article.title.trim()) .filter(Boolean); } function sentenceList(values: string[]) { if (values.length === 0) return ""; if (values.length === 1) return values[0] ?? ""; if (values.length === 2) return `${values[0]} and ${values[1]}`; return `${values.slice(0, -1).join(", ")}, and ${values[values.length - 1]}`; } async function fetchSearchCurrentWindow(params: { gatewayBase: string; config: NewsIntelligenceRoundupConfig; windowHours: number; }): Promise<WindowRoundupResult> { const response = await fetch(`${params.gatewayBase}/v1/news/search-current`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: params.config.query, asOf: params.config.asOf, limit: params.config.limitPerWindow, sourceIds: params.config.sourceIds, sourceFamilies: params.config.sourceFamilies, includePredictionMarkets: params.config.includePredictionMarkets, ingestOnRequest: params.config.ingestOnRequest, maxAgeHours: params.windowHours, }), }); if (!response.ok) { throw new Error( `search-current failed for ${params.windowHours}h window (${response.status})`, ); } const payload = (await response.json().catch(() => null)) as | SearchCurrentResponse | null; const articles = Array.isArray(payload?.articles) ? payload.articles : []; const dedupedArticles = new Map<string, RoundupArticle>(); for (const article of articles) { const key = articleIdentity(article); if (!key || dedupedArticles.has(key)) continue; dedupedArticles.set(key, withWindowTag(article, params.windowHours)); } return { windowHours: params.windowHours, articleCount: dedupedArticles.size, dataAgeMs: payload?.dataAgeMs ?? null, matchedEvents: Array.isArray(payload?.matchedEvents) ? payload.matchedEvents.map((event) => ({ eventId: event.eventId, eventKey: event.eventKey, title: event.title, taxonomy: event.taxonomy ?? null, state: event.state ?? null, stateConfidence: event.stateConfidence ?? null, })) : [], articles: Array.from(dedupedArticles.values()), }; } async function fetchLiveFeedWindow(params: { gatewayBase: string; config: NewsIntelligenceRoundupConfig; windowHours: number; }): Promise<WindowRoundupResult> { const response = await fetch(`${params.gatewayBase}/v1/news/live-feed`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ limit: params.config.limitPerWindow, sourceIds: params.config.sourceIds, sourceFamilies: params.config.sourceFamilies, maxAgeHours: params.windowHours, preset: "balanced", }), }); if (!response.ok) { throw new Error( `live-feed failed for ${params.windowHours}h window (${response.status})`, ); } const payload = (await response.json().catch(() => null)) as | LiveFeedResponse | null; const items = Array.isArray(payload?.items) ? payload.items : []; const dedupedArticles = new Map<string, RoundupArticle>(); const matchedEvents = new Map< string, { eventId: string; eventKey: string; title: string; taxonomy: string | null; state: string | null; stateConfidence: number | null; } >(); for (const item of items) { const key = articleIdentity(item); if (key && !dedupedArticles.has(key)) { dedupedArticles.set( key, withWindowTag( { articleId: item.articleId, title: item.title, sourceId: item.sourceId, sourceName: item.sourceName, sourceFamily: item.sourceFamily, canonicalUrl: item.canonicalUrl ?? null, publishedAt: item.publishedAt ?? null, summary: item.summary ?? null, whyMatched: item.mapping?.whyMatched ?? "Recent saved article", articleConfidence: null, dataAgeMs: item.dataAgeMs ?? null, }, params.windowHours ) ); } if (item.mapping?.eventId && item.mapping.eventKey && item.mapping.eventTitle) { matchedEvents.set(item.mapping.eventId, { eventId: item.mapping.eventId, eventKey: item.mapping.eventKey, title: item.mapping.eventTitle, taxonomy: item.mapping.taxonomy ?? null, state: item.mapping.eventState ?? null, stateConfidence: null, }); } } return { windowHours: params.windowHours, articleCount: dedupedArticles.size, dataAgeMs: payload?.dataAgeMs ?? null, matchedEvents: Array.from(matchedEvents.values()), articles: Array.from(dedupedArticles.values()), }; } async function fetchTopEventsWindow(params: { gatewayBase: string; config: NewsIntelligenceRoundupConfig; windowHours: number; }): Promise<WindowRoundupResult> { const response = await fetch(`${params.gatewayBase}/v1/news/top-events`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ limit: params.config.limitPerWindow, includePredictionMarkets: params.config.includePredictionMarkets === true, activeOnly: false, maxAgeHours: params.windowHours, preset: "balanced", }), }); if (!response.ok) { throw new Error( `top-events failed for ${params.windowHours}h window (${response.status})`, ); } const payload = (await response.json().catch(() => null)) as | TopEventResponse | null; const events = Array.isArray(payload?.events) ? payload.events : []; const articles = events.map((event, index) => { const primarySource = Array.isArray(event.sourceSummary) ? event.sourceSummary[0] : null; return withWindowTag( { articleId: `${event.eventId}:${primarySource?.sourceId ?? "event"}:${index}`, title: primarySource?.title ?? event.title, sourceId: primarySource?.sourceId ?? `event:${event.eventId}`, sourceName: primarySource?.sourceName ?? "Event summary", sourceFamily: "rss", canonicalUrl: primarySource?.canonicalUrl ?? null, publishedAt: primarySource?.publishedAt ?? event.latestEvidenceAt ?? null, summary: event.title, whyMatched: `Top ${event.eventState ?? "current"} event`, articleConfidence: event.eventConfidence ?? null, dataAgeMs: event.latestEvidenceAt != null ? Math.max(0, Date.now() - Date.parse(event.latestEvidenceAt)) : null, }, params.windowHours ); }); return { windowHours: params.windowHours, articleCount: articles.length, dataAgeMs: articles[0]?.dataAgeMs ?? null, matchedEvents: events.map((event) => ({ eventId: event.eventId, eventKey: event.eventKey, title: event.title, taxonomy: event.taxonomy ?? null, state: event.eventState ?? null, stateConfidence: event.eventConfidence ?? null, })), articles, }; } function buildFallbackSummary(params: { title: string; descriptor: string; windowResults: WindowRoundupResult[]; allArticles: RoundupArticle[]; summaryMaxArticles: number; }): RoundupSummary { const topArticles = params.allArticles.slice(0, params.summaryMaxArticles).map((article) => ({ articleId: article.articleId, title: article.title, sourceName: article.sourceName, canonicalUrl: article.canonicalUrl, publishedAt: article.publishedAt, reason: buildArticleReason(article), })); const headlineLabel = sentenceList(listHeadlines(params.allArticles, 3)); const overallSummary = params.allArticles.length === 0 ? `No major items were surfaced for ${params.descriptor}.` : headlineLabel ? `Market coverage is centered on ${headlineLabel}.` : `Recent market coverage was available for ${params.descriptor}.`; return { title: params.title, overallSummary, windowSummaries: params.windowResults.map((windowResult) => ({ windowHours: windowResult.windowHours, summary: (() => { if (windowResult.articleCount === 0) { return `No major market items stood out in the last ${windowResult.windowHours} hours.`; } const labels = sentenceList(listHeadlines(windowResult.articles, 2)); if (!labels) { return `${windowResult.articleCount} market-relevant items surfaced in the last ${windowResult.windowHours} hours.`; } return `In the last ${windowResult.windowHours} hours, the main focus was ${labels}.`; })(), topArticles: windowResult.articles .slice(0, params.summaryMaxArticles) .map((article) => ({ articleId: article.articleId, title: article.title, sourceName: article.sourceName, canonicalUrl: article.canonicalUrl, publishedAt: article.publishedAt, reason: buildArticleReason(article), })), })), topArticles, }; } function safeParseSummary(text: string): RoundupSummary | null { const direct = safeJsonParse(text); if (isRoundupSummary(direct)) return direct; const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start === -1 || end === -1 || end <= start) return null; return safeJsonParse(text.slice(start, end + 1)) as RoundupSummary | null; } function safeJsonParse(value: string): unknown | null { try { return JSON.parse(value) as unknown; } catch { return null; } } function isRoundupSummary(value: unknown): value is RoundupSummary { if (!value || typeof value !== "object") return false; const record = value as Record<string, unknown>; if (typeof record.title !== "string") return false; if (typeof record.overallSummary !== "string") return false; if (!Array.isArray(record.windowSummaries)) return false; if (!Array.isArray(record.topArticles)) return false; return true; } async function summarizeRoundup(params: { gatewayBase: string; config: NewsIntelligenceRoundupConfig; windowResults: WindowRoundupResult[]; allArticles: RoundupArticle[]; }): Promise<RoundupSummary> { const descriptor = params.config.mode === "query_search" && params.config.query ? `"${params.config.query}"` : normalizeModeLabel(params.config.mode); const editorialFocus = resolveEditorialFocus(params.config); if (params.allArticles.length === 0) { return { title: params.config.title, overallSummary: `No relevant items were found from ${descriptor} in the requested windows.`, windowSummaries: params.windowResults.map((windowResult) => ({ windowHours: windowResult.windowHours, summary: `No relevant items were found in the last ${windowResult.windowHours} hours.`, topArticles: [], })), topArticles: [], }; } if (params.config.mode !== "query_search") { return buildFallbackSummary({ title: params.config.title, descriptor, windowResults: params.windowResults, allArticles: params.allArticles, summaryMaxArticles: params.config.summaryMaxArticles, }); } const ai = createAIClient({ baseUrl: params.gatewayBase }); const summarySchema = { type: "object", additionalProperties: false, required: ["title", "overallSummary", "windowSummaries", "topArticles"], properties: { title: { type: "string" }, overallSummary: { type: "string" }, windowSummaries: { type: "array", items: { type: "object", additionalProperties: false, required: ["windowHours", "summary", "topArticles"], properties: { windowHours: { type: "number" }, summary: { type: "string" }, topArticles: { type: "array", items: { type: "object", additionalProperties: false, required: [ "articleId", "title", "sourceName", "canonicalUrl", "publishedAt", "reason", ], properties: { articleId: { type: "string" }, title: { type: "string" }, sourceName: { type: "string" }, canonicalUrl: { type: ["string", "null"] }, publishedAt: { type: ["string", "null"] }, reason: { type: "string" }, }, }, }, }, }, }, topArticles: { type: "array", items: { type: "object", additionalProperties: false, required: [ "articleId", "title", "sourceName", "canonicalUrl", "publishedAt", "reason", ], properties: { articleId: { type: "string" }, title: { type: "string" }, sourceName: { type: "string" }, canonicalUrl: { type: ["string", "null"] }, publishedAt: { type: ["string", "null"] }, reason: { type: "string" }, }, }, }, }, }; const candidateArticles = params.allArticles.slice( 0, Math.max(params.config.summaryMaxArticles * 3, 10), ); const result = await ai.generateText({ model: params.config.summaryModel ?? "gpt-4.1-mini", timeoutMs: 15_000, messages: [ { role: "system", content: "You summarize recent market news for traders. Return JSON only. Keep the overallSummary to 2-4 concise sentences. Focus on market-moving macro, geopolitics, policy, rates, inflation, oil, and crypto developments. Ignore sports, entertainment, celebrity, and other non-market items unless they clearly matter to markets. Do not mention excluded items. Use only supplied articleIds in topArticles. Each reason should be short and explain why the item matters.", }, { role: "user", content: JSON.stringify({ title: params.config.title, mode: params.config.mode, query: params.config.query, editorialFocus, summaryMaxArticles: params.config.summaryMaxArticles, windows: params.windowResults.map((windowResult) => ({ windowHours: windowResult.windowHours, articleCount: windowResult.articleCount, matchedEvents: windowResult.matchedEvents, articles: windowResult.articles.slice(0, params.config.summaryMaxArticles * 2).map((article) => ({ articleId: article.articleId, title: article.title, sourceName: article.sourceName, canonicalUrl: article.canonicalUrl, publishedAt: article.publishedAt, summary: article.summary, whyMatched: article.whyMatched, windows: article.windows, })), })), topArticleCandidates: candidateArticles.map((article) => ({ articleId: article.articleId, title: article.title, sourceName: article.sourceName, canonicalUrl: article.canonicalUrl, publishedAt: article.publishedAt, summary: article.summary, whyMatched: article.whyMatched, windows: article.windows, })), }), }, ], generation: { maxTokens: 900, responseFormat: { type: "json_schema", json_schema: { name: "news_intelligence_roundup", schema: summarySchema, }, }, }, }); const text = ensureTextContent(result.message, { errorMessage: "AI roundup summary did not include textual content.", }); const parsed = safeParseSummary(text); if (parsed) return parsed; return buildFallbackSummary({ title: params.config.title, descriptor, windowResults: params.windowResults, allArticles: params.allArticles, summaryMaxArticles: params.config.summaryMaxArticles, }); } export async function runNewsIntelligenceRoundup( config: NewsIntelligenceRoundupConfig, ): Promise<RoundupResult> { const gatewayBase = resolveGatewayBase(); if (!gatewayBase) { throw new Error("OPENPOND_GATEWAY_URL is required."); } const windowFetcher = config.mode === "recent_feed" ? fetchLiveFeedWindow : config.mode === "top_events" ? fetchTopEventsWindow : fetchSearchCurrentWindow; const windowResults = await Promise.all( config.windows.map((windowHours) => windowFetcher({ gatewayBase, config, windowHours, }), ), ); windowResults.sort((left, right) => left.windowHours - right.windowHours); const allArticles = mergeArticles(windowResults); const summary = await summarizeRoundup({ gatewayBase, config, windowResults, allArticles, }); return { ok: true, title: config.title, mode: config.mode, query: config.query, generatedAt: new Date().toISOString(), windows: [...config.windows], sourceIds: config.sourceIds, sourceFamilies: config.sourceFamilies, includePredictionMarkets: config.includePredictionMarkets, ingestOnRequest: config.ingestOnRequest, asOf: config.asOf, summary, windowResults, allArticles, }; }