feat: add pitch-deck service to core infrastructure
Migrated pitch-deck from breakpilot-pwa to breakpilot-core. Container: bp-core-pitch-deck on port 3012. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
246
pitch-deck/app/api/chat/route.ts
Normal file
246
pitch-deck/app/api/chat/route.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen2.5:32b'
|
||||
|
||||
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. AI-First: "Alles was durch KI loesbar ist, wird durch KI geloest. Kein klassischer Support, kein grosses Sales-Team."
|
||||
2. Skalierbarkeit: "10x Kunden ≠ 10x Personal. Die KI skaliert mit."
|
||||
3. Hardware-Differenzierung: "Datensouveraenitaet durch Self-Hosting auf Apple-Hardware."
|
||||
4. Kostenstruktur: "18 Mitarbeiter in 2030 bei 8.4 Mio EUR Umsatz."
|
||||
5. Marktchance: "12.4 Mrd EUR TAM, regulatorisch getrieben."
|
||||
|
||||
## 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:X]"
|
||||
|
||||
## 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 } = body
|
||||
|
||||
if (!message || typeof message !== 'string') {
|
||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const pitchContext = await loadPitchContext()
|
||||
|
||||
let systemContent = SYSTEM_PROMPT
|
||||
if (pitchContext) {
|
||||
systemContent += '\n' + pitchContext
|
||||
}
|
||||
// Slide context for contextual awareness
|
||||
if (slideContext) {
|
||||
const SLIDE_NAMES: Record<string, { de: string; en: string; index: number }> = {
|
||||
'cover': { de: 'Cover', en: 'Cover', index: 0 },
|
||||
'problem': { de: 'Das Problem', en: 'The Problem', index: 1 },
|
||||
'solution': { de: 'Die Loesung', en: 'The Solution', index: 2 },
|
||||
'product': { de: 'Produkte', en: 'Products', index: 3 },
|
||||
'how-it-works': { de: 'So funktionierts', en: 'How It Works', index: 4 },
|
||||
'market': { de: 'Markt', en: 'Market', index: 5 },
|
||||
'business-model': { de: 'Geschaeftsmodell', en: 'Business Model', index: 6 },
|
||||
'traction': { de: 'Traction', en: 'Traction', index: 7 },
|
||||
'competition': { de: 'Wettbewerb', en: 'Competition', index: 8 },
|
||||
'team': { de: 'Team', en: 'Team', index: 9 },
|
||||
'financials': { de: 'Finanzen', en: 'Financials', index: 10 },
|
||||
'the-ask': { de: 'The Ask', en: 'The Ask', index: 11 },
|
||||
'ai-qa': { de: 'KI Q&A', en: 'AI Q&A', index: 12 },
|
||||
}
|
||||
const slideKeys = Object.keys(SLIDE_NAMES)
|
||||
const visited: number[] = slideContext.visitedSlides || []
|
||||
const currentSlideName = SLIDE_NAMES[slideContext.currentSlide]?.[lang] || slideContext.currentSlide
|
||||
const notYetSeen = Object.entries(SLIDE_NAMES)
|
||||
.filter(([, v]) => !visited.includes(v.index))
|
||||
.map(([, v]) => `${v.index + 1}. ${v[lang]}`)
|
||||
|
||||
systemContent += `\n\n## Slide-Kontext (WICHTIG fuer kontextuelle Antworten)
|
||||
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von 13)
|
||||
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_NAMES[slideKeys[i]]?.[lang]).filter(Boolean).join(', ')}
|
||||
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
|
||||
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Investor hat gerade erst den Pitch geoeffnet' : 'Nein'}
|
||||
`
|
||||
}
|
||||
|
||||
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?")' },
|
||||
]
|
||||
|
||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: OLLAMA_MODEL,
|
||||
messages,
|
||||
stream: true,
|
||||
options: {
|
||||
temperature: 0.4,
|
||||
num_predict: 4096,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
const errorText = await ollamaResponse.text()
|
||||
console.error('Ollama error:', ollamaResponse.status, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status}).` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = ollamaResponse.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
const lines = chunk.split('\n').filter((l) => l.trim())
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line)
|
||||
if (json.message?.content) {
|
||||
controller.enqueue(encoder.encode(json.message.content))
|
||||
}
|
||||
} catch {
|
||||
// Partial JSON line, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user