# SuperSeller3000 — Plan 4: AI-Pipelines > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Vollständige AI-Pipeline-Infrastruktur: OllamaVisionAgent (Foto → Modellname+Seriennummer), SpecsResearchAgent (Web-Suche via SerpAPI → vollständige Specs), JsonCodingAgent (Specs → JSON), ValidationGate mit max. 3 Retries, EbayTextAgent, DraftArticleCreator — alles über Symfony Messenger auf dem `ai_pipeline`-Transport. Zusätzlich PXE-Inventur-Pipeline (ohne SpecsResearch). **Architecture:** Jeder Pipeline-Schritt ist eine eigene Message+Handler-Klasse. Handler lesen/schreiben `AIPipelineJob` (Zustandsobjekt) und dispatchen die nächste Message. Ollama wird über SSH-Tunnel (autossh) als `http://localhost:11434` angesprochen. SpecsResearchAgent verwendet SerpAPI (Pflicht, kein LLM-Wissen). **Tech Stack:** PHP 8.4, Symfony 7, Symfony Messenger, Symfony HttpClient, PHPStan Level 9 --- ## Dateistruktur (gesamter Plan) ``` src/ Domain/ Pipeline/ Repository/ AIPipelineJobRepositoryInterface.php # new Infrastructure/ AI/ OllamaClient.php Agent/ OllamaVisionAgent.php SpecsResearchAgent.php JsonCodingAgent.php EbayTextAgent.php Search/ WebSearchInterface.php SerpApiWebSearch.php Messenger/ Message/ PhotoUploadMessage.php SpecsResearchMessage.php JsonCodingMessage.php ValidationMessage.php DraftArticleMessage.php EbayTextMessage.php PxeInventoryMessage.php Handler/ PhotoUploadHandler.php SpecsResearchHandler.php JsonCodingHandler.php ValidationHandler.php DraftArticleHandler.php EbayTextHandler.php PxeInventoryHandler.php Persistence/ Repository/ DoctrineAIPipelineJobRepository.php Http/ Controller/ Api/ AIPipelineController.php config/ packages/ messenger.yaml # Routing ergänzen tests/ Unit/ Infrastructure/ AI/ Agent/ JsonCodingAgentTest.php OllamaVisionAgentTest.php Messenger/ Handler/ ValidationHandlerTest.php ``` --- ## Task 1: AIPipelineJob-Repository + Messenger-Routing **Files:** - Create: `src/Domain/Pipeline/Repository/AIPipelineJobRepositoryInterface.php` - Create: `src/Infrastructure/Persistence/Repository/DoctrineAIPipelineJobRepository.php` - Modify: `config/packages/messenger.yaml` - Modify: `config/services.yaml` - [ ] **Step 1: Repository-Interface** ```php */ public function findByStatus(AIPipelineJobStatus $status): array; public function save(AIPipelineJob $job): void; } ``` - [ ] **Step 2: Doctrine-Implementation** ```php em->find(AIPipelineJob::class, $id); } /** @return list */ public function findByStatus(AIPipelineJobStatus $status): array { /** @var list */ return $this->em->getRepository(AIPipelineJob::class)->findBy(['status' => $status]); } public function save(AIPipelineJob $job): void { $this->em->persist($job); $this->em->flush(); } } ``` - [ ] **Step 3: services.yaml ergänzen** ```yaml App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface: alias: App\Infrastructure\Persistence\Repository\DoctrineAIPipelineJobRepository ``` - [ ] **Step 4: AIPipelineJob-Entity um fehlende Methoden erweitern** Stelle sicher, dass `src/Domain/Pipeline/AIPipelineJob.php` diese Methoden hat: ```php public function getId(): Uuid { return $this->id; } public function getType(): AIPipelineJobType { return $this->type; } public function getStatus(): AIPipelineJobStatus { return $this->status; } public function getAttemptCount(): int { return $this->attemptCount; } public function getArticleId(): ?Uuid { return $this->articleId; } public function setArticleId(?Uuid $id): void { $this->articleId = $id; } /** @return array */ public function getInputData(): array { return $this->inputData; } /** @return array */ public function getOutputData(): array { return $this->outputData; } /** @return list */ public function getMissingFields(): array { return $this->missingFields; } public function markProcessing(): void { $this->status = AIPipelineJobStatus::Processing; } public function markCompleted(array $outputData): void { $this->status = AIPipelineJobStatus::Completed; $this->outputData = $outputData; $this->completedAt = new \DateTimeImmutable(); } public function markNeedsReview(string $reason): void { $this->status = AIPipelineJobStatus::NeedsReview; $this->errorMessage = $reason; $this->completedAt = new \DateTimeImmutable(); } public function markFailed(string $reason): void { $this->status = AIPipelineJobStatus::Failed; $this->errorMessage = $reason; $this->completedAt = new \DateTimeImmutable(); } /** @param list $missing */ public function incrementAttempt(array $missing): void { ++$this->attemptCount; $this->missingFields = $missing; } ``` - [ ] **Step 5: Messenger-Routing konfigurieren** Ersetze den Routing-Abschnitt in `config/packages/messenger.yaml`: ```yaml routing: App\Infrastructure\Messenger\Message\PhotoUploadMessage: ai_pipeline App\Infrastructure\Messenger\Message\SpecsResearchMessage: ai_pipeline App\Infrastructure\Messenger\Message\JsonCodingMessage: ai_pipeline App\Infrastructure\Messenger\Message\ValidationMessage: ai_pipeline App\Infrastructure\Messenger\Message\DraftArticleMessage: ai_pipeline App\Infrastructure\Messenger\Message\EbayTextMessage: ai_pipeline App\Infrastructure\Messenger\Message\PxeInventoryMessage: ai_pipeline ``` - [ ] **Step 6: Commit** ```bash git add src/Domain/Pipeline/Repository/ src/Infrastructure/Persistence/Repository/DoctrineAIPipelineJobRepository.php config/packages/messenger.yaml config/services.yaml git commit -m "feat: add AIPipelineJob repository, Messenger routing for ai_pipeline transport" ``` --- ## Task 2: Message-Klassen **Files:** - Create: alle Message-Klassen unter `src/Infrastructure/Messenger/Message/` - [ ] **Step 1: Message-Klassen schreiben** ```php $missingFields populated on retry, empty on first attempt */ public function __construct( public string $jobId, public string $articleTypeId, public string $specsText, public array $missingFields = [], ) {} } ``` ```php $attributes definitionId => value (raw from LLM) */ public function __construct( public string $jobId, public string $articleTypeId, public string $specsText, // original text, needed for retry public array $attributes, ) {} } ``` ```php $attributes */ public function __construct( public string $jobId, public string $articleTypeId, public array $attributes, public string $condition, // ArticleCondition value public ?string $inventoryNumber, // null = auto-generate (Pipeline A), set = PXE public ?string $serialNumber, ) {} } ``` ```php httpClient->request('POST', $this->ollamaBaseUrl.'/api/generate', [ 'json' => [ 'model' => $model, 'prompt' => $prompt, 'stream' => false, ], 'timeout' => 120, ]); /** @var array{response: string} $data */ $data = $response->toArray(); return $data['response']; } public function generateWithImage(string $model, string $prompt, string $imagePath): string { $imageData = \base64_encode((string) \file_get_contents($imagePath)); $response = $this->httpClient->request('POST', $this->ollamaBaseUrl.'/api/generate', [ 'json' => [ 'model' => $model, 'prompt' => $prompt, 'images' => [$imageData], 'stream' => false, ], 'timeout' => 180, ]); /** @var array{response: string} $data */ $data = $response->toArray(); return $data['response']; } } ``` - [ ] **Step 2: WebSearchInterface + SerpAPI-Implementierung** ```php httpClient->request('GET', 'https://serpapi.com/search', [ 'query' => [ 'q' => $query, 'api_key' => $this->serpApiKey, 'num' => 5, 'hl' => 'de', ], 'timeout' => 15, ]); /** @var array{organic_results?: list} $data */ $data = $response->toArray(); $results = $data['organic_results'] ?? []; if ([] === $results) { return ''; } $texts = []; foreach ($results as $result) { $title = $result['title'] ?? ''; $snippet = $result['snippet'] ?? ''; if ('' !== $title || '' !== $snippet) { $texts[] = $title."\n".$snippet; } } return \implode("\n\n", $texts); } } ``` - [ ] **Step 3: services.yaml + .env** ```yaml # config/services.yaml (ergänzen) App\Infrastructure\AI\OllamaClient: arguments: $ollamaBaseUrl: '%env(OLLAMA_BASE_URL)%' App\Infrastructure\Search\WebSearchInterface: alias: App\Infrastructure\Search\SerpApiWebSearch App\Infrastructure\Search\SerpApiWebSearch: arguments: $serpApiKey: '%env(SERP_API_KEY)%' ``` ```ini # .env (ergänzen) OLLAMA_BASE_URL=http://localhost:11434 SERP_API_KEY= ``` - [ ] **Step 4: Commit** ```bash git add src/Infrastructure/AI/OllamaClient.php src/Infrastructure/Search/ config/services.yaml .env git commit -m "feat: add OllamaClient (HTTP wrapper) and SerpApiWebSearch" ``` --- ## Task 4: AI-Agents **Files:** - Create: `src/Infrastructure/AI/Agent/OllamaVisionAgent.php` - Create: `src/Infrastructure/AI/Agent/SpecsResearchAgent.php` - Create: `src/Infrastructure/AI/Agent/JsonCodingAgent.php` - Create: `src/Infrastructure/AI/Agent/EbayTextAgent.php` - Test: `tests/Unit/Infrastructure/AI/Agent/OllamaVisionAgentTest.php` - Test: `tests/Unit/Infrastructure/AI/Agent/JsonCodingAgentTest.php` - [ ] **Step 1: Failing-Tests schreiben** ```php ollama = $this->createMock(OllamaClient::class); $this->agent = new OllamaVisionAgent($this->ollama, 'llava'); } public function test_parses_model_and_serial_from_response(): void { $this->ollama->method('generateWithImage') ->willReturn("MODEL: Dell Latitude 5520\nSERIAL: ABC12345"); $result = $this->agent->analyze('/tmp/photo.jpg'); $this->assertSame('Dell Latitude 5520', $result['model']); $this->assertSame('ABC12345', $result['serial']); } public function test_returns_empty_strings_when_not_found(): void { $this->ollama->method('generateWithImage') ->willReturn('I cannot read the nameplate clearly.'); $result = $this->agent->analyze('/tmp/photo.jpg'); $this->assertSame('', $result['model']); $this->assertSame('', $result['serial']); } } ``` ```php ollama = $this->createMock(OllamaClient::class); $this->agent = new JsonCodingAgent($this->ollama, 'llama3.2'); $this->type = new ArticleType('Notebook'); $ramDef = new AttributeDefinition('RAM', AttributeType::String); $this->type->addAttributeDefinition($ramDef); } public function test_returns_parsed_attributes(): void { $this->ollama->method('generate') ->willReturn('```json' . "\n" . '{"' . $this->type->getAttributeDefinitions()->first()->getId()->toRfc4122() . '": "16 GB"}' . "\n" . '```'); $result = $this->agent->encode($this->type, 'Dell Latitude 5520: 16 GB RAM, Intel i7'); $this->assertCount(1, $result); $this->assertSame('16 GB', \array_values($result)[0]); } public function test_extracts_json_from_markdown_fences(): void { $defId = $this->type->getAttributeDefinitions()->first()->getId()->toRfc4122(); $this->ollama->method('generate') ->willReturn("Here is the JSON:\n```json\n{{$defId}: \"16 GB\"}\n```\nDone."); $result = $this->agent->encode($this->type, 'Specs text'); $this->assertArrayHasKey($defId, $result); } } ``` - [ ] **Step 2: Tests ausführen — müssen fehlschlagen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/AI/Agent/ # Expected: FAIL — classes not found ``` - [ ] **Step 3: OllamaVisionAgent implementieren** ```php SERIAL: PROMPT; $response = $this->ollama->generateWithImage($this->model, $prompt, $imagePath); return [ 'model' => $this->extractField($response, 'MODEL'), 'serial' => $this->extractField($response, 'SERIAL'), ]; } private function extractField(string $response, string $field): string { if (\preg_match('/^'.$field.':\s*(.+)$/m', $response, $matches)) { return \trim($matches[1]); } return ''; } } ``` - [ ] **Step 4: SpecsResearchAgent implementieren** ```php webSearch->search($query); if ('' === $searchText) { $fallbackQuery = "{$modelName} specs datasheet"; $searchText = $this->webSearch->search($fallbackQuery); } if ('' === $searchText) { throw new \RuntimeException("No web search results found for model: {$modelName}"); } $prompt = <<ollama->generate($this->model, $prompt); } } ``` - [ ] **Step 5: JsonCodingAgent implementieren** ```php $missingFields attribute names to focus on (for retries) * @return array definitionId => value */ public function encode(ArticleType $articleType, string $specsText, array $missingFields = []): array { $schema = $this->buildSchema($articleType); $missingHint = [] !== $missingFields ? "\nIMPORTANT: The following fields were missing in the previous attempt. Make sure they are included: ".\implode(', ', $missingFields)."\n" : ''; $prompt = <<ollama->generate($this->model, $prompt); return $this->parseJson($response); } private function buildSchema(ArticleType $articleType): string { $lines = []; foreach ($articleType->getAttributeDefinitions() as $def) { $hint = match ($def->getType()->value) { 'int' => 'integer number', 'float' => 'decimal number', 'bool' => 'true or false', 'select' => 'one of: '.\implode(', ', $def->getOptions() ?? []), 'multi_select' => 'comma-separated list of: '.\implode(', ', $def->getOptions() ?? []), default => 'string'.($def->getUnit() !== null ? " in {$def->getUnit()}" : ''), }; $lines[] = "\"{$def->getId()->toRfc4122()}\": \"{$def->getName()}\" ({$hint})"; } return \implode("\n", $lines); } /** * @return array */ private function parseJson(string $response): array { // Strip markdown fences if present $cleaned = \preg_replace('/^```(?:json)?\s*/m', '', $response) ?? $response; $cleaned = \preg_replace('/^```\s*$/m', '', $cleaned) ?? $cleaned; $cleaned = \trim($cleaned); // Extract first { ... } block $start = \strpos($cleaned, '{'); $end = \strrpos($cleaned, '}'); if (false === $start || false === $end) { return []; } $json = \substr($cleaned, $start, $end - $start + 1); try { /** @var array $decoded */ $decoded = \json_decode($json, true, 512, JSON_THROW_ON_ERROR); return \array_map(static fn ($v) => (string) $v, $decoded); } catch (\JsonException) { return []; } } } ``` - [ ] **Step 6: EbayTextAgent implementieren** ```php getAttributeValues() as $value) { $attributes[] = $value->getAttributeDefinition()->getName().': '.$value->getValue(); } $attributeText = \implode("\n", $attributes); $typeName = $article->getArticleType()->getName(); $condition = $article->getCondition()->value; $conditionNotes = $article->getConditionNotes() ?? ''; $titlePrompt = <<ollama->generate($this->model, $titlePrompt)); $description = \trim($this->ollama->generate($this->model, $descriptionPrompt)); // Enforce title length limit if (\mb_strlen($title) > 80) { $title = \mb_substr($title, 0, 77).'...'; } return ['title' => $title, 'description' => $description]; } } ``` - [ ] **Step 7: services.yaml für Agents** ```yaml # config/services.yaml (ergänzen) App\Infrastructure\AI\Agent\OllamaVisionAgent: arguments: $model: '%env(OLLAMA_VISION_MODEL)%' App\Infrastructure\AI\Agent\SpecsResearchAgent: arguments: $model: '%env(OLLAMA_TEXT_MODEL)%' App\Infrastructure\AI\Agent\JsonCodingAgent: arguments: $model: '%env(OLLAMA_TEXT_MODEL)%' App\Infrastructure\AI\Agent\EbayTextAgent: arguments: $model: '%env(OLLAMA_TEXT_MODEL)%' ``` ```ini # .env (ergänzen) OLLAMA_VISION_MODEL=llava OLLAMA_TEXT_MODEL=llama3.2 ``` - [ ] **Step 8: Tests ausführen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/AI/Agent/ # Expected: PASS (4 tests) ``` - [ ] **Step 9: Commit** ```bash git add src/Infrastructure/AI/ src/Infrastructure/Search/ tests/Unit/Infrastructure/AI/ config/services.yaml .env git commit -m "feat: add OllamaVisionAgent, SpecsResearchAgent (SerpAPI), JsonCodingAgent, EbayTextAgent" ``` --- ## Task 5: Pipeline-A Handler-Kette **Files:** - Create: alle Handler unter `src/Infrastructure/Messenger/Handler/` - Test: `tests/Unit/Infrastructure/Messenger/Handler/ValidationHandlerTest.php` - [ ] **Step 1: Failing-Test für ValidationHandler** ```php jobRepo = $this->createMock(AIPipelineJobRepositoryInterface::class); $this->bus = $this->createMock(MessageBusInterface::class); $this->handler = new ValidationHandler($this->jobRepo, $this->bus); $type = new ArticleType('Notebook'); $ramDef = new AttributeDefinition('RAM', AttributeType::String); $cpuDef = new AttributeDefinition('CPU', AttributeType::String); $type->addAttributeDefinition($ramDef); $type->addAttributeDefinition($cpuDef); $this->job = new AIPipelineJob(AIPipelineJobType::Photo, ['test' => true]); } public function test_dispatches_draft_message_when_all_attributes_present(): void { $this->jobRepo->method('findById')->willReturn($this->job); // Get the actual definition IDs from the type after setup $type = new ArticleType('Notebook'); $ramDef = new AttributeDefinition('RAM', AttributeType::String); $cpuDef = new AttributeDefinition('CPU', AttributeType::String); $type->addAttributeDefinition($ramDef); $type->addAttributeDefinition($cpuDef); $attributes = [ $ramDef->getId()->toRfc4122() => '16 GB', $cpuDef->getId()->toRfc4122() => 'Intel i7', ]; $this->bus->expects($this->once()) ->method('dispatch') ->with($this->isInstanceOf(DraftArticleMessage::class)) ->willReturn(new Envelope(new \stdClass())); $message = new ValidationMessage( jobId: $this->job->getId()->toRfc4122(), articleTypeId: $type->getId()->toRfc4122(), specsText: 'some specs', attributes: $attributes, ); ($this->handler)($message); } public function test_retries_json_coding_when_fields_missing_and_under_limit(): void { $this->jobRepo->method('findById')->willReturn($this->job); $type = new ArticleType('Notebook'); $type->addAttributeDefinition(new AttributeDefinition('RAM', AttributeType::String)); $this->bus->expects($this->once()) ->method('dispatch') ->with($this->isInstanceOf(JsonCodingMessage::class)) ->willReturn(new Envelope(new \stdClass())); $message = new ValidationMessage( jobId: $this->job->getId()->toRfc4122(), articleTypeId: $type->getId()->toRfc4122(), specsText: 'some specs', attributes: [], // no attributes → all missing ); ($this->handler)($message); } } ``` - [ ] **Step 2: Test ausführen — muss fehlschlagen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Messenger/Handler/ValidationHandlerTest.php # Expected: FAIL ``` - [ ] **Step 3: PhotoUploadHandler** ```php jobRepository->findById(\Symfony\Component\Uid\Uuid::fromString($message->jobId)); if (null === $job) { return; } $job->markProcessing(); $this->jobRepository->save($job); $result = $this->visionAgent->analyze($message->storedPhotoPath); if ('' === $result['model']) { $job->markNeedsReview('OllamaVisionAgent: no model name detected on nameplate'); $this->jobRepository->save($job); return; } $this->bus->dispatch(new SpecsResearchMessage( jobId: $message->jobId, articleTypeId: $message->articleTypeId, modelName: $result['model'], serialNumber: $result['serial'], )); } } ``` - [ ] **Step 4: SpecsResearchHandler** ```php jobRepository->findById(\Symfony\Component\Uid\Uuid::fromString($message->jobId)); if (null === $job) { return; } try { $specsText = $this->specsAgent->research($message->modelName); } catch (\RuntimeException $e) { $job->markNeedsReview('SpecsResearchAgent: '.$e->getMessage()); $this->jobRepository->save($job); return; } $this->bus->dispatch(new JsonCodingMessage( jobId: $message->jobId, articleTypeId: $message->articleTypeId, specsText: $specsText, )); } } ``` - [ ] **Step 5: JsonCodingHandler** ```php jobRepository->findById(Uuid::fromString($message->jobId)); if (null === $job) { return; } $articleType = $this->articleTypeRepository->findById(Uuid::fromString($message->articleTypeId)); if (null === $articleType) { $job->markFailed("ArticleType {$message->articleTypeId} not found"); $this->jobRepository->save($job); return; } $attributes = $this->jsonAgent->encode($articleType, $message->specsText, $message->missingFields); $this->bus->dispatch(new ValidationMessage( jobId: $message->jobId, articleTypeId: $message->articleTypeId, specsText: $message->specsText, attributes: $attributes, )); } } ``` - [ ] **Step 6: ValidationHandler** ```php jobRepository->findById(Uuid::fromString($message->jobId)); if (null === $job) { return; } $missing = $this->findMissingFields($message); if ([] === $missing) { $this->bus->dispatch(new DraftArticleMessage( jobId: $message->jobId, articleTypeId: $message->articleTypeId, attributes: $message->attributes, condition: 'good', inventoryNumber: null, serialNumber: null, )); return; } if ($job->getAttemptCount() >= self::MAX_ATTEMPTS) { $job->markNeedsReview('Validation failed after '.self::MAX_ATTEMPTS.' attempts. Missing: '.\implode(', ', $missing)); $this->jobRepository->save($job); return; } $job->incrementAttempt($missing); $this->jobRepository->save($job); $this->bus->dispatch(new JsonCodingMessage( jobId: $message->jobId, articleTypeId: $message->articleTypeId, specsText: $message->specsText, missingFields: $missing, )); } /** @return list attribute names that are required but not present */ private function findMissingFields(ValidationMessage $message): array { if (null === $this->articleTypeRepository) { return []; } $articleType = $this->articleTypeRepository->findById(Uuid::fromString($message->articleTypeId)); if (null === $articleType) { return []; } $missing = []; foreach ($articleType->getAttributeDefinitions() as $def) { if (!\array_key_exists($def->getId()->toRfc4122(), $message->attributes)) { $missing[] = $def->getName(); } } return $missing; } } ``` - [ ] **Step 7: DraftArticleHandler** ```php jobRepository->findById(Uuid::fromString($message->jobId)); if (null === $job) { return; } $condition = ArticleCondition::tryFrom($message->condition) ?? ArticleCondition::Good; $article = $this->articleService->create( articleTypeId: Uuid::fromString($message->articleTypeId), condition: $condition, stock: 1, ); if (null !== $message->serialNumber) { $article->setSerialNumber($message->serialNumber); } if ([] !== $message->attributes) { $this->articleService->updateAttributes($article->getId(), $message->attributes); } $job->setArticleId($article->getId()); $job->markCompleted(['articleId' => $article->getId()->toRfc4122()]); $this->jobRepository->save($job); $this->bus->dispatch(new EbayTextMessage( jobId: $message->jobId, articleId: $article->getId()->toRfc4122(), )); } } ``` - [ ] **Step 8: EbayTextHandler** ```php articleRepository->findById(Uuid::fromString($message->articleId)); if (null === $article) { return; } $texts = $this->ebayTextAgent->generate($article); $this->articleService->setEbayTexts( articleId: $article->getId(), title: $texts['title'], description: $texts['description'], ); } } ``` - [ ] **Step 9: Tests ausführen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Messenger/Handler/ # Expected: PASS ``` - [ ] **Step 10: Commit** ```bash git add src/Infrastructure/Messenger/Handler/ tests/Unit/Infrastructure/Messenger/Handler/ git commit -m "feat: add Pipeline-A handlers (PhotoUpload→SpecsResearch→JsonCoding→Validation→DraftArticle→EbayText)" ``` --- ## Task 6: PXE-Pipeline-Handler **Files:** - Create: `src/Infrastructure/Messenger/Handler/PxeInventoryHandler.php` - [ ] **Step 1: PxeInventoryHandler implementieren** ```php jobRepository->findById(Uuid::fromString($message->jobId)); if (null === $job) { return; } $job->markProcessing(); $this->jobRepository->save($job); // PXE dump enthält vollständige Rohdaten → direkt zu JsonCoding, kein SpecsResearch $this->bus->dispatch(new JsonCodingMessage( jobId: $message->jobId, articleTypeId: $message->articleTypeId, specsText: $message->pxeDump, )); } } ``` **Hinweis:** Der DraftArticleHandler für PXE muss die `inventoryNumber` aus dem DraftArticleMessage übernehmen. Die `DraftArticleMessage.inventoryNumber` ist bereits optional: - `null` → ArticleService generiert automatisch (Pipeline A) - gesetzt → wird als Inventurnummer verwendet (Pipeline B) Der `DraftArticleHandler` muss `ArticleService.create()` erweitern, falls `inventoryNumber` übergeben wird. Da `ArticleService.create()` in Plan 2 immer auto-generiert, füge einen optionalen Parameter hinzu: Ergänze in `src/Application/Article/ArticleService.php`: ```php public function create( Uuid $articleTypeId, ArticleCondition $condition, int $stock = 1, ?string $conditionNotes = null, ?string $inventoryNumber = null, // null = auto-generate ): Article { // ... $inventoryNumber = $inventoryNumber ?? $this->nextInventoryNumber(); // ... } ``` Und `DraftArticleHandler` übergibt `$message->inventoryNumber`: ```php $article = $this->articleService->create( articleTypeId: Uuid::fromString($message->articleTypeId), condition: $condition, stock: 1, inventoryNumber: $message->inventoryNumber, ); ``` - [ ] **Step 2: Commit** ```bash git add src/Infrastructure/Messenger/Handler/PxeInventoryHandler.php src/Application/Article/ArticleService.php git commit -m "feat: add PXE inventory handler (skips SpecsResearch, uses provided inventory number)" ``` --- ## Task 7: API-Endpunkte für Pipeline-Trigger **Files:** - Create: `src/Infrastructure/Http/Controller/Api/AIPipelineController.php` - [ ] **Step 1: AIPipelineController implementieren** ```php request->getString('articleTypeId'); $file = $request->files->get('photo'); if ('' === $articleTypeId || null === $file) { return $this->json(['error' => 'articleTypeId and photo are required'], Response::HTTP_BAD_REQUEST); } $articleType = $this->articleTypeRepository->findById(Uuid::fromString($articleTypeId)); if (null === $articleType) { return $this->json(['error' => 'ArticleType not found'], Response::HTTP_NOT_FOUND); } $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; if (!\in_array($file->getMimeType(), $allowedMimes, strict: true)) { return $this->json(['error' => 'Only JPEG, PNG, WebP allowed'], Response::HTTP_BAD_REQUEST); } // Store photo permanently first (pipeline reads from permanent path) $stored = $this->photoService->uploadRaw($file->getPathname(), $file->getClientOriginalName()); $job = new AIPipelineJob(AIPipelineJobType::Photo, [ 'articleTypeId' => $articleTypeId, 'storedPhotoPath' => $stored->storagePath->getBasePath().'/'.$stored->filename, ]); $this->jobRepository->save($job); $this->bus->dispatch(new PhotoUploadMessage( jobId: $job->getId()->toRfc4122(), articleTypeId: $articleTypeId, storedPhotoPath: $stored->storagePath->getBasePath().'/'.$stored->filename, originalFilename: $file->getClientOriginalName(), )); return $this->json([ 'jobId' => $job->getId()->toRfc4122(), 'status' => $job->getStatus()->value, ], Response::HTTP_ACCEPTED); } /** * POST /api/pipeline/pxe-inventory * Startet Pipeline B: PXE-Dump → JsonCoding → Draft (kein SpecsResearch) */ #[Route('/pxe-inventory', name: 'pxe_inventory', methods: ['POST'])] public function pxeInventory(Request $request): JsonResponse { $data = $request->toArray(); $required = ['articleTypeId', 'pxeDump', 'inventoryNumber', 'condition']; foreach ($required as $field) { if (empty($data[$field])) { return $this->json(['error' => "{$field} is required"], Response::HTTP_BAD_REQUEST); } } $condition = ArticleCondition::tryFrom($data['condition']); if (null === $condition) { return $this->json(['error' => 'Invalid condition'], Response::HTTP_BAD_REQUEST); } $job = new AIPipelineJob(AIPipelineJobType::Pxe, [ 'articleTypeId' => $data['articleTypeId'], 'inventoryNumber' => $data['inventoryNumber'], ]); $this->jobRepository->save($job); $this->bus->dispatch(new PxeInventoryMessage( jobId: $job->getId()->toRfc4122(), articleTypeId: $data['articleTypeId'], pxeDump: $data['pxeDump'], inventoryNumber: $data['inventoryNumber'], condition: $data['condition'], )); return $this->json([ 'jobId' => $job->getId()->toRfc4122(), 'inventoryNumber' => $data['inventoryNumber'], 'status' => $job->getStatus()->value, ], Response::HTTP_ACCEPTED); } /** * GET /api/pipeline/jobs/{jobId} * Status eines laufenden Jobs abfragen */ #[Route('/jobs/{jobId}', name: 'job_status', methods: ['GET'])] public function jobStatus(string $jobId): JsonResponse { $job = $this->jobRepository->findById(Uuid::fromString($jobId)); if (null === $job) { return $this->json(['error' => 'Job not found'], Response::HTTP_NOT_FOUND); } return $this->json([ 'id' => $job->getId()->toRfc4122(), 'type' => $job->getType()->value, 'status' => $job->getStatus()->value, 'attemptCount' => $job->getAttemptCount(), 'articleId' => $job->getArticleId()?->toRfc4122(), 'missingFields' => $job->getMissingFields(), 'errorMessage' => null, ]); } /** * POST /api/pipeline/articles/{id}/regenerate-texts * EbayText für vorhandenen Artikel neu generieren */ #[Route('/articles/{id}/regenerate-texts', name: 'regenerate_texts', methods: ['POST'])] public function regenerateTexts(string $id): JsonResponse { $job = new AIPipelineJob(AIPipelineJobType::TextGen, ['articleId' => $id]); $this->jobRepository->save($job); $this->bus->dispatch(new EbayTextMessage( jobId: $job->getId()->toRfc4122(), articleId: $id, )); return $this->json(['jobId' => $job->getId()->toRfc4122()], Response::HTTP_ACCEPTED); } } ``` - [ ] **Step 2: PhotoService.uploadRaw hinzufügen** Der `PhotoService` in Plan 2 hat nur `upload()` (bindet Photo an Artikel). Für die Pipeline brauchen wir eine Variante, die das Foto speichert ohne sofort einen Artikel zu kennen: Ergänze `src/Application/Article/PhotoService.php`: ```php public function uploadRaw(string $tempPath, string $originalFilename): StoredFile { return $this->storageManager->store($tempPath, $originalFilename); } ``` - [ ] **Step 3: PHPStan + CS Fixer** ```bash docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff ``` - [ ] **Step 4: Commit** ```bash git add src/Infrastructure/Http/Controller/Api/AIPipelineController.php src/Application/Article/PhotoService.php git commit -m "feat: add AI pipeline API endpoints (photo-upload, pxe-inventory, job-status, regenerate-texts)" ``` --- ## Selbstreview **Spec-Abdeckung:** - OllamaVisionAgent → Modellname + Seriennummer (nur was sichtbar) ✓ (Task 4) - SpecsResearchAgent → Web-Suche via SerpAPI (Pflicht) ✓ (Task 4) - JsonCodingAgent → Specs-Text → JSON gegen ArticleType-Schema ✓ (Task 4) - ValidationGate → max. 3 Retries mit missing_fields Feedback ✓ (Task 5) - EbayTextAgent → Titel + Beschreibung ✓ (Task 4+5) - DraftArticleCreator → Inventurnummer vergeben ✓ (Task 5) - Pipeline A (Foto) ✓ (Task 5) - Pipeline B (PXE, ohne SpecsResearch) ✓ (Task 6) - Drei isolierte Transports (ai_pipeline / orders / channel_sync) ✓ (Task 1) - AIPipelineJob-Tracking ✓ (Task 1+5) - Status: needs_review nach 3 Fehlversuchen ✓ (Task 5) **Noch nicht in diesem Plan:** - Messenger-Worker für ai_pipeline (in docker-compose.yml aus Plan 1 konfiguriert) - needs_review → Admin-Benachrichtigung (späterer Enhancement) - SpecsResearch: zusätzliche Web-Such-Strategie (direkte Hersteller-Seiten) → späterer Enhancement