From 49e36a0a061646d399992e9ccf5a0ec6411e381b Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Mon, 18 May 2026 07:19:02 +0000 Subject: [PATCH] feat: editable AI prompt templates and articleType context in specs research All agent prompts are now stored in app.prompt_templates (migration 20260519000000) and editable by admins via the new AI Prompts CRUD page. If no DB entry exists for a key the hardcoded default is used automatically as fallback. PromptTemplateService renders templates with {{variable}} substitution. All four agents (SpecsResearch, JsonCoding, EbayText, OllamaVision) use the service. SpecsResearchAgent now receives the articleType name (e.g. "Laptop") so the specs prompt is scoped to the correct device category instead of being generic. SpecsResearchHandler loads the ArticleType from the repository for this purpose. Co-Authored-By: Claude Sonnet 4.6 --- config/services.yaml | 17 +-- migrations/Version20260519000000.php | 37 ++++++ src/Domain/AI/PromptTemplate.php | 65 +++++++++++ .../PromptTemplateRepositoryInterface.php | 19 ++++ src/Infrastructure/AI/Agent/EbayTextAgent.php | 46 ++++---- .../AI/Agent/JsonCodingAgent.php | 19 ++-- .../AI/Agent/OllamaVisionAgent.php | 14 +-- .../AI/Agent/SpecsResearchAgent.php | 39 +++---- .../AI/PromptTemplateService.php | 105 ++++++++++++++++++ .../Controller/Admin/DashboardController.php | 6 +- .../Admin/PromptTemplateCrudController.php | 70 ++++++++++++ .../Handler/SpecsResearchHandler.php | 20 +++- .../DoctrinePromptTemplateRepository.php | 41 +++++++ 13 files changed, 425 insertions(+), 73 deletions(-) create mode 100644 migrations/Version20260519000000.php create mode 100644 src/Domain/AI/PromptTemplate.php create mode 100644 src/Domain/AI/Repository/PromptTemplateRepositoryInterface.php create mode 100644 src/Infrastructure/AI/PromptTemplateService.php create mode 100644 src/Infrastructure/Http/Controller/Admin/PromptTemplateCrudController.php create mode 100644 src/Infrastructure/Persistence/Repository/DoctrinePromptTemplateRepository.php 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(); + } +}