1778 lines
56 KiB
Markdown
1778 lines
56 KiB
Markdown
|
|
# 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
|