Add episode transcripts, expand chatbot Q&A database to 28 entries, and build scripts

This commit is contained in:
nmemmert
2026-04-13 10:29:06 -04:00
parent 6e8b360398
commit 063f5d2a0e
31 changed files with 2779 additions and 134 deletions
+506 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Link, Routes, Route, useNavigate } from 'react-router-dom'
import AdminPage from './AdminPage'
import './App.css'
@@ -103,10 +103,509 @@ function AmazonMusicIcon() {
)
}
interface PublicQuestion {
id: string
firstName: string
question: string
answer: string
}
// ── Chatbot ────────────────────────────────────────────────────────────────
interface ChatbotEntry {
id: string
type: 'qa' | 'topic' | 'episode'
title: string
content: string
keywords: string[]
sourceLabel?: string
priority?: boolean
createdAt?: string
updatedAt?: string
}
interface ChatMessage {
role: 'bot' | 'user'
text: string
}
const STOP_WORDS = new Set([
'a','an','the','is','are','was','were','be','been','being','have','has','had','do','does','did',
'will','would','could','should','may','might','shall','can','i','you','he','she','it','we','they',
'me','him','her','us','them','my','your','his','its','our','their','this','that','these','those',
'and','but','or','nor','so','yet','for','of','in','on','at','to','from','with','by','about',
'what','how','why','when','where','who','which','if','then','than','as','do','just','not',
])
const TOKEN_ALIASES: Record<string, string[]> = {
bible: ['translation', 'version', 'scripture', 'bsb', 'berean'],
translation: ['version', 'bsb', 'berean', 'bible'],
version: ['translation', 'bsb', 'berean', 'bible'],
elders: ['elder', 'elders', 'leadership', 'leaders', 'overseer', 'overseers', 'pastor'],
leadership: ['elders', 'elder', 'overseer', 'overseers', 'leaders'],
grace: ['salvation', 'saved', 'godliness', 'mercy'],
quiet: ['prayer', 'devotional', 'reading'],
devotional: ['quiet', 'prayer', 'reading'],
family: ['skeptic', 'skeptical', 'gospel', 'faith'],
gospel: ['grace', 'salvation', 'jesus', 'faith'],
}
type ScoredEntry = { entry: ChatbotEntry; score: number }
function tokenize(text: string): string[] {
return text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 2 && !STOP_WORDS.has(w))
}
function extractVerseRefs(text: string): string[] {
const matches = text.match(/\b(?:[1-3]\s)?[A-Z][a-z]+\s\d+:\d+(?:[-]\d+)?\b/g) ?? []
return [...new Set(matches.map(m => m.toLowerCase()))]
}
function expandTokens(tokens: string[]): string[] {
const expanded = new Set(tokens)
for (const token of tokens) {
const aliases = TOKEN_ALIASES[token] ?? []
for (const alias of aliases) expanded.add(alias)
}
return [...expanded]
}
function buildEntryIndex(entry: ChatbotEntry): string[] {
return [
...tokenize(entry.title),
...tokenize(entry.content),
...entry.keywords.flatMap(k => tokenize(k)),
...extractVerseRefs(`${entry.title} ${entry.content} ${entry.keywords.join(' ')}`),
]
}
function getFreshnessBoost(entry: ChatbotEntry): number {
if (!entry.updatedAt) return 0
const ageDays = (Date.now() - new Date(entry.updatedAt).getTime()) / (1000 * 60 * 60 * 24)
if (!Number.isFinite(ageDays)) return 0
if (ageDays <= 14) return 1.5
if (ageDays <= 45) return 1
if (ageDays <= 120) return 0.5
return 0
}
function getSourceLabel(entry: ChatbotEntry): string {
if (entry.sourceLabel && entry.sourceLabel.trim()) return entry.sourceLabel.trim()
return entry.title
}
function scoreEntry(entry: ChatbotEntry, queryTokens: string[]): number {
if (queryTokens.length === 0) return 0
const expandedTokens = expandTokens(queryTokens)
const titleTokens = tokenize(entry.title)
const indexedTokens = buildEntryIndex(entry)
let score = 0
for (const qt of expandedTokens) {
if (titleTokens.some(t => t.includes(qt) || qt.includes(t))) score += 3
if (indexedTokens.some(t => t.includes(qt) || qt.includes(t))) score += 1
}
if (entry.priority === true) score += 4
score += getFreshnessBoost(entry)
return score
}
function isBibleVersionQuery(query: string, queryTokens: string[]): boolean {
const q = query.toLowerCase()
const hasBibleWord = ['bible', 'translation', 'version'].some(k => q.includes(k) || queryTokens.includes(k))
const hasIntentWord = ['use', 'recommend', 'podcast', 'read', 'study'].some(k => q.includes(k) || queryTokens.includes(k))
return hasBibleWord && hasIntentWord
}
function hasBsbSignal(entry: ChatbotEntry): boolean {
const haystack = `${entry.title} ${entry.content} ${entry.keywords.join(' ')}`.toLowerCase()
return haystack.includes('berean standard bible') || haystack.includes(' bsb ')
}
function buildEntryExcerpt(entry: ChatbotEntry, queryTokens: string[]): string {
const expandedTokens = expandTokens(queryTokens)
const sentences = entry.content
.split(/(?<=[.!?])\s+(?=[A-Z])/)
.map(s => s.trim())
.filter(s => s.length > 35 && !/^[A-Z\s]{4,}$/.test(s))
const scoredSentences = sentences.map(s => ({
text: s,
score: tokenize(s).filter(t => expandedTokens.some(qt => t.includes(qt) || qt.includes(t))).length,
}))
const topSentences = scoredSentences.some(s => s.score > 0)
? scoredSentences.sort((a, b) => b.score - a.score).slice(0, entry.type === 'episode' ? 2 : 1).map(s => s.text)
: sentences.slice(0, 1)
return topSentences.join(' ').slice(0, entry.type === 'episode' ? 320 : 220)
}
function buildFollowUpPrompts(topEntries: ChatbotEntry[], query: string): string[] {
const prompts = new Set<string>()
const queryLower = query.toLowerCase()
for (const entry of topEntries) {
if (entry.type === 'episode') prompts.add(`Which episode covers ${getSourceLabel(entry)}?`)
if (extractVerseRefs(entry.title).length > 0) prompts.add(`Give me a summary of ${extractVerseRefs(entry.title)[0]}`)
if (entry.title.toLowerCase().includes('grace')) prompts.add('What does Nate say about grace?')
if (entry.title.toLowerCase().includes('lead')) prompts.add('What makes a faithful leader according to Titus?')
}
if (!queryLower.includes('episode')) prompts.add('Which episode should I listen to next on this topic?')
return [...prompts].slice(0, 2)
}
function buildFallbackReply(): string {
return `I don't have enough in Nate's notes to answer that clearly yet.\n\nTry asking about a Bible passage, a Titus episode, prayer, daily Bible reading, or how Nate explains a topic on the podcast.\n\nYou can also submit your question with the contact form below.`
}
function synthesizeReply(scored: ScoredEntry[], query: string, queryTokens: string[]): string {
const bestScore = scored[0]?.score ?? 0
if (bestScore < 4) return buildFallbackReply()
const chosen = scored
.filter(({ score }, index) => index === 0 || score >= bestScore * 0.65)
.slice(0, 3)
.map(item => item.entry)
const shortAnswer = buildEntryExcerpt(chosen[0], queryTokens)
const sourceLines = chosen.map(entry => `From ${getSourceLabel(entry)}: ${buildEntryExcerpt(entry, queryTokens)}`)
const followUps = buildFollowUpPrompts(chosen, query)
let reply = `Short answer:\n${shortAnswer}`
if (sourceLines.length > 0) {
reply += `\n\nFrom Nate's notes:\n${sourceLines.join('\n\n')}`
}
if (followUps.length > 0) {
reply += `\n\nYou could also ask:\n${followUps.map(prompt => `- ${prompt}`).join('\n')}`
}
return reply
}
const SUGGESTED_PROMPTS = [
'What Bible translation do you use in the podcast?',
'What does Titus 1 teach about church leadership?',
'How can I stay consistent with daily Bible reading?',
'How do I handle a difficult or confusing passage?',
'How can I share my faith with skeptical family?',
'Give me a summary of Titus 3:4-7',
'What episode talks about grace training us?',
'How do I submit a question to Nate?',
]
const INLINE_CHAT_PROMPTS = SUGGESTED_PROMPTS.slice(0, 6)
const BOT_NAME = 'The Mine'
function ChatBot() {
const [open, setOpen] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'bot', text: `Welcome to ${BOT_NAME}. Dig into the Word, nugget by nugget. Ask about the podcast, Bible study, or a passage Nate has covered — or pick a prompt below.` },
])
const [input, setInput] = useState('')
const [entries, setEntries] = useState<ChatbotEntry[]>([])
const [loaded, setLoaded] = useState(false)
const [loading, setLoading] = useState(false)
const [pendingPrompt, setPendingPrompt] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const openChat = () => {
setOpen(true)
if (!loaded) {
setLoaded(true)
fetch('/api/chatbot-content')
.then(r => r.ok ? r.json() : [])
.then(data => setEntries(Array.isArray(data) ? data : []))
.catch(() => {})
}
}
const askPrompt = (prompt: string) => {
openChat()
if (entries.length > 0) {
respond(prompt)
} else {
setPendingPrompt(prompt)
}
}
useEffect(() => {
if (pendingPrompt !== null && entries.length > 0) {
const p = pendingPrompt
setPendingPrompt(null)
respond(p)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries, pendingPrompt])
useEffect(() => {
if (open) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, open])
const respond = (query: string) => {
const userMsg: ChatMessage = { role: 'user', text: query }
setMessages(m => [...m, userMsg])
setInput('')
setLoading(true)
setTimeout(() => {
const tokens = tokenize(query)
const bibleVersionIntent = isBibleVersionQuery(query, tokens)
const scored = entries
.map(e => {
let score = scoreEntry(e, tokens)
if (bibleVersionIntent && hasBsbSignal(e)) score += 12
if (bibleVersionIntent && e.type === 'episode') score += 2
return { entry: e, score }
})
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score)
let botReply: string
if (scored.length > 0) {
botReply = synthesizeReply(scored, query, tokens)
} else {
botReply = buildFallbackReply()
}
setMessages(m => [...m, { role: 'bot', text: botReply }])
setLoading(false)
}, 400)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const q = input.trim()
if (!q) return
respond(q)
}
return (
<>
<section id="the-mine" className="section-chatbot-feature" aria-label={`Ask ${BOT_NAME}`}>
<div className="section-inner chatbot-feature-inner">
<div className="chatbot-feature-copy">
<p className="eyebrow">Ask {BOT_NAME}</p>
<h2 className="section-heading">
<span className="ornament"></span> {BOT_NAME} <span className="ornament"></span>
</h2>
<p className="chatbot-feature-lead">
Your study companion for Verse by Verse with Nate. Ask about Titus, Bible study, prayer, daily reading, or anything Nate has already covered in his notes and episodes.
</p>
<p className="chatbot-feature-sub">
Have a question about the episode? {BOT_NAME} is here to help you dig deeper.
</p>
<div className="chatbot-feature-actions">
<button type="button" className="btn-primary" onClick={openChat}>
Open {BOT_NAME}
</button>
<a href="#contact" className="btn-secondary">
Ask Nate Directly
</a>
</div>
</div>
<div className="chatbot-feature-card">
<p className="chatbot-feature-kicker">Try asking:</p>
<div className="chatbot-feature-prompts">
{INLINE_CHAT_PROMPTS.map(prompt => (
<button key={prompt} className="chatbot-prompt-btn chatbot-prompt-btn--feature" onClick={() => askPrompt(prompt)}>
{prompt}
</button>
))}
</div>
</div>
</div>
</section>
{/* Floating bubble */}
<button
className={`chatbot-bubble ${open ? 'chatbot-bubble--open' : ''}`}
onClick={() => open ? setOpen(false) : openChat()}
aria-label={open ? `Close ${BOT_NAME}` : `Open ${BOT_NAME}`}
>
{open ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>
)}
</button>
{/* Chat panel */}
{open && (
<div className="chatbot-panel" role="dialog" aria-label={BOT_NAME}>
<div className="chatbot-header">
<span> {BOT_NAME}</span>
<button className="chatbot-close" onClick={() => setOpen(false)} aria-label="Close"></button>
</div>
<div className="chatbot-messages">
{messages.map((msg, i) => (
<div key={i} className={`chatbot-msg chatbot-msg--${msg.role}`}>
{msg.text.split('\n').map((line, j) => <p key={j}>{line}</p>)}
</div>
))}
{loading && (
<div className="chatbot-msg chatbot-msg--bot chatbot-msg--typing">
<span /><span /><span />
</div>
)}
<div ref={bottomRef} />
</div>
{messages.length === 1 && (
<div className="chatbot-prompts">
{SUGGESTED_PROMPTS.map(p => (
<button key={p} className="chatbot-prompt-btn" onClick={() => askPrompt(p)}>{p}</button>
))}
</div>
)}
<form className="chatbot-form" onSubmit={handleSubmit}>
<input
type="text"
className="chatbot-input"
placeholder="Ask a question…"
value={input}
onChange={e => setInput(e.target.value)}
maxLength={300}
aria-label="Your question"
/>
<button type="submit" className="chatbot-send" disabled={!input.trim() || loading} aria-label="Send">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</form>
</div>
)}
</>
)
}
const QA_PAGE_SIZE = 6
function QASection() {
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/questions')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
.then(data => {
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
setLoading(false)
})
.catch(() => {
setLoading(false)
})
}, [])
const filteredQuestions = questions.filter(q =>
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
)
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
const toggleExpanded = (id: string) => {
setExpanded(e => ({ ...e, [id]: !e[id] }))
}
// Reset to page 0 when search changes
const handleSearch = (value: string) => {
setSearchQuery(value)
setPage(0)
}
if (loading || questions.length === 0) return null
return (
<section className="section-qa" aria-label="Questions and answers">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Questions & Answers <span className="ornament"></span>
</h2>
<div className="qa-search">
<label>
<span>Search Questions</span>
<input
type="text"
placeholder="Search by topic..."
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
</label>
</div>
{filteredQuestions.length === 0 ? (
<div className="qa-no-results">
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
</div>
) : (
<>
<div className="qa-cards">
{pagedQuestions.map(question => (
<div key={question.id} className="qa-card-scene">
<div
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
onClick={() => toggleExpanded(question.id)}
role="button"
tabIndex={0}
aria-label={expanded[question.id] ? 'Show question' : 'Show answer'}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && toggleExpanded(question.id)}
>
<div className="qa-card-face qa-card-front">
<span className="qa-face-label">Q</span>
<p className="qa-question-text">{question.question}</p>
<span className="qa-flip-hint">tap to reveal answer</span>
</div>
<div className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span>
<p className="qa-answer-text">{question.answer}</p>
<span className="qa-flip-hint"> Answered by Nate</span>
</div>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div className="qa-pagination">
<button
className="qa-page-btn"
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
aria-label="Previous page"
>
Prev
</button>
<span className="qa-page-info">
{page + 1} / {totalPages}
</span>
<button
className="qa-page-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages - 1}
aria-label="Next page"
>
Next
</button>
</div>
)}
</>
)}
</div>
</section>
)
}
function ContactForm() {
const navigate = useNavigate()
const [fields, setFields] = useState({ name: '', email: '', message: '', messageType: 'question' })
const [subscribe, setSubscribe] = useState(false)
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
@@ -287,6 +786,7 @@ function LandingPage({ content }: { content: SiteContent }) {
<nav className="header-nav">
<a href="#listen">Listen</a>
<a href="#about">About</a>
<a href="#the-mine">The Mine</a>
<a href="#series">Series</a>
<a href="#contact">Contact</a>
<a
@@ -498,6 +998,10 @@ function LandingPage({ content }: { content: SiteContent }) {
</div>
</section>
{/* ── Q&A ── */}
<QASection />
<ChatBot />
{/* ── CONTACT ── */}
<section className="section-contact" id="contact" aria-label="Contact form">
<div className="section-inner contact-inner">