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>
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import pool from '@/lib/db'
|
|
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
|
|
|
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
|
|
const LITELLM_MODEL = process.env.LITELLM_MODEL || 'gpt-oss-120b'
|
|
const LITELLM_API_KEY = process.env.LITELLM_API_KEY || ''
|
|
|
|
// Build SLIDE_NAMES dynamically from SLIDE_ORDER
|
|
const SLIDE_DISPLAY_NAMES: Record<string, { de: string; en: string }> = {
|
|
'intro-presenter': { de: 'Intro', en: 'Intro' },
|
|
'cover': { de: 'Cover', en: 'Cover' },
|
|
'problem': { de: 'Das Problem', en: 'The Problem' },
|
|
'solution': { de: 'Die Loesung', en: 'The Solution' },
|
|
'product': { de: 'Produkte', en: 'Products' },
|
|
'how-it-works': { de: 'So funktionierts', en: 'How It Works' },
|
|
'market': { de: 'Markt', en: 'Market' },
|
|
'business-model': { de: 'Geschaeftsmodell', en: 'Business Model' },
|
|
'traction': { de: 'Traction', en: 'Traction' },
|
|
'competition': { de: 'Wettbewerb', en: 'Competition' },
|
|
'team': { de: 'Team', en: 'Team' },
|
|
'financials': { de: 'Finanzen', en: 'Financials' },
|
|
'the-ask': { de: 'The Ask', en: 'The Ask' },
|
|
'ai-qa': { de: 'KI Q&A', en: 'AI Q&A' },
|
|
'annex-assumptions': { de: 'Anhang: Annahmen', en: 'Appendix: Assumptions' },
|
|
'annex-architecture': { de: 'Anhang: Architektur', en: 'Appendix: Architecture' },
|
|
'annex-gtm': { de: 'Anhang: Go-to-Market', en: 'Appendix: Go-to-Market' },
|
|
'annex-regulatory': { de: 'Anhang: Regulatorik', en: 'Appendix: Regulatory' },
|
|
'annex-engineering': { de: 'Anhang: Engineering', en: 'Appendix: Engineering' },
|
|
'annex-aipipeline': { de: 'Anhang: KI-Pipeline', en: 'Appendix: AI Pipeline' },
|
|
}
|
|
|
|
const slideCount = SLIDE_ORDER.length
|
|
|
|
const SYSTEM_PROMPT = `# Investor Agent — BreakPilot ComplAI
|
|
|
|
## Identitaet
|
|
Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von
|
|
potenziellen Investoren ueber das Unternehmen, das Produkt, den Markt und die Finanzprognosen.
|
|
Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
|
|
|
|
## Kernprinzipien
|
|
- **Datengetrieben**: Beziehe dich immer auf die bereitgestellten Unternehmensdaten
|
|
- **Praezise**: Nenne immer konkrete Zahlen, Prozentsaetze und Zeitraeume
|
|
- **Begeisternd aber ehrlich**: Stelle das Unternehmen positiv dar, ohne zu uebertreiben
|
|
- **Zweisprachig**: Antworte in der Sprache, in der die Frage gestellt wird
|
|
|
|
## Kernbotschaften (IMMER betonen wenn passend)
|
|
1. Zielmarkt: "Maschinen- und Anlagenbauer (VDMA ~3.600 Mitglieder in DE, ~5.000 DACH) die eigene Software/Firmware entwickeln."
|
|
2. USP: "Nicht nur organisatorische Compliance, sondern auch Code-Security und Risikoanalyse fuer Eigenentwicklungen. Das koennen Proliance, DataGuard und heyData NICHT."
|
|
3. Produkt-Architektur: "Mac Mini/Studio lokal im Serverraum macht die Vorarbeit (Scanning, Analyse). Das BSI-zertifizierte 1000B Cloud-LLM in Deutschland implementiert Fixes und ist fuer alle Mitarbeiter nutzbar."
|
|
4. Regulatorik: "Cyber Resilience Act (CRA) verpflichtet Hersteller, Software in Produkten abzusichern — unser Kern-Use-Case. Plus DSGVO, AI Act und NIS2."
|
|
5. Skalierbarkeit: "AI-First — 10x Kunden ≠ 10x Personal. 380 Kunden in 2030 bei 5.5 Mio EUR Umsatz."
|
|
6. Marktchance: "8.7 Mrd EUR TAM, SOM 7.2 Mio EUR (500 DACH-Maschinenbauer x 14.400 EUR/Jahr)."
|
|
|
|
## Kommunikationsstil
|
|
- Professionell, knapp und ueberzeugend
|
|
- Strukturierte Antworten mit klaren Abschnitten
|
|
- Zahlen hervorheben und kontextualisieren
|
|
- Maximal 3-4 Absaetze pro Antwort
|
|
|
|
## IP-Schutz-Layer (KRITISCH)
|
|
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
|
|
Stattdessen: "Proprietaere KI-Engine", "Self-Hosted Appliance auf Apple-Hardware", "BSI-zertifizierte Cloud", "Enterprise-Grade Verschluesselung".
|
|
|
|
## Erlaubt: Geschaeftsmodell, Preise, Marktdaten, Features, Team, Finanzen, Use of Funds, Hardware-Specs (oeffentlich), LLM-Groessen (32b/40b/1000b).
|
|
|
|
## Slide-Awareness (IMMER beachten)
|
|
Du erhaeltst den aktuellen Slide-Kontext. Nutze ihn fuer kontextuelle Antworten.
|
|
Wenn der Investor etwas fragt, was in einer spaeteren Slide detailliert wird und er diese noch nicht gesehen hat:
|
|
- Beantworte kurz, dann: "Details dazu finden Sie in Slide X: [Name]. Moechten Sie dorthin springen? [GOTO:slide-id]"
|
|
- Verwende [GOTO:slide-id] mit der Slide-ID (z.B. [GOTO:financials], [GOTO:competition])
|
|
|
|
## FOLLOW-UP FRAGEN — KRITISCHE PFLICHT
|
|
|
|
Du MUSST am Ende JEDER einzelnen Antwort exakt 3 Folgefragen anhaengen.
|
|
Die Fragen muessen durch "---" getrennt und mit "[Q]" markiert sein.
|
|
JEDE Antwort ohne Folgefragen ist UNVOLLSTAENDIG und FEHLERHAFT.
|
|
|
|
EXAKTES FORMAT (keine Abweichung erlaubt):
|
|
|
|
[Deine Antwort hier]
|
|
|
|
---
|
|
[Q] Erste Folgefrage passend zum Thema?
|
|
[Q] Zweite Folgefrage die tiefer geht?
|
|
[Q] Dritte Folgefrage zu einem verwandten Aspekt?
|
|
|
|
KONKRETES BEISPIEL einer vollstaendigen Antwort:
|
|
|
|
"Unser AI-First-Ansatz ermoeglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt von 36k EUR (2026) auf 8.4 Mio EUR (2030), waehrend das Team nur von 2 auf 18 Personen waechst.
|
|
|
|
---
|
|
[Q] Wie sieht die Kostenstruktur im Detail aus?
|
|
[Q] Welche Unit Economics erreicht ihr in 2030?
|
|
[Q] Wie vergleicht sich die Personaleffizienz mit Wettbewerbern?"
|
|
|
|
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
|
|
|
|
async function loadPitchContext(): Promise<string> {
|
|
try {
|
|
const client = await pool.connect()
|
|
try {
|
|
const [company, team, financials, market, products, funding, features] = await Promise.all([
|
|
client.query('SELECT * FROM pitch_company LIMIT 1'),
|
|
client.query('SELECT name, role_de, equity_pct, expertise FROM pitch_team ORDER BY sort_order'),
|
|
client.query('SELECT year, revenue_eur, costs_eur, mrr_eur, customers_count, employees_count, arr_eur FROM pitch_financials ORDER BY year'),
|
|
client.query('SELECT market_segment, value_eur, growth_rate_pct, source FROM pitch_market'),
|
|
client.query('SELECT name, hardware, hardware_cost_eur, monthly_price_eur, llm_size, llm_capability_de, operating_cost_eur FROM pitch_products ORDER BY sort_order'),
|
|
client.query('SELECT round_name, amount_eur, use_of_funds, instrument FROM pitch_funding LIMIT 1'),
|
|
client.query('SELECT feature_name_de, breakpilot, proliance, dataguard, heydata, is_differentiator FROM pitch_features WHERE is_differentiator = true'),
|
|
])
|
|
|
|
return `
|
|
## Unternehmensdaten (fuer praezise Antworten nutzen)
|
|
|
|
### Firma
|
|
${JSON.stringify(company.rows[0], null, 2)}
|
|
|
|
### Team
|
|
${JSON.stringify(team.rows, null, 2)}
|
|
|
|
### Finanzprognosen (5-Jahres-Plan)
|
|
${JSON.stringify(financials.rows, null, 2)}
|
|
|
|
### Markt (TAM/SAM/SOM)
|
|
${JSON.stringify(market.rows, null, 2)}
|
|
|
|
### Produkte
|
|
${JSON.stringify(products.rows, null, 2)}
|
|
|
|
### Finanzierung
|
|
${JSON.stringify(funding.rows[0], null, 2)}
|
|
|
|
### Differenzierende Features (nur bei ComplAI)
|
|
${JSON.stringify(features.rows, null, 2)}
|
|
`
|
|
} finally {
|
|
client.release()
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not load pitch context from DB:', error)
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json()
|
|
const { message, history = [], lang = 'de', slideContext, faqAnswer } = 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
|
|
if (pitchContext) {
|
|
systemContent += '\n' + pitchContext
|
|
}
|
|
|
|
// Slide context for contextual awareness
|
|
if (slideContext) {
|
|
const visited: number[] = slideContext.visitedSlides || []
|
|
const currentSlideId = slideContext.currentSlide
|
|
const currentSlideName = SLIDE_DISPLAY_NAMES[currentSlideId]?.[lang] || currentSlideId
|
|
const notYetSeen = SLIDE_ORDER
|
|
.map((id, idx) => ({ id, idx, name: SLIDE_DISPLAY_NAMES[id]?.[lang] || id }))
|
|
.filter(s => !visited.includes(s.idx))
|
|
.map(s => `${s.idx + 1}. ${s.name}`)
|
|
|
|
systemContent += `\n\n## Slide-Kontext (WICHTIG fuer kontextuelle Antworten)
|
|
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${slideCount})
|
|
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[SLIDE_ORDER[i]]?.[lang] || SLIDE_ORDER[i]).filter(Boolean).join(', ')}
|
|
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
|
|
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Investor hat gerade erst den Pitch geoeffnet' : 'Nein'}
|
|
- Verfuegbare Slide-IDs fuer [GOTO:id]: ${SLIDE_ORDER.join(', ')}
|
|
`
|
|
}
|
|
|
|
systemContent += `\n\n## Aktuelle Sprache: ${lang === 'de' ? 'Deutsch' : 'English'}\nAntworte in ${lang === 'de' ? 'Deutsch' : 'English'}.`
|
|
|
|
const messages = [
|
|
{ role: 'system', content: systemContent },
|
|
...history.slice(-10).map((h: { role: string; content: string }) => ({
|
|
role: h.role === 'user' ? 'user' : 'assistant',
|
|
content: h.content,
|
|
})),
|
|
{ role: 'user', content: message + '\n\n(Erinnerung: Beende deine Antwort IMMER mit "---" gefolgt von 3 Folgefragen im Format "[Q] Frage?")' },
|
|
]
|
|
|
|
// LiteLLM (OpenAI-compatible API)
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
}
|
|
if (LITELLM_API_KEY) {
|
|
headers['Authorization'] = `Bearer ${LITELLM_API_KEY}`
|
|
}
|
|
|
|
const llmResponse = await fetch(`${LITELLM_URL}/v1/chat/completions`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
model: LITELLM_MODEL,
|
|
messages,
|
|
stream: true,
|
|
temperature: 0.4,
|
|
max_tokens: 4096,
|
|
}),
|
|
signal: AbortSignal.timeout(120000),
|
|
})
|
|
|
|
if (!llmResponse.ok) {
|
|
const errorText = await llmResponse.text()
|
|
console.error('LiteLLM error:', llmResponse.status, errorText)
|
|
return NextResponse.json(
|
|
{ error: `LLM nicht erreichbar (Status ${llmResponse.status}).` },
|
|
{ status: 502 }
|
|
)
|
|
}
|
|
|
|
// Parse SSE stream from LiteLLM and emit plain text to client
|
|
const encoder = new TextEncoder()
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const reader = llmResponse.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
// Keep the last (potentially incomplete) line in the buffer
|
|
buffer = lines.pop() || ''
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
|
const data = trimmed.slice(6)
|
|
if (data === '[DONE]') continue
|
|
|
|
try {
|
|
const json = JSON.parse(data)
|
|
const content = json.choices?.[0]?.delta?.content
|
|
if (content) {
|
|
controller.enqueue(encoder.encode(content))
|
|
}
|
|
} catch {
|
|
// Partial JSON, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process any remaining buffer
|
|
if (buffer.trim()) {
|
|
const trimmed = buffer.trim()
|
|
if (trimmed.startsWith('data: ') && trimmed.slice(6) !== '[DONE]') {
|
|
try {
|
|
const json = JSON.parse(trimmed.slice(6))
|
|
const content = json.choices?.[0]?.delta?.content
|
|
if (content) {
|
|
controller.enqueue(encoder.encode(content))
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Stream read error:', error)
|
|
} finally {
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new NextResponse(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('Investor agent chat error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Verbindung zum LLM fehlgeschlagen.' },
|
|
{ status: 503 }
|
|
)
|
|
}
|
|
}
|