Files
MtoRagSystem/src/Controller/Admin/DocumentController.php

498 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controller\Admin;
use App\Entity\Document;
use App\Entity\DocumentVersion;
use App\Entity\IngestJob;
use App\Service\DocumentService;
use App\Service\FormatText;
use App\Service\IngestJobService;
use App\Service\LockService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
use function function_exists;
#[Route('/admin/documents')]
class DocumentController extends AbstractController
{
#[Route('', name: 'admin_documents')]
public function index(EntityManagerInterface $em): Response
{
$documents = $em->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(),
]);
}
}