The_AI_Herald / lib /generate.js
Gorantla
Initial commit: The AI Herald
27cc612
Raw
History Blame Contribute Delete
8.89 kB
const fs = require("fs");
const path = require("path");
const NEWS_API_KEY = process.env.NEWS_API_KEY;
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-3.5-flash";
const DATA_DIR = path.join(__dirname, "..", "public", "data");
const LATEST_PATH = path.join(DATA_DIR, "latest.json");
const GENERAL_QUERY =
'"artificial intelligence" OR "AI model" OR "large language model" OR OpenAI OR Anthropic OR "generative AI" OR "AI regulation"';
const AUTOMOTIVE_QUERY =
'"self-driving" OR "autonomous vehicle" OR "autonomous driving" OR "AI in cars" OR "automotive AI" OR Waymo OR "Tesla Autopilot" OR "ADAS" OR robotaxi OR "EV software"';
async function fetchArticlesForQuery(query, { pageSize = 40 } = {}) {
if (!NEWS_API_KEY) {
throw new Error("Missing NEWS_API_KEY environment variable");
}
const from = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0];
const url = `https://newsapi.org/v2/everything?q=${encodeURIComponent(
query
)}&from=${from}&sortBy=publishedAt&language=en&pageSize=${pageSize}&apiKey=${NEWS_API_KEY}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`NewsAPI error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
return data.articles || [];
}
async function fetchArticlePools() {
const [automotiveRaw, generalRaw] = await Promise.all([
fetchArticlesForQuery(AUTOMOTIVE_QUERY),
fetchArticlesForQuery(GENERAL_QUERY),
]);
const seen = new Set();
let nextId = 0;
function normalize(rawList, pool, limit) {
const out = [];
for (const a of rawList) {
if (!a.title || !a.url || !a.urlToImage || !a.description) continue;
const key = a.title.slice(0, 60).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push({
id: nextId++,
pool,
title: a.title,
description: a.description,
url: a.url,
image: a.urlToImage,
source: (a.source && a.source.name) || "Unknown",
publishedAt: a.publishedAt,
});
if (out.length >= limit) break;
}
return out;
}
const automotive = normalize(automotiveRaw, "automotive", 20);
const general = normalize(generalRaw, "general", 25);
return { automotive, general, all: [...automotive, ...general] };
}
async function selectAndWriteStories(pools) {
if (!GEMINI_API_KEY) {
throw new Error("Missing GEMINI_API_KEY environment variable");
}
const trim = ({ id, title, description, source, publishedAt }) => ({
id,
title,
description,
source,
publishedAt,
});
const automotiveList = pools.automotive.map(trim);
const generalList = pools.general.map(trim);
const systemPrompt =
'You are the editor of "The AI Herald," a weekly old-fashioned newspaper covering the artificial intelligence industry. You write in a punchy, classic newspaper style (think a 1940s press room, but reporting on 2020s AI news). You NEVER invent facts, sources, or article ids - only use the articles given to you. You are producing content for a strict 2-page print layout split into two sections, so you must be selective and concise.';
const userPrompt = `SECTION 1 - AI & AUTOMOTIVE candidate articles (JSON array, fields: id, title, description, source, publishedAt):
${JSON.stringify(automotiveList)}
SECTION 2 - GENERAL AI WORLD candidate articles (JSON array, same fields):
${JSON.stringify(generalList)}
The candidate lists above are large (up to 20-25 articles each), so you should almost always be able to hit these targets:
For SECTION 1 (AI & Automotive): select 1 lead story PLUS EXACTLY 5 secondary stories (6 total). Treat different vehicles/companies/angles on a broad theme (e.g. one Waymo safety story vs. a separate Waymo expansion story) as distinct if they report different facts - do not discard good articles just because they share a company name. Only go below 5 secondary stories if the list truly does not contain 6 non-duplicate, substantive, on-topic articles (rare, given the list size) - minimum 2 secondary stories in that case.
For SECTION 2 (General AI World): select 1 lead story PLUS EXACTLY 6 secondary stories (7 total), covering diverse topics (model releases, research, funding/business, products, regulation, major tech-company moves). Only go below 6 secondary stories if the list truly does not contain 7 non-duplicate, substantive, on-topic articles (rare, given the list size) - minimum 4 secondary stories in that case.
Never invent content or pad with filler - only reduce the count below the targets above when the candidate list genuinely lacks enough distinct, substantive articles.
Return ONLY a JSON object with this exact shape:
{
"automotive": {
"lead": { "id": <id>, "headline": "<punchy newspaper headline, ALL CAPS, max 12 words>", "summary": "<3-4 sentence newspaper-style summary>", "category": "<short category label>" },
"stories": [ { "id": <id>, "headline": "<headline, max 10 words>", "summary": "<2-3 sentence summary>", "category": "<short category label>" } ]
},
"general": {
"lead": { "id": <id>, "headline": "<punchy newspaper headline, ALL CAPS, max 12 words>", "summary": "<3-4 sentence newspaper-style summary>", "category": "<short category label>" },
"stories": [ { "id": <id>, "headline": "<headline, max 10 words>", "summary": "<2-3 sentence summary>", "category": "<short category label>" } ]
}
}
Only use ids that are present in the respective list given above - do not mix ids between sections.`;
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
systemInstruction: { parts: [{ text: systemPrompt }] },
contents: [{ role: "user", parts: [{ text: userPrompt }] }],
generationConfig: {
temperature: 0.7,
responseMimeType: "application/json",
},
}),
}
);
if (!res.ok) {
throw new Error(`Gemini error: ${res.status} ${await res.text()}`);
}
const completion = await res.json();
const candidate = completion.candidates && completion.candidates[0];
const content =
candidate &&
candidate.content &&
candidate.content.parts &&
candidate.content.parts[0] &&
candidate.content.parts[0].text;
if (!content) throw new Error("Gemini returned no content");
return JSON.parse(content);
}
function mergeSection(sectionSelection, poolArticles) {
const byId = new Map(poolArticles.map((a) => [a.id, a]));
function attach(item) {
if (!item) return null;
const src = byId.get(item.id);
if (!src) return null;
return {
headline: item.headline,
summary: item.summary,
category: item.category,
source: src.source,
url: src.url,
image: src.image,
publishedAt: src.publishedAt,
};
}
const lead = attach(sectionSelection && sectionSelection.lead);
const stories = ((sectionSelection && sectionSelection.stories) || [])
.map(attach)
.filter(Boolean);
if (!lead) throw new Error("Section lead id did not match any fetched article");
return { lead, stories };
}
function getVolumeNumber(date) {
const start = new Date(date.getFullYear(), 0, 1);
const diff = date - start;
const oneWeek = 7 * 24 * 60 * 60 * 1000;
return Math.ceil(diff / oneWeek);
}
async function generateNewsletter() {
const pools = await fetchArticlePools();
if (pools.general.length < 5) {
throw new Error("Not enough qualifying general AI articles returned from NewsAPI");
}
if (pools.automotive.length < 3) {
throw new Error("Not enough qualifying automotive AI articles returned from NewsAPI");
}
const selection = await selectAndWriteStories(pools);
const automotive = mergeSection(selection.automotive, pools.automotive);
const general = mergeSection(selection.general, pools.general);
const now = new Date();
const issue = {
issueDate: now.toISOString(),
issueLabel: now.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}),
volume: getVolumeNumber(now),
automotive,
general,
};
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
fs.writeFileSync(LATEST_PATH, JSON.stringify(issue, null, 2));
const archivePath = path.join(
DATA_DIR,
`archive-${now.toISOString().split("T")[0]}.json`
);
fs.writeFileSync(archivePath, JSON.stringify(issue, null, 2));
return issue;
}
function getLatestNewsletter() {
if (!fs.existsSync(LATEST_PATH)) return null;
return JSON.parse(fs.readFileSync(LATEST_PATH, "utf8"));
}
module.exports = { generateNewsletter, getLatestNewsletter };