progressCallback = $callback; } private function emit(string $step, string $message, ?int $durationMs = null): void { if ($this->progressCallback !== null) { ($this->progressCallback)($step, $message, $durationMs); } } private function startStep(): void { $this->stepStart = microtime(true); } private function endStep(string $step, string $message): void { $durationMs = (int) round((microtime(true) - $this->stepStart) * Constants::MS_PER_SECOND); $this->emit($step, $message, $durationMs); } /** Execute chat with streaming progress. @param array $collections */ public function execute( string $sessionUuid, string $message, string $model, array $collections = ['documents'], int $contextLimit = 5, int $authorProfileId = 0, int $systemPromptId = 1, float $temperature = 0.7, int $maxTokens = 4096, int $structureId = 0, bool $qualityCheck = false ): ChatResponse { $totalStart = microtime(true); // Step 1: Validate session $this->emit('session', 'Session validieren...'); $this->startStep(); $session = $this->sessionRepo->findByUuid($sessionUuid); if ($session === null) { $this->emit('error', 'Session nicht gefunden'); return ChatResponse::error('Session nicht gefunden.'); } $sessionId = $session->getId() ?? 0; $this->endStep('session_done', 'Session validiert'); // Step 2: Validate message $message = trim($message); if ($message === '') { $this->emit('error', 'Keine Nachricht'); return ChatResponse::error('Bitte gib eine Frage ein.'); } // Step 3: Save user message $this->emit('save_user', 'User-Nachricht speichern...'); $this->startStep(); $this->messageRepo->save(sessionId: $sessionId, role: 'user', content: $message, model: $model); $this->endStep('save_user_done', 'User-Nachricht gespeichert'); // Step 4: Auto-set title $currentTitle = $session->getTitle(); if ($currentTitle === null || $currentTitle === 'Neuer Chat') { $this->sessionRepo->updateTitle($sessionId, mb_substr($message, 0, 50) . (mb_strlen($message) > 50 ? '...' : '')); } // Step 5: Get prompts $this->emit('prompts', 'Prompts laden...'); $this->startStep(); $stylePrompt = $this->getStylePromptFromProfile($authorProfileId); $systemPrompt = $this->getSystemPromptById($systemPromptId); $structurePrompt = $this->getStructurePrompt($structureId); if ($structurePrompt !== null) { $systemPrompt = ($systemPrompt ?? '') . "\n\n" . $structurePrompt; } $this->endStep('prompts_done', 'Prompts geladen'); // RAG Pipeline $searchResults = []; $context = ''; if ($collections !== []) { // Step 6+7: Semantic search via ContentSearchService $this->emit('search', 'Semantische Suche in ' . count($collections) . ' Collection(s)...'); $this->startStep(); $searchResults = $this->searchWithSemantics($message, $collections, $contextLimit); $semanticCount = count(array_filter($searchResults, static fn ($r) => isset($r['intent']))); $this->endStep('search_done', count($searchResults) . ' Chunks gefunden (' . $semanticCount . ' mit Semantik)'); // Step 8: Build context with semantic metadata if ($searchResults !== []) { $this->emit('context', 'Kontext aufbauen...'); $this->startStep(); $context = $this->buildSemanticContext($searchResults); $this->endStep('context_done', 'Kontext erstellt (' . strlen($context) . ' Zeichen)'); } } // Step 9: LLM Request $isOllama = str_starts_with($model, 'ollama:'); $isClaude = str_starts_with($model, 'claude-'); $hasContext = $context !== ''; $this->emit('llm', 'Anfrage an ' . ($isOllama ? substr($model, 7) : $model) . '...'); $this->startStep(); $llmStart = microtime(true); try { if ($isClaude) { $userPrompt = $hasContext ? $this->claude->buildRagPrompt($message, $context) : $message; $effectiveSystemPrompt = $systemPrompt ?? ($hasContext ? $this->claude->getDefaultSystemPrompt() : 'Du bist ein hilfreicher Assistent. Antworte auf Deutsch, präzise und hilfreich.'); if ($stylePrompt !== null && $stylePrompt !== '') { $effectiveSystemPrompt .= "\n\n" . $stylePrompt; } $llmResponse = $this->claude->ask($userPrompt, $effectiveSystemPrompt, $model, $maxTokens, $temperature); $answer = $llmResponse['text']; $usage = $llmResponse['usage']; } elseif ($isOllama) { $ollamaModel = substr($model, 7); $instructions = array_filter([$systemPrompt, $stylePrompt]); $instructionBlock = $instructions !== [] ? implode("\n\n", $instructions) . "\n\n" : ''; $userPrompt = $hasContext ? sprintf("%sKontext:\n\n%s\n\n---\n\nFrage: %s", $instructionBlock, $context, $message) : $instructionBlock . $message; $answer = $this->ollama->generate($userPrompt, $ollamaModel, $temperature); $usage = null; } else { $this->emit('error', "Unbekanntes Modell: {$model}"); return ChatResponse::error("Unknown model \"{$model}\"."); } } catch (\RuntimeException $e) { $this->emit('error', 'LLM-Fehler: ' . $e->getMessage()); return ChatResponse::error('LLM request failed: ' . $e->getMessage()); } $llmDuration = (int) round((microtime(true) - $llmStart) * Constants::MS_PER_SECOND); $tokenInfo = $usage !== null ? " ({$usage['input_tokens']} in / {$usage['output_tokens']} out)" : ''; $this->emit('llm_done', "Antwort erhalten{$tokenInfo}", $llmDuration); // Step 10: Extract sources $this->emit('sources', 'Quellen extrahieren...'); $this->startStep(); $sources = $this->extractSources($searchResults); $this->endStep('sources_done', count($sources) . ' Quellen extrahiert'); // Step 11: Save assistant message $this->emit('save_assistant', 'Antwort speichern...'); $this->startStep(); $sourcesForStorage = array_map(static fn (array $s): string => json_encode($s, JSON_THROW_ON_ERROR), $sources); $this->messageRepo->save( sessionId: $sessionId, role: 'assistant', content: $answer, model: $model, tokensInput: $usage['input_tokens'] ?? null, tokensOutput: $usage['output_tokens'] ?? null, sources: $sourcesForStorage, startMicrotime: $llmStart, endMicrotime: microtime(true), authorProfileId: $authorProfileId > 0 ? $authorProfileId : null, systemPromptId: $systemPromptId > 0 ? $systemPromptId : null, collectionsJson: json_encode($collections), contextLimit: $contextLimit ); $this->endStep('save_assistant_done', 'Antwort gespeichert'); // Step 12: Quality check $qualityValidation = null; if ($qualityCheck) { $this->emit('quality', 'Qualitätsprüfung...'); $this->startStep(); $structureName = $structureId > 0 ? $this->getStructureName($structureId) : null; $qualityValidation = $this->qualityValidator->validate( question: $message, answer: $answer, sources: $sources, structureName: $structureName ); $score = round($qualityValidation['score']); $this->endStep('quality_done', "Qualität: {$score}%"); } // Final $totalDuration = (int) round((microtime(true) - $totalStart) * Constants::MS_PER_SECOND); $this->emit('complete', "Fertig in {$totalDuration}ms", $totalDuration); // Build response $result = [ 'answer' => $answer, 'sources' => $sources, 'usage' => $usage, 'chunks_used' => count($searchResults), ]; $response = ChatResponse::fromServiceResponse($result, (microtime(true) - $totalStart)); if ($qualityValidation !== null) { $response = $response->withQualityValidation($qualityValidation); } return $response; } /** @param array $collections */ private function searchWithSemantics(string $query, array $collections, int $limit): array { $allResults = []; foreach ($collections as $collection) { if ($collection === 'documents') { // Use ContentSearchService for semantic-enriched search $semanticResults = $this->searchService->search($query, [], $limit); foreach ($semanticResults as $result) { $allResults[] = [ 'chunk_id' => $result['chunk_id'], 'content' => $result['content'], 'title' => $result['source_path'] ?? $result['heading_path'] ?? 'Unbekannt', 'score' => $result['relevance_score'], // Semantic metadata 'summary' => $result['summary'] ?? null, 'keywords' => $result['keywords'] ?? [], 'intent' => $result['intent'] ?? null, 'discourse_role' => $result['discourse_role'] ?? null, 'sentiment' => $result['sentiment'] ?? null, 'frame' => $result['frame'] ?? null, '_collection' => 'documents', ]; } } else { // Fallback to QdrantService for other collections try { $embedding = $this->ollama->getEmbedding($query); $results = $this->qdrant->search($embedding, $collection, $limit); foreach ($results as $result) { $payload = $result['payload'] ?? []; $allResults[] = [ 'chunk_id' => $payload['chunk_id'] ?? 0, 'content' => $payload['content'] ?? $payload['content_preview'] ?? '', 'title' => $payload['document_title'] ?? $payload['title'] ?? 'Unbekannt', 'score' => $result['score'] ?? 0.0, '_collection' => $collection, ]; } } catch (\RuntimeException) { continue; } } } // Sort by score and limit usort($allResults, static fn ($a, $b) => ($b['score'] ?? 0.0) <=> ($a['score'] ?? 0.0)); return array_slice($allResults, 0, $limit); } private function buildSemanticContext(array $searchResults, int $maxTokens = 3000): string { $contextParts = []; $totalChars = 0; $maxChars = $maxTokens * 4; foreach ($searchResults as $index => $result) { $content = (string) ($result['content'] ?? ''); $title = (string) ($result['title'] ?? 'Unbekannt'); if ($totalChars + strlen($content) > $maxChars) { break; } // Build semantic header if available $semanticInfo = []; if (isset($result['discourse_role'])) { $semanticInfo[] = match ($result['discourse_role']) { 'definition' => 'Definition', 'thesis' => 'These', 'evidence' => 'Beleg', 'example' => 'Beispiel', 'summary' => 'Zusammenfassung', default => $result['discourse_role'], }; } if (isset($result['intent'])) { $semanticInfo[] = match ($result['intent']) { 'explain' => 'erklärt', 'define' => 'definiert', 'argue' => 'argumentiert', 'compare' => 'vergleicht', 'exemplify' => 'veranschaulicht', default => $result['intent'], }; } $header = sprintf('[Quelle %d: %s]', $index + 1, $title); if ($semanticInfo !== []) { $header .= ' (' . implode(', ', $semanticInfo) . ')'; } $contextParts[] = $header . "\n" . $content; $totalChars += strlen($content); } return implode("\n\n---\n\n", $contextParts); } private function extractSources(array $searchResults): array { $sources = []; $seen = []; foreach ($searchResults as $result) { $payload = $result['payload']; $docTitle = (string) ($payload['document_title'] ?? $payload['title'] ?? ''); if ($docTitle === '' || isset($seen[$docTitle])) { continue; } $source = ['title' => $docTitle, 'score' => round($result['score'], 3)]; $content = $payload['content'] ?? $payload['content_preview'] ?? null; if (is_string($content) && $content !== '') { $source['content'] = $content; } if (isset($payload['_collection'])) { $source['collection'] = $payload['_collection']; } $sources[] = $source; $seen[$docTitle] = true; } return $sources; } private function getStylePromptFromProfile(int $profileId): ?string { if ($profileId === 0) { return null; } $profile = $this->configRepo->findByIdAndType($profileId, 'author_profile'); if ($profile === null) { return null; } $config = json_decode($profile['content'] ?? '{}', true); if ($config === null) { return null; } $parts = []; if (isset($config['stimme']['ton'])) { $parts[] = 'Ton: ' . $config['stimme']['ton']; } if (isset($config['stimme']['perspektive'])) { $parts[] = 'Perspektive: ' . $config['stimme']['perspektive']; } if (isset($config['stil']['fachsprache']) && $config['stil']['fachsprache']) { $parts[] = 'Verwende Fachsprache'; } if (isset($config['stil']['beispiele']) && $config['stil']['beispiele'] === 'häufig') { $parts[] = 'Nutze häufig Beispiele'; } if (isset($config['stil']['listen']) && $config['stil']['listen'] === 'bevorzugt') { $parts[] = 'Bevorzuge Listen und Bullet-Points'; } if (isset($config['tabus']) && is_array($config['tabus'])) { $parts[] = 'Vermeide: ' . implode(', ', $config['tabus']); } if ($parts === []) { return null; } return 'Schreibstil (' . ($profile['name'] ?? 'Profil') . '): ' . implode('. ', $parts) . '.'; } private function getSystemPromptById(int $promptId): ?string { if ($promptId === 0) { return null; } $prompt = $this->configRepo->findByIdAndType($promptId, 'system_prompt'); if ($prompt === null) { return null; } $content = json_decode($prompt['content'] ?? '{}', true); return $content['prompt'] ?? null; } private function getStructurePrompt(int $structureId): ?string { if ($structureId === 0) { return null; } $structure = $this->configRepo->findByIdAndType($structureId, 'structure'); if ($structure === null) { return null; } $name = $structure['name'] ?? 'Struktur'; $config = json_decode($structure['content'] ?? '{}', true); if ($config === null) { return null; } $parts = ["Formatiere deine Antwort als: {$name}"]; if (isset($config['sections']) && is_array($config['sections'])) { $parts[] = 'Struktur: ' . implode(' → ', $config['sections']); } if (isset($config['max_chars'])) { $parts[] = 'Maximale Länge: ' . $config['max_chars'] . ' Zeichen'; } if (isset($config['min_words'])) { $parts[] = 'Mindestens ' . $config['min_words'] . ' Wörter'; } if (isset($config['max_words'])) { $parts[] = 'Maximal ' . $config['max_words'] . ' Wörter'; } if (isset($config['format']) && $config['format'] === 'qa') { $parts[] = 'FAQ mit ' . ($config['min_questions'] ?? 3) . ' Paaren'; } if (isset($config['hashtags']) && $config['hashtags']) { $parts[] = 'Füge passende Hashtags hinzu'; } if (isset($config['cta']) && $config['cta']) { $parts[] = 'Schließe mit einem Call-to-Action ab'; } return implode('. ', $parts) . '.'; } private function getStructureName(int $structureId): ?string { $structure = $this->configRepo->findByIdAndType($structureId, 'structure'); return $structure['name'] ?? null; } }