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:
Benjamin Boenisch
2026-02-14 19:44:27 +01:00
parent 3739d2b8b9
commit f2a24d7341
68 changed files with 5911 additions and 0 deletions

View 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 }
)
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import pool from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
const client = await pool.connect()
try {
const [
companyRes,
teamRes,
financialsRes,
marketRes,
competitorsRes,
featuresRes,
milestonesRes,
metricsRes,
fundingRes,
productsRes,
] = await Promise.all([
client.query('SELECT * FROM pitch_company LIMIT 1'),
client.query('SELECT * FROM pitch_team ORDER BY sort_order'),
client.query('SELECT * FROM pitch_financials ORDER BY year'),
client.query('SELECT * FROM pitch_market ORDER BY id'),
client.query('SELECT * FROM pitch_competitors ORDER BY id'),
client.query('SELECT * FROM pitch_features ORDER BY sort_order'),
client.query('SELECT * FROM pitch_milestones ORDER BY sort_order'),
client.query('SELECT * FROM pitch_metrics ORDER BY id'),
client.query('SELECT * FROM pitch_funding LIMIT 1'),
client.query('SELECT * FROM pitch_products ORDER BY sort_order'),
])
return NextResponse.json({
company: companyRes.rows[0] || null,
team: teamRes.rows,
financials: financialsRes.rows,
market: marketRes.rows,
competitors: competitorsRes.rows,
features: featuresRes.rows,
milestones: milestonesRes.rows,
metrics: metricsRes.rows,
funding: fundingRes.rows[0] || null,
products: productsRes.rows,
})
} finally {
client.release()
}
} catch (error) {
console.error('Database query error:', error)
return NextResponse.json(
{ error: 'Failed to load pitch data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
// PUT: Update a single assumption and trigger recompute
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { scenarioId, key, value } = body
if (!scenarioId || !key || value === undefined) {
return NextResponse.json({ error: 'scenarioId, key, and value are required' }, { status: 400 })
}
const client = await pool.connect()
try {
const jsonValue = JSON.stringify(value)
await client.query(
'UPDATE pitch_fm_assumptions SET value = $1 WHERE scenario_id = $2 AND key = $3',
[jsonValue, scenarioId, key]
)
return NextResponse.json({ success: true })
} finally {
client.release()
}
} catch (error) {
console.error('Update assumption error:', error)
return NextResponse.json({ error: 'Failed to update assumption' }, { status: 500 })
}
}

View File

@@ -0,0 +1,181 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { scenarioId } = body
if (!scenarioId) {
return NextResponse.json({ error: 'scenarioId is required' }, { status: 400 })
}
const client = await pool.connect()
try {
// Load assumptions
const assumptionsRes = await client.query(
'SELECT key, value, value_type FROM pitch_fm_assumptions WHERE scenario_id = $1',
[scenarioId]
)
const a: Record<string, number | number[]> = {}
for (const row of assumptionsRes.rows) {
const val = typeof row.value === 'string' ? JSON.parse(row.value) : row.value
a[row.key] = val
}
// Extract scalar values
const initialFunding = Number(a.initial_funding) || 200000
const growthRate = (Number(a.monthly_growth_rate) || 15) / 100
const churnRate = (Number(a.churn_rate_monthly) || 3) / 100
const arpuMini = Number(a.arpu_mini) || 299
const arpuStudio = Number(a.arpu_studio) || 999
const arpuCloud = Number(a.arpu_cloud) || 1499
const mixMini = (Number(a.product_mix_mini) || 60) / 100
const mixStudio = (Number(a.product_mix_studio) || 25) / 100
const mixCloud = (Number(a.product_mix_cloud) || 15) / 100
const initialCustomers = Number(a.initial_customers) || 2
const cac = Number(a.cac) || 500
const hwCostMini = Number(a.hw_cost_per_mini) || 3200
const hwCostStudio = Number(a.hw_cost_per_studio) || 12000
const cloudOpex = Number(a.cloud_opex_per_customer) || 150
const salaryAvg = Number(a.salary_avg_monthly) || 6000
const hiringPlan: number[] = Array.isArray(a.hiring_plan) ? a.hiring_plan : [2, 4, 8, 12, 18]
const marketingMonthly = Number(a.marketing_monthly) || 2000
const infraBase = Number(a.infra_monthly_base) || 500
// Weighted ARPU
const weightedArpu = arpuMini * mixMini + arpuStudio * mixStudio + arpuCloud * mixCloud
// Weighted hardware cost (only for Mini and Studio — Cloud is OpEx)
const hwCostWeighted = hwCostMini * mixMini + hwCostStudio * mixStudio
const results = []
let totalCustomers = initialCustomers
let cashBalance = initialFunding
let cumulativeRevenue = 0
let breakEvenMonth: number | null = null
let peakBurn = 0
for (let m = 1; m <= 60; m++) {
const yearIndex = Math.floor((m - 1) / 12) // 0-4
const year = 2026 + yearIndex
const monthInYear = ((m - 1) % 12) + 1
// Employees from hiring plan
const employees = hiringPlan[Math.min(yearIndex, hiringPlan.length - 1)] || 2
// Customer dynamics
const newCustomers = m === 1 ? initialCustomers : Math.max(1, Math.round(totalCustomers * growthRate))
const churned = Math.round(totalCustomers * churnRate)
if (m > 1) {
totalCustomers = totalCustomers + newCustomers - churned
}
totalCustomers = Math.max(0, totalCustomers)
// Revenue
const mrr = totalCustomers * weightedArpu
const arr = mrr * 12
const revenue = mrr
// Costs
const personnelCost = employees * salaryAvg
const cogsHardware = newCustomers * hwCostWeighted
const cogsCloud = totalCustomers * mixCloud * cloudOpex
const cogs = cogsHardware + cogsCloud
const marketingCost = marketingMonthly + (newCustomers * cac)
const infraCost = infraBase + (totalCustomers * 5) // infra scales slightly
const totalCosts = personnelCost + cogs + marketingCost + infraCost
// Cash
const netCash = revenue - totalCosts
cashBalance += netCash
cumulativeRevenue += revenue
// KPIs
const grossMargin = revenue > 0 ? ((revenue - cogs) / revenue) * 100 : 0
const burnRate = netCash < 0 ? Math.abs(netCash) : 0
const runway = burnRate > 0 ? cashBalance / burnRate : 999
const avgLifetimeMonths = churnRate > 0 ? 1 / churnRate : 60
const ltv = weightedArpu * avgLifetimeMonths
const ltvCacRatio = cac > 0 ? ltv / cac : 0
if (peakBurn < burnRate) peakBurn = burnRate
// Break-even detection
if (breakEvenMonth === null && netCash >= 0 && m > 1) {
breakEvenMonth = m
}
results.push({
month: m,
year,
month_in_year: monthInYear,
new_customers: newCustomers,
churned_customers: churned,
total_customers: totalCustomers,
mrr_eur: Math.round(mrr * 100) / 100,
arr_eur: Math.round(arr * 100) / 100,
revenue_eur: Math.round(revenue * 100) / 100,
cogs_eur: Math.round(cogs * 100) / 100,
personnel_eur: Math.round(personnelCost * 100) / 100,
infra_eur: Math.round(infraCost * 100) / 100,
marketing_eur: Math.round(marketingCost * 100) / 100,
total_costs_eur: Math.round(totalCosts * 100) / 100,
employees_count: employees,
gross_margin_pct: Math.round(grossMargin * 100) / 100,
burn_rate_eur: Math.round(burnRate * 100) / 100,
runway_months: Math.round(Math.min(runway, 999) * 10) / 10,
cac_eur: cac,
ltv_eur: Math.round(ltv * 100) / 100,
ltv_cac_ratio: Math.round(ltvCacRatio * 100) / 100,
cash_balance_eur: Math.round(cashBalance * 100) / 100,
cumulative_revenue_eur: Math.round(cumulativeRevenue * 100) / 100,
})
}
// Save to DB (upsert)
await client.query('DELETE FROM pitch_fm_results WHERE scenario_id = $1', [scenarioId])
for (const r of results) {
await client.query(`
INSERT INTO pitch_fm_results (scenario_id, month, year, month_in_year,
new_customers, churned_customers, total_customers,
mrr_eur, arr_eur, revenue_eur,
cogs_eur, personnel_eur, infra_eur, marketing_eur, total_costs_eur,
employees_count, gross_margin_pct, burn_rate_eur, runway_months,
cac_eur, ltv_eur, ltv_cac_ratio,
cash_balance_eur, cumulative_revenue_eur)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)
`, [
scenarioId, r.month, r.year, r.month_in_year,
r.new_customers, r.churned_customers, r.total_customers,
r.mrr_eur, r.arr_eur, r.revenue_eur,
r.cogs_eur, r.personnel_eur, r.infra_eur, r.marketing_eur, r.total_costs_eur,
r.employees_count, r.gross_margin_pct, r.burn_rate_eur, r.runway_months,
r.cac_eur, r.ltv_eur, r.ltv_cac_ratio,
r.cash_balance_eur, r.cumulative_revenue_eur,
])
}
const lastResult = results[results.length - 1]
return NextResponse.json({
scenario_id: scenarioId,
results,
summary: {
final_arr: lastResult.arr_eur,
final_customers: lastResult.total_customers,
break_even_month: breakEvenMonth,
final_runway: lastResult.runway_months,
final_ltv_cac: lastResult.ltv_cac_ratio,
peak_burn: Math.round(peakBurn * 100) / 100,
total_funding_needed: Math.round(Math.abs(Math.min(...results.map(r => r.cash_balance_eur), 0)) * 100) / 100,
},
})
} finally {
client.release()
}
} catch (error) {
console.error('Compute error:', error)
return NextResponse.json({ error: 'Computation failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string }> }
) {
try {
const { scenarioId } = await params
const client = await pool.connect()
try {
const results = await client.query(
'SELECT * FROM pitch_fm_results WHERE scenario_id = $1 ORDER BY month',
[scenarioId]
)
return NextResponse.json(results.rows)
} finally {
client.release()
}
} catch (error) {
console.error('Load results error:', error)
return NextResponse.json({ error: 'Failed to load results' }, { status: 500 })
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET: Load all scenarios with their assumptions
export async function GET() {
try {
const client = await pool.connect()
try {
const scenarios = await client.query(
'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name'
)
const assumptions = await client.query(
'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order'
)
const result = scenarios.rows.map(s => ({
...s,
assumptions: assumptions.rows
.filter(a => a.scenario_id === s.id)
.map(a => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value,
})),
}))
return NextResponse.json(result)
} finally {
client.release()
}
} catch (error) {
console.error('Financial model load error:', error)
return NextResponse.json({ error: 'Failed to load scenarios' }, { status: 500 })
}
}
// POST: Create a new scenario
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, description, color, copyFrom } = body
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
}
const client = await pool.connect()
try {
const scenario = await client.query(
'INSERT INTO pitch_fm_scenarios (name, description, color) VALUES ($1, $2, $3) RETURNING *',
[name, description || '', color || '#6366f1']
)
// If copyFrom is set, copy assumptions from another scenario
if (copyFrom) {
await client.query(`
INSERT INTO pitch_fm_assumptions (scenario_id, key, label_de, label_en, value, value_type, unit, min_value, max_value, step_size, category, sort_order)
SELECT $1, key, label_de, label_en, value, value_type, unit, min_value, max_value, step_size, category, sort_order
FROM pitch_fm_assumptions WHERE scenario_id = $2
`, [scenario.rows[0].id, copyFrom])
}
return NextResponse.json(scenario.rows[0])
} finally {
client.release()
}
} catch (error) {
console.error('Create scenario error:', error)
return NextResponse.json({ error: 'Failed to create scenario' }, { status: 500 })
}
}