Protokoll #1526
| ID | 1526 |
|---|---|
| Zeitstempel | 2025-12-20 19:15:47.824710 |
| Client | root |
| IP | Warning: Undefined array key "request_ip" in /var/www/dev.campus.systemische-tools.de/src/View/protokoll/show.php on line 13 Deprecated: htmlspecialchars(): Passing null to parameter #1 ($string) of type string is deprecated in /var/www/dev.campus.systemische-tools.de/src/View/protokoll/show.php on line 13 |
| Modell | claude-sonnet-4-20250514 |
| Status | completed |
| Tokens | 5,620 (Input: 0, Output: 0) |
| Dauer | 96 ms |
| Request-Zeit | Warning: Undefined array key "request_timestamp" in /var/www/dev.campus.systemische-tools.de/src/View/protokoll/show.php on line 18 |
| Response-Zeit | - |
Warning: Undefined array key "error_message" in /var/www/dev.campus.systemische-tools.de/src/View/protokoll/show.php on line 22
Request
{
"event": "PreToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "\/var\/www\/dev.campus.systemische-tools.de\/src\/Infrastructure\/AI\/ChatService.php",
"old_string": " \/**\n * Extracts unique source information from search results.\n *\n * Collects document titles and scores from the search results,\n * deduplicating by title to provide a clean list of sources.\n * Optionally includes content preview if available.\n *\n * @param array<int, array{id: int|string, score: float, payload: array<string, mixed>}> $searchResults Vector search results\n *\n * @return array<int, array{title: string, score: float, content?: string}> Deduplicated source information\n *\/\n private function extractSources(array $searchResults): array\n {\n $sources = [];\n $seen = [];\n\n foreach ($searchResults as $result) {\n $payload = $result['payload'];\n $docTitle = (string) ($payload['document_title'] ?? '');\n\n \/\/ Skip empty titles or already seen titles\n if ($docTitle === '' || isset($seen[$docTitle])) {\n continue;\n }\n\n $source = [\n 'title' => $docTitle,\n 'score' => round($result['score'], 3),\n ];\n\n \/\/ Optionally include content preview\n if (isset($payload['content']) && is_string($payload['content'])) {\n $source['content'] = $payload['content'];\n }\n\n $sources[] = $source;\n $seen[$docTitle] = true;\n }\n\n return $sources;\n }\n}",
"new_string": " \/**\n * Searches across multiple Qdrant collections and merges results.\n *\n * Queries each collection with the same embedding vector, then merges\n * and sorts results by score. Returns the top N results across all collections.\n *\n * @param array<int, float> $embedding The query embedding vector\n * @param array<string> $collections Collections to search\n * @param int $limit Maximum total results to return\n *\n * @return array<int, array{id: int|string, score: float, payload: array<string, mixed>}> Merged search results\n *\/\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 \/\/ Sort by score descending\n usort($allResults, static fn($a, $b) => $b['score'] <=> $a['score']);\n\n \/\/ Return top N results\n return array_slice($allResults, 0, $limit);\n }\n\n \/**\n * Extracts unique source information from search results.\n *\n * Collects document titles and scores from the search results,\n * deduplicating by title to provide a clean list of sources.\n * Optionally includes content preview if available.\n *\n * @param array<int, array{id: int|string, score: float, payload: array<string, mixed>}> $searchResults Vector search results\n *\n * @return array<int, array{title: string, score: float, content?: string}> Deduplicated source information\n *\/\n private function extractSources(array $searchResults): array\n {\n $sources = [];\n $seen = [];\n\n foreach ($searchResults as $result) {\n $payload = $result['payload'];\n $docTitle = (string) ($payload['document_title'] ?? '');\n\n \/\/ Skip empty titles or already seen titles\n if ($docTitle === '' || isset($seen[$docTitle])) {\n continue;\n }\n\n $source = [\n 'title' => $docTitle,\n 'score' => round($result['score'], 3),\n ];\n\n \/\/ Optionally include content preview\n if (isset($payload['content']) && is_string($payload['content'])) {\n $source['content'] = $payload['content'];\n }\n\n \/\/ Add collection info if available\n if (isset($payload['_collection'])) {\n $source['collection'] = $payload['_collection'];\n }\n\n $sources[] = $source;\n $seen[$docTitle] = true;\n }\n\n return $sources;\n }\n}"
}
}
Response
{
"tool_response": {
"filePath": "\/var\/www\/dev.campus.systemische-tools.de\/src\/Infrastructure\/AI\/ChatService.php",
"oldString": " \/**\n * Extracts unique source information from search results.\n *\n * Collects document titles and scores from the search results,\n * deduplicating by title to provide a clean list of sources.\n * Optionally includes content preview if available.\n *\n * @param array<int, array{id: int|string, score: float, payload: array<string, mixed>}> $searchResults Vector search results\n *\n * @return array<int, array{title: string, score: float, content?: string}> Deduplicated source information\n *\/\n private function extractSources(array $searchResults): array\n {\n $sources = [];\n $seen = [];\n\n foreach ($searchResults as $result) {\n $payload = $result['payload'];\n $docTitle = (string) ($payload['document_title'] ?? '');\n\n \/\/ Skip empty titles or already seen titles\n if ($docTitle === '' || isset($seen[$docTitle])) {\n continue;\n }\n\n $source = [\n 'title' => $docTitle,\n 'score' => round($result['score'], 3),\n ];\n\n \/\/ Optionally include content preview\n if (isset($payload['content']) && is_string($payload['content'])) {\n $source['content'] = $payload['content'];\n }\n\n $sources[] = $source;\n $seen[$docTitle] = true;\n }\n\n return $sources;\n }\n}",
"newString": " \/**\n * Searches across multiple Qdrant collections and merges results.\n *\n * Queries each collection with the same embedding vector, then merges\n * and sorts results by score. Returns the top N results across all collections.\n *\n * @param array<int, float> $embedding The query embedding vector\n * @param array<string> $collections Collections to search\n * @param int $limit Maximum total results to return\n *\n * @return array<int, array{id: int|string, score: float, payload: array<string, mixed>}> Merged search results\n *\/\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 \/\/ Sort by score descending\n usort($allResults, static fn($a, $b) => $b['score'] <=> $a['score']);\n\n \/\/ Return top N results\n return array_slice($allResults, 0, $limit);\n }\n\n \/**\n * Extracts unique source information from search results.\n *\n * Collects document titles and scores from the search results,\n * deduplicating by title to provide a clean list of sources.\n * Optionally includes content preview if available.\n *\n * @param array<int, array{id: int|string, score: float, payload: array<string, mixed>}> $searchResults Vector search results\n *\n * @return array<int, array{title: string, score: float, content?: string}> Deduplicated source information\n *\/\n private function extractSources(array $searchResults): array\n {\n $sources = [];\n $seen = [];\n\n foreach ($searchResults as $result) {\n $payload = $result['payload'];\n $docTitle = (string) ($payload['document_title'] ?? '');\n\n \/\/ Skip empty titles or already seen titles\n if ($docTitle === '' || isset($seen[$docTitle])) {\n continue;\n }\n\n $source = [\n 'title' => $docTitle,\n 'score' => round($result['score'], 3),\n ];\n\n \/\/ Optionally include content preview\n if (isset($payload['content']) && is_string($payload['content'])) {\n $source['content'] = $payload['content'];\n }\n\n \/\/ Add collection info if available\n if (isset($payload['_collection'])) {\n $source['collection'] = $payload['_collection'];\n }\n\n $sources[] = $source;\n $seen[$docTitle] = true;\n }\n\n return $sources;\n }\n}",
"originalFile": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Infrastructure\\AI;\n\nuse RuntimeException;\n\n\/**\n * RAG (Retrieval-Augmented Generation) Chat Service.\n *\n * Provides a complete RAG pipeline that:\n * 1. Converts questions to embeddings using Ollama\n * 2. Searches for relevant document chunks in Qdrant\n * 3. Builds context from search results\n * 4. Generates answers using Claude or Ollama\n * 5. Returns structured responses with sources and metadata\n *\n * This service orchestrates the interaction between OllamaService,\n * QdrantService, and ClaudeService to implement a production-ready\n * RAG system for document-based question answering.\n *\n * @package Infrastructure\\AI\n * @author System Generated\n * @version 1.0.0\n *\/\nfinal readonly class ChatService\n{\n \/**\n * Constructs a new ChatService instance.\n *\n * @param OllamaService $ollama Ollama service for embeddings and optional LLM\n * @param QdrantService $qdrant Qdrant service for vector search\n * @param ClaudeService $claude Claude service for high-quality LLM responses\n *\/\n public function __construct(\n private OllamaService $ollama,\n private QdrantService $qdrant,\n private ClaudeService $claude\n ) {\n }\n\n \/**\n * Executes a complete RAG chat pipeline.\n *\n * Performs the following steps:\n * 1. Generates an embedding vector for the question (if collections selected)\n * 2. Searches for similar documents in the vector database(s)\n * 3. Builds context from the most relevant chunks\n * 4. Generates an answer using the specified LLM model\n * 5. Extracts source information\n * 6. Assembles a structured response\n *\n * If no collections are selected, steps 1-3 and 5 are skipped (no RAG).\n *\n * @param string $question The user's question to answer\n * @param string $model The LLM model (claude-* or ollama:*)\n * @param array<string> $collections Qdrant collections to search (empty = no RAG)\n * @param int $limit Maximum number of document chunks to retrieve (default: 5)\n * @param string|null $stylePrompt Optional style prompt from author profile\n * @param string|null $customSystemPrompt Optional custom system prompt (replaces default if set)\n * @param float $temperature Sampling temperature 0.0-1.0 (default: 0.7)\n * @param int $maxTokens Maximum tokens in response (default: 4096)\n *\n * @return array{\n * question: string,\n * answer: string,\n * sources: array<int, array{title: string, score: float, content?: string}>,\n * model: string,\n * usage?: array{input_tokens: int, output_tokens: int},\n * chunks_used: int\n * } Complete chat response with answer, sources, and metadata\n *\n * @throws RuntimeException If embedding generation fails\n * @throws RuntimeException If vector search fails\n * @throws RuntimeException If LLM request fails\n *\n * @example\n * $chat = new ChatService($ollama, $qdrant, $claude);\n * \/\/ With RAG (multiple collections)\n * $result = $chat->chat('Was ist systemisches Coaching?', 'claude-opus-4-5-20251101', ['documents', 'mail'], 5);\n * \/\/ Without RAG (no collections)\n * $result = $chat->chat('Erkläre mir Python', 'claude-opus-4-5-20251101', [], 5);\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 3: Build context from search results (if any found)\n if ($searchResults !== []) {\n $context = $this->buildContext($searchResults);\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 \/**\n * Builds a context string from search results.\n *\n * Concatenates the content from multiple search results into a single\n * context string, respecting a maximum character limit. Each chunk is\n * labeled with its source document title.\n *\n * @param array<int, array{id: int|string, score: float, payload: array<string, mixed>}> $searchResults Vector search results\n * @param int $maxTokens Maximum tokens to include (default: 3000)\n *\n * @return string The built context string\n *\/\n private function buildContext(array $searchResults, int $maxTokens = 3000): string\n {\n $contextParts = [];\n $totalChars = 0;\n $maxChars = $maxTokens * 4; \/\/ Approximate: 1 token ~ 4 characters\n\n foreach ($searchResults as $index => $result) {\n $payload = $result['payload'];\n $content = (string) ($payload['content'] ?? '');\n $docTitle = (string) ($payload['document_title'] ?? 'Unbekannt');\n\n \/\/ Check if adding this chunk would exceed ... [TRUNCATED-67a7aa5aba6fcdd3]",
"structuredPatch": [
{
"oldStart": 265,
"oldLines": 6,
"newStart": 265,
"newLines": 45,
"lines": [
" }",
" ",
" \/**",
"+ * Searches across multiple Qdrant collections and merges results.",
"+ *",
"+ * Queries each collection with the same embedding vector, then merges",
"+ * and sorts results by score. Returns the top N results across all collections.",
"+ *",
"+ * @param array<int, float> $embedding The query embedding vector",
"+ * @param array<string> $collections Collections to search",
"+ * @param int $limit Maximum total results to return",
"+ *",
"+ * @return array<int, array{id: int|string, score: float, payload: array<string, mixed>}> Merged search results",
"+ *\/",
"+ private function searchMultipleCollections(array $embedding, array $collections, int $limit): array",
"+ {",
"+ $allResults = [];",
"+",
"+ foreach ($collections as $collection) {",
"+ try {",
"+ $results = $this->qdrant->search($embedding, $collection, $limit);",
"+",
"+ \/\/ Add collection name to each result's payload for reference",
"+ foreach ($results as &$result) {",
"+ $result['payload']['_collection'] = $collection;",
"+ }",
"+",
"+ $allResults = array_merge($allResults, $results);",
"+ } catch (RuntimeException) {",
"+ \/\/ Skip unavailable collections, continue with others",
"+ continue;",
"+ }",
"+ }",
"+",
"+ \/\/ Sort by score descending",
"+ usort($allResults, static fn($a, $b) => $b['score'] <=> $a['score']);",
"+",
"+ \/\/ Return top N results",
"+ return array_slice($allResults, 0, $limit);",
"+ }",
"+",
"+ \/**",
" * Extracts unique source information from search results.",
" *",
" * Collects document titles and scores from the search results,"
]
},
{
"oldStart": 299,
"oldLines": 6,
"newStart": 338,
"newLines": 11,
"lines": [
" $source['content'] = $payload['content'];",
" }",
" ",
"+ \/\/ Add collection info if available",
"+ if (isset($payload['_collection'])) {",
"+ $source['collection'] = $payload['_collection'];",
"+ }",
"+",
" $sources[] = $source;",
" $seen[$docTitle] = true;",
" }"
]
}
],
"userModified": false,
"replaceAll": false
}
}