refactor: make prompt templates DB-only system prompts

Remove all hardcoded defaults from PromptTemplateService — the DB record
is now mandatory and render() throws if a key is missing. Admin UI
disables new/delete and makes the key field read-only so system prompts
cannot be renamed or removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 10:20:17 +00:00
parent 974bd239a5
commit 32da9bb48f
2 changed files with 11 additions and 76 deletions

View file

@ -8,68 +8,6 @@ use App\Domain\AI\Repository\PromptTemplateRepositoryInterface;
final class PromptTemplateService
{
/** @var array<string, string> */
private const array DEFAULTS = [
'specs_research' => <<<'PROMPT'
You are a hardware specifications expert. Extract the technical specifications for the {{articleType}}: "{{subject}}".
Web search results:
{{searchResults}}
Based on the search results above, list all technical specifications including:
processor, RAM, storage variants, display size and resolution, GPU, battery capacity,
ports, connectivity, weight, dimensions, OS, and any other relevant specs.
Be specific and accurate. If a spec is not found in the search results, omit it rather than guessing.
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, any model identifier (name or number), and serial number visible on the label.
If the label shows both a product name (e.g. "ThinkPad T490s") and a separate part/product code (e.g. "20NXS0BA00"), put the product name in MODEL_NAME and the code in MODEL_NUMBER.
If only one model field is visible, put it in MODEL_NAME and leave MODEL_NUMBER completely empty.
MODEL_NUMBER must never contain the serial number.
Do not guess or add information not visible on the label.
Respond in exactly this format:
MANUFACTURER: Lenovo
MODEL_NAME: ThinkBook 14 G6 IRL
MODEL_NUMBER:
SERIAL: PNV09SJZ
Use empty string (nothing after the colon) when a field is not visible.
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,
) {
@ -83,9 +21,11 @@ PROMPT,
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}");
if (null === $template) {
throw new \RuntimeException("System prompt '{$key}' not found in database.");
}
$body = $template->getBody();
$search = array_map(static fn (string $k) => '{{'.$k.'}}', array_keys($variables));
return str_replace($search, array_values($variables), $body);
@ -106,10 +46,4 @@ PROMPT,
'json_coding' => ['schema', 'missingHint', 'specsText'],
];
}
/** @return string */
public static function defaultFor(string $key): string
{
return self::DEFAULTS[$key] ?? '';
}
}

View file

@ -6,13 +6,15 @@ namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\AI\PromptTemplate;
use App\Infrastructure\AI\PromptTemplateService;
use Symfony\Component\Translation\TranslatableMessage;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
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;
use Symfony\Component\Translation\TranslatableMessage;
/** @extends AbstractCrudController<PromptTemplate> */
final class PromptTemplateCrudController extends AbstractCrudController
@ -30,17 +32,17 @@ final class PromptTemplateCrudController extends AbstractCrudController
->setDefaultSort(['key' => 'ASC']);
}
public function createEntity(string $entityFqcn): PromptTemplate
public function configureActions(Actions $actions): Actions
{
return new PromptTemplate('', '');
return $actions->disable(Action::NEW, Action::DELETE);
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm()->hideOnIndex();
yield TextField::new('key', new TranslatableMessage('field.prompt_key', [], 'admin'))
->setHelp(new TranslatableMessage('field.prompt_key_help', [], 'admin'))
->setColumns(4);
->setColumns(4)
->setFormTypeOption('disabled', true);
yield TextareaField::new('body', new TranslatableMessage('field.prompt_body', [], 'admin'))
->setHelp($this->buildVariableHelp())
->setNumOfRows(18)
@ -64,7 +66,6 @@ final class PromptTemplateCrudController extends AbstractCrudController
$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);
}