diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index e62a77f..d7da643 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -36,4 +36,16 @@ framework: options: stream: failed - routing: [] + 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 + App\Infrastructure\Messenger\Message\PublishToChannelMessage: channel_sync + App\Infrastructure\Messenger\Message\UpdateStockOnChannelsMessage: channel_sync + App\Infrastructure\Messenger\Message\DeactivateListingMessage: channel_sync + App\Infrastructure\Messenger\Message\OrderReceivedMessage: orders + App\Infrastructure\Messenger\Message\TrackingPushMessage: channel_sync diff --git a/src/Application/Channel/ChannelAdapterInterface.php b/src/Application/Channel/ChannelAdapterInterface.php new file mode 100644 index 0000000..7c8c0e7 --- /dev/null +++ b/src/Application/Channel/ChannelAdapterInterface.php @@ -0,0 +1,43 @@ + */ + private array $adapters = []; + + /** @param iterable $adapters */ + public function __construct(iterable $adapters) + { + foreach ($adapters as $adapter) { + $this->adapters[$adapter->getType()] = $adapter; + } + } + + public function get(string $type): ChannelAdapterInterface + { + return $this->adapters[$type] + ?? throw new \InvalidArgumentException("No channel adapter registered for type: {$type}"); + } + + public function has(string $type): bool + { + return isset($this->adapters[$type]); + } + + /** @return list */ + public function getTypes(): array + { + return array_keys($this->adapters); + } +} diff --git a/src/Infrastructure/AI/Agent/EbayTextAgent.php b/src/Infrastructure/AI/Agent/EbayTextAgent.php new file mode 100644 index 0000000..a76bdfa --- /dev/null +++ b/src/Infrastructure/AI/Agent/EbayTextAgent.php @@ -0,0 +1,58 @@ +getAttributeValues() as $value) { + $attributes[] = $value->getAttributeDefinition()->getName().': '.$value->getValue(); + } + $attributeText = implode("\n", $attributes); + $typeName = $article->getArticleType()->getName(); + $condition = $article->getCondition()->value; + $conditionNotes = $article->getConditionNotes() ?? ''; + + $titlePrompt = <<ollama->generate($this->model, $titlePrompt)); + $description = trim($this->ollama->generate($this->model, $descriptionPrompt)); + + if (mb_strlen($title) > 80) { + $title = mb_substr($title, 0, 77).'...'; + } + + return ['title' => $title, 'description' => $description]; + } +} diff --git a/src/Infrastructure/AI/Agent/JsonCodingAgent.php b/src/Infrastructure/AI/Agent/JsonCodingAgent.php new file mode 100644 index 0000000..cef7e2c --- /dev/null +++ b/src/Infrastructure/AI/Agent/JsonCodingAgent.php @@ -0,0 +1,96 @@ + $missingFields + * + * @return array + */ + public function encode(ArticleType $articleType, string $specsText, array $missingFields = []): array + { + $schema = $this->buildSchema($articleType); + $missingHint = [] !== $missingFields + ? "\nIMPORTANT: The following fields were missing in the previous attempt. Make sure they are included: ".implode(', ', $missingFields)."\n" + : ''; + + $prompt = <<ollama->generate($this->model, $prompt); + + return $this->parseJson($response); + } + + private function buildSchema(ArticleType $articleType): string + { + $lines = []; + foreach ($articleType->getAttributeDefinitions() as $def) { + $hint = match ($def->getType()->value) { + 'int' => 'integer number', + 'float' => 'decimal number', + 'bool' => 'true or false', + 'select' => 'one of: '.implode(', ', $def->getOptions() ?? []), + 'multi_select' => 'comma-separated list of: '.implode(', ', $def->getOptions() ?? []), + default => 'string'.(null !== $def->getUnit() ? " in {$def->getUnit()}" : ''), + }; + $lines[] = "\"{$def->getId()->toRfc4122()}\": \"{$def->getName()}\" ({$hint})"; + } + + return implode("\n", $lines); + } + + /** @return array */ + private function parseJson(string $response): array + { + $cleaned = preg_replace('/^```(?:json)?\s*/m', '', $response) ?? $response; + $cleaned = preg_replace('/^```\s*$/m', '', $cleaned) ?? $cleaned; + $cleaned = trim($cleaned); + + $start = strpos($cleaned, '{'); + $end = strrpos($cleaned, '}'); + + if (false === $start || false === $end) { + return []; + } + + $json = substr($cleaned, $start, $end - $start + 1); + + try { + /** @var array $decoded */ + $decoded = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + + $result = []; + foreach ($decoded as $k => $v) { + $result[$k] = \is_scalar($v) ? (string) $v : ''; + } + + return $result; + } catch (\JsonException) { + return []; + } + } +} diff --git a/src/Infrastructure/AI/Agent/OllamaVisionAgent.php b/src/Infrastructure/AI/Agent/OllamaVisionAgent.php new file mode 100644 index 0000000..c2cc2bf --- /dev/null +++ b/src/Infrastructure/AI/Agent/OllamaVisionAgent.php @@ -0,0 +1,45 @@ + + SERIAL: + PROMPT; + + $response = $this->ollama->generateWithImage($this->model, $prompt, $imagePath); + + return [ + 'model' => $this->extractField($response, 'MODEL'), + 'serial' => $this->extractField($response, 'SERIAL'), + ]; + } + + private function extractField(string $response, string $field): string + { + if (preg_match('/^'.$field.':\s*(.+)$/m', $response, $matches)) { + return trim($matches[1]); + } + + return ''; + } +} diff --git a/src/Infrastructure/AI/Agent/SpecsResearchAgent.php b/src/Infrastructure/AI/Agent/SpecsResearchAgent.php new file mode 100644 index 0000000..6f91570 --- /dev/null +++ b/src/Infrastructure/AI/Agent/SpecsResearchAgent.php @@ -0,0 +1,45 @@ +webSearch->search($query); + + if ('' === $searchText) { + $searchText = $this->webSearch->search("{$modelName} specs datasheet"); + } + + if ('' === $searchText) { + throw new \RuntimeException("No web search results found for model: {$modelName}"); + } + + $prompt = <<ollama->generate($this->model, $prompt); + } +} diff --git a/src/Infrastructure/AI/OllamaClient.php b/src/Infrastructure/AI/OllamaClient.php new file mode 100644 index 0000000..af97ad8 --- /dev/null +++ b/src/Infrastructure/AI/OllamaClient.php @@ -0,0 +1,53 @@ +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']; + } +} diff --git a/src/Infrastructure/AI/OllamaClientInterface.php b/src/Infrastructure/AI/OllamaClientInterface.php new file mode 100644 index 0000000..4ff78c2 --- /dev/null +++ b/src/Infrastructure/AI/OllamaClientInterface.php @@ -0,0 +1,12 @@ +articleRepository->findById(Uuid::fromString($message->articleId)); + if (null === $article) { + return; + } + + if (!$this->adapterRegistry->has($message->platformType)) { + return; + } + + $adapter = $this->adapterRegistry->get($message->platformType); + + try { + $adapter->deactivateListing($article); + + if (0 === $article->getStock() && ArticleStatus::Listed === $article->getStatus()) { + $article->transitionTo(ArticleStatus::Sold); + $this->articleRepository->save($article); + } + } catch (\RuntimeException $e) { + if ($message->attemptNumber >= self::ALERT_THRESHOLD) { + $this->logger->critical('CRITICAL: Failed to deactivate listing after {attempts} attempts. Risk of oversell!', [ + 'attempts' => $message->attemptNumber, + 'articleId' => $message->articleId, + 'platformType' => $message->platformType, + 'error' => $e->getMessage(), + ]); + } + + throw $e; + } + } +} diff --git a/src/Infrastructure/Messenger/Handler/DraftArticleHandler.php b/src/Infrastructure/Messenger/Handler/DraftArticleHandler.php new file mode 100644 index 0000000..b267162 --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/DraftArticleHandler.php @@ -0,0 +1,59 @@ +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, + inventoryNumber: $message->inventoryNumber, + ); + + 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(), + )); + } +} diff --git a/src/Infrastructure/Messenger/Handler/EbayTextHandler.php b/src/Infrastructure/Messenger/Handler/EbayTextHandler.php new file mode 100644 index 0000000..4ba4d05 --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/EbayTextHandler.php @@ -0,0 +1,39 @@ +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'], + ); + } +} diff --git a/src/Infrastructure/Messenger/Handler/JsonCodingHandler.php b/src/Infrastructure/Messenger/Handler/JsonCodingHandler.php new file mode 100644 index 0000000..312cae8 --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/JsonCodingHandler.php @@ -0,0 +1,51 @@ +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, + )); + } +} diff --git a/src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php b/src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php new file mode 100644 index 0000000..c68f670 --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php @@ -0,0 +1,130 @@ +orders->findByPlatformOrderId($message->platformOrderId)) { + $this->logger->info('OrderReceivedHandler: duplicate message, skipping', [ + 'platformOrderId' => $message->platformOrderId, + ]); + + return; + } + + $ebayOrder = $this->fulfillmentClient->getOrder($message->platformOrderId); + + $article = $this->articles->findByEbayListingId($ebayOrder['ebayListingId']); + if (null === $article) { + throw new UnrecoverableMessageHandlingException("Article not found for eBay listing ID: {$ebayOrder['ebayListingId']}"); + } + + $platform = $this->platforms->findByType($message->platformType); + if (null === $platform) { + throw new UnrecoverableMessageHandlingException("Platform '{$message->platformType}' not configured in database"); + } + + $locked = $this->articles->decrementStockAtomic($article->getId()); + if (!$locked) { + $this->logger->critical('OVERSTOCK: stock was already 0, sale cannot be fulfilled', [ + 'articleId' => $article->getId()->toRfc4122(), + 'platformOrderId' => $message->platformOrderId, + ]); + + throw new UnrecoverableMessageHandlingException("Overstock for article {$article->getId()->toRfc4122()} — manual intervention required"); + } + + $customer = $this->customerResolver->resolve( + platform: $message->platformType, + platformUserId: $ebayOrder['buyerUsername'], + name: $ebayOrder['buyerName'], + email: $ebayOrder['buyerEmail'], + address: [ + 'street' => $ebayOrder['shippingStreet'], + 'city' => $ebayOrder['shippingCity'], + 'zip' => $ebayOrder['shippingZip'], + ], + ); + + $order = new Order( + $article, + $customer, + $platform, + $message->platformOrderId, + $ebayOrder['salePrice'], + new \DateTimeImmutable($ebayOrder['saleDate']), + ); + $order->setStatus(OrderStatus::Processing); + $this->orders->save($order); + + $frappeInvoiceId = $this->erp->createSalesInvoice($order); + $pdfContent = $this->erp->fetchInvoicePdf($frappeInvoiceId); + + $tmpFile = tempnam(sys_get_temp_dir(), 'invoice_'); + \assert(false !== $tmpFile); + file_put_contents($tmpFile, $pdfContent); + $stored = $this->storage->store($tmpFile, 'invoice-'.$frappeInvoiceId.'.pdf'); + unlink($tmpFile); + + $invoice = new Invoice($order, $frappeInvoiceId, $stored->storagePath, $stored->filename); + $order->setInvoice($invoice); + $this->invoices->save($invoice); + + $this->mailer->sendInvoice($invoice); + $invoice->markAsEmailed(); + $this->invoices->save($invoice); + + $this->bus->dispatch(new UpdateStockOnChannelsMessage( + articleId: $article->getId()->toRfc4122(), + newStock: $article->getStock(), + )); + + $order->setStatus(OrderStatus::Completed); + $this->orders->save($order); + + $this->logger->info('Order processed successfully', [ + 'orderId' => $order->getId()->toRfc4122(), + 'platformOrderId' => $message->platformOrderId, + 'frappeInvoiceId' => $frappeInvoiceId, + ]); + } +} diff --git a/src/Infrastructure/Messenger/Handler/PhotoUploadHandler.php b/src/Infrastructure/Messenger/Handler/PhotoUploadHandler.php new file mode 100644 index 0000000..ddd6f02 --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/PhotoUploadHandler.php @@ -0,0 +1,51 @@ +jobRepository->findById(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'], + )); + } +} diff --git a/src/Infrastructure/Messenger/Handler/PublishToChannelHandler.php b/src/Infrastructure/Messenger/Handler/PublishToChannelHandler.php new file mode 100644 index 0000000..3213046 --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/PublishToChannelHandler.php @@ -0,0 +1,50 @@ +articleRepository->findById(Uuid::fromString($message->articleId)); + if (null === $article || ArticleStatus::Active !== $article->getStatus()) { + return; + } + + $platforms = $this->platformRepository->findAll(); + + foreach ($platforms as $platform) { + if (!$this->adapterRegistry->has($platform->getType())) { + continue; + } + + $adapter = $this->adapterRegistry->get($platform->getType()); + $listingId = $adapter->publishListing($article); + + if ('ebay' === $platform->getType()) { + $article->setEbayListingId($listingId); + } + + $article->transitionTo(ArticleStatus::Listed); + $this->articleRepository->save($article); + } + } +} diff --git a/src/Infrastructure/Messenger/Handler/PxeInventoryHandler.php b/src/Infrastructure/Messenger/Handler/PxeInventoryHandler.php new file mode 100644 index 0000000..d30bfd4 --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/PxeInventoryHandler.php @@ -0,0 +1,39 @@ +jobRepository->findById(Uuid::fromString($message->jobId)); + if (null === $job) { + return; + } + + $job->markProcessing(); + $this->jobRepository->save($job); + + $this->bus->dispatch(new JsonCodingMessage( + jobId: $message->jobId, + articleTypeId: $message->articleTypeId, + specsText: $message->pxeDump, + )); + } +} diff --git a/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php b/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php new file mode 100644 index 0000000..2c86c9c --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php @@ -0,0 +1,47 @@ +jobRepository->findById(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, + )); + } +} diff --git a/src/Infrastructure/Messenger/Handler/TrackingPushHandler.php b/src/Infrastructure/Messenger/Handler/TrackingPushHandler.php new file mode 100644 index 0000000..5cb15c9 --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/TrackingPushHandler.php @@ -0,0 +1,45 @@ +orders->findById(Uuid::fromString($message->orderId)); + if (null === $order) { + throw new UnrecoverableMessageHandlingException("Order {$message->orderId} not found"); + } + + $platformType = $order->getPlatform()->getType(); + $adapter = $this->channelAdapters->get($platformType); + + $adapter->pushTracking($order); + $order->markTrackingPushedToEbay(); + $this->orders->save($order); + + $this->logger->info('Tracking pushed to channel', [ + 'orderId' => $message->orderId, + 'platform' => $platformType, + 'trackingNumber' => $message->trackingNumber, + ]); + } +} diff --git a/src/Infrastructure/Messenger/Handler/UpdateStockOnChannelsHandler.php b/src/Infrastructure/Messenger/Handler/UpdateStockOnChannelsHandler.php new file mode 100644 index 0000000..90970ad --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/UpdateStockOnChannelsHandler.php @@ -0,0 +1,58 @@ +articleRepository->findById(Uuid::fromString($message->articleId)); + if (null === $article) { + return; + } + + if (0 === $message->newStock) { + $platforms = $this->platformRepository->findAll(); + foreach ($platforms as $platform) { + if ($this->adapterRegistry->has($platform->getType())) { + $this->bus->dispatch(new DeactivateListingMessage( + articleId: $message->articleId, + platformType: $platform->getType(), + )); + } + } + + return; + } + + $platforms = $this->platformRepository->findAll(); + foreach ($platforms as $platform) { + if (!$this->adapterRegistry->has($platform->getType())) { + continue; + } + + $adapter = $this->adapterRegistry->get($platform->getType()); + $adapter->updateStock($article, $message->newStock); + } + } +} diff --git a/src/Infrastructure/Messenger/Handler/ValidationHandler.php b/src/Infrastructure/Messenger/Handler/ValidationHandler.php new file mode 100644 index 0000000..22836a3 --- /dev/null +++ b/src/Infrastructure/Messenger/Handler/ValidationHandler.php @@ -0,0 +1,94 @@ +jobRepository->findById(Uuid::fromString($message->jobId)); + if (null === $job) { + return; + } + + $missing = $this->findMissingFields($message); + + if ([] === $missing) { + $this->bus->dispatch(new DraftArticleMessage( + jobId: $message->jobId, + articleTypeId: $message->articleTypeId, + attributes: $message->attributes, + condition: 'good', + inventoryNumber: null, + serialNumber: null, + )); + + return; + } + + if ($job->getAttemptCount() >= self::MAX_ATTEMPTS) { + $job->markNeedsReview('Validation failed after '.self::MAX_ATTEMPTS.' attempts. Missing: '.implode(', ', $missing)); + $this->jobRepository->save($job); + + return; + } + + $job->incrementAttempt($missing); + $this->jobRepository->save($job); + + $this->bus->dispatch(new JsonCodingMessage( + jobId: $message->jobId, + articleTypeId: $message->articleTypeId, + specsText: $message->specsText, + missingFields: $missing, + )); + } + + /** @return list attribute names that are required but not present */ + private function findMissingFields(ValidationMessage $message): array + { + // Empty attributes always means retry + if ([] === $message->attributes) { + return ['(no attributes returned by LLM)']; + } + + if (null === $this->articleTypeRepository) { + return []; + } + + $articleType = $this->articleTypeRepository->findById(Uuid::fromString($message->articleTypeId)); + if (null === $articleType) { + return []; + } + + $missing = []; + foreach ($articleType->getRequiredAttributeDefinitions() as $def) { + if (!\array_key_exists($def->getId()->toRfc4122(), $message->attributes)) { + $missing[] = $def->getName(); + } + } + + return $missing; + } +} diff --git a/src/Infrastructure/Messenger/Message/DeactivateListingMessage.php b/src/Infrastructure/Messenger/Message/DeactivateListingMessage.php new file mode 100644 index 0000000..921c088 --- /dev/null +++ b/src/Infrastructure/Messenger/Message/DeactivateListingMessage.php @@ -0,0 +1,15 @@ + $attributes */ + public function __construct( + public string $jobId, + public string $articleTypeId, + public array $attributes, + public string $condition, + public ?string $inventoryNumber, + public ?string $serialNumber, + ) { + } +} diff --git a/src/Infrastructure/Messenger/Message/EbayTextMessage.php b/src/Infrastructure/Messenger/Message/EbayTextMessage.php new file mode 100644 index 0000000..1415116 --- /dev/null +++ b/src/Infrastructure/Messenger/Message/EbayTextMessage.php @@ -0,0 +1,14 @@ + $missingFields */ + public function __construct( + public string $jobId, + public string $articleTypeId, + public string $specsText, + public array $missingFields = [], + ) { + } +} diff --git a/src/Infrastructure/Messenger/Message/OrderReceivedMessage.php b/src/Infrastructure/Messenger/Message/OrderReceivedMessage.php new file mode 100644 index 0000000..dd04e9e --- /dev/null +++ b/src/Infrastructure/Messenger/Message/OrderReceivedMessage.php @@ -0,0 +1,18 @@ + $rawPayload + */ + public function __construct( + public string $platformOrderId, + public string $platformType, + public array $rawPayload, + ) { + } +} diff --git a/src/Infrastructure/Messenger/Message/PhotoUploadMessage.php b/src/Infrastructure/Messenger/Message/PhotoUploadMessage.php new file mode 100644 index 0000000..4d53bd1 --- /dev/null +++ b/src/Infrastructure/Messenger/Message/PhotoUploadMessage.php @@ -0,0 +1,16 @@ + $attributes */ + public function __construct( + public string $jobId, + public string $articleTypeId, + public string $specsText, + public array $attributes, + ) { + } +} diff --git a/tests/Unit/Infrastructure/AI/Agent/JsonCodingAgentTest.php b/tests/Unit/Infrastructure/AI/Agent/JsonCodingAgentTest.php new file mode 100644 index 0000000..7f56d42 --- /dev/null +++ b/tests/Unit/Infrastructure/AI/Agent/JsonCodingAgentTest.php @@ -0,0 +1,56 @@ +ollama = $this->createMock(OllamaClientInterface::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 testReturnsParsedAttributes(): void + { + $first = $this->type->getAttributeDefinitions()->first(); + \assert($first instanceof AttributeDefinition); + $defId = $first->getId()->toRfc4122(); + $this->ollama->method('generate') + ->willReturn('```json'."\n".'{"'.$defId.'": "16 GB"}'."\n".'```'); + + $result = $this->agent->encode($this->type, 'Dell Latitude 5520: 16 GB RAM, Intel i7'); + + self::assertCount(1, $result); + self::assertSame('16 GB', array_values($result)[0]); + } + + public function testExtractsJsonFromMarkdownFences(): void + { + $first = $this->type->getAttributeDefinitions()->first(); + \assert($first instanceof AttributeDefinition); + $defId = $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'); + + self::assertArrayHasKey($defId, $result); + } +} diff --git a/tests/Unit/Infrastructure/AI/Agent/OllamaVisionAgentTest.php b/tests/Unit/Infrastructure/AI/Agent/OllamaVisionAgentTest.php new file mode 100644 index 0000000..5c8c93a --- /dev/null +++ b/tests/Unit/Infrastructure/AI/Agent/OllamaVisionAgentTest.php @@ -0,0 +1,44 @@ +ollama = $this->createMock(OllamaClientInterface::class); + $this->agent = new OllamaVisionAgent($this->ollama, 'llava'); + } + + public function testParsesModelAndSerialFromResponse(): void + { + $this->ollama->method('generateWithImage') + ->willReturn("MODEL: Dell Latitude 5520\nSERIAL: ABC12345"); + + $result = $this->agent->analyze('/tmp/photo.jpg'); + + self::assertSame('Dell Latitude 5520', $result['model']); + self::assertSame('ABC12345', $result['serial']); + } + + public function testReturnsEmptyStringsWhenNotFound(): void + { + $this->ollama->method('generateWithImage') + ->willReturn('I cannot read the nameplate clearly.'); + + $result = $this->agent->analyze('/tmp/photo.jpg'); + + self::assertSame('', $result['model']); + self::assertSame('', $result['serial']); + } +} diff --git a/tests/Unit/Infrastructure/Messenger/Handler/ValidationHandlerTest.php b/tests/Unit/Infrastructure/Messenger/Handler/ValidationHandlerTest.php new file mode 100644 index 0000000..7df201b --- /dev/null +++ b/tests/Unit/Infrastructure/Messenger/Handler/ValidationHandlerTest.php @@ -0,0 +1,85 @@ +jobRepo = $this->createMock(AIPipelineJobRepositoryInterface::class); + $this->bus = $this->createMock(MessageBusInterface::class); + $this->handler = new ValidationHandler($this->jobRepo, $this->bus); + + $this->job = new AIPipelineJob(AIPipelineJobType::Photo, ['test' => true]); + } + + public function testDispatchesDraftMessageWhenAllAttributesPresent(): void + { + $this->jobRepo->method('findById')->willReturn($this->job); + + $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(self::once()) + ->method('dispatch') + ->with(self::isInstanceOf(DraftArticleMessage::class)) + ->willReturn(new Envelope(new \stdClass())); + + ($this->handler)(new ValidationMessage( + jobId: $this->job->getId()->toRfc4122(), + articleTypeId: $type->getId()->toRfc4122(), + specsText: 'some specs', + attributes: $attributes, + )); + } + + public function testRetriesJsonCodingWhenFieldsMissingAndUnderLimit(): void + { + $this->jobRepo->method('findById')->willReturn($this->job); + + $type = new ArticleType('Notebook'); + $type->addAttributeDefinition(new AttributeDefinition('RAM', AttributeType::String)); + + $this->bus->expects(self::once()) + ->method('dispatch') + ->with(self::isInstanceOf(JsonCodingMessage::class)) + ->willReturn(new Envelope(new \stdClass())); + + ($this->handler)(new ValidationMessage( + jobId: $this->job->getId()->toRfc4122(), + articleTypeId: $type->getId()->toRfc4122(), + specsText: 'some specs', + attributes: [], + )); + } +}