← back to Professional Directory
agents/api-agent/routes/chat.js
100 lines
/**
* /api/chat — AI chat for the per-org mockup pages.
*
* Each org's mockup gets a floating chat widget that lets visitors ask
* questions about the practice (hours, services, location, providers).
* The chat uses LOCAL Ollama gemma3:12b on MS2 (no Anthropic, no OpenAI),
* with a system prompt populated from the org's actual data so answers
* stay grounded.
*
* POST /api/chat
* body: { orgId: number, message: string, history?: [{role, content}] }
* resp: { reply: string }
*/
const express = require('express');
const { fetch } = require('undici');
const { query } = require('../../shared/db');
const router = express.Router();
const OLLAMA = process.env.OLLAMA_BASE || 'http://127.0.0.1:11434';
const MODEL = process.env.CHAT_MODEL || 'gemma3:12b';
async function buildContext(orgId) {
const o = (await query(`SELECT id, name, type, address, city, state, zip, phone, website FROM organizations WHERE id=$1 AND opted_out=false`, [orgId])).rows[0];
if (!o) return null;
const pros = (await query(`
SELECT p.full_name, p.title, p.primary_specialty
FROM professional_locations pl JOIN professionals p ON p.id=pl.professional_id
WHERE pl.organization_id=$1 AND p.opted_out=false
ORDER BY p.source_confidence_score DESC NULLS LAST LIMIT 8`, [orgId])).rows;
const emails = (await query(`SELECT email, email_type FROM emails WHERE organization_id=$1 LIMIT 3`, [orgId])).rows;
return { o, pros, emails };
}
function systemPrompt(ctx) {
const { o, pros, emails } = ctx;
const lines = [
`You are a friendly receptionist for "${o.name}" (a ${o.type.replace(/_/g,' ')}) in ${o.city || 'Los Angeles County'}.`,
`Answer questions ONLY about this practice. If you don't know, say so and suggest the visitor call.`,
`Be brief — 2-3 sentences max. No medical advice; for clinical questions, tell them to call the office.`,
``,
`--- Practice details ---`,
o.address ? `Address: ${o.address}, ${o.city}, ${o.state} ${o.zip}` : '',
o.phone ? `Phone: ${o.phone}` : '',
o.website ? `Website: ${o.website}` : '',
emails.length ? `Email: ${emails.map(e => e.email).join(', ')}` : '',
pros.length ? `\nProviders on staff: ${pros.map(p => `${p.full_name}${p.title ? ', ' + p.title : ''}${p.primary_specialty ? ' (' + p.primary_specialty + ')' : ''}`).join('; ')}` : '',
``,
`Hours (default): Mon-Fri 9 AM - 5 PM. Sat by appointment. Closed Sun.`,
].filter(Boolean);
return lines.join('\n');
}
router.post('/', async (req, res, next) => {
try {
const orgId = Number(req.body?.orgId);
const message = String(req.body?.message || '').slice(0, 800).trim();
if (!orgId || !message) return res.status(400).json({ error: 'orgId + message required' });
const ctx = await buildContext(orgId);
if (!ctx) return res.status(404).json({ error: 'org not found' });
const sys = systemPrompt(ctx);
const history = Array.isArray(req.body?.history) ? req.body.history.slice(-6) : [];
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 30_000);
let reply = '';
try {
const r = await fetch(`${OLLAMA}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MODEL,
stream: false,
options: { temperature: 0.4, num_ctx: 4096 },
messages: [
{ role: 'system', content: sys },
...history,
{ role: 'user', content: message },
],
}),
signal: ac.signal,
});
clearTimeout(t);
if (!r.ok) {
return res.status(502).json({ error: 'Ollama upstream error' });
}
const j = await r.json();
reply = j?.message?.content || '(no response)';
} catch (e) {
clearTimeout(t);
return res.status(504).json({ error: 'Ollama timeout — check that gemma3:12b is loaded on MS2' });
}
res.json({ reply, model: MODEL });
} catch (e) { next(e); }
});
module.exports = router;