diff --git a/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php b/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php index c48de09..142a91b 100644 --- a/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php @@ -6,18 +6,40 @@ namespace App\Infrastructure\Http\Controller\Admin; use App\Domain\Pipeline\AIPipelineJob; use App\Domain\Pipeline\AIPipelineJobStatus; -use App\Domain\Pipeline\AIPipelineJobType; +use App\Infrastructure\Messenger\Message\JsonCodingMessage; +use App\Infrastructure\Messenger\Message\PhotoUploadMessage; +use App\Infrastructure\Messenger\Message\SpecsResearchMessage; +use App\Infrastructure\Messenger\Message\ValidationMessage; +use Doctrine\ORM\QueryBuilder; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; +use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; +use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; -use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; +use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; +use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; +use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; +use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; +use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; +use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\MessageBusInterface; /** @extends AbstractCrudController */ final class AIPipelineJobCrudController extends AbstractCrudController { + public function __construct( + private readonly MessageBusInterface $bus, + private readonly EntityManagerInterface $em, + ) { + } + public static function getEntityFqcn(): string { return AIPipelineJob::class; @@ -25,29 +47,153 @@ final class AIPipelineJobCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { - return $crud->setEntityLabelInSingular('AI Pipeline Job')->setEntityLabelInPlural('AI Pipeline Jobs'); + return $crud + ->setEntityLabelInSingular('Pipeline Job') + ->setEntityLabelInPlural('AI Pipeline — Active') + ->setDefaultSort(['createdAt' => 'DESC']) + ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { - return $actions->disable(Action::NEW, Action::EDIT, Action::DELETE); + $retryAction = Action::new('retry', 'Retry', 'fa fa-rotate-right') + ->linkToCrudAction('retryJob') + ->setCssClass('btn btn-sm btn-warning') + ->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')->hideOnForm(); - yield ChoiceField::new('type')->setChoices( - array_combine( - array_map(static fn ($t) => $t->value, AIPipelineJobType::cases()), - AIPipelineJobType::cases(), - ) - ); - yield ChoiceField::new('status')->setChoices( - array_combine( - array_map(static fn ($s) => $s->value, AIPipelineJobStatus::cases()), - AIPipelineJobStatus::cases(), - ) - ); - yield IntegerField::new('attemptCount', 'Attempts'); + yield IdField::new('id')->hideOnIndex(); + yield TextField::new('inventoryNumber', 'Inventory #') + ->hideOnForm() + ->formatValue(static fn ($v, AIPipelineJob $job): string => (string) ($job->getInputData()['inventoryNumber'] ?? '—')); + yield TextField::new('statusLabel', 'Status') + ->hideOnForm() + ->setCssClass('fw-bold'); + yield TextField::new('currentStep', 'Step') + ->hideOnForm() + ->formatValue(static fn ($v, AIPipelineJob $job): string => $job->getCurrentStep() ?? '—'); + yield IntegerField::new('attemptCount', 'Attempts')->hideOnForm(); + yield DateTimeField::new('createdAt', 'Started')->hideOnForm(); + yield TextField::new('errorMessage', 'Error') + ->hideOnIndex() + ->hideOnForm() + ->formatValue(static fn ($v, AIPipelineJob $job): string => $job->getErrorMessage() ?? ''); + yield TextareaField::new('aiResults', 'AI Results') + ->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', sprintf('Job %s re-queued from %s.', substr($jobId, 0, 8), $step)); + + 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); } }