SuperSeller3000/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php

200 lines
8 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Pipeline\AIPipelineJob;
use App\Domain\Pipeline\AIPipelineJobStatus;
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\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<AIPipelineJob> */
final class AIPipelineJobCrudController extends AbstractCrudController
{
public function __construct(
private readonly MessageBusInterface $bus,
private readonly EntityManagerInterface $em,
) {
}
public static function getEntityFqcn(): string
{
return AIPipelineJob::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Pipeline Job')
->setEntityLabelInPlural('AI Pipeline — Active')
->setDefaultSort(['createdAt' => 'DESC'])
->showEntityActionsInlined();
}
public function configureActions(Actions $actions): Actions
{
$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')->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);
}
}