feat: Investor Agent — FAQ als LLM-Kontext statt Direkt-Streaming
Architektur-Umbau: FAQ-Antworten werden NICHT mehr direkt gestreamt. Stattdessen werden die Top-3 relevanten FAQ-Einträge als Kontext ans LLM übergeben. Das LLM interpretiert die Frage, kombiniert mehrere FAQs bei komplexen Fragen und antwortet natürlich. Vorher: Frage → Keyword-Match → FAQ direkt streamen (LLM umgangen) Nachher: Frage → Top-3 FAQ-Matches → LLM-Prompt als Kontext → LLM antwortet Neue Funktionen: - matchFAQMultiple(): Top-N Matches statt nur bester - buildFAQContext(): Baut Kontext-String für LLM-Injection - faqContext statt faqAnswer im Request-Body - System-Prompt Anweisung: "Kombiniere bei Bedarf, natürlicher Fließtext" Behebt: Komplexe Fragen mit 2+ Themen werden jetzt korrekt beantwortet Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,42 +157,12 @@ ${JSON.stringify(features.rows, null, 2)}
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
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') {
|
if (!message || typeof message !== 'string') {
|
||||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
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()
|
const pitchContext = await loadPitchContext()
|
||||||
|
|
||||||
let systemContent = SYSTEM_PROMPT
|
let systemContent = SYSTEM_PROMPT
|
||||||
@@ -200,6 +170,11 @@ export async function POST(request: NextRequest) {
|
|||||||
systemContent += '\n' + pitchContext
|
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
|
// Slide context for contextual awareness
|
||||||
if (slideContext) {
|
if (slideContext) {
|
||||||
const visited: number[] = slideContext.visitedSlides || []
|
const visited: number[] = slideContext.visitedSlides || []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ChatMessage, Language, SlideId } from '@/lib/types'
|
|||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||||
import { PresenterState } from '@/lib/presenter/types'
|
import { PresenterState } from '@/lib/presenter/types'
|
||||||
import { matchFAQ, getFAQAnswer } from '@/lib/presenter/faq-matcher'
|
import { matchFAQMultiple, buildFAQContext } from '@/lib/presenter/faq-matcher'
|
||||||
|
|
||||||
interface ChatFABProps {
|
interface ChatFABProps {
|
||||||
lang: Language
|
lang: Language
|
||||||
@@ -227,8 +227,9 @@ export default function ChatFAB({
|
|||||||
setIsStreaming(true)
|
setIsStreaming(true)
|
||||||
setIsWaiting(true)
|
setIsWaiting(true)
|
||||||
|
|
||||||
// Check FAQ first for instant response
|
// Find relevant FAQ entries as context for the LLM
|
||||||
const faqMatch = matchFAQ(message, lang)
|
const faqMatches = matchFAQMultiple(message, lang, 3)
|
||||||
|
const faqContext = buildFAQContext(faqMatches, lang)
|
||||||
|
|
||||||
abortRef.current = new AbortController()
|
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)
|
// Send FAQ context to LLM (not direct streaming — LLM interprets and combines)
|
||||||
if (faqMatch) {
|
if (faqContext) {
|
||||||
requestBody.faqAnswer = getFAQAnswer(faqMatch, lang)
|
requestBody.faqContext = faqContext
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/chat', {
|
const res = await fetch('/api/chat', {
|
||||||
|
|||||||
@@ -7,11 +7,19 @@ import { PRESENTER_FAQ } from './presenter-faq'
|
|||||||
* Returns the best match if score exceeds threshold, or null for LLM fallback.
|
* Returns the best match if score exceeds threshold, or null for LLM fallback.
|
||||||
*/
|
*/
|
||||||
export function matchFAQ(query: string, lang: Language): FAQEntry | null {
|
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 normalized = query.toLowerCase().trim()
|
||||||
const queryWords = normalized.split(/\s+/)
|
const queryWords = normalized.split(/\s+/)
|
||||||
|
|
||||||
let bestMatch: FAQEntry | null = null
|
const scored: { entry: FAQEntry; score: number }[] = []
|
||||||
let bestScore = 0
|
|
||||||
|
|
||||||
for (const entry of PRESENTER_FAQ) {
|
for (const entry of PRESENTER_FAQ) {
|
||||||
let score = 0
|
let score = 0
|
||||||
@@ -20,23 +28,20 @@ export function matchFAQ(query: string, lang: Language): FAQEntry | null {
|
|||||||
for (const keyword of entry.keywords) {
|
for (const keyword of entry.keywords) {
|
||||||
const kwLower = keyword.toLowerCase()
|
const kwLower = keyword.toLowerCase()
|
||||||
if (kwLower.includes(' ')) {
|
if (kwLower.includes(' ')) {
|
||||||
// Multi-word keyword: check if phrase appears in query
|
|
||||||
if (normalized.includes(kwLower)) {
|
if (normalized.includes(kwLower)) {
|
||||||
score += 3 * entry.priority / 10
|
score += 3 * entry.priority / 10
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single keyword: check word-level match
|
|
||||||
if (queryWords.some(w => w === kwLower || w.startsWith(kwLower) || kwLower.startsWith(w))) {
|
if (queryWords.some(w => w === kwLower || w.startsWith(kwLower) || kwLower.startsWith(w))) {
|
||||||
score += 1
|
score += 1
|
||||||
}
|
}
|
||||||
// Also check if keyword appears anywhere in query (partial match)
|
|
||||||
if (normalized.includes(kwLower)) {
|
if (normalized.includes(kwLower)) {
|
||||||
score += 0.5
|
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 questionText = lang === 'de' ? entry.question_de : entry.question_en
|
||||||
const questionWords = questionText.toLowerCase().split(/\s+/)
|
const questionWords = questionText.toLowerCase().split(/\s+/)
|
||||||
const overlapCount = queryWords.filter(w =>
|
const overlapCount = queryWords.filter(w =>
|
||||||
@@ -46,22 +51,16 @@ export function matchFAQ(query: string, lang: Language): FAQEntry | null {
|
|||||||
score += overlapCount * 0.5
|
score += overlapCount * 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weight by priority
|
|
||||||
score *= (entry.priority / 10)
|
score *= (entry.priority / 10)
|
||||||
|
|
||||||
if (score > bestScore) {
|
if (score >= 1.0) {
|
||||||
bestScore = score
|
scored.push({ entry, score })
|
||||||
bestMatch = entry
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Threshold: need meaningful match to avoid false positives
|
// Sort by score descending, return top N
|
||||||
// Require at least 2 keyword hits or strong phrase match
|
scored.sort((a, b) => b.score - a.score)
|
||||||
if (bestScore < 1.5) {
|
return scored.slice(0, maxResults).map(s => s.entry)
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMatch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,3 +69,18 @@ export function matchFAQ(query: string, lang: Language): FAQEntry | null {
|
|||||||
export function getFAQAnswer(entry: FAQEntry, lang: Language): string {
|
export function getFAQAnswer(entry: FAQEntry, lang: Language): string {
|
||||||
return lang === 'de' ? entry.answer_de : entry.answer_en
|
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.`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user