fix(showcase): block financial data from AI Q&A, fix FAB overflow, fix presenter slide mapping
Build pitch-deck / build-push-deploy (push) Successful in 1m47s
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 41s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 32s

AI Q&A: fetch is_showcase from DB; showcase sessions receive no financial/funding
context and have an explicit LLM guard refusing to discuss investment details.
FAQ context and financial slide IDs stripped from system prompt.

FAB: flex layout so Fullscreen button is always visible regardless of panel height.

Presenter: pass activeSlideOrder to usePresenterMode so buildSlideAudioPlan maps
slideIdx → slideId from the filtered list, not the full SLIDE_ORDER. Progress
calculation also filters to active scripts only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-05-04 23:00:55 +02:00
parent be126a7a39
commit 2bd9b015eb
4 changed files with 88 additions and 48 deletions
+62 -31
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth' import { getSessionFromCookie } from '@/lib/auth'
import { SLIDE_ORDER } from '@/lib/slide-order' import { SLIDE_ORDER, SHOWCASE_HIDDEN_SLIDES } from '@/lib/slide-order'
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com' const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
const LITELLM_MODEL = process.env.LITELLM_MODEL || 'gpt-oss-120b' const LITELLM_MODEL = process.env.LITELLM_MODEL || 'gpt-oss-120b'
@@ -115,6 +115,19 @@ KONKRETES BEISPIEL einer vollständigen Antwort:
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.` WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
// Prepended to system content for showcase (customer demo) sessions
const SHOWCASE_GUARD = `## MODUS: PRODUKT-DEMO (KEIN INVESTOREN-PITCH)
Du bist im Showcase-Modus. Du beantwortest Fragen potenzieller Kunden über das Produkt — NICHT über Investitionsdetails.
ABSOLUTES VERBOT — niemals erwähnen oder beantworten:
- Finanzierungssumme, Investitionsbedarf, The Ask, Bewertung, Equity, Cap Table
- Finanzprognosen, Umsatzziele, Cashflow, Liquidität, Burn Rate
- Wandeldarlehen, SAFE, Gesellschafteranteile oder Kapitalstruktur
- Irgendwelche Zahlen aus dem Finanzplan oder Finanzierungsrunden
Wenn danach gefragt wird: "Für geschäftliche Details wenden Sie sich gerne direkt an unser Team."
Fokus: Produkt-Features, Compliance-Module, technische Architektur, Kundennutzen, Markt, Team.`
async function loadFpLiquiditaetSummary(scenarioName: string): Promise<string> { async function loadFpLiquiditaetSummary(scenarioName: string): Promise<string> {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -199,7 +212,7 @@ function extractMeta(
} }
} }
async function loadPitchContext(versionId?: string | null): Promise<PitchContextResult> { async function loadPitchContext(versionId?: string | null, isShowcase = false): Promise<PitchContextResult> {
try { try {
// Version-specific data path // Version-specific data path
if (versionId) { if (versionId) {
@@ -225,11 +238,11 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
const versionName = vNameRes.rows[0]?.name ?? '' const versionName = vNameRes.rows[0]?.name ?? ''
const meta = extractMeta(versionName, fmScenarios, funding, financials) const meta = extractMeta(versionName, fmScenarios, funding, financials)
const fpSummary = meta.scenarioName ? await loadFpLiquiditaetSummary(meta.scenarioName) : '' const fpSummary = (!isShowcase && meta.scenarioName) ? await loadFpLiquiditaetSummary(meta.scenarioName) : ''
return { return {
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary), contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary, isShowcase),
meta, meta: isShowcase ? { ...DEFAULT_META, versionName } : meta,
} }
} }
@@ -249,9 +262,9 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
return { return {
contextString: buildContextString( contextString: buildContextString(
company.rows[0], team.rows, financials.rows, market.rows, company.rows[0], team.rows, financials.rows, market.rows,
products.rows, funding.rows[0], features.rows, '' products.rows, funding.rows[0], features.rows, '', isShowcase
), ),
meta, meta: isShowcase ? DEFAULT_META : meta,
} }
} finally { } finally {
client.release() client.release()
@@ -264,8 +277,19 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
function buildContextString( function buildContextString(
company: unknown, team: unknown, financials: unknown, market: unknown, company: unknown, team: unknown, financials: unknown, market: unknown,
products: unknown, funding: unknown, features: unknown, fpSummary: string products: unknown, funding: unknown, features: unknown, fpSummary: string,
omitFinancials = false
): string { ): string {
const finSection = omitFinancials ? '' : `
### Finanzprognosen (5-Jahres-Plan)
${JSON.stringify(financials, null, 2)}
`
const fundSection = omitFinancials ? '' : `
### Finanzierung
${JSON.stringify(funding, null, 2)}
`
const fpSection = omitFinancials ? '' : (fpSummary ? '\n' + fpSummary : '')
return ` return `
## Unternehmensdaten (für präzise Antworten nutzen) ## Unternehmensdaten (für präzise Antworten nutzen)
@@ -274,22 +298,16 @@ ${JSON.stringify(company, null, 2)}
### Team ### Team
${JSON.stringify(team, null, 2)} ${JSON.stringify(team, null, 2)}
${finSection}
### Finanzprognosen (5-Jahres-Plan)
${JSON.stringify(financials, null, 2)}
### Markt (TAM/SAM/SOM) ### Markt (TAM/SAM/SOM)
${JSON.stringify(market, null, 2)} ${JSON.stringify(market, null, 2)}
### Produkte ### Produkte
${JSON.stringify(products, null, 2)} ${JSON.stringify(products, null, 2)}
${fundSection}
### Finanzierung
${JSON.stringify(funding, null, 2)}
### Differenzierende Features (nur bei ComplAI) ### Differenzierende Features (nur bei ComplAI)
${JSON.stringify(features, null, 2)} ${JSON.stringify(features, null, 2)}
${fpSummary ? '\n' + fpSummary : ''} ${fpSection}
` `
} }
@@ -303,22 +321,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 }) return NextResponse.json({ error: 'Message is required' }, { status: 400 })
} }
// Resolve investor's assigned version so the AI sees the correct scenario data // Resolve investor's assigned version and showcase flag
let versionId: string | null = null let versionId: string | null = null
let isShowcase = false
try { try {
const session = await getSessionFromCookie() const session = await getSessionFromCookie()
if (session?.sub) { if (session?.sub) {
const inv = await pool.query( const inv = await pool.query(
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`, `SELECT assigned_version_id, is_showcase FROM pitch_investors WHERE id = $1`,
[session.sub] [session.sub]
) )
versionId = inv.rows[0]?.assigned_version_id ?? null versionId = inv.rows[0]?.assigned_version_id ?? null
isShowcase = inv.rows[0]?.is_showcase === true
} }
} catch { } catch {
// Non-fatal: fall back to base tables // Non-fatal: fall back to base tables
} }
const { contextString, meta } = await loadPitchContext(versionId) const { contextString, meta } = await loadPitchContext(versionId, isShowcase)
// Build dynamic VERSIONS-ISOLATION and Kernbotschaft #9 from actual version data // Build dynamic VERSIONS-ISOLATION and Kernbotschaft #9 from actual version data
const fmt = (n: number) => n.toLocaleString('de-DE') const fmt = (n: number) => n.toLocaleString('de-DE')
@@ -340,40 +360,51 @@ export async function POST(request: NextRequest) {
const dynamicFinanzplanKernbotschaft = `9. Finanzplan: "Gründung August 2026. Pre-Seed über ${fundingStr}. ${customersStr} und ${revM} Umsatz bis 2030. ${employeesStr}."` const dynamicFinanzplanKernbotschaft = `9. Finanzplan: "Gründung August 2026. Pre-Seed über ${fundingStr}. ${customersStr} und ${revM} Umsatz bis 2030. ${employeesStr}."`
let systemContent = SYSTEM_PROMPT_PART1 let systemContent: string
if (isShowcase) {
// Showcase: product-only context, no financial details anywhere
systemContent = SHOWCASE_GUARD
+ '\n\n' + SYSTEM_PROMPT_PART1
+ SYSTEM_PROMPT_PART2
+ SYSTEM_PROMPT_PART3
} else {
systemContent = SYSTEM_PROMPT_PART1
+ '\n' + dynamicFinanzplanKernbotschaft + '\n' + dynamicFinanzplanKernbotschaft
+ SYSTEM_PROMPT_PART2 + SYSTEM_PROMPT_PART2
+ '\n\n' + dynamicVersionIsolation + '\n\n' + dynamicVersionIsolation
+ SYSTEM_PROMPT_PART3 + SYSTEM_PROMPT_PART3
}
if (contextString) { if (contextString) {
systemContent += '\n' + contextString systemContent += '\n' + contextString
} }
// FAQ context: relevant pre-researched answers as basis for the LLM // FAQ context — skip for showcase (may contain financial hints)
// IMPORTANT: FAQ entries contain hardcoded numbers written for specific scenarios. if (!isShowcase && faqContext && typeof faqContext === 'string') {
// They are hints only — the version-specific Unternehmensdaten above always take precedence.
if (faqContext && typeof faqContext === 'string') {
systemContent += '\n' + faqContext 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.' 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 // Slide context for contextual awareness — filter hidden slides for showcase
const visibleSlideOrder = isShowcase
? SLIDE_ORDER.filter(id => !SHOWCASE_HIDDEN_SLIDES.has(id))
: SLIDE_ORDER
if (slideContext) { if (slideContext) {
const visited: number[] = slideContext.visitedSlides || [] const visited: number[] = slideContext.visitedSlides || []
const currentSlideId = slideContext.currentSlide const currentSlideId = slideContext.currentSlide
const currentSlideName = SLIDE_DISPLAY_NAMES[currentSlideId]?.[lang] || currentSlideId const currentSlideName = SLIDE_DISPLAY_NAMES[currentSlideId]?.[lang] || currentSlideId
const notYetSeen = SLIDE_ORDER const notYetSeen = visibleSlideOrder
.map((id, idx) => ({ id, idx, name: SLIDE_DISPLAY_NAMES[id]?.[lang] || id })) .map((id, idx) => ({ id, idx, name: SLIDE_DISPLAY_NAMES[id]?.[lang] || id }))
.filter(s => !visited.includes(s.idx)) .filter(s => !visited.includes(s.idx))
.map(s => `${s.idx + 1}. ${s.name}`) .map(s => `${s.idx + 1}. ${s.name}`)
systemContent += `\n\n## Slide-Kontext (WICHTIG für kontextuelle Antworten) systemContent += `\n\n## Slide-Kontext (WICHTIG für kontextuelle Antworten)
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${slideCount}) - Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${visibleSlideOrder.length})
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[SLIDE_ORDER[i]]?.[lang] || SLIDE_ORDER[i]).filter(Boolean).join(', ')} - Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[visibleSlideOrder[i]]?.[lang] || visibleSlideOrder[i]).filter(Boolean).join(', ')}
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')} - Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Investor hat gerade erst den Pitch geöffnet' : 'Nein'} - Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Besucher hat gerade erst die Präsentation geöffnet' : 'Nein'}
- Verfügbare Slide-IDs für [GOTO:id]: ${SLIDE_ORDER.join(', ')} - Verfügbare Slide-IDs für [GOTO:id]: ${visibleSlideOrder.join(', ')}
` `
} }
+4 -4
View File
@@ -76,12 +76,12 @@ export default function NavigationFAB({
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="w-[300px] max-h-[80vh] rounded-2xl overflow-hidden className="w-[300px] max-h-[80vh] flex flex-col rounded-2xl overflow-hidden
bg-black/80 backdrop-blur-xl border border-white/10 bg-black/80 backdrop-blur-xl border border-white/10
shadow-2xl shadow-black/50" shadow-2xl shadow-black/50"
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10"> <div className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-white/10">
<span className="text-sm font-semibold text-white">{i.nav.slides}</span> <span className="text-sm font-semibold text-white">{i.nav.slides}</span>
<button <button
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
@@ -93,7 +93,7 @@ export default function NavigationFAB({
</div> </div>
{/* Slide List */} {/* Slide List */}
<div className="overflow-y-auto max-h-[55vh] py-2"> <div className="flex-1 overflow-y-auto py-2">
{activeSlideNames.map((name, idx) => { {activeSlideNames.map((name, idx) => {
const isActive = idx === currentIndex const isActive = idx === currentIndex
const isVisited = visitedSlides.has(idx) const isVisited = visitedSlides.has(idx)
@@ -134,7 +134,7 @@ export default function NavigationFAB({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="border-t border-white/10 px-4 py-3 space-y-2"> <div className="shrink-0 border-t border-white/10 px-4 py-3 space-y-2">
{/* Language Toggle */} {/* Language Toggle */}
<button <button
onClick={onToggleLanguage} onClick={onToggleLanguage}
+1
View File
@@ -105,6 +105,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
currentSlide: nav.currentIndex, currentSlide: nav.currentIndex,
totalSlides: nav.totalSlides, totalSlides: nav.totalSlides,
language: lang, language: lang,
slideOrder: activeSlideOrder,
}) })
// Audit tracking // Audit tracking
+17 -9
View File
@@ -5,6 +5,7 @@ import { Language } from '../types'
import { PresenterState, SlideScript } from '../presenter/types' import { PresenterState, SlideScript } from '../presenter/types'
import { PRESENTER_SCRIPT } from '../presenter/presenter-script' import { PRESENTER_SCRIPT } from '../presenter/presenter-script'
import { SLIDE_ORDER } from './useSlideNavigation' import { SLIDE_ORDER } from './useSlideNavigation'
import { SlideId } from '../types'
interface UsePresenterModeConfig { interface UsePresenterModeConfig {
goToSlide: (index: number) => void goToSlide: (index: number) => void
@@ -12,6 +13,7 @@ interface UsePresenterModeConfig {
totalSlides: number totalSlides: number
language: Language language: Language
ttsEnabled?: boolean ttsEnabled?: boolean
slideOrder?: SlideId[]
} }
interface UsePresenterModeReturn { interface UsePresenterModeReturn {
@@ -57,8 +59,8 @@ interface SlideAudioPlan {
segments: AudioSegment[] segments: AudioSegment[]
} }
function buildSlideAudioPlan(slideIdx: number, lang: Language): SlideAudioPlan | null { function buildSlideAudioPlan(slideIdx: number, lang: Language, activeSlideOrder: SlideId[]): SlideAudioPlan | null {
const slideId = SLIDE_ORDER[slideIdx] const slideId = activeSlideOrder[slideIdx]
const script = PRESENTER_SCRIPT.find(s => s.slideId === slideId) const script = PRESENTER_SCRIPT.find(s => s.slideId === slideId)
if (!script || script.paragraphs.length === 0) return null if (!script || script.paragraphs.length === 0) return null
@@ -121,7 +123,9 @@ export function usePresenterMode({
totalSlides, totalSlides,
language, language,
ttsEnabled: initialTtsEnabled = true, ttsEnabled: initialTtsEnabled = true,
slideOrder: slideOrderProp,
}: UsePresenterModeConfig): UsePresenterModeReturn { }: UsePresenterModeConfig): UsePresenterModeReturn {
const activeSlideOrder = slideOrderProp ?? SLIDE_ORDER
const [state, setState] = useState<PresenterState>('idle') const [state, setState] = useState<PresenterState>('idle')
const [currentParagraph, setCurrentParagraph] = useState(0) const [currentParagraph, setCurrentParagraph] = useState(0)
const [displayText, setDisplayText] = useState('') const [displayText, setDisplayText] = useState('')
@@ -193,7 +197,7 @@ export function usePresenterMode({
playSlideRef.current = async (slideIdx: number) => { playSlideRef.current = async (slideIdx: number) => {
if (stateRef.current !== 'presenting') return if (stateRef.current !== 'presenting') return
const plan = buildSlideAudioPlan(slideIdx, language) const plan = buildSlideAudioPlan(slideIdx, language, activeSlideOrder)
if (!plan) { if (!plan) {
// No script for this slide — skip to next // No script for this slide — skip to next
if (slideIdx < totalSlides - 1) { if (slideIdx < totalSlides - 1) {
@@ -216,7 +220,7 @@ export function usePresenterMode({
// Pre-fetch next slide's audio in background // Pre-fetch next slide's audio in background
if (slideIdx < totalSlides - 1) { if (slideIdx < totalSlides - 1) {
const nextPlan = buildSlideAudioPlan(slideIdx + 1, language) const nextPlan = buildSlideAudioPlan(slideIdx + 1, language, activeSlideOrder)
if (nextPlan) fetchAudio(nextPlan.fullText, language).catch(() => {}) if (nextPlan) fetchAudio(nextPlan.fullText, language).catch(() => {})
} }
@@ -329,7 +333,7 @@ export function usePresenterMode({
setIsSpeaking(false) setIsSpeaking(false)
} }
} }
}, [language, totalSlides, goToSlide, ttsAvailable, ttsEnabled]) }, [language, totalSlides, goToSlide, ttsAvailable, ttsEnabled, activeSlideOrder])
const start = useCallback(() => { const start = useCallback(() => {
unlockAudio() unlockAudio()
@@ -410,14 +414,18 @@ export function usePresenterMode({
} }
}, [unlockAudio, start, stop]) }, [unlockAudio, start, stop])
// Calculate overall progress // Calculate overall progress against the active slide order's scripts
const progress = (() => { const progress = (() => {
if (state === 'idle') return 0 if (state === 'idle') return 0
const totalScripts = PRESENTER_SCRIPT.length const currentSlideId = activeSlideOrder[currentSlide]
const currentScriptIdx = PRESENTER_SCRIPT.findIndex(s => s.slideId === SLIDE_ORDER[currentSlide]) const activeScripts = activeSlideOrder
.map(id => PRESENTER_SCRIPT.find(s => s.slideId === id))
.filter(Boolean) as typeof PRESENTER_SCRIPT
const totalScripts = activeScripts.length || 1
const currentScriptIdx = activeScripts.findIndex(s => s.slideId === currentSlideId)
if (currentScriptIdx < 0) return (currentSlide / totalSlides) * 100 if (currentScriptIdx < 0) return (currentSlide / totalSlides) * 100
const script = PRESENTER_SCRIPT[currentScriptIdx] const script = activeScripts[currentScriptIdx]
const slideProgress = script.paragraphs.length > 0 const slideProgress = script.paragraphs.length > 0
? currentParagraph / script.paragraphs.length ? currentParagraph / script.paragraphs.length
: 0 : 0