Files
Benjamin Admin 3a2567b44d
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
feat(pitch-deck): add AI Presenter mode with LiteLLM migration and FAQ system
- 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>
2026-03-20 11:45:55 +01:00

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
}