SuperSeller3000/docs/superpowers/plans/2026-05-13-04-ai-pipelines.md

1778 lines
56 KiB
Markdown
Raw Normal View History

# 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
<?php
// src/Domain/Pipeline/Repository/AIPipelineJobRepositoryInterface.php
declare(strict_types=1);
namespace App\Domain\Pipeline\Repository;
use App\Domain\Pipeline\AIPipelineJob;
use App\Domain\Pipeline\AIPipelineJobStatus;
use Symfony\Component\Uid\Uuid;
interface AIPipelineJobRepositoryInterface
{
public function findById(Uuid $id): ?AIPipelineJob;
/** @return list<AIPipelineJob> */
public function findByStatus(AIPipelineJobStatus $status): array;
public function save(AIPipelineJob $job): void;
}
```
- [ ] **Step 2: Doctrine-Implementation**
```php
<?php
// src/Infrastructure/Persistence/Repository/DoctrineAIPipelineJobRepository.php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Repository;
use App\Domain\Pipeline\AIPipelineJob;
use App\Domain\Pipeline\AIPipelineJobStatus;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;
final class DoctrineAIPipelineJobRepository implements AIPipelineJobRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function findById(Uuid $id): ?AIPipelineJob
{
return $this->em->find(AIPipelineJob::class, $id);
}
/** @return list<AIPipelineJob> */
public function findByStatus(AIPipelineJobStatus $status): array
{
/** @var list<AIPipelineJob> */
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<string, mixed> */
public function getInputData(): array { return $this->inputData; }
/** @return array<string, mixed> */
public function getOutputData(): array { return $this->outputData; }
/** @return list<string> */
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<string> $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
<?php
// src/Infrastructure/Messenger/Message/PhotoUploadMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class PhotoUploadMessage
{
public function __construct(
public string $jobId,
public string $articleTypeId,
public string $storedPhotoPath, // absoluter Pfad auf dem Server
public string $originalFilename,
) {}
}
```
```php
<?php
// src/Infrastructure/Messenger/Message/SpecsResearchMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class SpecsResearchMessage
{
public function __construct(
public string $jobId,
public string $articleTypeId,
public string $modelName,
public string $serialNumber,
) {}
}
```
```php
<?php
// src/Infrastructure/Messenger/Message/JsonCodingMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class JsonCodingMessage
{
/**
* @param list<string> $missingFields populated on retry, empty on first attempt
*/
public function __construct(
public string $jobId,
public string $articleTypeId,
public string $specsText,
public array $missingFields = [],
) {}
}
```
```php
<?php
// src/Infrastructure/Messenger/Message/ValidationMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class ValidationMessage
{
/**
* @param array<string, string> $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
<?php
// src/Infrastructure/Messenger/Message/DraftArticleMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class DraftArticleMessage
{
/**
* @param array<string, string> $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
<?php
// src/Infrastructure/Messenger/Message/EbayTextMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class EbayTextMessage
{
public function __construct(
public string $jobId,
public string $articleId,
) {}
}
```
```php
<?php
// src/Infrastructure/Messenger/Message/PxeInventoryMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class PxeInventoryMessage
{
public function __construct(
public string $jobId,
public string $articleTypeId,
public string $pxeDump, // lshw/dmidecode output
public string $inventoryNumber, // bereits vergeben via API
public string $condition, // ArticleCondition value
) {}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Infrastructure/Messenger/Message/
git commit -m "feat: add AI pipeline message classes (PhotoUpload, SpecsResearch, JsonCoding, Validation, DraftArticle, EbayText, PxeInventory)"
```
---
## Task 3: OllamaClient + WebSearch
**Files:**
- Create: `src/Infrastructure/AI/OllamaClient.php`
- Create: `src/Infrastructure/Search/WebSearchInterface.php`
- Create: `src/Infrastructure/Search/SerpApiWebSearch.php`
- [ ] **Step 1: OllamaClient implementieren**
```php
<?php
// src/Infrastructure/AI/OllamaClient.php
declare(strict_types=1);
namespace App\Infrastructure\AI;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class OllamaClient
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $ollamaBaseUrl,
) {}
public function generate(string $model, string $prompt): string
{
$response = $this->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
<?php
// src/Infrastructure/Search/WebSearchInterface.php
declare(strict_types=1);
namespace App\Infrastructure\Search;
interface WebSearchInterface
{
/**
* Performs a web search and returns a text summary of the top results.
* Returns empty string if no results found.
*/
public function search(string $query): string;
}
```
```php
<?php
// src/Infrastructure/Search/SerpApiWebSearch.php
declare(strict_types=1);
namespace App\Infrastructure\Search;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class SerpApiWebSearch implements WebSearchInterface
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $serpApiKey,
) {}
public function search(string $query): string
{
$response = $this->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<array{title?: string, snippet?: string, link?: string}>} $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
<?php
// tests/Unit/Infrastructure/AI/Agent/OllamaVisionAgentTest.php
declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\AI\Agent;
use App\Infrastructure\AI\Agent\OllamaVisionAgent;
use App\Infrastructure\AI\OllamaClient;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class OllamaVisionAgentTest extends TestCase
{
private OllamaClient&MockObject $ollama;
private OllamaVisionAgent $agent;
protected function setUp(): void
{
$this->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
<?php
// tests/Unit/Infrastructure/AI/Agent/JsonCodingAgentTest.php
declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\AI\Agent;
use App\Domain\Article\ArticleType;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType;
use App\Infrastructure\AI\Agent\JsonCodingAgent;
use App\Infrastructure\AI\OllamaClient;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class JsonCodingAgentTest extends TestCase
{
private OllamaClient&MockObject $ollama;
private JsonCodingAgent $agent;
private ArticleType $type;
protected function setUp(): void
{
$this->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
<?php
// src/Infrastructure/AI/Agent/OllamaVisionAgent.php
declare(strict_types=1);
namespace App\Infrastructure\AI\Agent;
use App\Infrastructure\AI\OllamaClient;
final class OllamaVisionAgent
{
public function __construct(
private readonly OllamaClient $ollama,
private readonly string $model,
) {}
/**
* Analyzes a nameplate photo and extracts model name and serial number.
*
* @return array{model: string, serial: string}
*/
public function analyze(string $imagePath): array
{
$prompt = <<<'PROMPT'
Look at this nameplate/label photo of IT hardware.
Extract ONLY the model name/designation and serial number that are visible on the label.
Do not guess or add information not on the label.
Respond in exactly this format (use empty string if not visible):
MODEL: <model name>
SERIAL: <serial number>
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
<?php
// src/Infrastructure/AI/Agent/SpecsResearchAgent.php
declare(strict_types=1);
namespace App\Infrastructure\AI\Agent;
use App\Infrastructure\AI\OllamaClient;
use App\Infrastructure\Search\WebSearchInterface;
final class SpecsResearchAgent
{
public function __construct(
private readonly WebSearchInterface $webSearch,
private readonly OllamaClient $ollama,
private readonly string $model,
) {}
/**
* Searches the web for hardware specs and returns a comprehensive text description.
* Web search is mandatory — LLM knowledge alone is not reliable enough for hardware specs.
*/
public function research(string $modelName): string
{
$query = "{$modelName} technical specifications full specs";
$searchText = $this->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 = <<<PROMPT
Based on the following search results about "{$modelName}", extract and list all technical specifications.
Include: processor, RAM, storage, display, GPU, battery, ports, weight, dimensions, and any other specs found.
Be complete and accurate. Use the search results as your source, not general knowledge.
Search results:
{$searchText}
List all specifications:
PROMPT;
return $this->ollama->generate($this->model, $prompt);
}
}
```
- [ ] **Step 5: JsonCodingAgent implementieren**
```php
<?php
// src/Infrastructure/AI/Agent/JsonCodingAgent.php
declare(strict_types=1);
namespace App\Infrastructure\AI\Agent;
use App\Domain\Article\ArticleType;
use App\Infrastructure\AI\OllamaClient;
final class JsonCodingAgent
{
public function __construct(
private readonly OllamaClient $ollama,
private readonly string $model,
) {}
/**
* Converts specs text to a JSON object mapping AttributeDefinition UUIDs to values.
*
* @param list<string> $missingFields attribute names to focus on (for retries)
* @return array<string, string> 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 = <<<PROMPT
Convert the following hardware specifications to a JSON object.
The JSON must use these exact keys (UUIDs) and follow the indicated value formats:
{$schema}
{$missingHint}
Specifications text:
{$specsText}
Return ONLY valid JSON. No explanation. No markdown. No extra text.
JSON:
PROMPT;
$response = $this->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<string, string>
*/
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<string, mixed> $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
<?php
// src/Infrastructure/AI/Agent/EbayTextAgent.php
declare(strict_types=1);
namespace App\Infrastructure\AI\Agent;
use App\Domain\Article\Article;
use App\Infrastructure\AI\OllamaClient;
final class EbayTextAgent
{
public function __construct(
private readonly OllamaClient $ollama,
private readonly string $model,
) {}
/** @return array{title: string, description: string} */
public function generate(Article $article): array
{
$attributes = [];
foreach ($article->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 = <<<PROMPT
Create a concise eBay listing title (max 80 characters) for this {$typeName}.
Use the most important specifications. Include condition if not "new".
Condition: {$condition}
Specs:
{$attributeText}
Return ONLY the title text, no quotes, no explanation.
PROMPT;
$descriptionPrompt = <<<PROMPT
Create a professional eBay listing description in German for this {$typeName}.
Include all specifications in a clear, structured format.
Mention the condition: {$condition}.
{$conditionNotes}
Specs:
{$attributeText}
Use HTML formatting (ul, li, strong tags). Max 2000 characters.
PROMPT;
$title = \trim($this->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
<?php
// tests/Unit/Infrastructure/Messenger/Handler/ValidationHandlerTest.php
declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\Messenger\Handler;
use App\Domain\Article\ArticleType;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType;
use App\Domain\Pipeline\AIPipelineJob;
use App\Domain\Pipeline\AIPipelineJobType;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\Messenger\Handler\ValidationHandler;
use App\Infrastructure\Messenger\Message\DraftArticleMessage;
use App\Infrastructure\Messenger\Message\JsonCodingMessage;
use App\Infrastructure\Messenger\Message\ValidationMessage;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class ValidationHandlerTest extends TestCase
{
private AIPipelineJobRepositoryInterface&MockObject $jobRepo;
private MessageBusInterface&MockObject $bus;
private AIPipelineJob $job;
private ValidationHandler $handler;
protected function setUp(): void
{
$this->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
<?php
// src/Infrastructure/Messenger/Handler/PhotoUploadHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\AI\Agent\OllamaVisionAgent;
use App\Infrastructure\Messenger\Message\PhotoUploadMessage;
use App\Infrastructure\Messenger\Message\SpecsResearchMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final class PhotoUploadHandler
{
public function __construct(
private readonly OllamaVisionAgent $visionAgent,
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke(PhotoUploadMessage $message): void
{
$job = $this->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
<?php
// src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\AI\Agent\SpecsResearchAgent;
use App\Infrastructure\Messenger\Message\JsonCodingMessage;
use App\Infrastructure\Messenger\Message\SpecsResearchMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final class SpecsResearchHandler
{
public function __construct(
private readonly SpecsResearchAgent $specsAgent,
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke(SpecsResearchMessage $message): void
{
$job = $this->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
<?php
// src/Infrastructure/Messenger/Handler/JsonCodingHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\AI\Agent\JsonCodingAgent;
use App\Infrastructure\Messenger\Message\JsonCodingMessage;
use App\Infrastructure\Messenger\Message\ValidationMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler]
final class JsonCodingHandler
{
public function __construct(
private readonly JsonCodingAgent $jsonAgent,
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke(JsonCodingMessage $message): void
{
$job = $this->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
<?php
// src/Infrastructure/Messenger/Handler/ValidationHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\Messenger\Message\DraftArticleMessage;
use App\Infrastructure\Messenger\Message\JsonCodingMessage;
use App\Infrastructure\Messenger\Message\ValidationMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler]
final class ValidationHandler
{
private const MAX_ATTEMPTS = 3;
public function __construct(
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly MessageBusInterface $bus,
private readonly ?ArticleTypeRepositoryInterface $articleTypeRepository = null,
) {}
public function __invoke(ValidationMessage $message): void
{
$job = $this->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<string> 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
<?php
// src/Infrastructure/Messenger/Handler/DraftArticleHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Application\Article\ArticleService;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\Messenger\Message\DraftArticleMessage;
use App\Infrastructure\Messenger\Message\EbayTextMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler]
final class DraftArticleHandler
{
public function __construct(
private readonly ArticleService $articleService,
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke(DraftArticleMessage $message): void
{
$job = $this->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
<?php
// src/Infrastructure/Messenger/Handler/EbayTextHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Application\Article\ArticleService;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Infrastructure\AI\Agent\EbayTextAgent;
use App\Infrastructure\Messenger\Message\EbayTextMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler]
final class EbayTextHandler
{
public function __construct(
private readonly EbayTextAgent $ebayTextAgent,
private readonly ArticleRepositoryInterface $articleRepository,
private readonly ArticleService $articleService,
) {}
public function __invoke(EbayTextMessage $message): void
{
$article = $this->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
<?php
// src/Infrastructure/Messenger/Handler/PxeInventoryHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\Messenger\Message\JsonCodingMessage;
use App\Infrastructure\Messenger\Message\PxeInventoryMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler]
final class PxeInventoryHandler
{
public function __construct(
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke(PxeInventoryMessage $message): void
{
$job = $this->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
<?php
// src/Infrastructure/Http/Controller/Api/AIPipelineController.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Api;
use App\Application\Article\PhotoService;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Domain\Pipeline\AIPipelineJob;
use App\Domain\Pipeline\AIPipelineJobType;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\Messenger\Message\EbayTextMessage;
use App\Infrastructure\Messenger\Message\PhotoUploadMessage;
use App\Infrastructure\Messenger\Message\PxeInventoryMessage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[Route('/pipeline', name: 'api_pipeline_')]
final class AIPipelineController extends AbstractController
{
public function __construct(
private readonly MessageBusInterface $bus,
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
private readonly PhotoService $photoService,
) {}
/**
* POST /api/pipeline/photo-upload
* Startet Pipeline A: Foto-Upload → OllamaVision → SpecsResearch → JsonCoding → Draft
*/
#[Route('/photo-upload', name: 'photo_upload', methods: ['POST'])]
public function photoUpload(Request $request): JsonResponse
{
$articleTypeId = $request->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