repository = new ContentRepository(); $this->collectionRepository = new CollectionRepository(); $this->collectionValidator = new CollectionValidator($this->collectionRepository); $this->generateUseCase = new GenerateContentUseCase(); } /** * GET /content * List all content orders */ public function index(): void { $filters = []; if (isset($_GET['status']) && $_GET['status'] !== '') { $filters['status'] = $_GET['status']; } $orders = $this->repository->findAllOrders($filters); $stats = $this->repository->getStatistics(); $this->view('content.index', [ 'title' => 'Content Studio', 'orders' => $orders, 'stats' => $stats, 'currentStatus' => $_GET['status'] ?? '', ]); } /** * GET /content/new * Show create form */ public function contentNew(): void { $collections = $this->getAvailableCollections(); $lastSettings = $this->repository->getLastOrderSettings(); $this->view('content.new', [ 'title' => 'Neuer Content-Auftrag', 'profiles' => $this->repository->findAllProfiles(), 'contracts' => $this->repository->findAllContracts(), 'structures' => $this->repository->findAllStructures(), 'models' => ModelConfig::getAll(), 'collections' => $collections, // Defaults from last order 'defaultModel' => $lastSettings['model'], 'defaultCollections' => $lastSettings['collections'], 'defaultContextLimit' => $lastSettings['context_limit'], 'defaultProfileId' => $lastSettings['author_profile_id'], 'defaultContractId' => $lastSettings['contract_id'], 'defaultStructureId' => $lastSettings['structure_id'], ]); } /** * POST /content * Store new order */ public function store(): void { $this->requireCsrf(); $title = trim($_POST['title'] ?? ''); $briefing = trim($_POST['briefing'] ?? ''); if ($title === '' || $briefing === '') { $_SESSION['error'] = 'Titel und Briefing sind erforderlich.'; header('Location: /content/new'); exit; } // Auto-apply first active contract if none selected $contractId = $_POST['contract_id'] ?? null; if ($contractId === null || $contractId === '') { $contracts = $this->repository->findAllContracts(); if ($contracts !== []) { $contractId = $contracts[0]['id']; } } // Process collections (multi-select) $collections = $_POST['collections'] ?? ['documents']; if (!is_array($collections)) { $collections = [$collections]; } // Validate collection compatibility $collections = $this->validateCollections($collections); $compatibility = $this->validateCollectionCompatibility($collections); if (!$compatibility['valid']) { $_SESSION['error'] = 'Collection-Fehler: ' . $compatibility['error']; header('Location: /content/new'); exit; } $model = ModelConfig::validate($_POST['model'] ?? ModelConfig::DEFAULT_MODEL); $contextLimit = (int) ($_POST['context_limit'] ?? 5); $orderId = $this->repository->createOrder([ 'title' => $title, 'briefing' => $briefing, 'author_profile_id' => $_POST['author_profile_id'] ?? null, 'contract_id' => $contractId, 'structure_id' => $_POST['structure_id'] ?? null, 'model' => $model, 'collections' => json_encode($collections), 'context_limit' => $contextLimit, ]); // If "generate" action: generate content immediately if (($_POST['action'] ?? 'save') === 'generate') { $collection = $collections[0] ?? 'documents'; $result = $this->generateUseCase->generate($orderId, $model, $collection, $contextLimit); if ($result->hasError()) { $_SESSION['error'] = 'Generierung fehlgeschlagen: ' . $result->getError(); } else { $_SESSION['success'] = 'Content wurde generiert.'; } } header('Location: /content/' . $orderId); exit; } /** * GET /content/{id} * Show order details */ public function show(int $id): void { $order = $this->repository->findOrder($id); if ($order === null) { $this->notFound('Auftrag nicht gefunden'); } $versions = $this->repository->findVersionsByOrder($id); $latestVersion = $versions[0] ?? null; $critiques = $latestVersion ? $this->repository->findCritiquesByVersion($latestVersion['id']) : []; $sources = $this->repository->findSourcesByOrder($id); // Get available collections for the dropdown $availableCollections = $this->getAvailableCollections(); $this->view('content.show', [ 'title' => $order['title'], 'order' => $order, 'versions' => $versions, 'latestVersion' => $latestVersion, 'critiques' => $critiques, 'sources' => $sources, 'models' => ModelConfig::getAll(), 'availableCollections' => $availableCollections, ]); } /** * GET /content/{id}/edit * Show edit form */ public function edit(int $id): void { $order = $this->repository->findOrder($id); if ($order === null) { $this->notFound('Auftrag nicht gefunden'); } $this->view('content.edit', [ 'title' => 'Auftrag bearbeiten', 'order' => $order, 'profiles' => $this->repository->findAllProfiles(), 'contracts' => $this->repository->findAllContracts(), 'structures' => $this->repository->findAllStructures(), ]); } /** * POST /content/{id}/generate * Generate content (HTMX) */ public function generate(int $id): void { $this->requireCsrf(); $model = $_POST['model'] ?? 'claude-opus-4-5-20251101'; $collection = $_POST['collection'] ?? 'documents'; $limit = (int) ($_POST['context_limit'] ?? 5); // Validate collection $collections = $this->validateCollections([$collection]); if (empty($collections)) { echo '
Ungültige Collection: ' . htmlspecialchars($collection) . '
'; return; } $collection = $collections[0]; // Validate compatibility (single collection always valid, but check exists) $compatibility = $this->validateCollectionCompatibility($collections); if (!$compatibility['valid']) { echo '
' . htmlspecialchars($compatibility['error']) . '
'; return; } $result = $this->generateUseCase->generate($id, $model, $collection, $limit); if ($result->hasError()) { echo '
Fehler: ' . htmlspecialchars($result->getError()) . '
'; return; } // Return updated content section $this->renderVersionPartial($result->toArray()); } /** * POST /content/{id}/critique * Run critique round (HTMX) */ public function critique(int $id): void { $this->requireCsrf(); // Get latest version $version = $this->repository->findLatestVersion($id); if ($version === null) { echo '
Keine Version vorhanden.
'; return; } $model = $_POST['model'] ?? 'claude-opus-4-5-20251101'; $result = $this->generateUseCase->critique($version['id'], $model); if ($result->hasError()) { echo '
Fehler: ' . htmlspecialchars($result->getError()) . '
'; return; } // Return critique results $this->renderCritiquePartial($result->toArray()); } /** * POST /content/{id}/revise * Create revision (HTMX) */ public function revise(int $id): void { $this->requireCsrf(); $version = $this->repository->findLatestVersion($id); if ($version === null) { echo '
Keine Version vorhanden.
'; return; } $model = $_POST['model'] ?? 'claude-opus-4-5-20251101'; $result = $this->callPython('revise', $version['id'], [$model]); if (isset($result['error'])) { echo '
Fehler: ' . htmlspecialchars($result['error']) . '
'; return; } $this->renderVersionPartial($result); } /** * POST /content/{id}/approve * Approve content */ public function approve(int $id): void { $this->requireCsrf(); $this->repository->updateOrderStatus($id, 'approve'); echo '
Content genehmigt!
'; echo ''; } /** * POST /content/{id}/decline * Decline content */ public function decline(int $id): void { $this->requireCsrf(); $this->repository->updateOrderStatus($id, 'draft'); echo '
Content abgelehnt. Zurück zu Entwurf.
'; echo ''; } /** * Allowed Python commands (whitelist). */ private const ALLOWED_COMMANDS = ['generate', 'critique', 'revise']; /** * Call Python script */ private function callPython(string $command, int $entityId, array $args = []): array { // Validate command against whitelist if (!in_array($command, self::ALLOWED_COMMANDS, true)) { return ['error' => 'Ungültiger Command: ' . $command]; } $scriptPath = $this->pipelinePath . '/web_generate.py'; // Build command array for proc_open (safer than shell_exec) $cmdArray = [ $this->pythonPath, $scriptPath, $command, (string) $entityId, ...$args, ]; $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $process = proc_open($cmdArray, $descriptors, $pipes); // nosemgrep: exec-use if (!is_resource($process)) { return ['error' => 'Script konnte nicht gestartet werden']; } fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $exitCode = proc_close($process); $output = $stdout . $stderr; if ($exitCode !== 0 && $output === '') { return ['error' => 'Script fehlgeschlagen (Exit: ' . $exitCode . ')']; } if (preg_match('/\{[\s\S]*\}/', $output, $matches)) { $result = json_decode($matches[0], true); if (json_last_error() === JSON_ERROR_NONE) { return $result; } } return ['error' => 'Ungültige Antwort: ' . substr($output, 0, 500)]; } /** * Render version partial */ private function renderVersionPartial(array $result): void { $this->view('content.partials.version', [ 'content' => $result['content'] ?? '', 'sources' => $result['sources'] ?? [], 'versionNumber' => $result['version_number'] ?? '?', ]); } /** * Render critique partial */ private function renderCritiquePartial(array $result): void { $this->view('content.partials.critique', [ 'critiques' => $result['critiques'] ?? [], 'allPassed' => $result['all_passed'] ?? false, 'round' => $result['round'] ?? '?', ]); } /** * Get available collections from database * * @return array> */ private function getAvailableCollections(): array { $collections = $this->collectionRepository->getSearchable(); if ($collections === []) { return [ ['collection_id' => 'documents', 'display_name' => 'Dokumente', 'points_count' => 0, 'vector_size' => 1024], ]; } return $collections; } /** * Validate collections against available ones * * @param array $collections * @return array */ private function validateCollections(array $collections): array { $availableIds = array_column($this->getAvailableCollections(), 'collection_id'); $valid = array_filter($collections, fn ($c) => in_array($c, $availableIds, true)); return array_values($valid); } /** * Validate collection compatibility (vector dimensions) * * @param array $collectionIds * @return array{valid: bool, error: string|null} */ private function validateCollectionCompatibility(array $collectionIds): array { if (empty($collectionIds)) { return ['valid' => true, 'error' => null]; } $result = $this->collectionValidator->validateSelection($collectionIds); return [ 'valid' => $result->isValid(), 'error' => $result->getError(), ]; } }