Add grounded Ollama rewrite flow for chatbot

This commit is contained in:
nmemmert
2026-04-14 12:46:56 -04:00
parent 50459b7234
commit 87967bf149
3 changed files with 333 additions and 9 deletions
+170
View File
@@ -55,6 +55,15 @@ const BACKUP_RETENTION_DAYS = 30
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'change-me-admin-password'
const CHATBOT_LLM_ENABLED = process.env.CHATBOT_LLM_ENABLED === 'true'
const CHATBOT_LLM_BASE_URL = process.env.CHATBOT_LLM_BASE_URL ?? 'http://127.0.0.1:11434'
const CHATBOT_LLM_MODEL = process.env.CHATBOT_LLM_MODEL ?? 'qwen2.5:3b-instruct'
const CHATBOT_LLM_TIMEOUT_MS = Number(process.env.CHATBOT_LLM_TIMEOUT_MS) > 0
? Number(process.env.CHATBOT_LLM_TIMEOUT_MS)
: 25000
const CHATBOT_LLM_NUM_CTX = Number(process.env.CHATBOT_LLM_NUM_CTX) > 0
? Number(process.env.CHATBOT_LLM_NUM_CTX)
: 2048
const EMPTY_VISITOR_STATS = {
totalVisits: 0,
@@ -784,12 +793,173 @@ async function refreshChatbotFromDiskIfChanged() {
}
}
function normalizeChatbotLlmContext(chunks) {
if (!Array.isArray(chunks)) return []
return chunks
.filter(chunk => chunk && typeof chunk.content === 'string')
.slice(0, 4)
.map((chunk, index) => {
const title = typeof chunk.title === 'string' ? chunk.title.trim().slice(0, 160) : `Context ${index + 1}`
const sourceLabel = typeof chunk.sourceLabel === 'string' && chunk.sourceLabel.trim()
? chunk.sourceLabel.trim().slice(0, 200)
: title
const content = chunk.content.trim().slice(0, 1400)
return {
title,
sourceLabel,
content,
}
})
.filter(chunk => chunk.content.length > 0)
}
function buildGroundedRewritePrompt({ question, draftAnswer, sources, contextChunks }) {
const sourceList = Array.isArray(sources) && sources.length > 0
? sources.slice(0, 6).map(source => `- ${String(source).slice(0, 220)}`).join('\n')
: '- No explicit source labels provided'
const contextBlock = contextChunks.length > 0
? contextChunks
.map((chunk, index) => (
`Context ${index + 1}: ${chunk.sourceLabel}\nTitle: ${chunk.title}\nExcerpt: ${chunk.content}`
))
.join('\n\n')
: 'No context excerpts were provided.'
return [
'You are rewriting a Bible-study chatbot answer.',
'Hard rules:',
'1) Use ONLY facts in DRAFT ANSWER and CONTEXT EXCERPTS.',
'2) If DRAFT ANSWER is uncertain or says content is missing, keep that uncertainty and do not fill gaps.',
'3) Do not add verses, names, history, or claims not present in the provided text.',
'4) Keep a warm pastoral tone but concise.',
'5) Return plain text only. No markdown headers, no bullets unless needed for clarity.',
'',
`QUESTION:\n${question}`,
'',
`DRAFT ANSWER:\n${draftAnswer}`,
'',
`SOURCES:\n${sourceList}`,
'',
`CONTEXT EXCERPTS:\n${contextBlock}`,
].join('\n')
}
async function rewriteWithChatbotLlm({ question, draftAnswer, sources, contextChunks }) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), CHATBOT_LLM_TIMEOUT_MS)
try {
const prompt = buildGroundedRewritePrompt({
question,
draftAnswer,
sources,
contextChunks,
})
const response = await fetch(`${CHATBOT_LLM_BASE_URL.replace(/\/$/, '')}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
model: CHATBOT_LLM_MODEL,
stream: false,
options: {
temperature: 0.2,
num_ctx: CHATBOT_LLM_NUM_CTX,
},
messages: [
{
role: 'system',
content: 'Rewrite grounded answers faithfully. Never add information not present in the provided draft/context.',
},
{
role: 'user',
content: prompt,
},
],
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(`LLM request failed (${response.status}): ${text.slice(0, 220)}`)
}
const payload = await response.json()
const rewritten = typeof payload?.message?.content === 'string'
? payload.message.content.trim()
: ''
if (!rewritten) {
throw new Error('LLM returned an empty response')
}
return rewritten.slice(0, 3500)
} finally {
clearTimeout(timeout)
}
}
// Public: return all chatbot entries for client-side matching
app.get('/api/chatbot-content', async (req, res) => {
await refreshChatbotFromDiskIfChanged()
res.json(chatbotEntries)
})
// Optional: grounded rewrite endpoint for local Ollama usage.
app.post('/api/chatbot-grounded-rewrite', async (req, res) => {
if (!CHATBOT_LLM_ENABLED) {
res.status(503).json({
ok: false,
message: 'Chatbot LLM is disabled. Set CHATBOT_LLM_ENABLED=true to enable.',
})
return
}
const question = typeof req.body?.question === 'string' ? req.body.question.trim() : ''
const draftAnswer = typeof req.body?.draftAnswer === 'string' ? req.body.draftAnswer.trim() : ''
const sources = Array.isArray(req.body?.sources)
? req.body.sources.filter(source => typeof source === 'string').slice(0, 8)
: []
const contextChunks = normalizeChatbotLlmContext(req.body?.contextChunks)
if (!question) {
res.status(400).json({ ok: false, message: 'Missing required field: question' })
return
}
if (!draftAnswer) {
res.status(400).json({ ok: false, message: 'Missing required field: draftAnswer' })
return
}
try {
const rewrittenAnswer = await rewriteWithChatbotLlm({
question,
draftAnswer,
sources,
contextChunks,
})
res.json({
ok: true,
text: rewrittenAnswer,
model: CHATBOT_LLM_MODEL,
grounded: true,
})
} catch (err) {
console.error('[chatbot-llm] rewrite failed:', err)
res.status(502).json({
ok: false,
message: 'LLM rewrite failed. Returning deterministic draft is recommended.',
error: String(err),
})
}
})
// Admin: get all entries
app.get('/api/admin/chatbot-content', async (req, res) => {
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }