getRepository(Document::class) ->createQueryBuilder('d') ->leftJoin('d.versions', 'v') ->addSelect('v') ->leftJoin('d.currentVersion', 'cv') ->addSelect('cv') ->orderBy('d.createdAt', 'DESC') ->getQuery() ->getResult(); return $this->render('admin/document/index.html.twig', [ 'documents' => $documents ]); } #[Route( '/{id}', name: 'admin_document_show', requirements: ['id' => '[0-9a-fA-F\-]{36}'] )] public function show(string $id, EntityManagerInterface $em): Response { try { $uuid = Uuid::fromString($id); } catch (\Exception $e) { throw new NotFoundHttpException(); } $document = $em->getRepository(Document::class)->find($uuid); if (!$document) { $this->addFlash('danger', 'Das Dokument existiert nicht mehr.'); } return $this->render('admin/document/show.html.twig', [ 'document' => $document ]); } #[Route('/new', name: 'admin_document_new')] public function new( Request $request, DocumentService $documentService, FormatText $formatText, IngestJobService $jobService, ParameterBagInterface $params ): Response { if ($request->isMethod('POST')) { /** @var UploadedFile|null $file */ $file = $request->files->get('file'); if (!$file instanceof UploadedFile) { throw new \InvalidArgumentException('No valid file uploaded.'); } $rawTitle = $request->request->get('title'); $title = is_string($rawTitle) && $rawTitle !== '' ? $rawTitle : $formatText->slugify($file->getClientOriginalName()); if (!$title) { $this->addFlash('error', 'Titel ist erforderlich.'); return $this->redirectToRoute('admin_document_new'); } $uploadDir = $params->get('mto.vector.data.upload.path'); if (!is_dir($uploadDir)) { mkdir($uploadDir, 0777, true); } $newFilename = uniqid() . '_' . $file->getClientOriginalName(); try { $file->move($uploadDir, $newFilename); } catch (FileException $e) { throw new \RuntimeException('File upload failed.'); } $filePath = $uploadDir . '/' . $newFilename; // Dokument erstellen $document = $documentService->createDocument( $title, $filePath, $this->getUser() ); // --------------------------------------------------------- // AUTO-INTEGRATION: gleicher Flow wie "Version aktivieren" // --------------------------------------------------------- $version = $document->getCurrentVersion(); $job = $jobService->startJob( IngestJob::TYPE_DOCUMENT_VERSION_ACTIVATE, $this->getUser(), $version->getDocument()->getId(), $version->getId(), null, IngestJob::STATUS_QUEUED ); $projectDir = (string)$this->getParameter('kernel.project_dir'); $console = $projectDir . '/bin/console'; $cmd = sprintf( '%s %s %s %s > /dev/null 2>&1 &', escapeshellarg($console), escapeshellarg('mto:agent:ingest:run'), escapeshellarg((string)$job->getId()), escapeshellarg('--no-interaction'), ); if (!function_exists('exec')) { $jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).'); $this->addFlash('danger', 'Dokument erstellt, aber Ingest konnte nicht asynchron gestartet werden.'); return $this->redirectToRoute('admin_documents'); } exec($cmd); return $this->redirectToRoute('admin_job_show', [ 'id' => (string)$job->getId(), ]); } return $this->render('admin/document/new.html.twig'); } #[Route('/{id}/version/new', name: 'admin_document_version_new', requirements: ['id' => '[0-9a-fA-F\-]{36}'])] public function newVersion( string $id, Request $request, EntityManagerInterface $em, DocumentService $documentService, ParameterBagInterface $params ): Response { $document = $em->getRepository(Document::class)->find($id); if (!$document) { throw $this->createNotFoundException(); } if ($request->isMethod('POST')) { $file = $request->files->get('file'); if (!$file) { $this->addFlash('error', 'Datei ist erforderlich.'); return $this->redirectToRoute('admin_document_version_new', ['id' => $id]); } $uploadDir = $params->get('mto.vector.data.upload.path'); if (!is_dir($uploadDir)) { mkdir($uploadDir, 0777, true); } $newFilename = uniqid() . '_' . $file->getClientOriginalName(); try { $file->move($uploadDir, $newFilename); } catch (FileException $e) { throw new \RuntimeException('File upload failed.'); } $filePath = $uploadDir . '/' . $newFilename; $documentService->addVersion( $document, $filePath, $this->getUser() ); return $this->redirectToRoute('admin_document_show', ['id' => $id]); } return $this->render('admin/document/new_version.html.twig', [ 'document' => $document ]); } #[Route( '/version/{versionId}/activate', name: 'admin_document_version_activate', requirements: ['versionId' => '[0-9a-fA-F\-]{36}'], methods: ['POST'] )] public function activateVersion( string $versionId, Request $request, EntityManagerInterface $em, DocumentService $documentService, IngestJobService $jobService, ): RedirectResponse { if (!$this->isCsrfTokenValid('activate_version', $request->request->get('_token'))) { throw $this->createAccessDeniedException(); } $version = $em->getRepository(DocumentVersion::class)->find($versionId); if (!$version) { throw $this->createNotFoundException(); } try { $documentService->activateVersion($version); // --------------------------------------------------------- // Saubere IngestJob-Integration: // 1) Job als QUEUED anlegen (spezieller Typ für Aktivierung) // 2) Symfony-Command im Hintergrund starten // 3) Direkt auf Job-Detailseite redirecten (Loader + Polling) // --------------------------------------------------------- $job = $jobService->startJob( IngestJob::TYPE_DOCUMENT_VERSION_ACTIVATE, $this->getUser(), $version->getDocument()->getId(), $version->getId(), null, IngestJob::STATUS_QUEUED ); // Hintergrundprozess starten (Provider-kompatibel, kein Worker/Daemon) $projectDir = (string)$this->getParameter('kernel.project_dir'); $console = $projectDir . '/bin/console'; $cmd = sprintf( '%s %s %s %s > /dev/null 2>&1 &', escapeshellarg($console), escapeshellarg('mto:agent:ingest:run'), escapeshellarg((string)$job->getId()), escapeshellarg('--no-interaction'), ); // Best effort: wenn exec deaktiviert ist, sauber abbrechen. if (!function_exists('exec')) { $jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).'); $this->addFlash('danger', 'Aktivierung ok, aber Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).'); return $this->redirectToRoute('admin_document_show', [ 'id' => $version->getDocument()->getId() ]); } exec($cmd); $this->addFlash('success', 'Version aktiviert. Ingest-Job wurde erstellt und gestartet.'); return $this->redirectToRoute('admin_job_show', [ 'id' => (string)$job->getId(), ]); } catch (\Throwable $e) { $this->addFlash('danger', 'Aktivierung/Re-Ingest fehlgeschlagen: ' . $e->getMessage()); } return $this->redirectToRoute('admin_document_show', [ 'id' => $version->getDocument()->getId() ]); } #[Route( '/version/{versionId}/ingest', name: 'admin_document_version_ingest', requirements: ['versionId' => '[0-9a-fA-F\-]{36}'], methods: ['POST'] )] public function ingestVersion( string $versionId, Request $request, EntityManagerInterface $em, IngestJobService $jobService, ): ?RedirectResponse { $dryRun = false; if (!$this->isCsrfTokenValid('ingest_version', $request->request->get('_token'))) { throw $this->createAccessDeniedException(); } $version = $em->getRepository(DocumentVersion::class)->find($versionId); if (!$version) { throw $this->createNotFoundException(); } $existing = $em->getRepository(IngestJob::class) ->findOneBy( ['documentVersionId' => $version->getId()], ['startedAt' => 'DESC'] ); if ($existing && $existing->getStartedAt() > new \DateTimeImmutable('-3 seconds')) { return null; } // --------------------------------------------------------- // Asynchroner Ingest (ohne Messenger): // 1) Job als QUEUED anlegen // 2) Symfony-Command im Hintergrund starten // 3) Direkt auf Job-Detailseite redirecten (Loader + Polling) // --------------------------------------------------------- $job = $jobService->startJob( IngestJob::TYPE_DOCUMENT, $this->getUser(), $version->getDocument()->getId(), $version->getId(), null, IngestJob::STATUS_QUEUED ); // Hintergrundprozess starten (Provider-kompatibel, kein Worker/Daemon) $projectDir = (string)$this->getParameter('kernel.project_dir'); $console = $projectDir . '/bin/console'; $cmd = sprintf( '%s %s %s %s > /dev/null 2>&1 &', escapeshellarg($console), escapeshellarg('mto:agent:ingest:run'), escapeshellarg((string)$job->getId()), escapeshellarg('--no-interaction'), ); // Best effort: wenn exec deaktiviert ist, sauber abbrechen. if (!function_exists('exec')) { $jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).'); $this->addFlash('error', 'Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).'); return $this->redirectToRoute('admin_document_show', [ 'id' => $version->getDocument()->getId() ]); } exec($cmd); return $this->redirectToRoute('admin_job_show', [ 'id' => (string)$job->getId(), ]); } #[Route( '/reset', name: 'admin_document_reset', methods: ['POST'] )] public function resetCompleteSystem(ParameterBagInterface $params, Connection $connection): ?RedirectResponse { if (!function_exists('exec')) { $this->addFlash('danger', 'Der Reset konnte nicht gestartet werden (exec deaktiviert).'); return $this->redirectToRoute('admin_dashboard'); } @unlink($params->get('mto.knowledge.ndjson')); @unlink($params->get('mto.knowledge.vector_index')); @unlink($params->get('mto.knowledge.vector_index_meta')); @unlink($params->get('mto.knowledge.index_meta')); @unlink($params->get('mto.runtime.meta')); exec('rm -rf ' . $params->get('mto.knowledge.upload')); $sql = ' SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE db.document; SET FOREIGN_KEY_CHECKS = 1; SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE db.document_version; SET FOREIGN_KEY_CHECKS = 1; SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE db.ingest_job; SET FOREIGN_KEY_CHECKS = 1; '; $connection->executeQuery($sql); $this->addFlash('success', 'Das System wurde erfolgreich zurückgesetzt.'); return $this->redirectToRoute('admin_dashboard'); } #[Route( '/{id}/delete', name: 'admin_document_delete', requirements: ['id' => '[0-9a-fA-F\-]{36}'], methods: ['POST'] )] public function deleteDocument( string $id, Request $request, EntityManagerInterface $em, IngestJobService $jobService, LockService $lockService, ): RedirectResponse { if (!$this->isCsrfTokenValid('delete_document', $request->request->get('_token'))) { throw $this->createAccessDeniedException(); } try { $uuid = Uuid::fromString($id); } catch (\Exception $e) { throw $this->createNotFoundException(); } $document = $em->getRepository(Document::class)->find($uuid); if (!$document) { throw $this->createNotFoundException(); } // --------------------------------------------------------- // 🔒 Delete nur erlauben wenn kein anderer Job läuft // --------------------------------------------------------- if (!$lockService->acquire()) { $this->addFlash('danger', 'Ein Ingest-Job läuft bereits. Löschen derzeit nicht möglich.'); return $this->redirectToRoute('admin_documents'); } // Nur Test-Lock – echter Lock im Orchestrator $lockService->release(); // --------------------------------------------------------- // 1) Delete-Job anlegen (QUEUED) // --------------------------------------------------------- $job = $jobService->startJob( IngestJob::TYPE_DOCUMENT_DELETE, $this->getUser(), $document->getId(), null, null, IngestJob::STATUS_QUEUED ); // --------------------------------------------------------- // 2) Hintergrundprozess starten // --------------------------------------------------------- $projectDir = (string)$this->getParameter('kernel.project_dir'); $console = $projectDir . '/bin/console'; $cmd = sprintf( '%s %s %s %s > /dev/null 2>&1 &', escapeshellarg($console), escapeshellarg('mto:agent:ingest:run'), escapeshellarg((string)$job->getId()), escapeshellarg('--no-interaction'), ); if (!function_exists('exec')) { $jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).'); $this->addFlash('danger', 'Löschen konnte nicht gestartet werden (exec deaktiviert).'); return $this->redirectToRoute('admin_documents'); } exec($cmd); $this->addFlash('success', 'Löschvorgang gestartet. Dokument wird nach Index-Rebuild entfernt.'); return $this->redirectToRoute('admin_job_show', [ 'id' => (string)$job->getId(), ]); } }