5 Commits

5 changed files with 1039 additions and 248 deletions
+44
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+194
View File
@@ -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
})
+211
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,
@@ -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
View File
@@ -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)
}