Compare commits
5 Commits
50459b7234
...
e815d7702e
| Author | SHA1 | Date | |
|---|---|---|---|
| e815d7702e | |||
| db942fd734 | |||
| 67b7d1024b | |||
| e6080349af | |||
| 87967bf149 |
@@ -57,6 +57,50 @@ Admin auth:
|
|||||||
|
|
||||||
The app will be available at `http://localhost:4173`.
|
The app will be available at `http://localhost:4173`.
|
||||||
|
|
||||||
|
## Optional local LLM (Ollama) for grounded rewrites
|
||||||
|
|
||||||
|
You can keep deterministic retrieval as the source-of-truth and optionally rewrite responses with a local model.
|
||||||
|
|
||||||
|
1. Install and run Ollama on your host.
|
||||||
|
2. Pull a small model suited to older hardware, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama pull qwen2.5:3b-instruct
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the API with these environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CHATBOT_LLM_ENABLED=true
|
||||||
|
CHATBOT_LLM_BASE_URL=http://127.0.0.1:11434
|
||||||
|
CHATBOT_LLM_MODEL=qwen2.5:3b-instruct
|
||||||
|
CHATBOT_LLM_TIMEOUT_MS=25000
|
||||||
|
CHATBOT_LLM_NUM_CTX=2048
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Call the rewrite endpoint from your existing chat flow:
|
||||||
|
|
||||||
|
`POST /api/chatbot-grounded-rewrite`
|
||||||
|
|
||||||
|
Request payload shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question": "Who was Titus?",
|
||||||
|
"draftAnswer": "Deterministic answer produced by current retrieval/synthesis.",
|
||||||
|
"sources": ["Episode 2 - Introduction to Titus"],
|
||||||
|
"contextChunks": [
|
||||||
|
{
|
||||||
|
"title": "Episode 2 - Introduction to Titus",
|
||||||
|
"sourceLabel": "Episode 2",
|
||||||
|
"content": "Titus was a Gentile..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the endpoint fails or is disabled, keep your deterministic answer and existing fallback behavior.
|
||||||
|
|
||||||
Persistent admin saves:
|
Persistent admin saves:
|
||||||
|
|
||||||
- Admin updates are written to `data/admin-content.json`.
|
- Admin updates are written to `data/admin-content.json`.
|
||||||
|
|||||||
+441
-238
File diff suppressed because one or more lines are too long
@@ -0,0 +1,194 @@
|
|||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const ROOT = '/Users/nate.emmert/Documents/github/Siteforge'
|
||||||
|
const DOCS_DIR = path.join(ROOT, 'Verse by Verse with Nate Complete Series')
|
||||||
|
const CHATBOT_FILE = path.join(ROOT, 'data', 'chatbot-content.json')
|
||||||
|
|
||||||
|
const STOP_WORDS = new Set([
|
||||||
|
'the', 'and', 'for', 'that', 'with', 'this', 'from', 'your', 'you', 'are', 'but', 'not', 'have',
|
||||||
|
'has', 'was', 'were', 'his', 'her', 'our', 'their', 'into', 'about', 'what', 'when', 'where',
|
||||||
|
'which', 'will', 'just', 'they', 'them', 'then', 'than', 'how', 'why', 'can', 'all', 'through',
|
||||||
|
])
|
||||||
|
|
||||||
|
function parseEpisodeNumber(filePath) {
|
||||||
|
const match = path.basename(filePath).match(/Episode(\d+)/i)
|
||||||
|
return match ? Number(match[1]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariantRank(filePath) {
|
||||||
|
const name = path.basename(filePath).toLowerCase()
|
||||||
|
let score = 0
|
||||||
|
if (name.includes('expanded')) score += 30
|
||||||
|
if (name.includes('updated')) score += 20
|
||||||
|
if (!name.includes('expanded') && !name.includes('updated')) score += 10
|
||||||
|
if (filePath.includes(`${path.sep}Done${path.sep}Old${path.sep}`)) score -= 25
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectDocxFiles(dir) {
|
||||||
|
const out = []
|
||||||
|
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = path.join(dir, item.name)
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
out.push(...await collectDocxFiles(fullPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!item.isFile()) continue
|
||||||
|
if (!item.name.toLowerCase().endsWith('.docx')) continue
|
||||||
|
if (item.name.startsWith('~$')) continue
|
||||||
|
out.push(fullPath)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestPerEpisode(docxFiles) {
|
||||||
|
const byEpisode = new Map()
|
||||||
|
|
||||||
|
for (const filePath of docxFiles) {
|
||||||
|
const episode = parseEpisodeNumber(filePath)
|
||||||
|
if (!episode) continue
|
||||||
|
|
||||||
|
const current = byEpisode.get(episode)
|
||||||
|
const next = {
|
||||||
|
filePath,
|
||||||
|
episode,
|
||||||
|
rank: getVariantRank(filePath),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current || next.rank > current.rank) {
|
||||||
|
byEpisode.set(episode, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byEpisode.values()].sort((a, b) => a.episode - b.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDocText(filePath) {
|
||||||
|
const output = execFileSync('textutil', ['-convert', 'txt', '-stdout', filePath], { encoding: 'utf8' })
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContent(text) {
|
||||||
|
const lines = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(line => line.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const filtered = lines.filter(line => {
|
||||||
|
const upper = line.toUpperCase()
|
||||||
|
if (upper === 'VERSE BY VERSE WITH NATE') return false
|
||||||
|
if (upper === 'A JOURNEY THROUGH SCRIPTURE') return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered.join(' ').replace(/\s{2,}/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKeywords(title, content, existingKeywords = []) {
|
||||||
|
const tokens = `${title} ${content.slice(0, 1600)}`
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s:-]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(token => token.length >= 3 && !STOP_WORDS.has(token))
|
||||||
|
|
||||||
|
const counts = new Map()
|
||||||
|
for (const token of tokens) {
|
||||||
|
counts.set(token, (counts.get(token) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = [...counts.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(([token]) => token)
|
||||||
|
|
||||||
|
return [...new Set([...(existingKeywords ?? []), ...top])].slice(0, 25)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEpisodeFromTitle(title = '') {
|
||||||
|
const match = title.match(/Episode\s+(\d+)/i)
|
||||||
|
return match ? Number(match[1]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntryTitleFallback(episodeNumber, rawText, existingTitle) {
|
||||||
|
if (existingTitle && existingTitle.trim()) return existingTitle
|
||||||
|
|
||||||
|
const lineMatch = rawText.match(new RegExp(`EPISODE\\s+${episodeNumber}\\s*[—-]\\s*([^\\n]+)`, 'i'))
|
||||||
|
if (lineMatch) {
|
||||||
|
return `Episode ${episodeNumber} — ${lineMatch[1].trim()}`
|
||||||
|
}
|
||||||
|
return `Episode ${episodeNumber}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const raw = await fs.readFile(CHATBOT_FILE, 'utf8')
|
||||||
|
const entries = JSON.parse(raw)
|
||||||
|
|
||||||
|
const docxFiles = await collectDocxFiles(DOCS_DIR)
|
||||||
|
const selected = pickBestPerEpisode(docxFiles)
|
||||||
|
|
||||||
|
const existingByEpisode = new Map()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const episode = getEpisodeFromTitle(entry.title)
|
||||||
|
if (episode) existingByEpisode.set(episode, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
let updated = 0
|
||||||
|
let added = 0
|
||||||
|
|
||||||
|
for (const item of selected) {
|
||||||
|
const rawText = extractDocText(item.filePath)
|
||||||
|
const content = normalizeContent(rawText)
|
||||||
|
if (!content) continue
|
||||||
|
|
||||||
|
const existing = existingByEpisode.get(item.episode)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.type = 'episode'
|
||||||
|
existing.title = getEntryTitleFallback(item.episode, rawText, existing.title)
|
||||||
|
existing.content = content
|
||||||
|
existing.keywords = buildKeywords(existing.title, content, existing.keywords)
|
||||||
|
existing.updatedAt = now
|
||||||
|
updated += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
type: 'episode',
|
||||||
|
title: getEntryTitleFallback(item.episode, rawText, ''),
|
||||||
|
content,
|
||||||
|
keywords: buildKeywords(`Episode ${item.episode}`, content, []),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const aEp = getEpisodeFromTitle(a.title)
|
||||||
|
const bEp = getEpisodeFromTitle(b.title)
|
||||||
|
if (aEp && bEp) return aEp - bEp
|
||||||
|
if (aEp && !bEp) return 1
|
||||||
|
if (!aEp && bEp) return -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
await fs.writeFile(CHATBOT_FILE, `${JSON.stringify(entries, null, 2)}\n`)
|
||||||
|
|
||||||
|
console.log(`Episodes selected from docs: ${selected.length}`)
|
||||||
|
console.log(`Updated entries: ${updated}`)
|
||||||
|
console.log(`Added entries: ${added}`)
|
||||||
|
for (const item of selected) {
|
||||||
|
console.log(`- Episode ${item.episode}: ${path.relative(ROOT, item.filePath)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(error => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
@@ -55,6 +55,15 @@ const BACKUP_RETENTION_DAYS = 30
|
|||||||
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||||
const ADMIN_SESSION_TTL_MS = 7 * 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 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 = {
|
const EMPTY_VISITOR_STATS = {
|
||||||
totalVisits: 0,
|
totalVisits: 0,
|
||||||
@@ -80,6 +89,32 @@ let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
|||||||
let lastHitStatsWrite = { ok: true, at: null, error: null }
|
let lastHitStatsWrite = { ok: true, at: null, error: null }
|
||||||
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
||||||
const adminSessions = new Map()
|
const adminSessions = new Map()
|
||||||
|
const llmResponseCache = new Map()
|
||||||
|
const MAX_CACHE_SIZE = 500
|
||||||
|
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
function getCacheKey(question, draftAnswer) {
|
||||||
|
return sha256(`${question}||${draftAnswer}`).slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedResponse(cacheKey) {
|
||||||
|
const cached = llmResponseCache.get(cacheKey)
|
||||||
|
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||||
|
return cached.text
|
||||||
|
}
|
||||||
|
if (cached) {
|
||||||
|
llmResponseCache.delete(cacheKey)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedResponse(cacheKey, text) {
|
||||||
|
if (llmResponseCache.size >= MAX_CACHE_SIZE) {
|
||||||
|
const firstKey = llmResponseCache.keys().next().value
|
||||||
|
if (firstKey) llmResponseCache.delete(firstKey)
|
||||||
|
}
|
||||||
|
llmResponseCache.set(cacheKey, { text, at: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
function sha256(value) {
|
function sha256(value) {
|
||||||
return createHash('sha256').update(value).digest('hex')
|
return createHash('sha256').update(value).digest('hex')
|
||||||
@@ -784,12 +819,188 @@ 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 a Bible study assistant helping rewrite responses to be clearer and more pastoral.',
|
||||||
|
'',
|
||||||
|
'HARD RULES:',
|
||||||
|
'1) Use ONLY facts from DRAFT ANSWER and CONTEXT EXCERPTS. Never add new information.',
|
||||||
|
'2) If the draft is uncertain or incomplete, preserve that. Do not fill gaps or speculate.',
|
||||||
|
'3) Do not invent verses, names, historical details, or theological claims.',
|
||||||
|
'4) Maintain a warm, encouraging pastoral tone—like Nate teaching directly.',
|
||||||
|
'5) Keep sentences clear and direct. Avoid jargon unless biblical.',
|
||||||
|
'6) Return plain text only. Use paragraph breaks but no markdown formatting.',
|
||||||
|
'',
|
||||||
|
'GOALS:',
|
||||||
|
'- Help the reader understand Scripture better',
|
||||||
|
'- Stay faithful to Nate\'s teaching and tone',
|
||||||
|
'- Be encouraging but honest about limitations',
|
||||||
|
'',
|
||||||
|
`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)
|
||||||
|
const cacheKey = getCacheKey(question, draftAnswer)
|
||||||
|
const cached = getCachedResponse(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = rewritten.slice(0, 3500)
|
||||||
|
setCachedResponse(cacheKey, result)
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public: return all chatbot entries for client-side matching
|
// Public: return all chatbot entries for client-side matching
|
||||||
app.get('/api/chatbot-content', async (req, res) => {
|
app.get('/api/chatbot-content', async (req, res) => {
|
||||||
await refreshChatbotFromDiskIfChanged()
|
await refreshChatbotFromDiskIfChanged()
|
||||||
res.json(chatbotEntries)
|
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
|
// Admin: get all entries
|
||||||
app.get('/api/admin/chatbot-content', async (req, res) => {
|
app.get('/api/admin/chatbot-content', async (req, res) => {
|
||||||
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
|
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
|
||||||
|
|||||||
+149
-10
@@ -295,6 +295,8 @@ interface IntentAnalysis {
|
|||||||
subject: string
|
subject: string
|
||||||
excludedSubject: string
|
excludedSubject: string
|
||||||
isCorrection: boolean
|
isCorrection: boolean
|
||||||
|
needsClarification: boolean
|
||||||
|
clarificationPrompt: string
|
||||||
verseRefs: string[]
|
verseRefs: string[]
|
||||||
episodeNumber: number | null
|
episodeNumber: number | null
|
||||||
isFollowUp: boolean
|
isFollowUp: boolean
|
||||||
@@ -345,7 +347,7 @@ function sanitizeSubject(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractIdentitySubject(normalizedQuery: string): string {
|
function extractIdentitySubject(normalizedQuery: string): string {
|
||||||
const match = normalizedQuery.match(/\b(?:who\s+(?:is|was)|tell me about)\s+([a-z][a-z\s'-]{1,40})$/i)
|
const match = normalizedQuery.match(/\b(?:who\s+(?:is|was)|tell me about)\s+([a-z][a-z\s'-]{1,40})\b/i)
|
||||||
if (!match) return ''
|
if (!match) return ''
|
||||||
const cleaned = sanitizeSubject(match[1])
|
const cleaned = sanitizeSubject(match[1])
|
||||||
const withoutArticles = cleaned.replace(/^(a|an|the)\s+/, '').trim()
|
const withoutArticles = cleaned.replace(/^(a|an|the)\s+/, '').trim()
|
||||||
@@ -700,6 +702,25 @@ function buildConversationOnlyReply(cue: ConversationCue, context: ChatContextSt
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildClarificationReply(analysis: IntentAnalysis, context: ChatContextState): ChatResponse {
|
||||||
|
const followupSuggestions = context.lastChunks.length > 0
|
||||||
|
? buildSmartFollowUpPrompts(
|
||||||
|
context.lastChunks,
|
||||||
|
analyzeIntent(context.lastPrompt || 'go deeper', context),
|
||||||
|
context.lastChunks[0]?.content ?? '',
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
{ label: 'Who was Titus?', prompt: 'Who was Titus?' },
|
||||||
|
{ label: 'Give me a summary of Titus 3:4-7', prompt: 'Give me a summary of Titus 3:4-7' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: `${analysis.clarificationPrompt}\n\nI want to answer from Nate's notes accurately, so one more detail will help me match the right content.`,
|
||||||
|
suggestions: followupSuggestions.slice(0, 3),
|
||||||
|
sources: context.lastChunks.length > 0 ? buildSourceCitations(context.lastChunks) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function analyzeIntent(query: string, context: ChatContextState): IntentAnalysis {
|
function analyzeIntent(query: string, context: ChatContextState): IntentAnalysis {
|
||||||
const rawQuery = query.trim()
|
const rawQuery = query.trim()
|
||||||
const normalized = rawQuery.toLowerCase()
|
const normalized = rawQuery.toLowerCase()
|
||||||
@@ -711,12 +732,32 @@ function analyzeIntent(query: string, context: ChatContextState): IntentAnalysis
|
|||||||
const wantsDefinition = /\bmean\b|\bmeaning\b|\bdefine\b|\bwhat is\b|\bwhat does\b/.test(normalized)
|
const wantsDefinition = /\bmean\b|\bmeaning\b|\bdefine\b|\bwhat is\b|\bwhat does\b/.test(normalized)
|
||||||
const wantsIdentity = /\bwho is\b|\bwho was\b|\btell me about\b/.test(normalized)
|
const wantsIdentity = /\bwho is\b|\bwho was\b|\btell me about\b/.test(normalized)
|
||||||
const wantsPractice = /\bhow can i\b|\bhow do i\b|\bhow should i\b|\bpray\b|\bpractice\b/.test(normalized)
|
const wantsPractice = /\bhow can i\b|\bhow do i\b|\bhow should i\b|\bpray\b|\bpractice\b/.test(normalized)
|
||||||
|
const wantsComparison = /\bdifference\b|\bcompare\b|\bvs\.?\b|\bversus\b/.test(normalized)
|
||||||
const correctionSubjects = extractCorrectionSubjects(normalized)
|
const correctionSubjects = extractCorrectionSubjects(normalized)
|
||||||
const isFollowUp = isContextualFollowUp(rawQuery) || correctionSubjects !== null
|
const isFollowUp = isContextualFollowUp(rawQuery) || correctionSubjects !== null
|
||||||
const explicitSubject = extractIdentitySubject(normalized)
|
const explicitSubject = extractIdentitySubject(normalized)
|
||||||
const subject = correctionSubjects?.expected || explicitSubject || (isFollowUp ? context.lastSubject : '')
|
const subject = correctionSubjects?.expected || explicitSubject || (isFollowUp ? context.lastSubject : '')
|
||||||
const excludedSubject = correctionSubjects?.rejected ?? ''
|
const excludedSubject = correctionSubjects?.rejected ?? ''
|
||||||
const isCorrection = correctionSubjects !== null
|
const isCorrection = correctionSubjects !== null
|
||||||
|
const tokenCount = tokenize(rawQuery).length
|
||||||
|
const hasStandalonePronoun = /\b(this|that|it|he|she|they|them|those|these|him|her)\b/.test(normalized)
|
||||||
|
|
||||||
|
let needsClarification = false
|
||||||
|
let clarificationPrompt = ''
|
||||||
|
|
||||||
|
if (!isFollowUp && hasStandalonePronoun && context.turnCount === 0) {
|
||||||
|
needsClarification = true
|
||||||
|
clarificationPrompt = 'Can you name the specific person, verse, or episode you mean?'
|
||||||
|
} else if (!isFollowUp && tokenCount < 2 && verseRefs.length === 0 && episodeNumber === null && !wantsIdentity) {
|
||||||
|
needsClarification = true
|
||||||
|
clarificationPrompt = 'Can you make that a bit more specific so I can match Nate\'s notes?'
|
||||||
|
} else if (/\bwho\s+(is|was)\b/.test(normalized) && !subject) {
|
||||||
|
needsClarification = true
|
||||||
|
clarificationPrompt = 'Who would you like to ask about specifically?'
|
||||||
|
} else if (wantsComparison && !/\band\b|\bbetween\b/.test(normalized)) {
|
||||||
|
needsClarification = true
|
||||||
|
clarificationPrompt = 'What two things would you like me to compare?'
|
||||||
|
}
|
||||||
|
|
||||||
let type: ChatIntent = 'general'
|
let type: ChatIntent = 'general'
|
||||||
if (wantsEpisode) type = 'episode-lookup'
|
if (wantsEpisode) type = 'episode-lookup'
|
||||||
@@ -727,6 +768,7 @@ function analyzeIntent(query: string, context: ChatContextState): IntentAnalysis
|
|||||||
else if (wantsPractice) type = 'practice'
|
else if (wantsPractice) type = 'practice'
|
||||||
else if (wantsSummary) type = 'overview'
|
else if (wantsSummary) type = 'overview'
|
||||||
else if (isFollowUp) type = 'follow-up'
|
else if (isFollowUp) type = 'follow-up'
|
||||||
|
else if (wantsComparison) type = 'overview'
|
||||||
|
|
||||||
let searchQuery = rawQuery
|
let searchQuery = rawQuery
|
||||||
if (type === 'identity' && subject) {
|
if (type === 'identity' && subject) {
|
||||||
@@ -757,6 +799,8 @@ function analyzeIntent(query: string, context: ChatContextState): IntentAnalysis
|
|||||||
subject,
|
subject,
|
||||||
excludedSubject,
|
excludedSubject,
|
||||||
isCorrection,
|
isCorrection,
|
||||||
|
needsClarification,
|
||||||
|
clarificationPrompt,
|
||||||
verseRefs,
|
verseRefs,
|
||||||
episodeNumber,
|
episodeNumber,
|
||||||
isFollowUp,
|
isFollowUp,
|
||||||
@@ -969,15 +1013,23 @@ function buildLowConfidenceReply(scored: ScoredChunk[], analysis: IntentAnalysis
|
|||||||
const choices = getTopDistinctChunks(scored, 3)
|
const choices = getTopDistinctChunks(scored, 3)
|
||||||
const topLabels = choices.slice(0, 2).map(chunk => chunk.sourceLabel)
|
const topLabels = choices.slice(0, 2).map(chunk => chunk.sourceLabel)
|
||||||
const text = topLabels.length === 2
|
const text = topLabels.length === 2
|
||||||
? `I want to be accurate, so I need one quick clarification. Did you mean "${topLabels[0]}" or "${topLabels[1]}"?`
|
? `I don't want to make up an answer. I couldn't find a confident match in Nate's notes yet. Did you mean "${topLabels[0]}" or "${topLabels[1]}"?`
|
||||||
: `I want to be accurate, and I don't have enough confidence to answer yet. Pick the closest direction and I'll continue.`
|
: `I don't want to make up an answer. I couldn't find this clearly in Nate's current notes yet. You can submit this question directly to Nate below.`
|
||||||
|
|
||||||
|
const choiceSuggestions = choices.map(chunk => ({
|
||||||
|
label: chunk.sourceLabel,
|
||||||
|
prompt: buildPromptForChunk(chunk, analysis),
|
||||||
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
suggestions: choices.map(chunk => ({
|
suggestions: [
|
||||||
label: chunk.sourceLabel,
|
...choiceSuggestions,
|
||||||
prompt: buildPromptForChunk(chunk, analysis),
|
{
|
||||||
})),
|
label: 'How do I submit a question to Nate?',
|
||||||
|
prompt: 'How do I submit a question to Nate?',
|
||||||
|
},
|
||||||
|
].slice(0, 3),
|
||||||
sources: buildSourceCitations(choices),
|
sources: buildSourceCitations(choices),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1010,6 +1062,22 @@ function buildSmartFollowUpPrompts(chunks: SearchChunk[], analysis: IntentAnalys
|
|||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map(prompt => ({ label: prompt, prompt }))
|
.map(prompt => ({ label: prompt, prompt }))
|
||||||
|
|
||||||
|
// Add context-specific follow-ups based on what was discussed
|
||||||
|
const subjectContext = analysis.subject || extractIdentitySubject(analysis.rawQuery.toLowerCase())
|
||||||
|
if (subjectContext && !analysis.rawQuery.toLowerCase().includes('apply')) {
|
||||||
|
promptSuggestions.unshift({
|
||||||
|
label: `How does this apply to my life?`,
|
||||||
|
prompt: `Based on what you just said about ${subjectContext}, how should I apply this today?`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryVerse && analysis.type === 'identity') {
|
||||||
|
promptSuggestions.unshift({
|
||||||
|
label: `What's the historical context?`,
|
||||||
|
prompt: `What was the historical and cultural context of ${formatVerseRef(primaryVerse)}?`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const detailedNotesReply = buildDetailedNotesReplyFromChunks(chunks)
|
const detailedNotesReply = buildDetailedNotesReplyFromChunks(chunks)
|
||||||
const canOfferDetailedNotes = hasMeaningfulExtraDetail(shortAnswer, detailedNotesReply)
|
const canOfferDetailedNotes = hasMeaningfulExtraDetail(shortAnswer, detailedNotesReply)
|
||||||
|
|
||||||
@@ -1036,10 +1104,10 @@ function buildSmartFallbackReply(analysis: IntentAnalysis, context: ChatContextS
|
|||||||
if (context.lastVerseRefs[0]) suggestions.add(`How does ${formatVerseRef(context.lastVerseRefs[0])} connect to the rest of the chapter?`)
|
if (context.lastVerseRefs[0]) suggestions.add(`How does ${formatVerseRef(context.lastVerseRefs[0])} connect to the rest of the chapter?`)
|
||||||
suggestions.add('What does Titus 1 teach about church leadership?')
|
suggestions.add('What does Titus 1 teach about church leadership?')
|
||||||
suggestions.add('What does grace train us to do?')
|
suggestions.add('What does grace train us to do?')
|
||||||
suggestions.add('How should I apply this today?')
|
suggestions.add('How do I submit a question to Nate?')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: `I don't have a strong enough match yet to answer that clearly. Try one of these more specific prompts and I'll narrow it down.`,
|
text: `I don't want to make up an answer. I couldn't find this in Nate's current notes. Please submit this question to Nate using the contact form below.`,
|
||||||
suggestions: [...suggestions].slice(0, 3).map(prompt => ({ label: prompt, prompt })),
|
suggestions: [...suggestions].slice(0, 3).map(prompt => ({ label: prompt, prompt })),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1100,6 +1168,18 @@ function synthesizeSmartReply(scored: ScoredChunk[], analysis: IntentAnalysis, c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addConfidenceLabel(response: ChatResponse, confidence: 'high' | 'medium' | 'low'): ChatResponse {
|
||||||
|
const label = confidence === 'high'
|
||||||
|
? '✓ High confidence'
|
||||||
|
: confidence === 'medium'
|
||||||
|
? '~ Medium confidence'
|
||||||
|
: '? Low confidence'
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
text: `${response.text}\n\n[${label}]`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildNextChatContext(query: string, analysis: IntentAnalysis, scored: ScoredChunk[], previousContext: ChatContextState): ChatContextState {
|
function buildNextChatContext(query: string, analysis: IntentAnalysis, scored: ScoredChunk[], previousContext: ChatContextState): ChatContextState {
|
||||||
const topChunks = getTopDistinctChunks(scored, 3)
|
const topChunks = getTopDistinctChunks(scored, 3)
|
||||||
if (topChunks.length === 0) return EMPTY_CHAT_CONTEXT
|
if (topChunks.length === 0) return EMPTY_CHAT_CONTEXT
|
||||||
@@ -1280,6 +1360,41 @@ function ChatBot({ mode = 'embedded' }: { mode?: 'embedded' | 'standalone' }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maybeRewriteWithLlm = async (
|
||||||
|
question: string,
|
||||||
|
draft: ChatResponse,
|
||||||
|
contextChunks: SearchChunk[],
|
||||||
|
): Promise<ChatResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/chatbot-grounded-rewrite', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
question,
|
||||||
|
draftAnswer: draft.text,
|
||||||
|
sources: draft.sources ?? [],
|
||||||
|
contextChunks: contextChunks.slice(0, 4).map(chunk => ({
|
||||||
|
title: chunk.title,
|
||||||
|
sourceLabel: chunk.sourceLabel,
|
||||||
|
content: chunk.content,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) return draft
|
||||||
|
const payload = await response.json()
|
||||||
|
const rewritten = typeof payload?.text === 'string' ? payload.text.trim() : ''
|
||||||
|
if (!rewritten) return draft
|
||||||
|
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
text: rewritten,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const respond = async (query: string) => {
|
const respond = async (query: string) => {
|
||||||
const userMsg: ChatMessage = { role: 'user', text: query }
|
const userMsg: ChatMessage = { role: 'user', text: query }
|
||||||
setMessages(m => [...m, userMsg])
|
setMessages(m => [...m, userMsg])
|
||||||
@@ -1293,6 +1408,7 @@ function ChatBot({ mode = 'embedded' }: { mode?: 'embedded' | 'standalone' }) {
|
|||||||
const contextualQuery = enrichQueryWithContext(trimmedQuery, activeContext)
|
const contextualQuery = enrichQueryWithContext(trimmedQuery, activeContext)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
const cue = detectConversationCue(trimmedQuery)
|
const cue = detectConversationCue(trimmedQuery)
|
||||||
const cueReply = buildConversationOnlyReply(cue, activeContext, responseStyle)
|
const cueReply = buildConversationOnlyReply(cue, activeContext, responseStyle)
|
||||||
if (cueReply) {
|
if (cueReply) {
|
||||||
@@ -1327,6 +1443,18 @@ function ChatBot({ mode = 'embedded' }: { mode?: 'embedded' | 'standalone' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const analysis = analyzeIntent(contextualQuery, activeContext)
|
const analysis = analyzeIntent(contextualQuery, activeContext)
|
||||||
|
if (analysis.needsClarification) {
|
||||||
|
const clarificationReply = buildClarificationReply(analysis, activeContext)
|
||||||
|
setMessages(m => [...m, {
|
||||||
|
role: 'bot',
|
||||||
|
text: formatResponseText(clarificationReply),
|
||||||
|
suggestions: clarificationReply.suggestions,
|
||||||
|
sources: clarificationReply.sources,
|
||||||
|
}])
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const bibleVersionIntent = isBibleVersionQuery(analysis.searchQuery, analysis.queryTokens)
|
const bibleVersionIntent = isBibleVersionQuery(analysis.searchQuery, analysis.queryTokens)
|
||||||
const scored = latestChunks
|
const scored = latestChunks
|
||||||
.map(chunk => {
|
.map(chunk => {
|
||||||
@@ -1340,17 +1468,28 @@ function ChatBot({ mode = 'embedded' }: { mode?: 'embedded' | 'standalone' }) {
|
|||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
|
|
||||||
let botReply: ChatResponse
|
let botReply: ChatResponse
|
||||||
|
let topChunksForRewrite: SearchChunk[] = []
|
||||||
|
let shouldTryLlmRewrite = false
|
||||||
if (scored.length > 0) {
|
if (scored.length > 0) {
|
||||||
|
const confidence = determineConfidence(scored, analysis)
|
||||||
|
topChunksForRewrite = getTopDistinctChunks(scored, 3)
|
||||||
botReply = synthesizeSmartReply(scored, analysis, activeContext, responseStyle)
|
botReply = synthesizeSmartReply(scored, analysis, activeContext, responseStyle)
|
||||||
if (determineConfidence(scored, analysis) !== 'low') {
|
if (confidence !== 'low') {
|
||||||
|
shouldTryLlmRewrite = true
|
||||||
chatContextRef.current = buildNextChatContext(contextualQuery, analysis, scored, activeContext)
|
chatContextRef.current = buildNextChatContext(contextualQuery, analysis, scored, activeContext)
|
||||||
|
botReply = addConfidenceLabel(botReply, confidence)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
botReply = buildSmartFallbackReply(analyzeIntent(contextualQuery, activeContext), activeContext)
|
botReply = buildSmartFallbackReply(analyzeIntent(contextualQuery, activeContext), activeContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldTryLlmRewrite) {
|
||||||
|
botReply = await maybeRewriteWithLlm(trimmedQuery, botReply, topChunksForRewrite)
|
||||||
|
}
|
||||||
|
|
||||||
setMessages(m => [...m, { role: 'bot', text: formatResponseText(botReply), suggestions: botReply.suggestions, sources: botReply.sources }])
|
setMessages(m => [...m, { role: 'bot', text: formatResponseText(botReply), suggestions: botReply.suggestions, sources: botReply.sources }])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
})()
|
||||||
}, 400)
|
}, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user