*/ final class AIPipelineJobCrudController extends AbstractCrudController { public function __construct( private readonly MessageBusInterface $bus, private readonly EntityManagerInterface $em, private readonly TranslatorInterface $translator, ) { } public static function getEntityFqcn(): string { return AIPipelineJob::class; } public function configureCrud(Crud $crud): Crud { return $crud ->setEntityLabelInSingular(new TranslatableMessage('crud.pipeline_job.singular', [], 'admin')) ->setEntityLabelInPlural(new TranslatableMessage('crud.pipeline_job.plural_active', [], 'admin')) ->setDefaultSort(['createdAt' => 'DESC']) ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { $retryAction = Action::new('retry', new TranslatableMessage('action.retry', [], 'admin'), 'fa fa-rotate-right') ->linkToCrudAction('retryJob') ->setCssClass('btn btn-sm btn-warning') ->askConfirmation(new TranslatableMessage('action.retry_confirm', [], 'admin')) ->displayIf(static fn (AIPipelineJob $job) => \in_array( $job->getStatus(), [AIPipelineJobStatus::NeedsReview, AIPipelineJobStatus::Failed, AIPipelineJobStatus::Processing], true, )); return $actions ->add(Crud::PAGE_INDEX, $retryAction) ->add(Crud::PAGE_DETAIL, $retryAction) ->add(Crud::PAGE_INDEX, Action::DETAIL) ->disable(Action::NEW, Action::EDIT, Action::DELETE); } public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnIndex(); yield TextField::new('inventoryNumber', new TranslatableMessage('field.inventory_number', [], 'admin')) ->hideOnForm() ->formatValue(static fn ($v, AIPipelineJob $job): string => (string) ($job->getInputData()['inventoryNumber'] ?? '—')); yield TextField::new('statusLabel', new TranslatableMessage('field.status', [], 'admin')) ->hideOnForm() ->setCssClass('fw-bold'); yield TextField::new('currentStep', new TranslatableMessage('field.step', [], 'admin')) ->hideOnForm() ->formatValue(static fn ($v, AIPipelineJob $job): string => $job->getCurrentStep() ?? '—'); yield IntegerField::new('attemptCount', new TranslatableMessage('field.attempts', [], 'admin'))->hideOnForm(); yield DateTimeField::new('createdAt', new TranslatableMessage('field.started', [], 'admin'))->hideOnForm(); yield TextField::new('errorMessage', new TranslatableMessage('field.error', [], 'admin')) ->hideOnIndex() ->hideOnForm() ->formatValue(static fn ($v, AIPipelineJob $job): string => $job->getErrorMessage() ?? ''); yield TextareaField::new('aiResults', new TranslatableMessage('field.ai_results', [], 'admin')) ->onlyOnDetail() ->formatValue(static fn ($v, AIPipelineJob $job): string => self::formatStepResults($job)); } public function createIndexQueryBuilder( SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters, ): QueryBuilder { $qb = $this->container->get(EntityRepository::class)->createQueryBuilder($searchDto, $entityDto, $fields, $filters); $qb->andWhere('entity.status != :completed') ->setParameter('completed', AIPipelineJobStatus::Completed); return $qb; } #[AdminRoute('/retry', name: 'retryJob')] public function retryJob(AdminContext $context): Response { /** @var AIPipelineJob $job */ $job = $context->getEntity()->getInstance(); $output = $job->getOutputData(); $input = $job->getInputData(); $job->resetForRetry(); $this->em->flush(); $jobId = $job->getId()->toRfc4122(); $articleTypeId = (string) ($input['articleTypeId'] ?? ''); // Dispatch from the furthest step that has complete data, working backwards if (isset($output['json_coding']['attributes'], $output['specs_research']['specsText'])) { $this->bus->dispatch(new ValidationMessage( jobId: $jobId, articleTypeId: $articleTypeId, specsText: (string) $output['specs_research']['specsText'], attributes: (array) $output['json_coding']['attributes'], serialNumber: isset($output['vision']['serial']) ? (string) $output['vision']['serial'] : null, )); $step = 'validation'; } elseif (isset($output['specs_research']['specsText'])) { $this->bus->dispatch(new JsonCodingMessage( jobId: $jobId, articleTypeId: $articleTypeId, specsText: (string) $output['specs_research']['specsText'], serialNumber: isset($output['vision']['serial']) ? (string) $output['vision']['serial'] : null, )); $step = 'json coding'; } elseif (isset($output['vision']['model']) && '' !== $output['vision']['model']) { $this->bus->dispatch(new SpecsResearchMessage( jobId: $jobId, articleTypeId: $articleTypeId, modelName: (string) $output['vision']['model'], serialNumber: (string) ($output['vision']['serial'] ?? ''), )); $step = 'specs research'; } else { $this->bus->dispatch(new PhotoUploadMessage( jobId: $jobId, articleTypeId: $articleTypeId, storedPhotoPath: (string) ($input['storedPhotoPath'] ?? $input['filePath'] ?? ''), originalFilename: (string) ($input['originalFilename'] ?? ''), )); $step = 'vision'; } $this->addFlash('success', $this->translator->trans('flash.job_requeued', ['%id%' => substr($jobId, 0, 8), '%step%' => $step], 'admin')); return $this->redirect($context->getRequest()->headers->get('referer', $this->generateUrl('easyadmin'))); } private static function formatStepResults(AIPipelineJob $job): string { $output = $job->getOutputData(); if ([] === $output) { return '(none yet)'; } $lines = []; $labels = [ 'vision' => 'Vision', 'specs_research' => 'Specs Research', 'json_coding' => 'JSON Coding', 'validation' => 'Validation', ]; foreach ($labels as $key => $label) { if (!isset($output[$key])) { continue; } $data = $output[$key]; $lines[] = "=== {$label} ==="; foreach ($data as $k => $v) { if (\is_array($v)) { $lines[] = "{$k}: ".json_encode($v, \JSON_UNESCAPED_UNICODE | \JSON_PRETTY_PRINT); } else { $lines[] = "{$k}: {$v}"; } } $lines[] = ''; } return implode("\n", $lines); } }