← back to Professional Directory
scripts/seed-reviews-from-reddit.js
79 lines
#!/usr/bin/env node
/**
* Seed `reviews` from `org_signals` where source='reddit'.
*
* For each Reddit signal we don't already have a review for:
* 1. Send title+body to local Ollama (gemma3:4b) for sentiment + 1-5 score.
* 2. Insert a review with source='reddit', source_url=permalink,
* reviewer_display=reddit username, scores from LLM analysis.
* 3. Skip if we've already seeded that URL.
*
* Run nightly via launchd (com.steve.pd-reddit-reviews-nightly).
*/
const { fetch } = require('undici');
const { pool, query } = require('../agents/shared/db');
const OLLAMA = process.env.OLLAMA_BASE || 'http://127.0.0.1:11434';
const MODEL = process.env.SENTIMENT_MODEL || 'gemma3:4b';
async function classifySentiment(title, body) {
const text = (title + '\n\n' + body).slice(0, 4000);
const prompt = `Classify the sentiment of this Reddit post about a healthcare practice. Output STRICT JSON only:
{"score": 1-5, "summary": "one sentence summary of the patient's actual experience"}
Score 1=terrible, 3=neutral, 5=excellent. If the post is just a question without an experience, output {"score": null, "summary": "asking for recommendations"}.
Post:
"""
${text}
"""
JSON:`;
try {
const r = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, prompt, stream: false, format: 'json',
options: { temperature: 0.2, num_ctx: 4096 } }),
signal: AbortSignal.timeout(45_000),
});
if (!r.ok) return null;
const j = await r.json();
const out = JSON.parse(j.response || '{}');
const score = out.score != null ? Math.max(1, Math.min(5, Math.round(Number(out.score)))) : null;
return { score, summary: String(out.summary || '').slice(0, 1000) };
} catch { return null; }
}
async function main() {
const r = await query(`
SELECT s.id AS signal_id, s.organization_id, s.title, s.body, s.author, s.posted_at, s.url
FROM org_signals s
WHERE s.source = 'reddit'
AND NOT EXISTS (SELECT 1 FROM reviews rv WHERE rv.source_url = s.url)
ORDER BY s.id
LIMIT ${Number(process.env.LIMIT) || 1000}
`);
console.log(`[reddit→reviews] candidates=${r.rowCount} model=${MODEL}`);
let ok = 0, skipped = 0, fail = 0;
for (const s of r.rows) {
const sent = await classifySentiment(s.title || '', s.body || '');
if (!sent || sent.score == null) { skipped++; continue; }
try {
await query(`
INSERT INTO reviews (target_organization_id, reviewer_display,
overall_score, body, source, source_url, source_posted_at)
VALUES ($1, $2, $3, $4, 'reddit', $5, $6)
ON CONFLICT (source_url) DO NOTHING
`, [s.organization_id, s.author || 'reddit user', sent.score, (sent.summary || s.title || s.body || '').slice(0, 5000), s.url, s.posted_at || null]);
ok++;
if (ok % 10 === 0) console.log(`[reddit→reviews] ok=${ok} skipped=${skipped} fail=${fail}`);
} catch (e) { fail++; }
}
console.log(`[reddit→reviews] done. ok=${ok} skipped=${skipped} fail=${fail}`);
await pool.end();
}
main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });