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:
Simon Kuehn 2026-05-18 07:18:39 +00:00
parent 740c9a4e08
commit 020a5ddbc8
7 changed files with 664 additions and 0 deletions

View 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);
}
}
}

View file

@ -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);
}
}

View file

@ -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(),
]);
}
}

View file

@ -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);
}
}

View 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]);
}
}

View 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]);
}
}

View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
{% endblock %}