Protokoll #29086

ID29086
Zeitstempel2025-12-29 00:04:17.610452
Clientroot
IP145.224.72.140
Modellclaude-sonnet-4-20250514
Statuscompleted
Tokens10,935 (Input: 2,740, Output: 8,195)
Dauer90 ms
Request-Zeit2025-12-29 00:04:17.610452
Response-Zeit2025-12-29 00:04:17.700500

Request

{
    "event": "PreToolUse",
    "tool_name": "Edit",
    "tool_input": {
        "file_path": "\/var\/www\/dev.campus.systemische-tools.de\/src\/UseCases\/Chat\/StreamingChatMessageUseCase.php",
        "old_string": "    \/** @param array<string> $collections *\/\n    private function searchWithSemantics(string $query, array $collections, int $limit): array\n    {\n        $allResults = [];\n\n        foreach ($collections as $collection) {\n            if ($collection === 'documents') {\n                \/\/ Use ContentSearchService for semantic-enriched search\n                $semanticResults = $this->searchService->search($query, [], $limit);\n                foreach ($semanticResults as $result) {\n                    $allResults[] = [\n                        'chunk_id' => $result['chunk_id'],\n                        'content' => $result['content'],\n                        'title' => $result['source_path'] ?? $result['heading_path'] ?? 'Unbekannt',\n                        'score' => $result['relevance_score'],\n                        \/\/ Semantic metadata\n                        'summary' => $result['summary'] ?? null,\n                        'keywords' => $result['keywords'] ?? [],\n                        'intent' => $result['intent'] ?? null,\n                        'discourse_role' => $result['discourse_role'] ?? null,\n                        'sentiment' => $result['sentiment'] ?? null,\n                        'frame' => $result['frame'] ?? null,\n                        '_collection' => 'documents',\n                    ];\n                }\n            } else {\n                \/\/ Fallback to QdrantService for other collections\n                try {\n                    $embedding = $this->ollama->getEmbedding($query);\n                    $results = $this->qdrant->search($embedding, $collection, $limit);\n                    foreach ($results as $result) {\n                        $payload = $result['payload'];\n                        $allResults[] = [\n                            'chunk_id' => $payload['chunk_id'] ?? 0,\n                            'content' => $payload['content'] ?? $payload['content_preview'] ?? '',\n                            'title' => $payload['document_title'] ?? $payload['title'] ?? 'Unbekannt',\n                            'score' => $result['score'],\n                            '_collection' => $collection,\n                        ];\n                    }\n                } catch (\\RuntimeException) {\n                    continue;\n                }\n            }\n        }\n\n        \/\/ Sort by score and limit\n        usort($allResults, static fn ($a, $b) => ($b['score'] ?? 0.0) <=> ($a['score'] ?? 0.0));\n\n        return array_slice($allResults, 0, $limit);\n    }\n\n    private function buildSemanticContext(array $searchResults, int $maxTokens = 3000): string\n    {\n        $contextParts = [];\n        $totalChars = 0;\n        $maxChars = $maxTokens * 4;\n\n        foreach ($searchResults as $index => $result) {\n            $content = (string) ($result['content'] ?? '');\n            $title = (string) ($result['title'] ?? 'Unbekannt');\n\n            if ($totalChars + strlen($content) > $maxChars) {\n                break;\n            }\n\n            \/\/ Build semantic header if available\n            $semanticInfo = [];\n            if (isset($result['discourse_role'])) {\n                $semanticInfo[] = match ($result['discourse_role']) {\n                    'definition' => 'Definition',\n                    'thesis' => 'These',\n                    'evidence' => 'Beleg',\n                    'example' => 'Beispiel',\n                    'summary' => 'Zusammenfassung',\n                    default => $result['discourse_role'],\n                };\n            }\n            if (isset($result['intent'])) {\n                $semanticInfo[] = match ($result['intent']) {\n                    'explain' => 'erklärt',\n                    'define' => 'definiert',\n                    'argue' => 'argumentiert',\n                    'compare' => 'vergleicht',\n                    'exemplify' => 'veranschaulicht',\n                    default => $result['intent'],\n                };\n            }\n\n            $header = sprintf('[Quelle %d: %s]', $index + 1, $title);\n            if ($semanticInfo !== []) {\n                $header .= ' (' . implode(', ', $semanticInfo) . ')';\n            }\n\n            $contextParts[] = $header . \"\\n\" . $content;\n            $totalChars += strlen($content);\n        }\n\n        return implode(\"\\n\\n---\\n\\n\", $contextParts);\n    }\n\n    private function extractSources(array $searchResults): array\n    {\n        $sources = [];\n        $seen = [];\n\n        foreach ($searchResults as $result) {\n            $title = (string) ($result['title'] ?? '');\n            if ($title === '' || isset($seen[$title])) {\n                continue;\n            }\n\n            $source = [\n                'title' => $title,\n                'score' => round((float) ($result['score'] ?? 0), 3),\n            ];\n\n            \/\/ Add content preview\n            $content = $result['content'] ?? null;\n            if (is_string($content) && $content !== '') {\n                $source['content'] = mb_substr($content, 0, 300);\n            }\n\n            \/\/ Add collection\n            if (isset($result['_collection'])) {\n                $source['collection'] = $result['_collection'];\n            }\n\n            \/\/ Add semantic metadata if available\n            if (isset($result['discourse_role'])) {\n                $source['discourse_role'] = $result['discourse_role'];\n            }\n            if (isset($result['intent'])) {\n                $source['intent'] = $result['intent'];\n            }\n            if (isset($result['summary'])) {\n                $source['summary'] = $result['summary'];\n            }\n\n            $sources[] = $source;\n            $seen[$title] = true;\n        }\n\n        return $sources;\n    }\n\n    private function getStylePromptFromProfile(int $profileId): ?string\n    {\n        if ($profileId === 0) { return null; }\n        $profile = $this->configRepo->findByIdAndType($profileId, 'author_profile');\n        if ($profile === null) { return null; }\n        $config = json_decode($profile['content'] ?? '{}', true);\n        if ($config === null) { return null; }\n        $parts = [];\n        if (isset($config['stimme']['ton'])) { $parts[] = 'Ton: ' . $config['stimme']['ton']; }\n        if (isset($config['stimme']['perspektive'])) { $parts[] = 'Perspektive: ' . $config['stimme']['perspektive']; }\n        if (isset($config['stil']['fachsprache']) && $config['stil']['fachsprache']) { $parts[] = 'Verwende Fachsprache'; }\n        if (isset($config['stil']['beispiele']) && $config['stil']['beispiele'] === 'häufig') { $parts[] = 'Nutze häufig Beispiele'; }\n        if (isset($config['stil']['listen']) && $config['stil']['listen'] === 'bevorzugt') { $parts[] = 'Bevorzuge Listen und Bullet-Points'; }\n        if (isset($config['tabus']) && is_array($config['tabus'])) { $parts[] = 'Vermeide: ' . implode(', ', $config['tabus']); }\n        if ($parts === []) { return null; }\n        return 'Schreibstil (' . ($profile['name'] ?? 'Profil') . '): ' . implode('. ', $parts) . '.';\n    }\n\n    private function getSystemPromptById(int $promptId): ?string\n    {\n        if ($promptId === 0) { return null; }\n        $prompt = $this->configRepo->findByIdAndType($promptId, 'system_prompt');\n        if ($prompt === null) { return null; }\n        $content = json_decode($prompt['content'] ?? '{}', true);\n        return $content['prompt'] ?? null;\n    }\n\n    private function getStructurePrompt(int $structureId): ?string\n    {\n        if ($structureId === 0) { return null; }\n        $structure = $this->configRepo->findByIdAndType($structureId, 'structure');\n        if ($structure === null) { return null; }\n        $name = $structure['name'] ?? 'Struktur';\n        $config = json_decode($structure['content'] ?? '{}', true);\n        if ($config === null) { return null; }\n        $parts = [\"Formatiere deine Antwort als: {$name}\"];\n        if (isset($config['sections']) && is_array($config['sections'])) { $parts[] = 'Struktur: ' . implode(' → ', $config['sections']); }\n        if (isset($config['max_chars'])) { $parts[] = 'Maximale Länge: ' . $config['max_chars'] . ' Zeichen'; }\n        if (isset($config['min_words'])) { $parts[] = 'Mindestens ' . $config['min_words'] . ' Wörter'; }\n        if (isset($config['max_words'])) { $parts[] = 'Maximal ' . $config['max_words'] . ' Wörter'; }\n        if (isset($config['format']) && $config['format'] === 'qa') { $parts[] = 'FAQ mit ' . ($config['min_questions'] ?? 3) . ' Paaren'; }\n        if (isset($config['hashtags']) && $config['hashtags']) { $parts[] = 'Füge passende Hashtags hinzu'; }\n        if (isset($config['cta']) && $config['cta']) { $parts[] = 'Schließe mit einem Call-to-Action ab'; }\n        return implode('. ', $parts) . '.';\n    }\n\n    private function getStructureName(int $structureId): ?string\n    {\n        $structure = $this->configRepo->findByIdAndType($structureId, 'structure');\n        return $structure['name'] ?? null;\n    }\n}",
        "new_string": "    \/**\n     * Add Qdrant search results for non-documents collections.\n     *\n     * @param array<array<string, mixed>> $results Existing results\n     * @param array<string> $collections Collections to search\n     * @return array<array<string, mixed>> Combined results\n     *\/\n    private function addQdrantFallback(array $results, string $query, array $collections, int $limit): array\n    {\n        $nonDocCollections = array_filter($collections, static fn ($c) => $c !== 'documents');\n\n        if ($nonDocCollections === []) {\n            return $results;\n        }\n\n        foreach ($nonDocCollections as $collection) {\n            try {\n                $embedding = $this->ollama->getEmbedding($query);\n                $qdrantResults = $this->qdrant->search($embedding, $collection, $limit);\n\n                foreach ($qdrantResults as $result) {\n                    $payload = $result['payload'];\n                    $results[] = [\n                        'chunk_id' => $payload['chunk_id'] ?? 0,\n                        'content' => $payload['content'] ?? $payload['content_preview'] ?? '',\n                        'title' => $payload['document_title'] ?? $payload['title'] ?? 'Unbekannt',\n                        'score' => $result['score'],\n                        '_collection' => $collection,\n                    ];\n                }\n            } catch (\\RuntimeException) {\n                continue;\n            }\n        }\n\n        \/\/ Sort by score and limit\n        usort($results, static fn ($a, $b) => ($b['score'] ?? 0.0) <=> ($a['score'] ?? 0.0));\n\n        return array_slice($results, 0, $limit);\n    }\n}"
    }
}

Response

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