All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m41s
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 44s
CI / test-python-voice (push) Successful in 39s
CI / test-bqas (push) Successful in 31s
Using terms like 'Version X' or 'Szenario Y' in the VERSIONS-ISOLATION instruction implies other versions exist. Rewritten to never reference version/scenario names — just 'this pitch deck, created for you, the only one'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
486 lines
22 KiB
TypeScript
486 lines
22 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import pool from '@/lib/db'
|
|
import { getSessionFromCookie } from '@/lib/auth'
|
|
import { SLIDE_ORDER } from '@/lib/slide-order'
|
|
|
|
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 Lösung', en: 'The Solution' },
|
|
'usp': { de: 'USP', en: 'USP' },
|
|
'product': { de: 'Produkte', en: 'Products' },
|
|
'how-it-works': { de: 'So funktioniert\'s', en: 'How It Works' },
|
|
'market': { de: 'Markt', en: 'Market' },
|
|
'business-model': { de: 'Geschäftsmodell', en: 'Business Model' },
|
|
'traction': { de: 'Meilensteine', en: 'Milestones' },
|
|
'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' },
|
|
'annex-sdk-demo': { de: 'Anhang: SDK Demo', en: 'Appendix: SDK Demo' },
|
|
}
|
|
|
|
const slideCount = SLIDE_ORDER.length
|
|
|
|
const SYSTEM_PROMPT = `# Investor Agent — BreakPilot ComplAI
|
|
|
|
## Identität
|
|
Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von
|
|
potenziellen Investoren über 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
|
|
- **Präzise**: Nenne immer konkrete Zahlen, Prozentsätze und Zeiträume
|
|
- **Begeisternd aber ehrlich**: Stelle das Unternehmen positiv dar, ohne zu übertreiben
|
|
- **Zweisprachig**: Antworte in der Sprache, in der die Frage gestellt wird
|
|
|
|
## Kernbotschaften (IMMER betonen wenn passend)
|
|
1. Kern-Produkt: "BreakPilot COMPLAI — DSGVO-konforme KI-Plattform mit 12 Modulen. Kontinuierliche Code-Security und Compliance-Automatisierung. 380+ Gesetze und Regularien, 25.000+ Prüfaspekte."
|
|
2. Das Problem: "Unternehmen stehen vor einem strategischen Dilemma: Ohne KI verlieren sie Wettbewerbsfähigkeit. Mit US-KI riskieren sie Datenkontrollverlust. Über 30.000 Unternehmen in DE durch EU-Regulierungen belastet."
|
|
3. 12 Module: "Code Security (SAST/DAST/SBOM/Pentesting), CE-Software-Risikobeurteilung, Compliance-Dokumente (VVT/DSFA/TOMs), Audit Manager, DSR/Betroffenenrechte, Consent Management, Notfallpläne, Cookie-Generator, Compliance LLM, Academy, Integration in Kundenprozesse, Sichere Kommunikation."
|
|
4. Code & CE: "Kontinuierlich statt einmal im Jahr. CE-Software-Risikobeurteilung auf Code-Basis schon in der Entwicklung. Findings als Tickets mit Implementierungsvorschlägen."
|
|
5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces."
|
|
6. Zielgruppen: "Maschinen- und Anlagenbauer, Automobilindustrie, Zulieferer und alle produzierenden Unternehmen."
|
|
7. Geschäftsmodell: "SaaS, mitarbeiterbasiertes Pricing. Drei Tiers: Starter (3.600 EUR/Jahr), Professional (15.000-40.000 EUR/Jahr), Enterprise (ab 50.000 EUR/Jahr). Plus Beratung & Service (10.000-30.000 EUR/Monat). Kunden sparen mehr als sie zahlen — ROI ab Tag 1."
|
|
8. Team: "Lean-Team: 2 Gründer + 7 Mitarbeiter bis 2030 (9 Personen gesamt). Erste Einstellung: IT-Recht/Datenschutzjurist (50%). Dann: Security Engineer, Vertrieb, Backend, Kundenbetreuer, Marketing, DevOps. Jede Einstellung an konkreten Umsatzmeilenstein gekoppelt."
|
|
9. Finanzplan: [SIEHE DYNAMISCHE VERSIONSDATEN — wird zur Laufzeit gesetzt]
|
|
|
|
## Kommunikationsstil
|
|
- Antworte IMMER wie ein Mensch in einem persönlichen Gespräch — ausformulierte Sätze, natürlicher Redefluss
|
|
- KEINE Bulletpoint-Listen. KEINE Aufzählungen mit Spiegelstrichen. Schreibe Fließtext in Absätzen.
|
|
- Erkläre das WARUM hinter jeder Aussage. Nicht nur "was" ihr tut, sondern begründe die Entscheidung.
|
|
- Nutze Übergangssätze wie "Der Grund dafür ist...", "Das haben wir bewusst so entschieden, weil...", "Besonders wichtig ist dabei..."
|
|
- Zahlen und Fakten natürlich in den Text einbetten, nicht als Liste aufreihen
|
|
- 3-5 Absätze pro Antwort, jeder Absatz ein eigenständiger Gedanke
|
|
- Der Text muss sich gut anhören wenn er vorgelesen wird (TTS-optimiert)
|
|
|
|
## VERSIONS-ISOLATION (ABSOLUT KRITISCH)
|
|
[WIRD ZUR LAUFZEIT GESETZT — enthält die exakten Zahlen dieser Investor-Version]
|
|
|
|
## IP-Schutz-Layer (KRITISCH)
|
|
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
|
|
Stattdessen: "Proprietäre KI-Engine", "BSI-zertifizierte EU-Cloud (SysEleven, Hetzner)", "Isolierte Kunden-Namespaces", "Enterprise-Grade Verschlüsselung".
|
|
|
|
## Erlaubt: Geschäftsmodell, Preise, Marktdaten, Features, Team, Finanzen, Use of Funds, Hardware-Specs (öffentlich), LLM-Größen (32b/40b/1000b), CE-Risikobeurteilung, Issue-Tracker-Integration, Meeting-Recorder, Matrix/Jitsi.
|
|
|
|
## Team-Antworten (WICHTIG)
|
|
Wenn nach dem Team gefragt wird: IMMER die Namen, Rollen und Expertise der Gründer aus den bereitgestellten Daten nennen. NIEMALS vage Antworten wie "unser Team vereint Expertise" ohne Namen. Zitiere die konkreten Personen aus den Unternehmensdaten.
|
|
|
|
## Slide-Awareness (IMMER beachten)
|
|
Du erhältst den aktuellen Slide-Kontext. Nutze ihn für kontextuelle Antworten.
|
|
Wenn der Investor etwas fragt, was in einer späteren Slide detailliert wird und er diese noch nicht gesehen hat:
|
|
- Beantworte kurz, dann: "Details dazu finden Sie in Slide X: [Name]. Möchten 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 anhängen.
|
|
Die Fragen müssen durch "---" getrennt und mit "[Q]" markiert sein.
|
|
JEDE Antwort ohne Folgefragen ist UNVOLLSTÄNDIG 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 vollständigen Antwort:
|
|
|
|
"Unser AI-First-Ansatz ermöglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt planmäßig über die Jahre stark an, während das Team lean bleibt.
|
|
|
|
---
|
|
[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 loadFpLiquiditaetSummary(scenarioName: string): Promise<string> {
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`SELECT l.row_label, l.values
|
|
FROM fp_liquiditaet l
|
|
JOIN fp_scenarios s ON s.id = l.scenario_id
|
|
WHERE s.name = $1
|
|
AND l.row_label IN ('LIQUIDITÄT', 'LIQUIDITAET', 'Summe ERTRÄGE', 'Summe EINZAHLUNGEN', 'Summe AUSZAHLUNGEN', 'ÜBERSCHUSS')
|
|
ORDER BY l.sort_order`,
|
|
[scenarioName]
|
|
)
|
|
if (rows.length === 0) return ''
|
|
|
|
const years = [2026, 2027, 2028, 2029, 2030]
|
|
const summary: Record<string, Record<number, number>> = {}
|
|
for (const row of rows) {
|
|
const label = row.row_label === 'LIQUIDITAET' ? 'LIQUIDITÄT' : row.row_label
|
|
summary[label] = {}
|
|
for (let yi = 0; yi < years.length; yi++) {
|
|
const start = yi * 12 + 1
|
|
const end = start + 11
|
|
// LIQUIDITÄT is a balance (end-of-period): use December value
|
|
if (label === 'LIQUIDITÄT') {
|
|
summary[label][years[yi]] = Math.round(row.values[`m${end}`] || 0)
|
|
} else {
|
|
let s = 0
|
|
for (let m = start; m <= end; m++) s += row.values[`m${m}`] || 0
|
|
summary[label][years[yi]] = Math.round(s)
|
|
}
|
|
}
|
|
}
|
|
|
|
const lines = [`### Finanzplan-Liquidität (Szenario: ${scenarioName})`]
|
|
for (const [label, yearVals] of Object.entries(summary)) {
|
|
const vals = years.map(y => `${y}: ${(yearVals[y] || 0).toLocaleString('de-DE')} EUR`).join(' | ')
|
|
lines.push(`${label}: ${vals}`)
|
|
}
|
|
return lines.join('\n')
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
interface VersionMeta {
|
|
versionName: string
|
|
scenarioName: string
|
|
fundingAmount: number
|
|
fundingInstrument: string
|
|
customers2030: number
|
|
revenue2030: number
|
|
employees2030: number
|
|
}
|
|
|
|
interface PitchContextResult {
|
|
contextString: string
|
|
meta: VersionMeta
|
|
}
|
|
|
|
const DEFAULT_META: VersionMeta = {
|
|
versionName: '', scenarioName: '', fundingAmount: 0,
|
|
fundingInstrument: 'Wandeldarlehen', customers2030: 0, revenue2030: 0, employees2030: 0,
|
|
}
|
|
|
|
function extractMeta(
|
|
versionName: string,
|
|
fmScenarios: Array<{ name: string }> | undefined,
|
|
funding: Record<string, unknown> | null,
|
|
financials: Array<Record<string, unknown>>
|
|
): VersionMeta {
|
|
const fin2030 = financials.find(f => Number(f.year) === 2030) ?? {}
|
|
return {
|
|
versionName,
|
|
scenarioName: fmScenarios?.[0]?.name ?? versionName,
|
|
fundingAmount: Number(funding?.amount_eur ?? 0),
|
|
fundingInstrument: String(funding?.instrument ?? 'Wandeldarlehen'),
|
|
customers2030: Number(fin2030.customers_count ?? 0),
|
|
revenue2030: Number(fin2030.revenue_eur ?? 0),
|
|
employees2030: Number(fin2030.employees_count ?? 0),
|
|
}
|
|
}
|
|
|
|
async function loadPitchContext(versionId?: string | null): Promise<PitchContextResult> {
|
|
try {
|
|
// Version-specific data path
|
|
if (versionId) {
|
|
const [vDataRes, vNameRes] = await Promise.all([
|
|
pool.query(`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`, [versionId]),
|
|
pool.query(`SELECT name FROM pitch_versions WHERE id = $1`, [versionId]),
|
|
])
|
|
|
|
const map: Record<string, unknown> = {}
|
|
for (const r of vDataRes.rows) {
|
|
map[r.table_name] = typeof r.data === 'string' ? JSON.parse(r.data) : r.data
|
|
}
|
|
|
|
const company = (map.company as unknown[])?.[0] ?? null
|
|
const team = (map.team as unknown[]) ?? []
|
|
const financials = (map.financials as Array<Record<string, unknown>>) ?? []
|
|
const market = (map.market as unknown[]) ?? []
|
|
const products = (map.products as unknown[]) ?? []
|
|
const funding = ((map.funding as unknown[])?.[0] as Record<string, unknown>) ?? null
|
|
const features = ((map.features as Array<Record<string, unknown>>) ?? [])
|
|
.filter(f => f.is_differentiator)
|
|
const fmScenarios = map.fm_scenarios as Array<{ name: string }> | undefined
|
|
|
|
const versionName = vNameRes.rows[0]?.name ?? ''
|
|
const meta = extractMeta(versionName, fmScenarios, funding, financials)
|
|
const fpSummary = meta.scenarioName ? await loadFpLiquiditaetSummary(meta.scenarioName) : ''
|
|
|
|
return {
|
|
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary),
|
|
meta,
|
|
}
|
|
}
|
|
|
|
// Fallback: base tables
|
|
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'),
|
|
])
|
|
const meta = extractMeta('', undefined, funding.rows[0] ?? null, financials.rows)
|
|
return {
|
|
contextString: buildContextString(
|
|
company.rows[0], team.rows, financials.rows, market.rows,
|
|
products.rows, funding.rows[0], features.rows, ''
|
|
),
|
|
meta,
|
|
}
|
|
} finally {
|
|
client.release()
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not load pitch context from DB:', error)
|
|
return { contextString: '', meta: DEFAULT_META }
|
|
}
|
|
}
|
|
|
|
function buildContextString(
|
|
company: unknown, team: unknown, financials: unknown, market: unknown,
|
|
products: unknown, funding: unknown, features: unknown, fpSummary: string
|
|
): string {
|
|
return `
|
|
## Unternehmensdaten (für präzise Antworten nutzen)
|
|
|
|
### Firma
|
|
${JSON.stringify(company, null, 2)}
|
|
|
|
### Team
|
|
${JSON.stringify(team, null, 2)}
|
|
|
|
### Finanzprognosen (5-Jahres-Plan)
|
|
${JSON.stringify(financials, null, 2)}
|
|
|
|
### Markt (TAM/SAM/SOM)
|
|
${JSON.stringify(market, null, 2)}
|
|
|
|
### Produkte
|
|
${JSON.stringify(products, null, 2)}
|
|
|
|
### Finanzierung
|
|
${JSON.stringify(funding, null, 2)}
|
|
|
|
### Differenzierende Features (nur bei ComplAI)
|
|
${JSON.stringify(features, null, 2)}
|
|
${fpSummary ? '\n' + fpSummary : ''}
|
|
`
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json()
|
|
const { message, history = [], lang = 'de', slideContext, faqContext } = body
|
|
|
|
if (!message || typeof message !== 'string') {
|
|
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
|
}
|
|
|
|
// Resolve investor's assigned version so the AI sees the correct scenario data
|
|
let versionId: string | null = null
|
|
try {
|
|
const session = await getSessionFromCookie()
|
|
if (session?.sub) {
|
|
const inv = await pool.query(
|
|
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
|
|
[session.sub]
|
|
)
|
|
versionId = inv.rows[0]?.assigned_version_id ?? null
|
|
}
|
|
} catch {
|
|
// Non-fatal: fall back to base tables
|
|
}
|
|
|
|
const { contextString, meta } = await loadPitchContext(versionId)
|
|
|
|
// Build dynamic VERSIONS-ISOLATION and Kernbotschaft #9 from actual version data
|
|
const fmt = (n: number) => n.toLocaleString('de-DE')
|
|
const revM = meta.revenue2030 > 0
|
|
? `~${(meta.revenue2030 / 1_000_000).toFixed(1).replace('.', ',')} Mio. EUR`
|
|
: 'laut Finanzplan'
|
|
const fundingStr = meta.fundingAmount > 0
|
|
? `${fmt(meta.fundingAmount)} EUR ${meta.fundingInstrument}`
|
|
: meta.fundingInstrument
|
|
const customersStr = meta.customers2030 > 0 ? `~${meta.customers2030} Kunden` : 'laut Finanzplan'
|
|
const employeesStr = meta.employees2030 > 0 ? `${meta.employees2030} Mitarbeiter` : 'laut Finanzplan'
|
|
|
|
const dynamicVersionIsolation = `## VERSIONS-ISOLATION (ABSOLUT KRITISCH)
|
|
- Es gibt NUR dieses eine Pitch Deck. Es wurde individuell für diesen Investor erstellt.
|
|
- Wenn gefragt wird ob es andere Versionen, andere Pitch Decks oder andere Konditionen gibt: "Dieses Pitch Deck wurde persönlich für Sie erstellt. Es gibt genau dieses."
|
|
- NIEMALS Begriffe wie "Version", "Szenario", "Variante" oder "diese Version" verwenden — das impliziert, es könnte andere geben.
|
|
- NIEMALS erwähnen: andere Finanzierungssummen, andere Bewertungen, andere Cap Tables, andere Szenarien.
|
|
- Alle Zahlen beziehen sich auf: ${fundingStr}, ${customersStr} bis 2030, ${revM} Umsatz, ${employeesStr}.`
|
|
|
|
const dynamicFinanzplanKernbotschaft = `9. Finanzplan: "Gründung August 2026. Pre-Seed über ${fundingStr}. ${customersStr} und ${revM} Umsatz bis 2030. ${employeesStr}."`
|
|
|
|
let systemContent = SYSTEM_PROMPT
|
|
.replace('9. Finanzplan: [SIEHE DYNAMISCHE VERSIONSDATEN — wird zur Laufzeit gesetzt]', dynamicFinanzplanKernbotschaft)
|
|
.replace('## VERSIONS-ISOLATION (ABSOLUT KRITISCH)\n[WIRD ZUR LAUFZEIT GESETZT — enthält die exakten Zahlen dieser Investor-Version]', dynamicVersionIsolation)
|
|
|
|
if (contextString) {
|
|
systemContent += '\n' + contextString
|
|
}
|
|
|
|
// FAQ context: relevant pre-researched answers as basis for the LLM
|
|
// IMPORTANT: FAQ entries contain hardcoded numbers written for specific scenarios.
|
|
// They are hints only — the version-specific Unternehmensdaten above always take precedence.
|
|
if (faqContext && typeof faqContext === 'string') {
|
|
systemContent += '\n' + faqContext
|
|
systemContent += '\n\n## Versions-Datenvorrang (ABSOLUT VERBINDLICH)\nWenn die vorrecherchierten Antworten oben Zahlen, Beträge oder Details nennen, die von den "Unternehmensdaten" oder dem "Finanzplan-Liquidität" weiter oben abweichen, haben die Unternehmensdaten IMMER Vorrang. Die FAQ-Antworten sind allgemein formuliert und könnten veraltete oder szenario-fremde Zahlen enthalten. Nutze sie nur für Struktur und Formulierung — die konkreten Zahlen kommen ausschließlich aus den Unternehmensdaten dieses Investors.'
|
|
}
|
|
|
|
// 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 für 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 geöffnet' : 'Nein'}
|
|
- Verfügbare Slide-IDs für [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 }
|
|
)
|
|
}
|
|
}
|