ChatService.php

Code Hygiene Score: 83

Keine Issues gefunden.

Dependencies 6

Klassen 1

Funktionen 5

Verwendet von 5

Versionen 22

Code

<?php

declare(strict_types=1);

namespace Infrastructure\AI;

// @responsibility: RAG-Pipeline für KI-Chat (Embedding, Suche, Antwort-Generierung)

use RuntimeException;

final readonly class ChatService
{
    public function __construct(
        private OllamaService $ollama,
        private QdrantService $qdrant,
        private ClaudeService $claude,
        private ScoringService $scoring,
        private ?SemanticEnrichmentService $semantic = null
    ) {
    }

    /**
     * Executes a complete RAG chat pipeline: Embedding → Search → Context → LLM → Response
     * If no collections selected, skips RAG steps (direct LLM query).
     *
     * @param array<string> $collections Qdrant collections to search (empty = no RAG)
     * @return array{question: string, answer: string, sources: array, model: string, usage?: array, chunks_used: int}
     * @throws RuntimeException If embedding/search/LLM fails
     */
    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 2b: Semantic enrichment (graceful degradation - works without)
            $semanticSummary = '';
            if ($searchResults !== [] && $this->semantic !== null) {
                $searchResults = $this->semantic->enrichSearchResults($searchResults);
                $semanticSummary = $this->semantic->buildSemanticSummary($searchResults);
            }

            // Step 3: Build context from search results (if any found)
            if ($searchResults !== []) {
                $context = $this->buildContext($searchResults, 3000, $semanticSummary);
            }
        }

        // Step 4: Parse model string and generate answer
        $isOllama = str_starts_with($model, 'ollama:');
        $isClaude = str_starts_with($model, 'claude-');
        $hasContext = $context !== '';

        if ($isClaude) {
            try {
                // Build prompt: RAG with context or direct question
                if ($hasContext) {
                    $userPrompt = $this->claude->buildRagPrompt($question, $context);
                } else {
                    $userPrompt = $question;
                }

                // Build system prompt hierarchy: Default -> Custom -> Style
                if ($customSystemPrompt !== null && $customSystemPrompt !== '') {
                    $systemPrompt = $customSystemPrompt;
                } else {
                    $systemPrompt = $hasContext
                        ? $this->claude->getDefaultSystemPrompt()
                        : 'Du bist ein hilfreicher Assistent. Antworte auf Deutsch, präzise und hilfreich.';
                }

                // Append style prompt from author profile if provided
                if ($stylePrompt !== null && $stylePrompt !== '') {
                    $systemPrompt .= "\n\n" . $stylePrompt;
                }

                $llmResponse = $this->claude->ask($userPrompt, $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" : '';

                // Build prompt: RAG with context or direct question
                if ($hasContext) {
                    $userPrompt = sprintf(
                        "%sKontext aus den Dokumenten:\n\n%s\n\n---\n\nFrage: %s",
                        $instructionBlock,
                        $context,
                        $question
                    );
                } else {
                    $userPrompt = $instructionBlock . $question;
                }

                $answer = $this->ollama->generate($userPrompt, $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 context string from search results (respects maxTokens limit). */
    private function buildContext(array $searchResults, int $maxTokens = 3000, string $semanticSummary = ''): string
    {
        $contextParts = [];
        $totalChars = 0;
        $maxChars = $maxTokens * 4; // Approximate: 1 token ~ 4 characters

        // Prepend semantic summary if available
        if ($semanticSummary !== '') {
            $contextParts[] = "[Semantischer Kontext]\n" . $semanticSummary;
            $totalChars += strlen($semanticSummary);
        }

        foreach ($searchResults as $index => $result) {
            $payload = $result['payload'];
            // Support both payload schemas: documents + dokumentation_chunks
            $content = (string) ($payload['content'] ?? $payload['content_preview'] ?? '');
            $docTitle = (string) ($payload['document_title'] ?? $payload['title'] ?? 'Unbekannt');

            // Check if adding this chunk would exceed the limit
            if ($totalChars + strlen($content) > $maxChars) {
                break;
            }

            // Build chunk header with optional entity info
            $entities = $payload['entities'] ?? [];
            $entityInfo = '';
            if ($entities !== []) {
                $entityNames = array_slice(array_column($entities, 'name'), 0, 3);
                $entityInfo = ' | Enthält: ' . implode(', ', $entityNames);
            }

            $contextParts[] = sprintf('[Quelle %d: %s%s]%s%s', $index + 1, $docTitle, $entityInfo, "\n", $content);
            $totalChars += strlen($content);
        }

        return implode("\n\n---\n\n", $contextParts);
    }

    /** Searches multiple collections, merges and returns top N results by weighted score. */
    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;
            }
        }

        // Apply weighted scoring (similarity + recency + authority)
        foreach ($allResults as &$result) {
            $processedAt = $result['payload']['processed_at'] ?? null;
            $documentDate = $processedAt !== null
                ? new \DateTime($processedAt)
                : new \DateTime();

            $authorityScore = (float) ($result['payload']['authority_score'] ?? 0.5);

            $result['weighted_score'] = $this->scoring->calculateScore(
                $result['score'],
                $documentDate,
                $authorityScore
            );
        }
        unset($result);

        // Sort by weighted score descending
        usort($allResults, static fn ($a, $b) => ($b['weighted_score'] ?? 0.0) <=> ($a['weighted_score'] ?? 0.0));

        // Return top N results
        return array_slice($allResults, 0, $limit);
    }

    /** Extracts unique source information (title, score, optional content) from search results. */
    private function extractSources(array $searchResults): array
    {
        $sources = [];
        $seen = [];

        foreach ($searchResults as $result) {
            $payload = $result['payload'];
            // Support both payload schemas: documents + dokumentation_chunks
            $docTitle = (string) ($payload['document_title'] ?? $payload['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 (support both field names)
            $content = $payload['content'] ?? $payload['content_preview'] ?? null;
            if (is_string($content) && $content !== '') {
                $source['content'] = $content;
            }

            // Add collection info if available
            if (isset($payload['_collection'])) {
                $source['collection'] = $payload['_collection'];
            }

            // Add semantic data if available (graceful degradation)
            $entities = $payload['entities'] ?? [];
            if ($entities !== []) {
                $source['entities'] = array_map(
                    static fn ($e) => ['name' => $e['name'], 'type' => $e['type']],
                    array_slice($entities, 0, 5)
                );
            }

            $taxonomy = $payload['taxonomy'] ?? [];
            if ($taxonomy !== []) {
                $source['taxonomy'] = array_map(
                    static fn ($t) => $t['term_name'],
                    array_slice($taxonomy, 0, 3)
                );
            }

            $sources[] = $source;
            $seen[$docTitle] = true;
        }

        return $sources;
    }
}
← Übersicht Graph