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 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 07:19:02 +00:00
parent d667db7b7d
commit 49e36a0a06
13 changed files with 425 additions and 73 deletions

View file

@ -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'

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260519000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create prompt_templates table for editable AI prompts';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Domain\AI;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'prompt_templates', schema: 'app')]
class PromptTemplate
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(type: 'string', length: 100, unique: true)]
private string $key;
#[ORM\Column(type: 'text')]
private string $body;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
public function __construct(string $key, string $body)
{
$this->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();
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Domain\AI\Repository;
use App\Domain\AI\PromptTemplate;
interface PromptTemplateRepositoryInterface
{
public function findByKey(string $key): ?PromptTemplate;
/** @return list<PromptTemplate> */
public function findAll(): array;
public function save(PromptTemplate $template): void;
public function remove(PromptTemplate $template): void;
}

View file

@ -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 = <<<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;
$specsSection = $attributeText !== ''
? "Known attributes:\n{$attributeText}"
: ($specsText !== '' ? "Research notes:\n".mb_substr($specsText, 0, 1500) : '');
$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;
$titlePrompt = $this->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));

View file

@ -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 = <<<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;
$prompt = $this->prompts->render('json_coding', [
'schema' => $schema,
'missingHint' => $missingHint,
'specsText' => $specsText,
]);
$response = $this->ollama->generate($this->model, $prompt);

View file

@ -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: <model name>
SERIAL: <serial number>
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'),
];

View file

@ -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 = <<<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);
return $result;
}
}

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\AI;
use App\Domain\AI\Repository\PromptTemplateRepositoryInterface;
final class PromptTemplateService
{
/** @var array<string, string> */
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: <brand name, e.g. Dell, HP, Lenovo, Medion>
MODEL: <model number or designation>
SERIAL: <serial number>
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<string, string> $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<string, list<string>>
*/
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] ?? '';
}
}

View file

@ -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');

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\AI\PromptTemplate;
use App\Infrastructure\AI\PromptTemplateService;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
/** @extends AbstractCrudController<PromptTemplate> */
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. <code>specs_research</code>). 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 <code>{{variableName}}</code> as placeholders. Known keys and their variables:'];
$lines[] = '<ul>';
foreach ($known as $key => $vars) {
$varList = $vars !== [] ? implode(', ', array_map(static fn (string $v) => "<code>{{$v}}</code>", $vars)) : '—';
$lines[] = "<li><strong>{$key}</strong>: {$varList}</li>";
}
$lines[] = '</ul>';
$lines[] = 'If no DB entry exists for a key, the built-in default is used automatically.';
return implode('', $lines);
}
}

View file

@ -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,
));
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Repository;
use App\Domain\AI\PromptTemplate;
use App\Domain\AI\Repository\PromptTemplateRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrinePromptTemplateRepository implements PromptTemplateRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
public function findByKey(string $key): ?PromptTemplate
{
/** @var PromptTemplate|null */
return $this->em->getRepository(PromptTemplate::class)->findOneBy(['key' => $key]);
}
/** @return list<PromptTemplate> */
public function findAll(): array
{
/** @var list<PromptTemplate> */
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();
}
}