← back to Norma
agents/datasource-agent/skills/rate-sources.js
264 lines
/**
* Rate Sources Skill
*
* Rates unrated items across all 3 source tables (discovered_apis,
* newspaper_frontpages, nonprofit_statements) using Gemini 2.0 Flash
* to score relevance against current pulse_topics.
*/
const fetch = require('node-fetch');
const { logAction } = require('../../shared/audit-logger');
const AGENT = 'datasource-agent';
const PLATFORM = 'rating';
const GEMINI_URL =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GOOGLE_API_KEY}';
const DB_URL = process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/sdcc';
let _pool;
function getPool() {
if (!_pool) {
const { Pool } = require('pg');
_pool = new Pool({ connectionString: DB_URL, max: 5 });
}
return _pool;
}
/**
* Get current pulse topics for relevance scoring context.
*
* @returns {Promise<string[]>}
*/
async function getPulseTopics() {
const pool = getPool();
try {
const { rows } = await pool.query(
`SELECT name, category, urgency FROM pulse_topics ORDER BY created_at DESC LIMIT 20`
);
return rows.map((r) => `${r.name} (${r.category}, ${r.urgency})`);
} catch (err) {
console.error(`[${AGENT}] Error fetching pulse topics:`, err.message);
// Fallback default topics
return [
'nonprofit advocacy',
'civic engagement',
'education policy',
'economic inequality',
'social impact',
'consumer finance protection',
'public benefits access',
];
}
}
/**
* Score an item with Gemini against current pulse topics.
*
* @param {string} itemName - Name/title of the item
* @param {string} itemDescription - Description/content of the item
* @param {string[]} pulseTopics - Current pulse topics
* @returns {Promise<{ score: number, topics: string[] }>}
*/
async function geminiScoreItem(itemName, itemDescription, pulseTopics) {
try {
const topicsList = pulseTopics.join(', ');
const prompt = `Rate the following item's relevance (0-100) to these active topics: ${topicsList}
Item: ${itemName}
Details: ${(itemDescription || 'No details available').substring(0, 500)}
Respond in EXACTLY this JSON format (no markdown, no code fences):
{"score": 50, "topics": ["matched topic 1", "matched topic 2"]}`;
const res = await fetch(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: 300 },
}),
});
const data = await res.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
return {
score: Math.min(100, Math.max(0, Number(parsed.score) || 0)),
topics: Array.isArray(parsed.topics) ? parsed.topics : [],
};
}
return { score: 0, topics: [] };
} catch (err) {
console.error(`[${AGENT}] Gemini scoring error for "${itemName}":`, err.message);
return { score: 0, topics: [] };
}
}
/**
* Rate unrated discovered APIs.
*
* @param {string[]} pulseTopics - Current pulse topics
* @returns {Promise<number>} Number rated
*/
async function rateApis(pulseTopics) {
const pool = getPool();
let rated = 0;
try {
const { rows } = await pool.query(
`SELECT id, name, description FROM discovered_apis
WHERE relevance_score = 0
ORDER BY created_at DESC
LIMIT 20`
);
for (const row of rows) {
const result = await geminiScoreItem(row.name, row.description, pulseTopics);
await pool.query(
`UPDATE discovered_apis SET relevance_score = $1, match_topics = $2, status = $3 WHERE id = $4`,
[result.score, result.topics, result.score >= 50 ? 'relevant' : 'discovered', row.id]
);
rated++;
await new Promise((r) => setTimeout(r, 300));
}
} catch (err) {
console.error(`[${AGENT}] Error rating APIs:`, err.message);
}
return rated;
}
/**
* Rate unrated newspaper frontpages from today.
*
* @param {string[]} pulseTopics - Current pulse topics
* @returns {Promise<number>} Number rated
*/
async function rateFrontpages(pulseTopics) {
const pool = getPool();
let rated = 0;
try {
const { rows } = await pool.query(
`SELECT id, newspaper_name, top_headline, headlines FROM newspaper_frontpages
WHERE relevance_score = 0 AND scraped_date = CURRENT_DATE
ORDER BY created_at DESC
LIMIT 20`
);
for (const row of rows) {
// Build description from top headline and first few headlines
let desc = row.top_headline || '';
try {
const headlines = typeof row.headlines === 'string' ? JSON.parse(row.headlines) : row.headlines;
if (Array.isArray(headlines)) {
desc += ' | Headlines: ' + headlines.slice(0, 5).map((h) => h.title || '').join('; ');
}
} catch (e) { /* ignore */ }
const result = await geminiScoreItem(row.newspaper_name, desc, pulseTopics);
await pool.query(
`UPDATE newspaper_frontpages SET relevance_score = $1, match_topics = $2 WHERE id = $3`,
[result.score, result.topics, row.id]
);
rated++;
await new Promise((r) => setTimeout(r, 300));
}
} catch (err) {
console.error(`[${AGENT}] Error rating frontpages:`, err.message);
}
return rated;
}
/**
* Rate unrated nonprofit statements.
*
* @param {string[]} pulseTopics - Current pulse topics
* @returns {Promise<number>} Number rated
*/
async function rateStatements(pulseTopics) {
const pool = getPool();
let rated = 0;
try {
const { rows } = await pool.query(
`SELECT id, org_name, statement_title, statement_body FROM nonprofit_statements
WHERE relevance_score = 0
ORDER BY created_at DESC
LIMIT 20`
);
for (const row of rows) {
const desc = (row.statement_body || row.statement_title || '').substring(0, 500);
const result = await geminiScoreItem(
`${row.org_name}: ${row.statement_title}`,
desc,
pulseTopics
);
await pool.query(
`UPDATE nonprofit_statements SET relevance_score = $1, match_topics = $2 WHERE id = $3`,
[result.score, result.topics, row.id]
);
rated++;
await new Promise((r) => setTimeout(r, 300));
}
} catch (err) {
console.error(`[${AGENT}] Error rating statements:`, err.message);
}
return rated;
}
/**
* Main rate-sources skill handler.
*
* @param {Object} body - Request body (unused)
* @returns {Promise<Object>} Rating results
*/
module.exports = async function rateSources(body = {}) {
console.log(`[${AGENT}] Rating unscored sources...`);
// Get current pulse topics for context
const pulseTopics = await getPulseTopics();
console.log(`[${AGENT}] Using ${pulseTopics.length} pulse topics for scoring`);
// Rate all three source types
const apiCount = await rateApis(pulseTopics);
const frontpageCount = await rateFrontpages(pulseTopics);
const statementCount = await rateStatements(pulseTopics);
const total = apiCount + frontpageCount + statementCount;
await logAction({
agent: AGENT,
actionType: 'rate',
platform: PLATFORM,
content: `Rated ${total} items across 3 source types`,
responseData: {
apis: apiCount,
frontpages: frontpageCount,
statements: statementCount,
total,
pulse_topics_used: pulseTopics.length,
},
status: 'success',
});
return {
rated: {
apis: apiCount,
frontpages: frontpageCount,
statements: statementCount,
},
total_rated: total,
pulse_topics_count: pulseTopics.length,
rated_at: new Date().toISOString(),
};
};