{
"tool_response": {
"type": "text",
"file": {
"filePath": "\/var\/www\/dev.campus.systemische-tools.de\/src\/Infrastructure\/AI\/ChatService.php",
"content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Infrastructure\\AI;\n\n\/\/ @responsibility: RAG-Pipeline für KI-Chat (Embedding, Suche, Antwort-Generierung)\n\nuse RuntimeException;\n\nfinal readonly class ChatService\n{\n public function __construct(\n private OllamaService $ollama,\n private QdrantService $qdrant,\n private ClaudeService $claude,\n private ScoringService $scoring,\n private ?SemanticEnrichmentService $semantic = null\n ) {\n }\n\n \/**\n * Executes a complete RAG chat pipeline: Embedding → Search → Context → LLM → Response\n * If no collections selected, skips RAG steps (direct LLM query).\n *\n * @param array<string> $collections Qdrant collections to search (empty = no RAG)\n * @return array{question: string, answer: string, sources: array, model: string, usage?: array, chunks_used: int}\n * @throws RuntimeException If embedding\/search\/LLM fails\n *\/\n public function chat(\n string $question,\n string $model = 'claude-opus-4-5-20251101',\n array $collections = [],\n int $limit = 5,\n ?string $stylePrompt = null,\n ?string $customSystemPrompt = null,\n float $temperature = 0.7,\n int $maxTokens = 4096\n ): array {\n $searchResults = [];\n $context = '';\n\n \/\/ Only perform RAG if collections are selected\n if ($collections !== []) {\n \/\/ Step 1: Generate embedding for the question\n try {\n $queryEmbedding = $this->ollama->getEmbedding($question);\n } catch (RuntimeException $e) {\n throw new RuntimeException(\n 'Embedding generation failed: ' . $e->getMessage(),\n 0,\n $e\n );\n }\n\n if ($queryEmbedding === []) {\n throw new RuntimeException('Embedding generation returned empty vector');\n }\n\n \/\/ Step 2: Search across all selected collections\n try {\n $searchResults = $this->searchMultipleCollections($queryEmbedding, $collections, $limit);\n } catch (RuntimeException $e) {\n throw new RuntimeException(\n 'Vector search failed: ' . $e->getMessage(),\n 0,\n $e\n );\n }\n\n \/\/ Step 2b: Semantic enrichment (graceful degradation - works without)\n $semanticSummary = '';\n if ($searchResults !== [] && $this->semantic !== null) {\n $searchResults = $this->semantic->enrichSearchResults($searchResults);\n $semanticSummary = $this->semantic->buildSemanticSummary($searchResults);\n }\n\n \/\/ Step 3: Build context from search results (if any found)\n if ($searchResults !== []) {\n $context = $this->buildContext($searchResults, 3000, $semanticSummary);\n }\n }\n\n \/\/ Step 4: Parse model string and generate answer\n $isOllama = str_starts_with($model, 'ollama:');\n $isClaude = str_starts_with($model, 'claude-');\n $hasContext = $context !== '';\n\n if ($isClaude) {\n try {\n \/\/ Build prompt: RAG with context or direct question\n if ($hasContext) {\n $userPrompt = $this->claude->buildRagPrompt($question, $context);\n } else {\n $userPrompt = $question;\n }\n\n \/\/ Build system prompt hierarchy: Default -> Custom -> Style\n if ($customSystemPrompt !== null && $customSystemPrompt !== '') {\n $systemPrompt = $customSystemPrompt;\n } else {\n $systemPrompt = $hasContext\n ? $this->claude->getDefaultSystemPrompt()\n : 'Du bist ein hilfreicher Assistent. Antworte auf Deutsch, präzise und hilfreich.';\n }\n\n \/\/ Append style prompt from author profile if provided\n if ($stylePrompt !== null && $stylePrompt !== '') {\n $systemPrompt .= \"\\n\\n\" . $stylePrompt;\n }\n\n $llmResponse = $this->claude->ask($userPrompt, $systemPrompt, $model, $maxTokens, $temperature);\n\n $answer = $llmResponse['text'];\n $usage = $llmResponse['usage'];\n } catch (RuntimeException $e) {\n throw new RuntimeException(\n 'Claude API request failed: ' . $e->getMessage(),\n 0,\n $e\n );\n }\n } elseif ($isOllama) {\n try {\n \/\/ Extract actual model name (remove \"ollama:\" prefix)\n $ollamaModel = substr($model, 7);\n\n \/\/ Build instruction from custom prompt and style\n $instructions = [];\n if ($customSystemPrompt !== null && $customSystemPrompt !== '') {\n $instructions[] = $customSystemPrompt;\n }\n if ($stylePrompt !== null && $stylePrompt !== '') {\n $instructions[] = $stylePrompt;\n }\n $instructionBlock = $instructions !== [] ? implode(\"\\n\\n\", $instructions) . \"\\n\\n\" : '';\n\n \/\/ Build prompt: RAG with context or direct question\n if ($hasContext) {\n $userPrompt = sprintf(\n \"%sKontext aus den Dokumenten:\\n\\n%s\\n\\n---\\n\\nFrage: %s\",\n $instructionBlock,\n $context,\n $question\n );\n } else {\n $userPrompt = $instructionBlock . $question;\n }\n\n $answer = $this->ollama->generate($userPrompt, $ollamaModel, $temperature);\n $usage = null;\n } catch (RuntimeException $e) {\n throw new RuntimeException(\n 'Ollama generation failed: ' . $e->getMessage(),\n 0,\n $e\n );\n }\n } else {\n throw new RuntimeException(\n sprintf('Unknown model \"%s\". Use claude-* or ollama:* format.', $model)\n );\n }\n\n \/\/ Step 5: Extract source information\n $sources = $this->extractSources($searchResults);\n\n \/\/ Step 6: Assemble response\n $response = [\n 'question' => $question,\n 'answer' => $answer,\n 'sources' => $sources,\n 'model' => $model,\n 'chunks_used' => count($searchResults),\n ];\n\n if ($usage !== null) {\n $response['usage'] = $usage;\n }\n\n return $response;\n }\n\n \/** Builds context string from search results (respects maxTokens limit). *\/\n private function buildContext(array $searchResults, int $maxTokens = 3000, string $semanticSummary = ''): string\n {\n $contextParts = [];\n $totalChars = 0;\n $maxChars = $maxTokens * 4; \/\/ Approximate: 1 token ~ 4 characters\n\n \/\/ Prepend semantic summary if available\n if ($semanticSummary !== '') {\n $contextParts[] = \"[Semantischer Kontext]\\n\" . $semanticSummary;\n $totalChars += strlen($semanticSummary);\n }\n\n foreach ($searchResults as $index => $result) {\n $payload = $result['payload'];\n \/\/ Support both payload schemas: documents + dokumentation_chunks\n $content = (string) ($payload['content'] ?? $payload['content_preview'] ?? '');\n $docTitle = (string) ($payload['document_title'] ?? $payload['title'] ?? 'Unbekannt');\n\n \/\/ Check if adding this chunk would exceed the limit\n if ($totalChars + strlen($content) > $maxChars) {\n break;\n }\n\n \/\/ Build chunk header with optional entity info\n $entities = $payload['entities'] ?? [];\n $entityInfo = '';\n if ($entities !== []) {\n $entityNames = array_slice(array_column($entities, 'name'), 0, 3);\n $entityInfo = ' | Enthält: ' . implode(', ', $entityNames);\n }\n\n $contextParts[] = sprintf('[Quelle %d: %s%s]%s%s', $index + 1, $docTitle, $entityInfo, \"\\n\", $content);\n $totalChars += strlen($content);\n }\n\n return implode(\"\\n\\n---\\n\\n\", $contextParts);\n }\n\n \/** Searches multiple collections, merges and returns top N results by weighted score. *\/\n private function searchMultipleCollections(array $embedding, array $collections, int $limit): array\n {\n $allResults = [];\n\n foreach ($collections as $collection) {\n try {\n $results = $this->qdrant->search($embedding, $collection, $limit);\n\n \/\/ Add collection name to each result's payload for reference\n foreach ($results as &$result) {\n $result['payload']['_collection'] = $collection;\n }\n\n $allResults = array_merge($allResults, $results);\n } catch (RuntimeException) {\n \/\/ Skip unavailable collections, continue with others\n continue;\n }\n }\n\n \/\/ Apply weighted scoring (similarity + recency + authority)\n foreach ($allResults as &$result) {\n $processedAt = $result['payload']['processed_at'] ?? null;\n $documentDate = $processedAt !== null\n ? new \\DateTime($processedAt)\n : new \\DateTime();\n\n $authorityScore = (float) ($result['payload']['authority_score'] ?? 0.5);\n\n $result['weighted_score'] = $this->scoring->calculateScore(\n $result['score'],\n $documentDate,\n $authorityScore\n );\n }\n unset($result);\n\n \/\/ Sort by weighted score descending\n usort($allResults, static f... [TRUNCATED-307db0020aaac423]",
"numLines": 324,
"startLine": 1,
"totalLines": 324
}
}
}