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`.
|
||||
|
||||
## 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:
|
||||
|
||||
- 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 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,
|
||||
@@ -80,6 +89,32 @@ let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
||||
let lastHitStatsWrite = { ok: true, at: null, error: null }
|
||||
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
||||
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) {
|
||||
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
|
||||
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 }
|
||||
|
||||
+149
-10
@@ -295,6 +295,8 @@ interface IntentAnalysis {
|
||||
subject: string
|
||||
excludedSubject: string
|
||||
isCorrection: boolean
|
||||
needsClarification: boolean
|
||||
clarificationPrompt: string
|
||||
verseRefs: string[]
|
||||
episodeNumber: number | null
|
||||
isFollowUp: boolean
|
||||
@@ -345,7 +347,7 @@ function sanitizeSubject(value: 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 ''
|
||||
const cleaned = sanitizeSubject(match[1])
|
||||
const withoutArticles = cleaned.replace(/^(a|an|the)\s+/, '').trim()
|
||||
@@ -700,6 +702,25 @@ function buildConversationOnlyReply(cue: ConversationCue, context: ChatContextSt
|
||||
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 {
|
||||
const rawQuery = query.trim()
|
||||
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 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 wantsComparison = /\bdifference\b|\bcompare\b|\bvs\.?\b|\bversus\b/.test(normalized)
|
||||
const correctionSubjects = extractCorrectionSubjects(normalized)
|
||||
const isFollowUp = isContextualFollowUp(rawQuery) || correctionSubjects !== null
|
||||
const explicitSubject = extractIdentitySubject(normalized)
|
||||
const subject = correctionSubjects?.expected || explicitSubject || (isFollowUp ? context.lastSubject : '')
|
||||
const excludedSubject = correctionSubjects?.rejected ?? ''
|
||||
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'
|
||||
if (wantsEpisode) type = 'episode-lookup'
|
||||
@@ -727,6 +768,7 @@ function analyzeIntent(query: string, context: ChatContextState): IntentAnalysis
|
||||
else if (wantsPractice) type = 'practice'
|
||||
else if (wantsSummary) type = 'overview'
|
||||
else if (isFollowUp) type = 'follow-up'
|
||||
else if (wantsComparison) type = 'overview'
|
||||
|
||||
let searchQuery = rawQuery
|
||||
if (type === 'identity' && subject) {
|
||||
@@ -757,6 +799,8 @@ function analyzeIntent(query: string, context: ChatContextState): IntentAnalysis
|
||||
subject,
|
||||
excludedSubject,
|
||||
isCorrection,
|
||||
needsClarification,
|
||||
clarificationPrompt,
|
||||
verseRefs,
|
||||
episodeNumber,
|
||||
isFollowUp,
|
||||
@@ -969,15 +1013,23 @@ function buildLowConfidenceReply(scored: ScoredChunk[], analysis: IntentAnalysis
|
||||
const choices = getTopDistinctChunks(scored, 3)
|
||||
const topLabels = choices.slice(0, 2).map(chunk => chunk.sourceLabel)
|
||||
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 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 a confident match in Nate's notes yet. Did you mean "${topLabels[0]}" or "${topLabels[1]}"?`
|
||||
: `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 {
|
||||
text,
|
||||
suggestions: choices.map(chunk => ({
|
||||
label: chunk.sourceLabel,
|
||||
prompt: buildPromptForChunk(chunk, analysis),
|
||||
})),
|
||||
suggestions: [
|
||||
...choiceSuggestions,
|
||||
{
|
||||
label: 'How do I submit a question to Nate?',
|
||||
prompt: 'How do I submit a question to Nate?',
|
||||
},
|
||||
].slice(0, 3),
|
||||
sources: buildSourceCitations(choices),
|
||||
}
|
||||
}
|
||||
@@ -1010,6 +1062,22 @@ function buildSmartFollowUpPrompts(chunks: SearchChunk[], analysis: IntentAnalys
|
||||
.slice(0, 3)
|
||||
.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 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?`)
|
||||
suggestions.add('What does Titus 1 teach about church leadership?')
|
||||
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 {
|
||||
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 })),
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
const topChunks = getTopDistinctChunks(scored, 3)
|
||||
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 userMsg: ChatMessage = { role: 'user', text: query }
|
||||
setMessages(m => [...m, userMsg])
|
||||
@@ -1293,6 +1408,7 @@ function ChatBot({ mode = 'embedded' }: { mode?: 'embedded' | 'standalone' }) {
|
||||
const contextualQuery = enrichQueryWithContext(trimmedQuery, activeContext)
|
||||
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
const cue = detectConversationCue(trimmedQuery)
|
||||
const cueReply = buildConversationOnlyReply(cue, activeContext, responseStyle)
|
||||
if (cueReply) {
|
||||
@@ -1327,6 +1443,18 @@ function ChatBot({ mode = 'embedded' }: { mode?: 'embedded' | 'standalone' }) {
|
||||
}
|
||||
|
||||
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 scored = latestChunks
|
||||
.map(chunk => {
|
||||
@@ -1340,17 +1468,28 @@ function ChatBot({ mode = 'embedded' }: { mode?: 'embedded' | 'standalone' }) {
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
let botReply: ChatResponse
|
||||
let topChunksForRewrite: SearchChunk[] = []
|
||||
let shouldTryLlmRewrite = false
|
||||
if (scored.length > 0) {
|
||||
const confidence = determineConfidence(scored, analysis)
|
||||
topChunksForRewrite = getTopDistinctChunks(scored, 3)
|
||||
botReply = synthesizeSmartReply(scored, analysis, activeContext, responseStyle)
|
||||
if (determineConfidence(scored, analysis) !== 'low') {
|
||||
if (confidence !== 'low') {
|
||||
shouldTryLlmRewrite = true
|
||||
chatContextRef.current = buildNextChatContext(contextualQuery, analysis, scored, activeContext)
|
||||
botReply = addConfidenceLabel(botReply, confidence)
|
||||
}
|
||||
} else {
|
||||
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 }])
|
||||
setLoading(false)
|
||||
})()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user