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,
};
}