ChatController.php

Code Hygiene Score: 52

Issues 2

Zeile Typ Beschreibung
326 magic_number Magic Number gefunden: 100
- complexity Datei hat 511 Zeilen (max: 500)

Dependencies 23

Klassen 1

Funktionen 20

Versionen 131

Code

<?php

declare(strict_types=1);

namespace Controller;

// @responsibility: HTTP-Endpunkte für KI-Chat (Sessions, Nachrichten, Export)

use Domain\Factory\ChatSessionFactory;
use Domain\Repository\ContentRepositoryInterface;
use Domain\Service\ModelRegistryInterface;
use Framework\Controller;
use Infrastructure\Formatting\ChatMessageFormatter;
use UseCases\Chat\CreateChatSessionUseCaseInterface;
use UseCases\Chat\DeleteChatSessionUseCaseInterface;
use UseCases\Chat\ExportChatSessionUseCase;
use UseCases\Chat\GetChatSessionUseCaseInterface;
use UseCases\Chat\SendChatMessageUseCase;
use UseCases\Chat\StreamingChatMessageUseCase;
use UseCases\Chat\UpdateChatSessionUseCaseInterface;

class ChatController extends Controller
{
    public function __construct(
        private CreateChatSessionUseCaseInterface $createSessionUseCase,
        private GetChatSessionUseCaseInterface $getSessionUseCase,
        private UpdateChatSessionUseCaseInterface $updateSessionUseCase,
        private DeleteChatSessionUseCaseInterface $deleteSessionUseCase,
        private SendChatMessageUseCase $messageUseCase,
        private StreamingChatMessageUseCase $streamingUseCase,
        private ChatMessageFormatter $formatter,
        private ExportChatSessionUseCase $exportUseCase,
        private ModelRegistryInterface $modelRegistry,
        private ContentRepositoryInterface $contentRepository
    ) {
    }

    public function index(): void
    {
        $uuid = $this->createSessionUseCase->createSession();
        header('Location: /chat/' . $uuid);
        exit;
    }

    public function show(string $uuid): void
    {
        $session = $this->getSessionUseCase->getSession($uuid);

        if ($session === null) {
            header('Location: /chat');
            exit;
        }

        // Convert entities to arrays for views
        $messages = $this->getSessionUseCase->getMessages($session->getId() ?? 0);
        $messagesArray = array_map(fn ($m) => $m->toArray(), $messages);

        $this->view('chat.index', [
            'title' => $session->getTitle() ?? 'KI-Chat',
            'session' => ChatSessionFactory::toArray($session),
            'messages' => $messagesArray,
            'sessions' => $this->getSessionUseCase->getAllSessionsWithStats(),
            'authorProfiles' => $this->getSessionUseCase->getAuthorProfiles(),
            'systemPrompts' => $this->getSessionUseCase->getSystemPrompts(),
            'outputStructures' => $this->getSessionUseCase->getOutputStructures(),
            'collections' => $this->getSessionUseCase->getAvailableCollections(),
            'models' => $this->modelRegistry->getChatModels(),
            'defaultModel' => $this->modelRegistry->getDefaultChatModel(),
        ]);
    }

    public function sessionList(): void
    {
        $this->view('chat.partials.session-list', [
            'sessions' => $this->getSessionUseCase->getAllSessionsWithStats(),
            'currentUuid' => $this->getString('current') ?: null,
        ]);
    }

    public function message(string $uuid): void
    {
        $session = $this->getSessionUseCase->getSession($uuid);

        if ($session === null) {
            $this->view('chat.partials.error', ['error' => 'Session nicht gefunden.']);

            return;
        }

        $params = $this->extractChatParams($session);
        $this->updateSessionIfChanged(
            $session,
            $params['model'],
            $params['collections'],
            $params['contextLimit'],
            $params['authorProfileId'],
            $params['temperature'],
            $params['maxTokens']
        );

        if ($params['question'] === '') {
            $this->view('chat.partials.error', ['error' => 'Bitte gib eine Frage ein.']);

            return;
        }

        $validation = $this->validateCollections($params['collections']);
        if (!$validation['valid']) {
            $this->view('chat.partials.error', [
                'error' => 'Collection-Fehler: ' . $validation['error'],
                'details' => 'Bitte wähle nur Collections mit gleichem Embedding-Modell.',
            ]);

            return;
        }

        // Release session lock before long-running LLM call
        session_write_close();

        $response = $this->messageUseCase->execute(
            sessionUuid: $uuid,
            message: $params['question'],
            model: $params['model'],
            collections: $params['collections'],
            contextLimit: $params['contextLimit'],
            authorProfileId: $params['authorProfileId'],
            systemPromptId: $params['systemPromptId'],
            temperature: $params['temperature'],
            maxTokens: $params['maxTokens'],
            structureId: $params['structureId'],
            qualityCheck: $params['qualityCheck']
        );

        if ($response->hasError()) {
            $this->view('chat.partials.error', ['error' => $response->getError()]);

            return;
        }

        $result = $response->toArray();
        $this->view('chat.partials.response', [
            'question' => $params['question'],
            'result' => $result,
            'model' => $params['model'],
            'formattedAnswer' => $this->formatter->formatAnswer($result['answer'] ?? ''),
        ]);
    }

    /**
     * Streaming message endpoint with SSE progress events
     */
    public function messageStream(string $uuid): void
    {
        $session = $this->getSessionUseCase->getSession($uuid);

        if ($session === null) {
            $this->sseError('Session nicht gefunden.');

            return;
        }

        $params = $this->extractChatParams($session);
        $this->updateSessionIfChanged(
            $session,
            $params['model'],
            $params['collections'],
            $params['contextLimit'],
            $params['authorProfileId'],
            $params['temperature'],
            $params['maxTokens']
        );

        if ($params['question'] === '') {
            $this->sseError('Bitte gib eine Frage ein.');

            return;
        }

        $validation = $this->validateCollections($params['collections']);
        if (!$validation['valid']) {
            $this->sseError('Collection-Fehler: ' . $validation['error']);

            return;
        }

        $this->setupSseStream();
        $this->setupProgressCallback();

        // Release session lock before long-running LLM call
        // This allows other requests from same user to proceed
        session_write_close();

        // Execute with streaming
        $response = $this->streamingUseCase->execute(
            sessionUuid: $uuid,
            message: $params['question'],
            model: $params['model'],
            collections: $params['collections'],
            contextLimit: $params['contextLimit'],
            authorProfileId: $params['authorProfileId'],
            systemPromptId: $params['systemPromptId'],
            temperature: $params['temperature'],
            maxTokens: $params['maxTokens'],
            structureId: $params['structureId'],
            qualityCheck: $params['qualityCheck'],
            requestIp: $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'
        );

        // Send final result
        $this->sendStreamResult($response, $params['question'], $params['model']);
    }

    private function sseError(string $message): void
    {
        header('Content-Type: text/event-stream; charset=utf-8');
        header('Cache-Control: no-cache, no-store, must-revalidate');
        header('Content-Encoding: none');
        while (ob_get_level()) {
            ob_end_clean();
        }
        // Padding for browser buffering
        echo ':' . str_repeat(' ', 4096) . "\n\n";
        $errorData = ['error' => $message];
        echo "event: error\n";
        echo 'data: ' . json_encode($errorData, JSON_UNESCAPED_UNICODE) . "\n\n";
        flush();
    }

    public function updateTitle(string $uuid): void
    {
        $session = $this->getSessionUseCase->getSession($uuid);

        if ($session === null) {
            $this->notFound('Session nicht gefunden');
        }

        $title = $this->updateSessionUseCase->updateTitle($session->getId() ?? 0, $_POST['title'] ?? '');
        $this->html(htmlspecialchars($title));
    }

    public function systemPrompt(string $uuid): void
    {
        $session = $this->getSessionUseCase->getSession($uuid);

        if ($session === null) {
            $this->notFound('Session nicht gefunden');
        }

        $result = $this->updateSessionUseCase->updateSystemPrompt($session->getId() ?? 0, $_POST['system_prompt'] ?? '');
        $this->view('chat.partials.success', ['message' => $result->message]);
    }

    public function getSystemPrompt(string $uuid): void
    {
        $session = $this->getSessionUseCase->getSession($uuid);

        if ($session === null) {
            $this->notFound('Session nicht gefunden');
        }

        $this->view('chat.partials.system-prompt-modal', [
            'session' => ChatSessionFactory::toArray($session),
            'currentPrompt' => $this->getSessionUseCase->getSystemPromptById($session->getSystemPromptId()),
            'defaultPrompt' => $this->getSessionUseCase->getDefaultSystemPrompt(),
        ]);
    }

    public function delete(string $uuid): void
    {
        $session = $this->getSessionUseCase->getSession($uuid);

        if ($session !== null) {
            $this->deleteSessionUseCase->deleteSession($session->getId() ?? 0);
        }

        $this->htmxRedirect('/chat');
    }

    public function deleteAll(): void
    {
        $this->deleteSessionUseCase->deleteAllSessions();
        $this->htmxRedirect('/chat');
    }

    public function export(string $uuid): void
    {
        $format = $this->getString('format') ?: 'markdown';
        $filename = $this->exportUseCase->generateFilename($uuid, $format);

        if ($format === 'json') {
            $data = $this->exportUseCase->exportAsJson($uuid);

            if ($data === null) {
                $this->notFound('Session nicht gefunden');
            }

            $content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
            $this->download($content, $filename, 'application/json');
        } else {
            $content = $this->exportUseCase->exportAsMarkdown($uuid);

            if ($content === null) {
                $this->notFound('Session nicht gefunden');
            }

            $this->download($content, $filename, 'text/markdown');
        }
    }

    /**
     * POST /chat/promote - Create Content-Studio order from chat response
     */
    public function promoteToContent(): void
    {
        $this->requireCsrf();

        $question = trim($_POST['question'] ?? '');
        $answer = trim($_POST['answer'] ?? '');
        $model = $_POST['model'] ?? 'claude-sonnet-4-20250514';

        if ($question === '') {
            $this->redirect('/chat');
        }

        // Create title from question (max 100 chars)
        $title = mb_strlen($question) > 100
            ? mb_substr($question, 0, 97) . '...'
            : $question;

        // Create briefing with context from chat
        $briefing = $question;
        if ($answer !== '') {
            $briefing .= "\n\n---\n\n**Kontext aus Chat:**\n\n" . $answer;
        }

        // Get defaults from last order
        $defaults = $this->contentRepository->getLastOrderSettings();

        // Create the content order
        $orderId = $this->contentRepository->createOrder([
            'title' => $title,
            'briefing' => $briefing,
            'author_profile_id' => $defaults['author_profile_id'],
            'contract_id' => $defaults['contract_id'],
            'structure_id' => $defaults['structure_id'],
            'model' => $model,
            'collections' => json_encode($defaults['collections']),
            'context_limit' => $defaults['context_limit'],
        ]);

        // Redirect to Content-Studio
        $this->redirect('/content/' . $orderId);
    }

    /**
     * Extract chat message parameters from POST and session.
     *
     * @return array{
     *   question: string,
     *   model: string,
     *   collections: array<string>,
     *   contextLimit: int,
     *   authorProfileId: int,
     *   systemPromptId: int,
     *   structureId: int,
     *   temperature: float,
     *   maxTokens: int,
     *   qualityCheck: bool
     * }
     */
    private function extractChatParams(\Domain\Entity\ChatSession $session): array
    {
        $requestedModel = $_POST['model'] ?? $session->getModel();
        $model = $this->modelRegistry->isValid($requestedModel)
            ? $requestedModel
            : $this->modelRegistry->getDefaultChatModel();

        return [
            'question' => trim($_POST['message'] ?? ''),
            'model' => $model,
            'collections' => $_POST['collections'] ?? $session->getCollections(),
            'contextLimit' => (int) ($_POST['context_limit'] ?? $session->getContextLimit()),
            'authorProfileId' => (int) ($_POST['author_profile_id'] ?? $session->getAuthorProfileId() ?? 0),
            'systemPromptId' => (int) ($_POST['system_prompt_id'] ?? $session->getSystemPromptId() ?? 1),
            'structureId' => (int) ($_POST['structure_id'] ?? 0),
            'temperature' => (float) ($_POST['temperature'] ?? $session->getTemperature()),
            'maxTokens' => (int) ($_POST['max_tokens'] ?? $session->getMaxTokens()),
            'qualityCheck' => isset($_POST['quality_check']) && $_POST['quality_check'] === '1',
        ];
    }

    /**
     * Update session settings if changed.
     *
     * @param array<string> $collections
     */
    private function updateSessionIfChanged(
        \Domain\Entity\ChatSession $session,
        string $model,
        array $collections,
        int $contextLimit,
        int $authorProfileId,
        float $temperature,
        int $maxTokens
    ): void {
        if ($this->updateSessionUseCase->settingsHaveChanged($session, $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens)) {
            $this->updateSessionUseCase->updateSettings($session->getId() ?? 0, $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens);
        }
    }

    /**
     * Validate collections compatibility.
     *
     * @param array<string> $collections
     * @return array{valid: bool, error: string|null}
     */
    private function validateCollections(array $collections): array
    {
        if (empty($collections)) {
            return ['valid' => true, 'error' => null];
        }

        $compatibility = $this->updateSessionUseCase->validateCollectionCompatibility($collections);

        return [
            'valid' => $compatibility['valid'],
            'error' => $compatibility['error'] ?? null,
        ];
    }

    /**
     * Setup SSE stream headers and disable buffering.
     */
    private function setupSseStream(): void
    {
        header('Content-Type: text/event-stream; charset=utf-8');
        header('Cache-Control: no-cache, no-store, must-revalidate');
        header('Pragma: no-cache');
        header('Connection: keep-alive');
        header('X-Accel-Buffering: no');
        header('Content-Encoding: none');

        if (function_exists('apache_setenv')) {
            apache_setenv('no-gzip', '1');
        }
        @ini_set('zlib.output_compression', '0');
        @ini_set('implicit_flush', '1');
        @ini_set('output_buffering', '0');

        while (ob_get_level()) {
            ob_end_clean();
        }
        ob_implicit_flush(true);

        // Send padding to force buffer flush
        echo ':' . str_repeat(' ', 4096) . "\n\n";
        flush();
    }

    /**
     * Setup progress callback for SSE events.
     */
    private function setupProgressCallback(): void
    {
        $this->streamingUseCase->setProgressCallback(function (string $step, string $message, ?int $durationMs) {
            $timestamp = (new \DateTime())->format('H:i:s.v');
            $data = [
                'ts' => $timestamp,
                'step' => $step,
                'msg' => $message,
                'ms' => $durationMs,
            ];
            echo "event: progress\n";
            echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
            echo ':' . str_repeat(' ', 4096) . "\n";
            flush();
        });
    }

    /**
     * Send streaming result as SSE event.
     */
    private function sendStreamResult(\UseCases\Chat\ChatResponse $response, string $question, string $model): void
    {
        if ($response->hasError()) {
            $errorData = ['error' => $response->getError()];
            echo "event: error\n";
            echo 'data: ' . json_encode($errorData, JSON_UNESCAPED_UNICODE) . "\n\n";
        } else {
            $result = $response->toArray();
            $formattedAnswer = $this->formatter->formatAnswer($result['answer'] ?? '');

            ob_start();
            $this->view('chat.partials.response', [
                'question' => $question,
                'result' => $result,
                'model' => $model,
                'formattedAnswer' => $formattedAnswer,
            ]);
            $html = ob_get_clean();

            $doneData = ['html' => $html];
            echo "event: done\n";
            echo 'data: ' . json_encode($doneData, JSON_UNESCAPED_UNICODE) . "\n\n";
        }

        @ob_flush();
        flush();
    }
}
← Übersicht Graph