diff --git a/config/services.yaml b/config/services.yaml index 0b3e786..f7f0aa5 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -55,8 +55,7 @@ services: # Switch between Ollama and Mistral by changing the alias target below App\Infrastructure\AI\OllamaClientInterface: - alias: App\Infrastructure\AI\OllamaClient - # alias: App\Infrastructure\AI\MistralClient + alias: App\Infrastructure\AI\MistralClient App\Infrastructure\AI\OllamaClient: arguments: @@ -67,12 +66,13 @@ services: $mistralApiKey: '%env(MISTRAL_API_KEY)%' $mistralBaseUrl: '%env(MISTRAL_BASE_URL)%' - App\Infrastructure\Search\WebSearchInterface: - alias: App\Infrastructure\Search\SerpApiWebSearch - - App\Infrastructure\Search\SerpApiWebSearch: + App\Infrastructure\AI\AiConfigService: arguments: - $serpApiKey: '%env(SERP_API_KEY)%' + $visionModel: '%env(AI_VISION_MODEL)%' + $textModel: '%env(AI_TEXT_MODEL)%' + $ollamaBaseUrl: '%env(OLLAMA_BASE_URL)%' + $mistralBaseUrl: '%env(MISTRAL_BASE_URL)%' + $mistralApiKey: '%env(MISTRAL_API_KEY)%' App\Infrastructure\AI\Agent\OllamaVisionAgent: arguments: @@ -93,6 +93,9 @@ services: App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface: alias: App\Infrastructure\Persistence\Repository\DoctrineAIPipelineJobRepository + App\Domain\AI\Repository\PromptTemplateRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrinePromptTemplateRepository + App\Infrastructure\Console\BackupCommand: arguments: $backupDir: '%kernel.project_dir%/var/backups' diff --git a/migrations/Version20260519000000.php b/migrations/Version20260519000000.php new file mode 100644 index 0000000..3391b63 --- /dev/null +++ b/migrations/Version20260519000000.php @@ -0,0 +1,37 @@ +addSql(<<<'SQL' + CREATE TABLE app.prompt_templates ( + id UUID NOT NULL, + key VARCHAR(100) NOT NULL, + body TEXT NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uq_prompt_template_key UNIQUE (key) + ) + SQL); + $this->addSql("COMMENT ON COLUMN app.prompt_templates.id IS '(DC2Type:uuid)'"); + $this->addSql("COMMENT ON COLUMN app.prompt_templates.updated_at IS '(DC2Type:datetime_immutable)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE app.prompt_templates'); + } +} diff --git a/src/Domain/AI/PromptTemplate.php b/src/Domain/AI/PromptTemplate.php new file mode 100644 index 0000000..144d878 --- /dev/null +++ b/src/Domain/AI/PromptTemplate.php @@ -0,0 +1,65 @@ +id = Uuid::v7(); + $this->key = $key; + $this->body = $body; + $this->updatedAt = new \DateTimeImmutable(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getKey(): string + { + return $this->key; + } + + public function getBody(): string + { + return $this->body; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + public function setKey(string $key): void + { + $this->key = $key; + } + + public function setBody(string $body): void + { + $this->body = $body; + $this->updatedAt = new \DateTimeImmutable(); + } +} diff --git a/src/Domain/AI/Repository/PromptTemplateRepositoryInterface.php b/src/Domain/AI/Repository/PromptTemplateRepositoryInterface.php new file mode 100644 index 0000000..59b7bb2 --- /dev/null +++ b/src/Domain/AI/Repository/PromptTemplateRepositoryInterface.php @@ -0,0 +1,19 @@ + */ + public function findAll(): array; + + public function save(PromptTemplate $template): void; + + public function remove(PromptTemplate $template): void; +} diff --git a/src/Infrastructure/AI/Agent/EbayTextAgent.php b/src/Infrastructure/AI/Agent/EbayTextAgent.php index a76bdfa..d7c30b2 100644 --- a/src/Infrastructure/AI/Agent/EbayTextAgent.php +++ b/src/Infrastructure/AI/Agent/EbayTextAgent.php @@ -6,45 +6,53 @@ namespace App\Infrastructure\AI\Agent; use App\Domain\Article\Article; use App\Infrastructure\AI\OllamaClientInterface; +use App\Infrastructure\AI\PromptTemplateService; final class EbayTextAgent { public function __construct( private readonly OllamaClientInterface $ollama, + private readonly PromptTemplateService $prompts, private readonly string $model, ) { } /** @return array{title: string, description: string} */ - public function generate(Article $article): array + public function generate(Article $article, string $specsText = ''): array { $attributes = []; foreach ($article->getAttributeValues() as $value) { - $attributes[] = $value->getAttributeDefinition()->getName().': '.$value->getValue(); + if ('' !== $value->getValue()) { + $attributes[] = $value->getAttributeDefinition()->getName().': '.$value->getValue(); + } } $attributeText = implode("\n", $attributes); + $typeName = $article->getArticleType()->getName(); $condition = $article->getCondition()->value; $conditionNotes = $article->getConditionNotes() ?? ''; + $manufacturer = $article->getManufacturer() ?? ''; + $modelNumber = $article->getModelNumber() ?? ''; + $deviceLabel = trim("{$manufacturer} {$modelNumber}") ?: $typeName; - $titlePrompt = <<prompts->render('ebay_title', [ + 'typeName' => $typeName, + 'deviceLabel' => $deviceLabel, + 'condition' => $condition, + 'specsSection' => $specsSection, + ]); + + $descriptionPrompt = $this->prompts->render('ebay_description', [ + 'typeName' => $typeName, + 'deviceLabel' => $deviceLabel, + 'condition' => $condition, + 'conditionNotes' => $conditionNotes, + 'specsSection' => $specsSection, + ]); $title = trim($this->ollama->generate($this->model, $titlePrompt)); $description = trim($this->ollama->generate($this->model, $descriptionPrompt)); diff --git a/src/Infrastructure/AI/Agent/JsonCodingAgent.php b/src/Infrastructure/AI/Agent/JsonCodingAgent.php index cef7e2c..11b514b 100644 --- a/src/Infrastructure/AI/Agent/JsonCodingAgent.php +++ b/src/Infrastructure/AI/Agent/JsonCodingAgent.php @@ -6,11 +6,13 @@ namespace App\Infrastructure\AI\Agent; use App\Domain\Article\ArticleType; use App\Infrastructure\AI\OllamaClientInterface; +use App\Infrastructure\AI\PromptTemplateService; final class JsonCodingAgent { public function __construct( private readonly OllamaClientInterface $ollama, + private readonly PromptTemplateService $prompts, private readonly string $model, ) { } @@ -27,18 +29,11 @@ final class JsonCodingAgent ? "\nIMPORTANT: The following fields were missing in the previous attempt. Make sure they are included: ".implode(', ', $missingFields)."\n" : ''; - $prompt = <<prompts->render('json_coding', [ + 'schema' => $schema, + 'missingHint' => $missingHint, + 'specsText' => $specsText, + ]); $response = $this->ollama->generate($this->model, $prompt); diff --git a/src/Infrastructure/AI/Agent/OllamaVisionAgent.php b/src/Infrastructure/AI/Agent/OllamaVisionAgent.php index c2cc2bf..2f483d0 100644 --- a/src/Infrastructure/AI/Agent/OllamaVisionAgent.php +++ b/src/Infrastructure/AI/Agent/OllamaVisionAgent.php @@ -5,30 +5,26 @@ declare(strict_types=1); namespace App\Infrastructure\AI\Agent; use App\Infrastructure\AI\OllamaClientInterface; +use App\Infrastructure\AI\PromptTemplateService; final class OllamaVisionAgent { public function __construct( private readonly OllamaClientInterface $ollama, + private readonly PromptTemplateService $prompts, private readonly string $model, ) { } - /** @return array{model: string, serial: string} */ + /** @return array{manufacturer: string, 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: - SERIAL: - PROMPT; + $prompt = $this->prompts->render('vision_analyze'); $response = $this->ollama->generateWithImage($this->model, $prompt, $imagePath); return [ + 'manufacturer' => $this->extractField($response, 'MANUFACTURER'), 'model' => $this->extractField($response, 'MODEL'), 'serial' => $this->extractField($response, 'SERIAL'), ]; diff --git a/src/Infrastructure/AI/Agent/SpecsResearchAgent.php b/src/Infrastructure/AI/Agent/SpecsResearchAgent.php index 6f91570..2e4c635 100644 --- a/src/Infrastructure/AI/Agent/SpecsResearchAgent.php +++ b/src/Infrastructure/AI/Agent/SpecsResearchAgent.php @@ -5,41 +5,32 @@ declare(strict_types=1); namespace App\Infrastructure\AI\Agent; use App\Infrastructure\AI\OllamaClientInterface; -use App\Infrastructure\Search\WebSearchInterface; +use App\Infrastructure\AI\PromptTemplateService; final class SpecsResearchAgent { public function __construct( - private readonly WebSearchInterface $webSearch, - private readonly OllamaClientInterface $ollama, + private readonly OllamaClientInterface $client, + private readonly PromptTemplateService $prompts, private readonly string $model, ) { } - public function research(string $modelName): string + public function research(string $modelName, string $articleTypeName, string $manufacturer = ''): string { - $query = "{$modelName} technical specifications full specs"; - $searchText = $this->webSearch->search($query); + $subject = trim(($manufacturer !== '' ? $manufacturer.' ' : '').$modelName); - if ('' === $searchText) { - $searchText = $this->webSearch->search("{$modelName} specs datasheet"); + $prompt = $this->prompts->render('specs_research', [ + 'articleType' => $articleTypeName, + 'subject' => $subject, + ]); + + $result = $this->client->generate($this->model, $prompt); + + if ('' === trim($result)) { + throw new \RuntimeException("No specifications found for model: {$modelName}"); } - if ('' === $searchText) { - throw new \RuntimeException("No web search results found for model: {$modelName}"); - } - - $prompt = <<ollama->generate($this->model, $prompt); + return $result; } } diff --git a/src/Infrastructure/AI/PromptTemplateService.php b/src/Infrastructure/AI/PromptTemplateService.php new file mode 100644 index 0000000..27b940a --- /dev/null +++ b/src/Infrastructure/AI/PromptTemplateService.php @@ -0,0 +1,105 @@ + */ + private const array DEFAULTS = [ + 'specs_research' => <<<'PROMPT' +List all known technical specifications for the {{articleType}}: "{{subject}}". +Include: processor, RAM, storage variants, display size and resolution, GPU, battery capacity, +ports, connectivity, weight, dimensions, OS, and any other relevant specs. +If you know this device, be specific and complete. If it is unknown, say so explicitly. +PROMPT, + + 'ebay_title' => <<<'PROMPT' +Create a concise eBay listing title (max 80 characters) for this {{typeName}}. +Device: {{deviceLabel}} +Use the most important specifications. Include condition if not "new". +Condition: {{condition}} +{{specsSection}} +Return ONLY the title text, no quotes, no explanation. +PROMPT, + + 'ebay_description' => <<<'PROMPT' +Create a professional eBay listing description in German for this {{typeName}}. +Device: {{deviceLabel}} +Include all available specifications in a clear, structured format. +Mention the condition: {{condition}}. +{{conditionNotes}} +{{specsSection}} +Use HTML formatting (ul, li, strong tags). Max 2000 characters. +PROMPT, + + 'vision_analyze' => <<<'PROMPT' +Look at this nameplate/label photo of IT hardware. +Extract the manufacturer (brand), model number/designation, and serial number 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): +MANUFACTURER: +MODEL: +SERIAL: +PROMPT, + + 'json_coding' => <<<'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, + ]; + + public function __construct( + private readonly PromptTemplateRepositoryInterface $repository, + ) { + } + + /** + * Renders a prompt template by key, substituting {{variable}} placeholders. + * + * @param array $variables + */ + public function render(string $key, array $variables = []): string + { + $template = $this->repository->findByKey($key); + $body = $template?->getBody() ?? self::DEFAULTS[$key] + ?? throw new \InvalidArgumentException("Unknown prompt template key: {$key}"); + + $search = array_map(static fn (string $k) => '{{'.$k.'}}', array_keys($variables)); + + return str_replace($search, array_values($variables), $body); + } + + /** + * Returns all known prompt keys with their available variables. + * + * @return array> + */ + public static function knownKeys(): array + { + return [ + 'specs_research' => ['articleType', 'subject'], + 'ebay_title' => ['typeName', 'deviceLabel', 'condition', 'specsSection'], + 'ebay_description' => ['typeName', 'deviceLabel', 'condition', 'conditionNotes', 'specsSection'], + 'vision_analyze' => [], + 'json_coding' => ['schema', 'missingHint', 'specsText'], + ]; + } + + /** @return string */ + public static function defaultFor(string $key): string + { + return self::DEFAULTS[$key] ?? ''; + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/DashboardController.php b/src/Infrastructure/Http/Controller/Admin/DashboardController.php index 86bedfa..6b28198 100644 --- a/src/Infrastructure/Http/Controller/Admin/DashboardController.php +++ b/src/Infrastructure/Http/Controller/Admin/DashboardController.php @@ -40,10 +40,14 @@ final class DashboardController extends AbstractDashboardController public function configureMenuItems(): iterable { yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home'); + yield MenuItem::linkToRoute('Ingest Article', 'fa fa-camera', 'admin_manual_ingest'); yield MenuItem::linkTo(ArticleCrudController::class, 'Articles', 'fa fa-box'); yield MenuItem::linkTo(ArticleTypeCrudController::class, 'Article Types', 'fa fa-tags'); yield MenuItem::linkTo(AttributeDefinitionCrudController::class, 'Attributes', 'fa fa-list-check'); - yield MenuItem::linkTo(AIPipelineJobCrudController::class, 'AI Pipeline Jobs', 'fa fa-robot'); + yield MenuItem::section('Pipeline'); + yield MenuItem::linkTo(AIPipelineJobCrudController::class, 'Active Jobs', 'fa fa-robot'); + yield MenuItem::linkTo(PipelineArchiveCrudController::class, 'Archive', 'fa fa-box-archive'); + yield MenuItem::linkTo(PromptTemplateCrudController::class, 'AI Prompts', 'fa fa-message'); yield MenuItem::linkTo(UserCrudController::class, 'Users', 'fa fa-users'); yield MenuItem::linkTo(LogEntryCrudController::class, 'Logs', 'fa fa-list'); yield MenuItem::section('Sales'); diff --git a/src/Infrastructure/Http/Controller/Admin/PromptTemplateCrudController.php b/src/Infrastructure/Http/Controller/Admin/PromptTemplateCrudController.php new file mode 100644 index 0000000..ad082de --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/PromptTemplateCrudController.php @@ -0,0 +1,70 @@ + */ +final class PromptTemplateCrudController extends AbstractCrudController +{ + public static function getEntityFqcn(): string + { + return PromptTemplate::class; + } + + public function configureCrud(Crud $crud): Crud + { + return $crud + ->setEntityLabelInSingular('Prompt Template') + ->setEntityLabelInPlural('Prompt Templates') + ->setDefaultSort(['key' => 'ASC']); + } + + public function createEntity(string $entityFqcn): PromptTemplate + { + return new PromptTemplate('', ''); + } + + public function configureFields(string $pageName): iterable + { + yield IdField::new('id')->hideOnForm(); + yield TextField::new('key', 'Key') + ->setHelp('Slug identifying the prompt (e.g. specs_research). Must be unique.') + ->setColumns(4); + yield TextareaField::new('body', 'Prompt Body') + ->setHelp($this->buildVariableHelp()) + ->setNumOfRows(18) + ->hideOnIndex(); + yield TextField::new('body', 'Body Preview') + ->formatValue(static fn (string $v): string => mb_substr($v, 0, 120).(mb_strlen($v) > 120 ? '…' : '')) + ->hideOnForm() + ->setSortable(false); + yield DateTimeField::new('updatedAt', 'Last Updated') + ->hideOnForm() + ->setFormat('yyyy-MM-dd HH:mm'); + } + + private function buildVariableHelp(): string + { + $known = PromptTemplateService::knownKeys(); + $lines = ['Use {{variableName}} as placeholders. Known keys and their variables:']; + $lines[] = '
    '; + foreach ($known as $key => $vars) { + $varList = $vars !== [] ? implode(', ', array_map(static fn (string $v) => "{{$v}}", $vars)) : '—'; + $lines[] = "
  • {$key}: {$varList}
  • "; + } + $lines[] = '
'; + $lines[] = 'If no DB entry exists for a key, the built-in default is used automatically.'; + + return implode('', $lines); + } +} diff --git a/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php b/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php index 2c86c9c..e9de043 100644 --- a/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php +++ b/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php @@ -4,6 +4,7 @@ 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\SpecsResearchAgent; use App\Infrastructure\Messenger\Message\JsonCodingMessage; @@ -17,6 +18,7 @@ final class SpecsResearchHandler { public function __construct( private readonly SpecsResearchAgent $specsAgent, + private readonly ArticleTypeRepositoryInterface $articleTypeRepository, private readonly AIPipelineJobRepositoryInterface $jobRepository, private readonly MessageBusInterface $bus, ) { @@ -29,8 +31,20 @@ final class SpecsResearchHandler return; } + $articleType = $this->articleTypeRepository->findById(Uuid::fromString($message->articleTypeId)); + if (null === $articleType) { + $job->markFailed("ArticleType {$message->articleTypeId} not found"); + $this->jobRepository->save($job); + + return; + } + try { - $specsText = $this->specsAgent->research($message->modelName); + $specsText = $this->specsAgent->research( + $message->modelName, + $articleType->getName(), + $message->manufacturer, + ); } catch (\RuntimeException $e) { $job->markNeedsReview('SpecsResearchAgent: '.$e->getMessage()); $this->jobRepository->save($job); @@ -38,10 +52,14 @@ final class SpecsResearchHandler return; } + $job->recordStep('specs_research', ['specsText' => $specsText]); + $this->jobRepository->save($job); + $this->bus->dispatch(new JsonCodingMessage( jobId: $message->jobId, articleTypeId: $message->articleTypeId, specsText: $specsText, + serialNumber: $message->serialNumber, )); } } diff --git a/src/Infrastructure/Persistence/Repository/DoctrinePromptTemplateRepository.php b/src/Infrastructure/Persistence/Repository/DoctrinePromptTemplateRepository.php new file mode 100644 index 0000000..e47a696 --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrinePromptTemplateRepository.php @@ -0,0 +1,41 @@ +em->getRepository(PromptTemplate::class)->findOneBy(['key' => $key]); + } + + /** @return list */ + public function findAll(): array + { + /** @var list */ + return $this->em->getRepository(PromptTemplate::class)->findBy([], ['key' => 'ASC']); + } + + public function save(PromptTemplate $template): void + { + $this->em->persist($template); + $this->em->flush(); + } + + public function remove(PromptTemplate $template): void + { + $this->em->remove($template); + $this->em->flush(); + } +}