All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 25s
CI / test-bqas (push) Successful in 25s
CI / Deploy (push) Successful in 4s
- Migrate chat API from Ollama to LiteLLM (OpenAI-compatible SSE) - Add 15-min presenter storyline with bilingual scripts for all 20 slides - Add FAQ system (30 entries) with keyword matching for instant answers - Add IntroPresenterSlide with avatar placeholder and start button - Add PresenterOverlay (progress bar, subtitle text, play/pause/stop) - Add AvatarPlaceholder with pulse animation during speaking - Add usePresenterMode hook (state machine: idle→presenting→paused→answering→resuming) - Add 'P' keyboard shortcut to toggle presenter mode - Support [GOTO:slide-id] markers in chat responses - Dynamic slide count (was hardcoded 13, now from SLIDE_ORDER) - TTS stub prepared for future Piper integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
73 lines
2.1 KiB
TypeScript
73 lines
2.1 KiB
TypeScript
import { Language } from '../types'
|
|
import { FAQEntry } from './types'
|
|
import { PRESENTER_FAQ } from './presenter-faq'
|
|
|
|
/**
|
|
* Match a user query against pre-cached FAQ entries.
|
|
* Returns the best match if score exceeds threshold, or null for LLM fallback.
|
|
*/
|
|
export function matchFAQ(query: string, lang: Language): FAQEntry | null {
|
|
const normalized = query.toLowerCase().trim()
|
|
const queryWords = normalized.split(/\s+/)
|
|
|
|
let bestMatch: FAQEntry | null = null
|
|
let bestScore = 0
|
|
|
|
for (const entry of PRESENTER_FAQ) {
|
|
let score = 0
|
|
|
|
// Check keyword matches
|
|
for (const keyword of entry.keywords) {
|
|
const kwLower = keyword.toLowerCase()
|
|
if (kwLower.includes(' ')) {
|
|
// Multi-word keyword: check if phrase appears in query
|
|
if (normalized.includes(kwLower)) {
|
|
score += 3 * entry.priority / 10
|
|
}
|
|
} else {
|
|
// Single keyword: check word-level match
|
|
if (queryWords.some(w => w === kwLower || w.startsWith(kwLower) || kwLower.startsWith(w))) {
|
|
score += 1
|
|
}
|
|
// Also check if keyword appears anywhere in query (partial match)
|
|
if (normalized.includes(kwLower)) {
|
|
score += 0.5
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if query matches the question text closely
|
|
const questionText = lang === 'de' ? entry.question_de : entry.question_en
|
|
const questionWords = questionText.toLowerCase().split(/\s+/)
|
|
const overlapCount = queryWords.filter(w =>
|
|
w.length > 2 && questionWords.some(qw => qw.includes(w) || w.includes(qw))
|
|
).length
|
|
if (overlapCount >= 2) {
|
|
score += overlapCount * 0.5
|
|
}
|
|
|
|
// Weight by priority
|
|
score *= (entry.priority / 10)
|
|
|
|
if (score > bestScore) {
|
|
bestScore = score
|
|
bestMatch = entry
|
|
}
|
|
}
|
|
|
|
// Threshold: need meaningful match to avoid false positives
|
|
// Require at least 2 keyword hits or strong phrase match
|
|
if (bestScore < 1.5) {
|
|
return null
|
|
}
|
|
|
|
return bestMatch
|
|
}
|
|
|
|
/**
|
|
* Get FAQ answer text in the requested language
|
|
*/
|
|
export function getFAQAnswer(entry: FAQEntry, lang: Language): string {
|
|
return lang === 'de' ? entry.answer_de : entry.answer_en
|
|
}
|