498 lines
17 KiB
PHP
498 lines
17 KiB
PHP
<?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(),
|
||
]);
|
||
}
|
||
}
|