StreamingChatMessageUseCase.php

Code Hygiene Score: 72

Keine Issues gefunden.

Dependencies 19

Klassen 1

Funktionen 7

Verwendet von 3

Versionen 29

Code

<?php

declare(strict_types=1);

namespace UseCases\Chat;

// @responsibility: Orchestriert Chat-Nachrichten mit SSE-Progress-Events

use Domain\Constants;
use Domain\Repository\ChatMessageRepositoryInterface;
use Domain\Repository\ChatSessionRepositoryInterface;
use Infrastructure\AI\ClaudeService;
use Infrastructure\AI\ContentQualityValidator;
use Infrastructure\AI\OllamaService;
use Infrastructure\AI\QdrantService;
use Infrastructure\AI\ScoringService;
use Infrastructure\Logging\KiProtokollService;

class StreamingChatMessageUseCase
{
    /** @var callable|null */
    private $progressCallback;

    private float $stepStart = 0.0;

    public function __construct(
        private OllamaService $ollama,
        private QdrantService $qdrant,
        private ClaudeService $claude,
        ScoringService $scoring,
        private ChatSessionRepositoryInterface $sessionRepo,
        private ChatMessageRepositoryInterface $messageRepo,
        private ChatPromptLoader $promptLoader,
        private ContentQualityValidator $qualityValidator,
        private RagContextBuilder $ragBuilder,
        private KiProtokollService $protokollService
    ) {
        // $scoring kept for BC but no longer used - ContentSearchService handles reranking
        unset($scoring);
    }

    /**
     * Set progress callback for SSE events
     *
     * @param callable $callback fn(string $step, string $message, ?int $durationMs): void
     */
    public function setProgressCallback(callable $callback): void
    {
        $this->progressCallback = $callback;
    }

    private function emit(string $step, string $message, ?int $durationMs = null): void
    {
        if ($this->progressCallback !== null) {
            ($this->progressCallback)($step, $message, $durationMs);
        }
    }

    private function startStep(): void
    {
        $this->stepStart = microtime(true);
    }

    private function endStep(string $step, string $message): void
    {
        $durationMs = (int) round((microtime(true) - $this->stepStart) * Constants::MS_PER_SECOND);
        $this->emit($step, $message, $durationMs);
    }

    /**
     * Execute chat with streaming progress.
     *
     * @param array<string> $collections
     */
    public function execute(
        string $sessionUuid,
        string $message,
        string $model,
        array $collections = ['documents'],
        int $contextLimit = 5,
        int $authorProfileId = 0,
        int $systemPromptId = 1,
        float $temperature = 0.7,
        int $maxTokens = 4096,
        int $structureId = 0,
        bool $qualityCheck = false,
        string $requestIp = '127.0.0.1'
    ): ChatResponse {
        $totalStart = microtime(true);

        // Log to protokoll (crash-safe, returns null on failure)
        $protokollId = $this->protokollService->logRequest('web-chat', $message, $model, $requestIp);
        // Step 1: Validate session
        $this->emit('session', 'Session validieren...');
        $this->startStep();
        $session = $this->sessionRepo->findByUuid($sessionUuid);
        if ($session === null) {
            $this->emit('error', 'Session nicht gefunden');
            if ($protokollId !== null) {
                $this->protokollService->logFailure($protokollId, 'Session nicht gefunden');
            }

            return ChatResponse::error('Session nicht gefunden.');
        }
        $sessionId = $session->getId() ?? 0;
        $this->endStep('session_done', 'Session validiert');
        // Step 2: Validate message
        $message = trim($message);
        if ($message === '') {
            $this->emit('error', 'Keine Nachricht');
            if ($protokollId !== null) {
                $this->protokollService->logFailure($protokollId, 'Leere Nachricht');
            }

            return ChatResponse::error('Bitte gib eine Frage ein.');
        }
        // Step 3: Save user message
        $this->emit('save_user', 'User-Nachricht speichern...');
        $this->startStep();
        $this->messageRepo->save(sessionId: $sessionId, role: 'user', content: $message, model: $model);
        $this->endStep('save_user_done', 'User-Nachricht gespeichert');
        // Step 4: Auto-set title
        $currentTitle = $session->getTitle();
        if ($currentTitle === null || $currentTitle === 'Neuer Chat') {
            $this->sessionRepo->updateTitle($sessionId, mb_substr($message, 0, 50) . (mb_strlen($message) > 50 ? '...' : ''));
        }
        // Step 5: Get prompts
        $this->emit('prompts', 'Prompts laden...');
        $this->startStep();
        $stylePrompt = $this->promptLoader->getStylePrompt($authorProfileId);
        $systemPrompt = $this->promptLoader->getSystemPrompt($systemPromptId);
        $structurePrompt = $this->promptLoader->getStructurePrompt($structureId);
        if ($structurePrompt !== null) {
            $systemPrompt = ($systemPrompt ?? '') . "\n\n" . $structurePrompt;
        }
        $this->endStep('prompts_done', 'Prompts geladen');

        // RAG Pipeline
        $searchResults = [];
        $context = '';
        if ($collections !== []) {
            // Step 6+7: Semantic search
            $this->emit('search', 'Semantische Suche in ' . count($collections) . ' Collection(s)...');
            $this->startStep();
            $searchResults = $this->ragBuilder->search($message, $collections, $contextLimit);
            // Add Qdrant fallback for non-documents collections
            $searchResults = $this->addQdrantFallback($searchResults, $message, $collections, $contextLimit);
            $semanticCount = count(array_filter($searchResults, static fn ($r) => isset($r['intent'])));
            $this->endStep('search_done', count($searchResults) . ' Chunks gefunden (' . $semanticCount . ' mit Semantik)');

            // Step 8: Build context
            if ($searchResults !== []) {
                $this->emit('context', 'Kontext aufbauen...');
                $this->startStep();
                $context = $this->ragBuilder->buildContext($searchResults);
                $this->endStep('context_done', 'Kontext erstellt (' . strlen($context) . ' Zeichen)');
            }
        }

        // Step 9: LLM Request
        $isOllama = str_starts_with($model, 'ollama:');
        $isClaude = str_starts_with($model, 'claude-');
        $hasContext = $context !== '';
        $this->emit('llm', 'Anfrage an ' . ($isOllama ? substr($model, 7) : $model) . '...');
        $this->startStep();
        $llmStart = microtime(true);

        try {
            if ($isClaude) {
                $userPrompt = $hasContext ? $this->claude->buildRagPrompt($message, $context) : $message;
                $effectiveSystemPrompt = $systemPrompt ?? ($hasContext ? $this->claude->getDefaultSystemPrompt() : 'Du bist ein hilfreicher Assistent. Antworte auf Deutsch, präzise und hilfreich.');
                if ($stylePrompt !== null && $stylePrompt !== '') {
                    $effectiveSystemPrompt .= "\n\n" . $stylePrompt;
                }

                // Update protokoll with full prompt before LLM call
                if ($protokollId !== null) {
                    $fullPrompt = "=== SYSTEM ===\n" . $effectiveSystemPrompt . "\n\n=== USER ===\n" . $userPrompt;
                    $this->protokollService->updateFullPrompt($protokollId, $fullPrompt);
                }

                $llmResponse = $this->claude->ask($userPrompt, $effectiveSystemPrompt, $model, $maxTokens, $temperature);
                $answer = $llmResponse['text'];
                $usage = $llmResponse['usage'];
            } elseif ($isOllama) {
                $ollamaModel = substr($model, 7);
                $instructions = array_filter([$systemPrompt, $stylePrompt]);
                $instructionBlock = $instructions !== [] ? implode("\n\n", $instructions) . "\n\n" : '';
                $userPrompt = $hasContext ? sprintf("%sKontext:\n\n%s\n\n---\n\nFrage: %s", $instructionBlock, $context, $message) : $instructionBlock . $message;

                // Update protokoll with full prompt before LLM call
                if ($protokollId !== null) {
                    $this->protokollService->updateFullPrompt($protokollId, $userPrompt);
                }

                $answer = $this->ollama->generate($userPrompt, $ollamaModel, $temperature);
                $usage = null;
            } else {
                $this->emit('error', "Unbekanntes Modell: {$model}");
                if ($protokollId !== null) {
                    $this->protokollService->logFailure($protokollId, "Unbekanntes Modell: {$model}");
                }

                return ChatResponse::error("Unknown model \"{$model}\".");
            }
        } catch (\RuntimeException $e) {
            $this->emit('error', 'LLM-Fehler: ' . $e->getMessage());
            if ($protokollId !== null) {
                $this->protokollService->logFailure($protokollId, 'LLM-Fehler: ' . $e->getMessage());
            }

            return ChatResponse::error('LLM request failed: ' . $e->getMessage());
        }
        $llmDuration = (int) round((microtime(true) - $llmStart) * Constants::MS_PER_SECOND);
        $tokenInfo = $usage !== null ? " ({$usage['input_tokens']} in / {$usage['output_tokens']} out)" : '';
        $this->emit('llm_done', "Antwort erhalten{$tokenInfo}", $llmDuration);
        // Step 10: Extract sources
        $this->emit('sources', 'Quellen extrahieren...');
        $this->startStep();
        $sources = $this->ragBuilder->extractSources($searchResults);
        $this->endStep('sources_done', count($sources) . ' Quellen extrahiert');
        // Step 11: Save assistant message
        $this->emit('save_assistant', 'Antwort speichern...');
        $this->startStep();
        $sourcesForStorage = array_map(static fn (array $s): string => json_encode($s, JSON_THROW_ON_ERROR), $sources);
        $this->messageRepo->save(
            sessionId: $sessionId,
            role: 'assistant',
            content: $answer,
            model: $model,
            tokensInput: $usage['input_tokens'] ?? null,
            tokensOutput: $usage['output_tokens'] ?? null,
            sources: $sourcesForStorage,
            startMicrotime: $llmStart,
            endMicrotime: microtime(true),
            authorProfileId: $authorProfileId > 0 ? $authorProfileId : null,
            systemPromptId: $systemPromptId > 0 ? $systemPromptId : null,
            collectionsJson: json_encode($collections),
            contextLimit: $contextLimit,
            llmRequestId: $protokollId
        );
        $this->endStep('save_assistant_done', 'Antwort gespeichert');

        // Log success to protokoll
        if ($protokollId !== null) {
            $this->protokollService->logSuccess(
                $protokollId,
                $answer,
                $llmDuration,
                $usage['input_tokens'] ?? null,
                $usage['output_tokens'] ?? null
            );
        }

        // Step 12: Quality check
        $qualityValidation = null;
        if ($qualityCheck) {
            $this->emit('quality', 'Qualitätsprüfung...');
            $this->startStep();
            $structureName = $structureId > 0 ? $this->promptLoader->getStructureName($structureId) : null;
            $qualityValidation = $this->qualityValidator->validate(
                question: $message,
                answer: $answer,
                sources: $sources,
                structureName: $structureName
            );
            $score = round($qualityValidation['score']);
            $this->endStep('quality_done', "Qualität: {$score}%");
        }

        // Final
        $totalDuration = (int) round((microtime(true) - $totalStart) * Constants::MS_PER_SECOND);
        $this->emit('complete', "Fertig in {$totalDuration}ms", $totalDuration);

        // Build response
        $result = [
            'answer' => $answer,
            'sources' => $sources,
            'usage' => $usage,
            'chunks_used' => count($searchResults),
        ];

        $response = ChatResponse::fromServiceResponse($result, (microtime(true) - $totalStart));
        if ($qualityValidation !== null) {
            $response = $response->withQualityValidation($qualityValidation);
        }

        return $response;
    }

    /**
     * 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
    {
        $nonDocCollections = array_filter($collections, static fn ($c) => $c !== 'documents');

        if ($nonDocCollections === []) {
            return $results;
        }

        foreach ($nonDocCollections as $collection) {
            try {
                $embedding = $this->ollama->getEmbedding($query);
                $qdrantResults = $this->qdrant->search($embedding, $collection, $limit);

                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;
            }
        }

        // Sort by score and limit
        usort($results, static fn ($a, $b) => ($b['score'] ?? 0.0) <=> ($a['score'] ?? 0.0));

        return array_slice($results, 0, $limit);
    }
}
← Übersicht Graph