2026-05-17 22:44:03 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Infrastructure\Http\Controller\Admin;
|
|
|
|
|
|
|
|
|
|
use App\Domain\Pipeline\AIPipelineJob;
|
|
|
|
|
use App\Domain\Pipeline\AIPipelineJobStatus;
|
2026-05-18 07:18:33 +00:00
|
|
|
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;
|
2026-05-17 22:44:03 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
2026-05-18 07:18:33 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
|
2026-05-17 22:44:03 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
2026-05-18 07:18:33 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
|
2026-05-17 22:44:03 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
|
2026-05-18 07:18:33 +00:00
|
|
|
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;
|
2026-05-17 22:44:03 +00:00
|
|
|
|
|
|
|
|
/** @extends AbstractCrudController<AIPipelineJob> */
|
|
|
|
|
final class AIPipelineJobCrudController extends AbstractCrudController
|
|
|
|
|
{
|
2026-05-18 07:18:33 +00:00
|
|
|
public function __construct(
|
|
|
|
|
private readonly MessageBusInterface $bus,
|
|
|
|
|
private readonly EntityManagerInterface $em,
|
|
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 22:44:03 +00:00
|
|
|
public static function getEntityFqcn(): string
|
|
|
|
|
{
|
|
|
|
|
return AIPipelineJob::class;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function configureCrud(Crud $crud): Crud
|
|
|
|
|
{
|
2026-05-18 07:18:33 +00:00
|
|
|
return $crud
|
|
|
|
|
->setEntityLabelInSingular('Pipeline Job')
|
|
|
|
|
->setEntityLabelInPlural('AI Pipeline — Active')
|
|
|
|
|
->setDefaultSort(['createdAt' => 'DESC'])
|
|
|
|
|
->showEntityActionsInlined();
|
2026-05-17 22:44:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function configureActions(Actions $actions): Actions
|
|
|
|
|
{
|
2026-05-18 07:18:33 +00:00
|
|
|
$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);
|
2026-05-17 22:44:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function configureFields(string $pageName): iterable
|
|
|
|
|
{
|
2026-05-18 07:18:33 +00:00
|
|
|
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);
|
2026-05-17 22:44:03 +00:00
|
|
|
}
|
|
|
|
|
}
|