feat: add manual ingest form, AI status page and pipeline archive
- ManualIngestController: photo upload form that starts a new pipeline job - AiStatusController: shows active backend config and runs live connectivity tests - PipelineArchiveCrudController: read-only view of completed/failed jobs - ManualIngestType / AttributeValueFormType: form types for ingest and attribute editing - AiConfigService: encapsulates backend info and test methods for the status page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
740c9a4e08
commit
020a5ddbc8
7 changed files with 664 additions and 0 deletions
145
src/Infrastructure/AI/AiConfigService.php
Normal file
145
src/Infrastructure/AI/AiConfigService.php
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\AI;
|
||||||
|
|
||||||
|
final class AiConfigService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OllamaClientInterface $activeClient,
|
||||||
|
private readonly MistralClient $mistralClient,
|
||||||
|
private readonly string $visionModel,
|
||||||
|
private readonly string $textModel,
|
||||||
|
private readonly string $ollamaBaseUrl,
|
||||||
|
private readonly string $mistralBaseUrl,
|
||||||
|
private readonly string $mistralApiKey,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* backend: string,
|
||||||
|
* vision_model: string,
|
||||||
|
* text_model: string,
|
||||||
|
* ollama_base_url: string,
|
||||||
|
* mistral_base_url: string,
|
||||||
|
* mistral_key_set: bool,
|
||||||
|
* mistral_key_hint: string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function getConfig(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'backend' => $this->activeClient instanceof MistralClient ? 'Mistral' : 'Ollama',
|
||||||
|
'vision_model' => $this->visionModel,
|
||||||
|
'text_model' => $this->textModel,
|
||||||
|
'ollama_base_url' => $this->ollamaBaseUrl,
|
||||||
|
'mistral_base_url' => $this->mistralBaseUrl,
|
||||||
|
'mistral_key_set' => '' !== $this->mistralApiKey,
|
||||||
|
'mistral_key_hint' => '' !== $this->mistralApiKey
|
||||||
|
? substr($this->mistralApiKey, 0, 8).'...'
|
||||||
|
: '(not set)',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the active backend — both text and vision models.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* backend: string,
|
||||||
|
* text: array{ok: bool, model: string, ms?: int, response?: string, error?: string},
|
||||||
|
* vision: array{ok: bool, model: string, ms?: int, response?: string, error?: string},
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function testActive(): array
|
||||||
|
{
|
||||||
|
$backend = $this->activeClient instanceof MistralClient ? 'Mistral' : 'Ollama';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'backend' => $backend,
|
||||||
|
'text' => $this->testText($this->activeClient, $this->textModel),
|
||||||
|
'vision' => $this->testVisionWithPlaceholder($this->activeClient, $this->visionModel),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always tests Mistral directly, regardless of active backend.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* backend: string,
|
||||||
|
* text: array{ok: bool, model: string, ms?: int, response?: string, error?: string},
|
||||||
|
* vision: array{ok: bool, model: string, ms?: int, response?: string, error?: string},
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function testMistral(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'backend' => 'Mistral',
|
||||||
|
'text' => $this->testText($this->mistralClient, $this->textModel),
|
||||||
|
'vision' => $this->testVisionWithPlaceholder($this->mistralClient, $this->visionModel),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeTinyPng(): string
|
||||||
|
{
|
||||||
|
// 1×1 white RGB PNG built from raw bytes — no GD required
|
||||||
|
$chunk = static function (string $type, string $data): string {
|
||||||
|
return pack('N', \strlen($data)).$type.$data.pack('N', crc32($type.$data) & 0xFFFFFFFF);
|
||||||
|
};
|
||||||
|
|
||||||
|
$ihdr = pack('N', 1).pack('N', 1)."\x08\x02\x00\x00\x00"; // 1×1, 8-bit RGB
|
||||||
|
$idat = (string) gzcompress("\x00\xff\xff\xff", 9); // filter(none) + white pixel
|
||||||
|
|
||||||
|
return "\x89PNG\r\n\x1a\n".$chunk('IHDR', $ihdr).$chunk('IDAT', $idat).$chunk('IEND', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractApiError(\Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface $e): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$body = $e->getResponse()->getContent(false);
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
|
||||||
|
return $decoded['message'] ?? $decoded['error']['message'] ?? $body ?: $e->getMessage();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok: bool, model: string, ms?: int, response?: string, error?: string} */
|
||||||
|
private function testText(OllamaClientInterface $client, string $model): array
|
||||||
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
try {
|
||||||
|
$response = $client->generate($model, 'Reply with exactly one word: ok');
|
||||||
|
$ms = (int) ((microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
|
return ['ok' => true, 'model' => $model, 'ms' => $ms, 'response' => trim(substr($response, 0, 120))];
|
||||||
|
} catch (\Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface $e) {
|
||||||
|
return ['ok' => false, 'model' => $model, 'error' => $this->extractApiError($e)];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ok' => false, 'model' => $model, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok: bool, model: string, ms?: int, response?: string, error?: string} */
|
||||||
|
private function testVisionWithPlaceholder(OllamaClientInterface $client, string $model): array
|
||||||
|
{
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'ai_test_').'.png';
|
||||||
|
file_put_contents($tmpFile, $this->makeTinyPng());
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
try {
|
||||||
|
$response = $client->generateWithImage($model, 'Describe this image in one word.', $tmpFile);
|
||||||
|
$ms = (int) ((microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
|
return ['ok' => true, 'model' => $model, 'ms' => $ms, 'response' => trim(substr($response, 0, 120))];
|
||||||
|
} catch (\Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface $e) {
|
||||||
|
return ['ok' => false, 'model' => $model, 'error' => $this->extractApiError($e)];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ok' => false, 'model' => $model, 'error' => $e->getMessage()];
|
||||||
|
} finally {
|
||||||
|
@unlink($tmpFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Infrastructure\AI\AiConfigService;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[Route('/admin/ai/test', name: 'admin_ai_test', methods: ['POST'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
final class AiStatusController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AiConfigService $aiConfig)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$target = $request->request->get('target', 'mistral');
|
||||||
|
|
||||||
|
$result = match ($target) {
|
||||||
|
'active' => $this->aiConfig->testActive(),
|
||||||
|
default => $this->aiConfig->testMistral(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new JsonResponse($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Application\Article\ArticleService;
|
||||||
|
use App\Application\Article\PhotoService;
|
||||||
|
use App\Domain\Article\ArticleType;
|
||||||
|
use App\Domain\Pipeline\AIPipelineJob;
|
||||||
|
use App\Domain\Pipeline\AIPipelineJobType;
|
||||||
|
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
|
||||||
|
use App\Infrastructure\AI\AiConfigService;
|
||||||
|
use App\Infrastructure\Http\Form\ManualIngestType;
|
||||||
|
use App\Infrastructure\Messenger\Message\PhotoUploadMessage;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[Route('/admin/ingest', name: 'admin_manual_ingest')]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
final class ManualIngestController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ArticleService $articleService,
|
||||||
|
private readonly PhotoService $photoService,
|
||||||
|
private readonly AIPipelineJobRepositoryInterface $jobRepository,
|
||||||
|
private readonly MessageBusInterface $bus,
|
||||||
|
private readonly AiConfigService $aiConfig,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(ManualIngestType::class);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
$catalogNumber = null;
|
||||||
|
$jobId = null;
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
/** @var UploadedFile $image */
|
||||||
|
$image = $form->get('image')->getData();
|
||||||
|
/** @var ArticleType $articleType */
|
||||||
|
$articleType = $form->get('articleType')->getData();
|
||||||
|
|
||||||
|
$stored = $this->photoService->uploadRaw(
|
||||||
|
$image->getRealPath(),
|
||||||
|
$image->getClientOriginalName(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$catalogNumber = $this->articleService->reserveInventoryNumber();
|
||||||
|
|
||||||
|
$storedPath = $stored->storagePath->resolveFilePath($stored->filename);
|
||||||
|
|
||||||
|
$job = new AIPipelineJob(AIPipelineJobType::Photo, [
|
||||||
|
'inventoryNumber' => $catalogNumber,
|
||||||
|
'articleTypeId' => $articleType->getId()->toRfc4122(),
|
||||||
|
'condition' => $form->get('condition')->getData()->value,
|
||||||
|
'conditionNotes' => $form->get('conditionNotes')->getData(),
|
||||||
|
'originalFilename' => $image->getClientOriginalName(),
|
||||||
|
'storedPhotoPath' => $storedPath,
|
||||||
|
]);
|
||||||
|
$this->jobRepository->save($job);
|
||||||
|
|
||||||
|
$this->bus->dispatch(new PhotoUploadMessage(
|
||||||
|
jobId: $job->getId()->toRfc4122(),
|
||||||
|
articleTypeId: $articleType->getId()->toRfc4122(),
|
||||||
|
storedPhotoPath: $storedPath,
|
||||||
|
originalFilename: $image->getClientOriginalName(),
|
||||||
|
));
|
||||||
|
|
||||||
|
$jobId = $job->getId()->toRfc4122();
|
||||||
|
|
||||||
|
// Reset form for the next item
|
||||||
|
$form = $this->createForm(ManualIngestType::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/manual_ingest.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
'catalogNumber' => $catalogNumber,
|
||||||
|
'jobId' => $jobId,
|
||||||
|
'aiConfig' => $this->aiConfig->getConfig(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Domain\Pipeline\AIPipelineJob;
|
||||||
|
use App\Domain\Pipeline\AIPipelineJobStatus;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** @extends AbstractCrudController<AIPipelineJob> */
|
||||||
|
final class PipelineArchiveCrudController extends AbstractCrudController
|
||||||
|
{
|
||||||
|
public static function getEntityFqcn(): string
|
||||||
|
{
|
||||||
|
return AIPipelineJob::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureCrud(Crud $crud): Crud
|
||||||
|
{
|
||||||
|
return $crud
|
||||||
|
->setEntityLabelInSingular('Pipeline Job')
|
||||||
|
->setEntityLabelInPlural('AI Pipeline — Archive')
|
||||||
|
->setDefaultSort(['completedAt' => 'DESC'])
|
||||||
|
->showEntityActionsInlined();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureActions(Actions $actions): Actions
|
||||||
|
{
|
||||||
|
return $actions
|
||||||
|
->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();
|
||||||
|
yield IntegerField::new('attemptCount', 'Attempts')->hideOnForm();
|
||||||
|
yield DateTimeField::new('createdAt', 'Started')->hideOnForm();
|
||||||
|
yield DateTimeField::new('completedAt', 'Completed')->hideOnForm();
|
||||||
|
yield TextField::new('articleId', 'Article')
|
||||||
|
->hideOnForm()
|
||||||
|
->formatValue(static fn ($v, AIPipelineJob $job): string => $job->getArticleId()?->toRfc4122() ?? '—');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatStepResults(AIPipelineJob $job): string
|
||||||
|
{
|
||||||
|
$output = $job->getOutputData();
|
||||||
|
if ([] === $output) {
|
||||||
|
return '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/Infrastructure/Http/Form/AttributeValueFormType.php
Normal file
39
src/Infrastructure/Http/Form/AttributeValueFormType.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Form;
|
||||||
|
|
||||||
|
use App\Domain\Article\AttributeValue;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\Form\FormEvent;
|
||||||
|
use Symfony\Component\Form\FormEvents;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
final class AttributeValueFormType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder->addEventListener(FormEvents::PRE_SET_DATA, static function (FormEvent $event): void {
|
||||||
|
$av = $event->getData();
|
||||||
|
if (!$av instanceof AttributeValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$def = $av->getAttributeDefinition();
|
||||||
|
$label = $def->getName().($def->getUnit() ? ' ('.$def->getUnit().')' : '');
|
||||||
|
|
||||||
|
$event->getForm()->add('value', TextType::class, [
|
||||||
|
'label' => $label,
|
||||||
|
'required' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults(['data_class' => AttributeValue::class]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Infrastructure/Http/Form/ManualIngestType.php
Normal file
56
src/Infrastructure/Http/Form/ManualIngestType.php
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Form;
|
||||||
|
|
||||||
|
use App\Domain\Article\ArticleCondition;
|
||||||
|
use App\Domain\Article\ArticleType;
|
||||||
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\Image;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotNull;
|
||||||
|
|
||||||
|
final class ManualIngestType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('articleType', EntityType::class, [
|
||||||
|
'class' => ArticleType::class,
|
||||||
|
'choice_label' => 'name',
|
||||||
|
'label' => 'Article Type',
|
||||||
|
'placeholder' => '— select type —',
|
||||||
|
'attr' => ['data-ea-widget' => 'ea-autocomplete'],
|
||||||
|
])
|
||||||
|
->add('condition', EnumType::class, [
|
||||||
|
'class' => ArticleCondition::class,
|
||||||
|
'label' => 'Condition',
|
||||||
|
])
|
||||||
|
->add('image', FileType::class, [
|
||||||
|
'label' => 'Nameplate / Label Photo',
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => true,
|
||||||
|
'constraints' => [
|
||||||
|
new NotNull(message: 'Please upload an image.'),
|
||||||
|
new Image(maxSize: '10M'),
|
||||||
|
],
|
||||||
|
'attr' => ['accept' => 'image/*', 'capture' => 'environment'],
|
||||||
|
])
|
||||||
|
->add('conditionNotes', TextareaType::class, [
|
||||||
|
'label' => 'Condition Notes',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => ['rows' => 3, 'placeholder' => 'Optional — describe any damage or defects'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults(['data_class' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
templates/admin/manual_ingest.html.twig
Normal file
187
templates/admin/manual_ingest.html.twig
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
{% extends '@EasyAdmin/page/content.html.twig' %}
|
||||||
|
|
||||||
|
{% block page_title %}Ingest Article{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
|
||||||
|
{% if catalogNumber %}
|
||||||
|
<div class="alert alert-success d-flex align-items-center gap-3 mb-4" role="alert">
|
||||||
|
<i class="fa fa-check-circle fa-2x"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Queued — catalog number:</strong>
|
||||||
|
<span class="fs-4 ms-2 font-monospace fw-bold">{{ catalogNumber }}</span>
|
||||||
|
<div class="small mt-1">
|
||||||
|
Pipeline running.
|
||||||
|
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\AIPipelineJobCrudController').setAction('index').generateUrl() }}">Check job status →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
{# ── Left: ingest form ─────────────────────────────────── #}
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0"><i class="fa fa-camera me-2"></i>Scan Nameplate</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_start(form, {'attr': {'enctype': 'multipart/form-data', 'novalidate': 'novalidate'}}) }}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.articleType) }}
|
||||||
|
{{ form_widget(form.articleType) }}
|
||||||
|
{{ form_errors(form.articleType) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.condition) }}
|
||||||
|
{{ form_widget(form.condition) }}
|
||||||
|
{{ form_errors(form.condition) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.image) }}
|
||||||
|
<div id="image-preview-wrapper" class="mb-2" style="display:none">
|
||||||
|
<img id="image-preview" src="" alt="Preview" class="img-thumbnail" style="max-height:220px">
|
||||||
|
</div>
|
||||||
|
{{ form_widget(form.image, {'attr': {'class': 'form-control', 'id': 'ingest-image-input'}}) }}
|
||||||
|
{{ form_errors(form.image) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.conditionNotes) }}
|
||||||
|
{{ form_widget(form.conditionNotes, {'attr': {'class': 'form-control'}}) }}
|
||||||
|
{{ form_errors(form.conditionNotes) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||||
|
<i class="fa fa-rocket me-2"></i>Submit to AI Pipeline
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Right: AI config panel ────────────────────────────── #}
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0"><i class="fa fa-robot me-2"></i>AI Configuration</h5>
|
||||||
|
<span class="badge bg-{{ aiConfig.backend == 'Mistral' ? 'primary' : 'secondary' }}">
|
||||||
|
{{ aiConfig.backend }} active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3 text-muted fw-normal" style="width:45%">Vision model</th>
|
||||||
|
<td class="font-monospace">{{ aiConfig.vision_model }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3 text-muted fw-normal">Text model</th>
|
||||||
|
<td class="font-monospace">{{ aiConfig.text_model }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3 text-muted fw-normal">Mistral endpoint</th>
|
||||||
|
<td class="font-monospace small">{{ aiConfig.mistral_base_url }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3 text-muted fw-normal">Mistral API key</th>
|
||||||
|
<td>
|
||||||
|
{% if aiConfig.mistral_key_set %}
|
||||||
|
<span class="badge bg-success me-1">set</span>
|
||||||
|
<span class="font-monospace small text-muted">{{ aiConfig.mistral_key_hint }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">not set</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3 text-muted fw-normal">Ollama endpoint</th>
|
||||||
|
<td class="font-monospace small">{{ aiConfig.ollama_base_url }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button id="btn-test-mistral" class="btn btn-outline-primary btn-sm w-100"
|
||||||
|
{% if not aiConfig.mistral_key_set %}disabled title="Set MISTRAL_API_KEY first"{% endif %}>
|
||||||
|
<i class="fa fa-plug me-1"></i>Test Mistral Connection
|
||||||
|
</button>
|
||||||
|
<div id="test-result" class="mt-3" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('ingest-image-input')?.addEventListener('change', function () {
|
||||||
|
const file = this.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const wrapper = document.getElementById('image-preview-wrapper');
|
||||||
|
const img = document.getElementById('image-preview');
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
wrapper.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-test-mistral')?.addEventListener('click', async function () {
|
||||||
|
const btn = this;
|
||||||
|
const resultDiv = document.getElementById('test-result');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i>Testing…';
|
||||||
|
resultDiv.style.display = 'none';
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('target', 'mistral');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('{{ path('admin_ai_test') }}', { method: 'POST', body });
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
resultDiv.innerHTML = renderTestResult(data);
|
||||||
|
} catch (e) {
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
resultDiv.innerHTML = '<div class="alert alert-danger py-2 mb-0">Request failed: ' + e.message + '</div>';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fa fa-plug me-1"></i>Test Mistral Connection';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderTestResult(data) {
|
||||||
|
const rows = [
|
||||||
|
modelRow('Text', data.text),
|
||||||
|
modelRow('Vision', data.vision),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
return `<table class="table table-sm mb-0">${rows}</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelRow(label, r) {
|
||||||
|
if (r.ok) {
|
||||||
|
return `<tr>
|
||||||
|
<td><span class="badge bg-success">OK</span> <strong>${label}</strong></td>
|
||||||
|
<td class="font-monospace small">${escHtml(r.model)}</td>
|
||||||
|
<td class="text-muted small">${r.ms} ms</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="3" class="ps-3 text-muted small">${escHtml(r.response ?? '')}</td></tr>`;
|
||||||
|
}
|
||||||
|
return `<tr>
|
||||||
|
<td><span class="badge bg-danger">FAIL</span> <strong>${label}</strong></td>
|
||||||
|
<td colspan="2" class="font-monospace small text-danger">${escHtml(r.error ?? 'unknown error')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in a new issue