diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index 9afeaeb..3e13b64 100644 --- a/pitch-deck/app/api/chat/route.ts +++ b/pitch-deck/app/api/chat/route.ts @@ -157,42 +157,12 @@ ${JSON.stringify(features.rows, null, 2)} export async function POST(request: NextRequest) { try { const body = await request.json() - const { message, history = [], lang = 'de', slideContext, faqAnswer } = body + const { message, history = [], lang = 'de', slideContext, faqContext } = body if (!message || typeof message !== 'string') { return NextResponse.json({ error: 'Message is required' }, { status: 400 }) } - // FAQ shortcut: if client sends a pre-cached FAQ answer, stream it directly (no LLM call) - if (faqAnswer && typeof faqAnswer === 'string') { - const encoder = new TextEncoder() - const stream = new ReadableStream({ - start(controller) { - // Stream the FAQ answer in chunks for consistent UX - const words = faqAnswer.split(' ') - let i = 0 - const interval = setInterval(() => { - if (i < words.length) { - const chunk = (i === 0 ? '' : ' ') + words[i] - controller.enqueue(encoder.encode(chunk)) - i++ - } else { - clearInterval(interval) - controller.close() - } - }, 30) - }, - }) - - return new NextResponse(stream, { - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }, - }) - } - const pitchContext = await loadPitchContext() let systemContent = SYSTEM_PROMPT @@ -200,6 +170,11 @@ export async function POST(request: NextRequest) { systemContent += '\n' + pitchContext } + // FAQ context: relevant pre-researched answers as basis for the LLM + if (faqContext && typeof faqContext === 'string') { + systemContent += '\n' + faqContext + } + // Slide context for contextual awareness if (slideContext) { const visited: number[] = slideContext.visitedSlides || [] diff --git a/pitch-deck/components/ChatFAB.tsx b/pitch-deck/components/ChatFAB.tsx index 1d80d73..26006af 100644 --- a/pitch-deck/components/ChatFAB.tsx +++ b/pitch-deck/components/ChatFAB.tsx @@ -7,7 +7,7 @@ import { ChatMessage, Language, SlideId } from '@/lib/types' import { t } from '@/lib/i18n' import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation' import { PresenterState } from '@/lib/presenter/types' -import { matchFAQ, getFAQAnswer } from '@/lib/presenter/faq-matcher' +import { matchFAQMultiple, buildFAQContext } from '@/lib/presenter/faq-matcher' interface ChatFABProps { lang: Language @@ -227,8 +227,9 @@ export default function ChatFAB({ setIsStreaming(true) setIsWaiting(true) - // Check FAQ first for instant response - const faqMatch = matchFAQ(message, lang) + // Find relevant FAQ entries as context for the LLM + const faqMatches = matchFAQMultiple(message, lang, 3) + const faqContext = buildFAQContext(faqMatches, lang) abortRef.current = new AbortController() @@ -245,9 +246,9 @@ export default function ChatFAB({ }, } - // If FAQ matched, send the cached answer for fast streaming (no LLM call) - if (faqMatch) { - requestBody.faqAnswer = getFAQAnswer(faqMatch, lang) + // Send FAQ context to LLM (not direct streaming — LLM interprets and combines) + if (faqContext) { + requestBody.faqContext = faqContext } const res = await fetch('/api/chat', { diff --git a/pitch-deck/lib/presenter/faq-matcher.ts b/pitch-deck/lib/presenter/faq-matcher.ts index 159f551..cce6dbf 100644 --- a/pitch-deck/lib/presenter/faq-matcher.ts +++ b/pitch-deck/lib/presenter/faq-matcher.ts @@ -7,11 +7,19 @@ import { PRESENTER_FAQ } from './presenter-faq' * Returns the best match if score exceeds threshold, or null for LLM fallback. */ export function matchFAQ(query: string, lang: Language): FAQEntry | null { + const matches = matchFAQMultiple(query, lang, 1) + return matches.length > 0 ? matches[0] : null +} + +/** + * Match a user query and return the top N relevant FAQ entries as context. + * Used to feed multiple relevant FAQs into the LLM prompt. + */ +export function matchFAQMultiple(query: string, lang: Language, maxResults: number = 3): FAQEntry[] { const normalized = query.toLowerCase().trim() const queryWords = normalized.split(/\s+/) - let bestMatch: FAQEntry | null = null - let bestScore = 0 + const scored: { entry: FAQEntry; score: number }[] = [] for (const entry of PRESENTER_FAQ) { let score = 0 @@ -20,23 +28,20 @@ export function matchFAQ(query: string, lang: Language): FAQEntry | null { 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 + // Check question text overlap const questionText = lang === 'de' ? entry.question_de : entry.question_en const questionWords = questionText.toLowerCase().split(/\s+/) const overlapCount = queryWords.filter(w => @@ -46,22 +51,16 @@ export function matchFAQ(query: string, lang: Language): FAQEntry | null { score += overlapCount * 0.5 } - // Weight by priority score *= (entry.priority / 10) - if (score > bestScore) { - bestScore = score - bestMatch = entry + if (score >= 1.0) { + scored.push({ entry, score }) } } - // 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 + // Sort by score descending, return top N + scored.sort((a, b) => b.score - a.score) + return scored.slice(0, maxResults).map(s => s.entry) } /** @@ -70,3 +69,18 @@ export function matchFAQ(query: string, lang: Language): FAQEntry | null { export function getFAQAnswer(entry: FAQEntry, lang: Language): string { return lang === 'de' ? entry.answer_de : entry.answer_en } + +/** + * Build a context string from multiple FAQ matches for LLM injection + */ +export function buildFAQContext(entries: FAQEntry[], lang: Language): string { + if (entries.length === 0) return '' + + const parts = entries.map((entry, idx) => { + const q = lang === 'de' ? entry.question_de : entry.question_en + const a = lang === 'de' ? entry.answer_de : entry.answer_en + return `### Relevante Information ${idx + 1}: ${q}\n${a}` + }) + + return `\n\n## Vorrecherchierte Antworten (nutze diese als Basis, kombiniere bei Bedarf)\n${parts.join('\n\n')}\n\nWICHTIG: Formuliere die Antwort in deinen eigenen Worten als natürlichen Fließtext. Kombiniere die Informationen wenn die Frage mehrere Themen berührt. Antworte nicht mit Bulletlisten.` +}