Backup #2104

ID2104
Dateipfad/var/www/dev.campus.systemische-tools.de/src/UseCases/Chat/StreamingChatMessageUseCase.php
Version26
Typ modified
Größe12.2 KB
Hash4ee660112154c3ee86ad6cf89ee0a2f32ec89e7d618470dd648e850e33ecdc5c
Datum2025-12-29 08:46:28
Geändert vonclaude-code-hook
GrundClaude Code Pre-Hook Backup vor Edit-Operation
Datei existiert Ja

Dateiinhalt

<?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; }
                $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;
                $answer = $this->ollama->generate($userPrompt, $ollamaModel, $temperature);
                $usage = null;
            } else {
                $this->emit('error', "Unbekanntes Modell: {$model}");
                return ChatResponse::error("Unknown model \"{$model}\".");
            }
        } catch (\RuntimeException $e) {
            $this->emit('error', '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
        );
        $this->endStep('save_assistant_done', 'Antwort gespeichert');

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

Vollständig herunterladen

Aktionen

Herunterladen

Andere Versionen dieser Datei

ID Version Typ Größe Datum
2118 29 modified 12.9 KB 2025-12-29 09:09
2106 28 modified 12.8 KB 2025-12-29 08:47
2105 27 modified 12.5 KB 2025-12-29 08:46
2104 26 modified 12.2 KB 2025-12-29 08:46
2103 25 modified 11.9 KB 2025-12-29 08:46
2102 24 modified 11.7 KB 2025-12-29 08:45
2101 23 modified 11.6 KB 2025-12-29 08:45
2100 22 modified 11.6 KB 2025-12-29 08:45
2077 21 modified 18.7 KB 2025-12-29 00:04
2076 20 modified 18.7 KB 2025-12-29 00:03
2075 19 modified 18.7 KB 2025-12-29 00:02
2074 18 modified 18.5 KB 2025-12-29 00:02
2073 17 modified 18.6 KB 2025-12-29 00:02
2041 16 modified 18.6 KB 2025-12-28 23:25
2039 15 modified 18.6 KB 2025-12-28 23:25
2035 14 modified 18.5 KB 2025-12-28 23:25
2029 13 modified 18.0 KB 2025-12-28 23:23
2028 12 modified 15.6 KB 2025-12-28 23:22
2027 11 modified 16.0 KB 2025-12-28 23:21
2026 10 modified 16.0 KB 2025-12-28 23:21
2023 9 modified 15.9 KB 2025-12-28 23:21
1978 8 modified 16.6 KB 2025-12-28 02:37
1977 7 modified 17.0 KB 2025-12-28 02:36
1976 6 modified 17.8 KB 2025-12-28 02:35
1975 5 modified 18.3 KB 2025-12-28 02:34
1825 4 modified 18.2 KB 2025-12-27 23:16
1824 3 modified 18.2 KB 2025-12-27 23:16
1665 2 modified 18.1 KB 2025-12-27 11:23
1566 1 modified 18.1 KB 2025-12-26 20:31

← Zurück zur Übersicht