feat: enhance pipeline job admin with retry action and step detail view
- Retry button on index and detail pages for failed/needs-review/processing jobs - Shows inventory number, current step, attempt count, created-at on index - Detail page renders full AI step output (vision, specs, attributes) - Filters active jobs by non-completed status via custom query builder Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6d8a06f151
commit
740c9a4e08
1 changed files with 164 additions and 18 deletions
|
|
@ -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<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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue