[split-required] [guardrail-change] Enforce 500 LOC budget across all services

Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook)
and split all 44 files exceeding 500 LOC into domain-focused modules:

- consent-service (Go): models, handlers, services, database splits
- backend-core (Python): security_api, rbac_api, pdf_service, auth splits
- admin-core (TypeScript): 5 page.tsx + sidebar extractions
- pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits
- voice-service (Python): enhanced_task_orchestrator split

Result: 0 violations, 36 exempted (pipeline, tests, pure-data files).
Go build verified clean. No behavior changes — pure structural splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-27 00:09:30 +02:00
parent 5ef039a6bc
commit 92c86ec6ba
162 changed files with 23853 additions and 23034 deletions

View File

@@ -3,101 +3,17 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight, Volume2, VolumeX } from 'lucide-react'
import { ChatMessage, Language, SlideId } from '@/lib/types'
import { ChatMessage } from '@/lib/types'
import { t } from '@/lib/i18n'
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
import { PresenterState } from '@/lib/presenter/types'
import { matchFAQMultiple, buildFAQContext } from '@/lib/presenter/faq-matcher'
interface ChatFABProps {
lang: Language
currentSlide: SlideId
currentIndex: number
visitedSlides: Set<number>
onGoToSlide: (index: number) => void
presenterState?: PresenterState
onPresenterInterrupt?: () => void
}
interface ParsedMessage {
text: string
followUps: string[]
gotos: { index: number; label: string }[]
}
function parseAgentResponse(content: string, lang: Language): ParsedMessage {
const followUps: string[] = []
const gotos: { index: number; label: string }[] = []
// Split on the follow-up separator — flexible: "---", "- - -", "___", or multiple dashes
const parts = content.split(/\n\s*[-_]{3,}\s*\n/)
let text = parts[0]
// Parse follow-up questions from second part
if (parts.length > 1) {
const qSection = parts.slice(1).join('\n')
// Match [Q], **[Q]**, or numbered/bulleted question patterns
const qMatches = qSection.matchAll(/(?:\[Q\]|\*\*\[Q\]\*\*)\s*(.+?)(?:\n|$)/g)
for (const m of qMatches) {
const q = m[1].trim().replace(/^\*\*|\*\*$/g, '')
if (q.length > 5) followUps.push(q)
}
// Fallback: if no [Q] markers found, look for numbered or bulleted questions in the section
if (followUps.length === 0) {
const lineMatches = qSection.matchAll(/(?:^|\n)\s*(?:\d+[\.\)]\s*|[-•]\s*)(.+?\?)\s*$/gm)
for (const m of lineMatches) {
const q = m[1].trim()
if (q.length > 5 && followUps.length < 3) followUps.push(q)
}
}
}
// Also look for [Q] questions anywhere in the text (sometimes model puts them without ---)
if (followUps.length === 0) {
const inlineMatches = content.matchAll(/\[Q\]\s*(.+?)(?:\n|$)/g)
const inlineQs: string[] = []
for (const m of inlineMatches) {
inlineQs.push(m[1].trim())
}
if (inlineQs.length >= 2) {
followUps.push(...inlineQs)
// Remove [Q] lines from main text
text = text.replace(/\n?\s*\[Q\]\s*.+?(?:\n|$)/g, '\n').trim()
}
}
// Parse GOTO markers — support both [GOTO:N] (numeric) and [GOTO:slide-id] (string)
const gotoRegex = /\[GOTO:([\w-]+)\]/g
let gotoMatch
while ((gotoMatch = gotoRegex.exec(text)) !== null) {
const target = gotoMatch[1]
let slideIndex: number
// Try numeric index first
const numericIndex = parseInt(target)
if (!isNaN(numericIndex) && numericIndex >= 0 && numericIndex < SLIDE_ORDER.length) {
slideIndex = numericIndex
} else {
// Try slide ID lookup
slideIndex = SLIDE_ORDER.indexOf(target as SlideId)
}
if (slideIndex >= 0) {
gotos.push({
index: slideIndex,
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
})
}
}
// Remove GOTO markers from visible text
text = text.replace(/\s*\[GOTO:[\w-]+\]/g, '')
// Clean up trailing reminder instruction that might leak through
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
return { text: text.trim(), followUps, gotos }
}
import {
ChatFABProps,
ParsedMessage,
parseAgentResponse,
cleanTextForTts,
detectTtsLanguage,
} from './ChatFAB.helpers'
export default function ChatFAB({
lang,
@@ -140,21 +56,14 @@ export default function ChatFAB({
if (!chatTtsEnabled) return
cancelChatAudio()
// Clean text for TTS: remove markdown formatting, keep plain speech
const cleanText = text
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\[GOTO:[\w-]+\]/g, '')
.replace(/\[Q\]\s*/g, '')
.replace(/---/g, '')
.trim()
const cleanText = cleanTextForTts(text)
if (!cleanText) return
const controller = new AbortController()
ttsAbortRef.current = controller
try {
const textLang = /[äöüÄÖÜß]|(?:^|\s)(?:das|die|der|und|ist|wir|ein|für|mit|auf|von|den|des)\s/i.test(cleanText) ? 'de' : lang
const textLang = detectTtsLanguage(cleanText, lang)
const res = await fetch('/api/presenter/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -169,14 +78,8 @@ export default function ChatFAB({
chatAudioRef.current = audio
setIsChatSpeaking(true)
audio.onended = () => {
setIsChatSpeaking(false)
chatAudioRef.current = null
}
audio.onerror = () => {
setIsChatSpeaking(false)
chatAudioRef.current = null
}
audio.onended = () => { setIsChatSpeaking(false); chatAudioRef.current = null }
audio.onerror = () => { setIsChatSpeaking(false); chatAudioRef.current = null }
await audio.play()
} catch {
@@ -196,8 +99,8 @@ export default function ChatFAB({
// Parse the latest assistant message when streaming ends
const lastAssistantIndex = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return i
for (let idx = messages.length - 1; idx >= 0; idx--) {
if (messages[idx].role === 'assistant') return idx
}
return -1
}, [messages])
@@ -207,8 +110,6 @@ export default function ChatFAB({
const msg = messages[lastAssistantIndex]
const parsed = parseAgentResponse(msg.content, lang)
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
// Speak the response via TTS
speakResponse(parsed.text)
}
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
@@ -217,7 +118,6 @@ export default function ChatFAB({
const message = text || input.trim()
if (!message || isStreaming) return
// Interrupt presenter if it's running
if (presenterState === 'presenting' && onPresenterInterrupt) {
onPresenterInterrupt()
}
@@ -228,7 +128,6 @@ export default function ChatFAB({
setIsStreaming(true)
setIsWaiting(true)
// Find relevant FAQ entries as context for the LLM
const faqMatches = matchFAQMultiple(message, lang, 3)
const faqContext = buildFAQContext(faqMatches, lang)
@@ -240,17 +139,13 @@ export default function ChatFAB({
history: messages.slice(-10),
lang,
slideContext: {
currentSlide,
currentIndex,
currentSlide, currentIndex,
visitedSlides: Array.from(visitedSlides),
totalSlides: SLIDE_ORDER.length,
},
}
// Send FAQ context to LLM (not direct streaming — LLM interprets and combines)
if (faqContext) {
requestBody.faqContext = faqContext
}
if (faqContext) requestBody.faqContext = faqContext
const res = await fetch('/api/chat', {
method: 'POST',
@@ -291,8 +186,7 @@ export default function ChatFAB({
if (topMatch?.goto_slide) {
const gotoIdx = SLIDE_ORDER.indexOf(topMatch.goto_slide)
if (gotoIdx >= 0) {
const suffix = `\n\n[GOTO:${topMatch.goto_slide}]`
content += suffix
content += `\n\n[GOTO:${topMatch.goto_slide}]`
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content }
@@ -319,10 +213,7 @@ export default function ChatFAB({
}
function stopGeneration() {
if (abortRef.current) {
abortRef.current.abort()
setIsStreaming(false)
}
if (abortRef.current) { abortRef.current.abort(); setIsStreaming(false) }
}
const suggestions = i.aiqa.suggestions.slice(0, 3)
@@ -338,7 +229,6 @@ export default function ChatFAB({
<span className="inline-block w-1.5 h-3.5 bg-indigo-400 animate-pulse ml-0.5" />
)}
{/* GOTO Buttons */}
{parsed && parsed.gotos.length > 0 && (
<div className="mt-2 space-y-1">
{parsed.gotos.map((g, gi) => (
@@ -357,7 +247,6 @@ export default function ChatFAB({
</div>
)}
{/* Follow-Up Suggestions */}
{parsed && parsed.followUps.length > 0 && !isStreaming && (
<div className="mt-3 space-y-1.5 border-t border-white/10 pt-2">
{parsed.followUps.map((q, qi) => (
@@ -380,7 +269,7 @@ export default function ChatFAB({
return (
<>
{/* FAB Button — sits to the left of NavigationFAB */}
{/* FAB Button */}
<AnimatePresence>
{!isOpen && (
<motion.button
@@ -402,7 +291,6 @@ export default function ChatFAB({
<circle cx="12" cy="10" r="1" fill="currentColor" />
<circle cx="15" cy="10" r="1" fill="currentColor" />
</svg>
{/* Presenter active indicator */}
{presenterState !== 'idle' && (
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-green-400 border-2 border-black animate-pulse" />
)}
@@ -445,12 +333,7 @@ export default function ChatFAB({
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
setChatTtsEnabled(prev => {
if (prev) cancelChatAudio()
return !prev
})
}}
onClick={() => { setChatTtsEnabled(prev => { if (prev) cancelChatAudio(); return !prev }) }}
className={`w-7 h-7 rounded-full flex items-center justify-center transition-colors ${
isChatSpeaking ? 'bg-indigo-500/30' : 'bg-white/10 hover:bg-white/20'
}`}
@@ -471,10 +354,7 @@ export default function ChatFAB({
{isExpanded ? <Minimize2 className="w-3.5 h-3.5 text-white/60" /> : <Maximize2 className="w-3.5 h-3.5 text-white/60" />}
</button>
<button
onClick={() => {
cancelChatAudio()
setIsOpen(false)
}}
onClick={() => { cancelChatAudio(); setIsOpen(false) }}
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
>
<X className="w-4 h-4 text-white/60" />
@@ -521,12 +401,12 @@ export default function ChatFAB({
<Bot className="w-3.5 h-3.5 text-indigo-400" />
</div>
<div className="bg-white/[0.06] rounded-2xl px-3.5 py-3 flex items-center gap-1">
{[0, 1, 2].map(i => (
{[0, 1, 2].map(dotIdx => (
<motion.span
key={i}
key={dotIdx}
className="block w-1.5 h-1.5 rounded-full bg-indigo-400/70"
animate={{ opacity: [0.3, 1, 0.3], y: [0, -3, 0] }}
transition={{ duration: 0.7, repeat: Infinity, delay: i * 0.15 }}
transition={{ duration: 0.7, repeat: Infinity, delay: dotIdx * 0.15 }}
/>
))}
</div>
@@ -535,24 +415,15 @@ export default function ChatFAB({
</AnimatePresence>
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}
>
<div key={idx} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="w-3.5 h-3.5 text-indigo-400" />
</div>
)}
<div
className={`
max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed
${msg.role === 'user'
? 'bg-indigo-500/20 text-white'
: 'bg-white/[0.06] text-white/80'
}
`}
>
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed ${
msg.role === 'user' ? 'bg-indigo-500/20 text-white' : 'bg-white/[0.06] text-white/80'
}`}>
{msg.role === 'assistant' ? renderMessageContent(msg, idx) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}