Add grounded Ollama rewrite flow for chatbot
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user