From 020a5ddbc832314b2ea0be81ff8b7c4153b71bac Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Mon, 18 May 2026 07:18:39 +0000 Subject: [PATCH] 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 --- src/Infrastructure/AI/AiConfigService.php | 145 ++++++++++++++ .../Controller/Admin/AiStatusController.php | 33 ++++ .../Admin/ManualIngestController.php | 90 +++++++++ .../Admin/PipelineArchiveCrudController.php | 114 +++++++++++ .../Http/Form/AttributeValueFormType.php | 39 ++++ .../Http/Form/ManualIngestType.php | 56 ++++++ templates/admin/manual_ingest.html.twig | 187 ++++++++++++++++++ 7 files changed, 664 insertions(+) create mode 100644 src/Infrastructure/AI/AiConfigService.php create mode 100644 src/Infrastructure/Http/Controller/Admin/AiStatusController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/ManualIngestController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/PipelineArchiveCrudController.php create mode 100644 src/Infrastructure/Http/Form/AttributeValueFormType.php create mode 100644 src/Infrastructure/Http/Form/ManualIngestType.php create mode 100644 templates/admin/manual_ingest.html.twig diff --git a/src/Infrastructure/AI/AiConfigService.php b/src/Infrastructure/AI/AiConfigService.php new file mode 100644 index 0000000..32aa336 --- /dev/null +++ b/src/Infrastructure/AI/AiConfigService.php @@ -0,0 +1,145 @@ + $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); + } + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/AiStatusController.php b/src/Infrastructure/Http/Controller/Admin/AiStatusController.php new file mode 100644 index 0000000..2961dbd --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/AiStatusController.php @@ -0,0 +1,33 @@ +request->get('target', 'mistral'); + + $result = match ($target) { + 'active' => $this->aiConfig->testActive(), + default => $this->aiConfig->testMistral(), + }; + + return new JsonResponse($result); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/ManualIngestController.php b/src/Infrastructure/Http/Controller/Admin/ManualIngestController.php new file mode 100644 index 0000000..2bf43e7 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/ManualIngestController.php @@ -0,0 +1,90 @@ +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(), + ]); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/PipelineArchiveCrudController.php b/src/Infrastructure/Http/Controller/Admin/PipelineArchiveCrudController.php new file mode 100644 index 0000000..f75f6c6 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/PipelineArchiveCrudController.php @@ -0,0 +1,114 @@ + */ +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); + } +} diff --git a/src/Infrastructure/Http/Form/AttributeValueFormType.php b/src/Infrastructure/Http/Form/AttributeValueFormType.php new file mode 100644 index 0000000..118242d --- /dev/null +++ b/src/Infrastructure/Http/Form/AttributeValueFormType.php @@ -0,0 +1,39 @@ +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]); + } +} diff --git a/src/Infrastructure/Http/Form/ManualIngestType.php b/src/Infrastructure/Http/Form/ManualIngestType.php new file mode 100644 index 0000000..55b61e6 --- /dev/null +++ b/src/Infrastructure/Http/Form/ManualIngestType.php @@ -0,0 +1,56 @@ +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]); + } +} diff --git a/templates/admin/manual_ingest.html.twig b/templates/admin/manual_ingest.html.twig new file mode 100644 index 0000000..a5ef8a2 --- /dev/null +++ b/templates/admin/manual_ingest.html.twig @@ -0,0 +1,187 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block page_title %}Ingest Article{% endblock %} + +{% block main %} + +{% if catalogNumber %} + +{% endif %} + +
+ + {# ── Left: ingest form ─────────────────────────────────── #} +
+
+
+
Scan Nameplate
+
+
+ {{ form_start(form, {'attr': {'enctype': 'multipart/form-data', 'novalidate': 'novalidate'}}) }} + +
+ {{ form_label(form.articleType) }} + {{ form_widget(form.articleType) }} + {{ form_errors(form.articleType) }} +
+ +
+ {{ form_label(form.condition) }} + {{ form_widget(form.condition) }} + {{ form_errors(form.condition) }} +
+ +
+ {{ form_label(form.image) }} + + {{ form_widget(form.image, {'attr': {'class': 'form-control', 'id': 'ingest-image-input'}}) }} + {{ form_errors(form.image) }} +
+ +
+ {{ form_label(form.conditionNotes) }} + {{ form_widget(form.conditionNotes, {'attr': {'class': 'form-control'}}) }} + {{ form_errors(form.conditionNotes) }} +
+ + + + {{ form_end(form) }} +
+
+
+ + {# ── Right: AI config panel ────────────────────────────── #} +
+
+
+
AI Configuration
+ + {{ aiConfig.backend }} active + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Vision model{{ aiConfig.vision_model }}
Text model{{ aiConfig.text_model }}
Mistral endpoint{{ aiConfig.mistral_base_url }}
Mistral API key + {% if aiConfig.mistral_key_set %} + set + {{ aiConfig.mistral_key_hint }} + {% else %} + not set + {% endif %} +
Ollama endpoint{{ aiConfig.ollama_base_url }}
+
+ +
+
+ +
+ + + +{% endblock %}