$collections Qdrant collections to search (empty = no RAG) * @param int $limit Maximum number of document chunks to retrieve (default: 5) * @param string|null $stylePrompt Optional style prompt from author profile * @param string|null $customSystemPrompt Optional custom system prompt (replaces default if set) * @param float $temperature Sampling temperature 0.0-1.0 (default: 0.7) * @param int $maxTokens Maximum tokens in response (default: 4096) * * @return array{ * question: string, * answer: string, * sources: array, * model: string, * usage?: array{input_tokens: int, output_tokens: int}, * chunks_used: int * } Complete chat response with answer, sources, and metadata * * @throws RuntimeException If embedding generation fails * @throws RuntimeException If vector search fails * @throws RuntimeException If LLM request fails * * @example * $chat = new ChatService($ollama, $qdrant, $claude); * // With RAG (multiple collections) * $result = $chat->chat('Was ist systemisches Coaching?', 'claude-opus-4-5-20251101', ['documents', 'mail'], 5); * // Without RAG (no collections) * $result = $chat->chat('Erkläre mir Python', 'claude-opus-4-5-20251101', [], 5); */ public function chat( string $question, string $model = 'claude-opus-4-5-20251101', array $collections = [], int $limit = 5, ?string $stylePrompt = null, ?string $customSystemPrompt = null, float $temperature = 0.7, int $maxTokens = 4096 ): array { $searchResults = []; $context = ''; // Only perform RAG if collections are selected if ($collections !== []) { // Step 1: Generate embedding for the question try { $queryEmbedding = $this->ollama->getEmbedding($question); } catch (RuntimeException $e) { throw new RuntimeException( 'Embedding generation failed: ' . $e->getMessage(), 0, $e ); } if ($queryEmbedding === []) { throw new RuntimeException('Embedding generation returned empty vector'); } // Step 2: Search across all selected collections try { $searchResults = $this->searchMultipleCollections($queryEmbedding, $collections, $limit); } catch (RuntimeException $e) { throw new RuntimeException( 'Vector search failed: ' . $e->getMessage(), 0, $e ); } // Step 3: Build context from search results (if any found) if ($searchResults !== []) { $context = $this->buildContext($searchResults); } } // Step 4: Parse model string and generate answer $isOllama = str_starts_with($model, 'ollama:'); $isClaude = str_starts_with($model, 'claude-'); if ($isClaude) { try { $ragPrompt = $this->claude->buildRagPrompt($question, $context); // Build system prompt hierarchy: Default -> Custom -> Style if ($customSystemPrompt !== null && $customSystemPrompt !== '') { $systemPrompt = $customSystemPrompt; } else { $systemPrompt = $this->claude->getDefaultSystemPrompt(); } // Append style prompt from author profile if provided if ($stylePrompt !== null && $stylePrompt !== '') { $systemPrompt .= "\n\n" . $stylePrompt; } $llmResponse = $this->claude->ask($ragPrompt, $systemPrompt, $model, $maxTokens, $temperature); $answer = $llmResponse['text']; $usage = $llmResponse['usage']; } catch (RuntimeException $e) { throw new RuntimeException( 'Claude API request failed: ' . $e->getMessage(), 0, $e ); } } elseif ($isOllama) { try { // Extract actual model name (remove "ollama:" prefix) $ollamaModel = substr($model, 7); // Build instruction from custom prompt and style $instructions = []; if ($customSystemPrompt !== null && $customSystemPrompt !== '') { $instructions[] = $customSystemPrompt; } if ($stylePrompt !== null && $stylePrompt !== '') { $instructions[] = $stylePrompt; } $instructionBlock = $instructions !== [] ? implode("\n\n", $instructions) . "\n\n" : ''; $ragPrompt = sprintf( "%sKontext aus den Dokumenten:\n\n%s\n\n---\n\nFrage: %s", $instructionBlock, $context, $question ); $answer = $this->ollama->generate($ragPrompt, $ollamaModel, $temperature); $usage = null; } catch (RuntimeException $e) { throw new RuntimeException( 'Ollama generation failed: ' . $e->getMessage(), 0, $e ); } } else { throw new RuntimeException( sprintf('Unknown model "%s". Use claude-* or ollama:* format.', $model) ); } // Step 5: Extract source information $sources = $this->extractSources($searchResults); // Step 6: Assemble response $response = [ 'question' => $question, 'answer' => $answer, 'sources' => $sources, 'model' => $model, 'chunks_used' => count($searchResults), ]; if ($usage !== null) { $response['usage'] = $usage; } return $response; } /** * Builds a context string from search results. * * Concatenates the content from multiple search results into a single * context string, respecting a maximum character limit. Each chunk is * labeled with its source document title. * * @param array}> $searchResults Vector search results * @param int $maxTokens Maximum tokens to include (default: 3000) * * @return string The built context string */ private function buildContext(array $searchResults, int $maxTokens = 3000): string { $contextParts = []; $totalChars = 0; $maxChars = $maxTokens * 4; // Approximate: 1 token ~ 4 characters foreach ($searchResults as $index => $result) { $payload = $result['payload']; $content = (string) ($payload['content'] ?? ''); $docTitle = (string) ($payload['document_title'] ?? 'Unbekannt'); // Check if adding this chunk would exceed the limit if ($totalChars + strlen($content) > $maxChars) { break; } $contextParts[] = sprintf('[Quelle %d: %s]%s%s', $index + 1, $docTitle, "\n", $content); $totalChars += strlen($content); } return implode("\n\n---\n\n", $contextParts); } /** * Extracts unique source information from search results. * * Collects document titles and scores from the search results, * deduplicating by title to provide a clean list of sources. * Optionally includes content preview if available. * * @param array}> $searchResults Vector search results * * @return array Deduplicated source information */ private function extractSources(array $searchResults): array { $sources = []; $seen = []; foreach ($searchResults as $result) { $payload = $result['payload']; $docTitle = (string) ($payload['document_title'] ?? ''); // Skip empty titles or already seen titles if ($docTitle === '' || isset($seen[$docTitle])) { continue; } $source = [ 'title' => $docTitle, 'score' => round($result['score'], 3), ]; // Optionally include content preview if (isset($payload['content']) && is_string($payload['content'])) { $source['content'] = $payload['content']; } $sources[] = $source; $seen[$docTitle] = true; } return $sources; } }