refactor(ai): move chat + text-gen prompts into resources/ai/prompts/

Extracts the two inline LLM prompts (AiAssistantService's chat system
prompt and AiTextGenerationService's writing preamble) out of PHP
heredocs and into plain-markdown template files under resources/ai/
prompts/. Each file can now be edited without opening a service
class, without wrestling with PHP string interpolation, and with
proper markdown syntax highlighting in editors.

A tiny PromptLoader helper at app/Support/Ai/PromptLoader.php reads
the file and does {{placeholder}} substitution via strtr() — no
Blade, because Blade's {{ $var }} HTML-escapes ampersands and quotes,
which is wrong for LLM prompts (a company called "Smith & Co" would
be sent as "Smith & Co"). Missing templates throw RuntimeException
so they fail loud during development.

Pure refactor: no prompt wording changes. Existing AI feature tests
(AiChatFlowTest, AiGenerationTest) pass unchanged — they assert on
message structure via ScriptedAiDriver, not on prompt content. Three
new unit tests in PromptLoaderTest lock the helper's contract:
placeholder substitution, no-var loading, missing-file error.
This commit is contained in:
Darko Gjorgjijoski
2026-04-11 21:08:43 +02:00
parent f66d0f80b8
commit 5b53a7c283
6 changed files with 122 additions and 21 deletions

View File

@@ -9,6 +9,7 @@ use App\Models\User;
use App\Services\AiConfigurationService;
use App\Support\Ai\AiChatResponse;
use App\Support\Ai\AiException;
use App\Support\Ai\PromptLoader;
use Carbon\Carbon;
use InvalidArgumentException;
use Throwable;
@@ -229,29 +230,21 @@ class AiAssistantService
/**
* Compose the system prompt with company context.
*
* The template itself lives at resources/ai/prompts/chat-system.md so
* it can be edited without touching this class. See PromptLoader for
* the substitution rules.
*/
protected function buildSystemPrompt(AiConversation $conversation): string
{
$company = Company::find($conversation->company_id);
$user = User::find($conversation->user_id);
$companyName = $company?->name ?? 'this company';
$userName = $user?->name ?? 'the user';
$today = Carbon::now()->toDateString();
return <<<PROMPT
You are the InvoiceShelf assistant, embedded in an invoicing application. You help {$userName} answer questions about **{$companyName}**'s data: invoices, estimates, payments, expenses, customers, and items.
Today is {$today}.
Rules:
- Use the provided tools whenever the user asks about specific records. Do not invent data.
- Only answer questions about {$companyName}'s data. If asked about another company or unrelated topics, politely decline.
- You cannot modify data — you are read-only. If the user asks you to create/update/delete something, explain that they need to do it in the UI.
- Be concise. Format responses in Markdown (headings, **bold**, bullet lists, tables when comparing multiple records).
- Use emoji sparingly as visual cues on status and totals: ✅ paid, 🟡 partially paid, ⚠️ overdue, 📝 draft, 📤 sent, 👁️ viewed, ❌ declined/rejected, 💰 totals, 📅 dates, 📊 stats, 💡 tips. One emoji per bullet is plenty — don't decorate every sentence.
- If a tool returns an error or empty result, say so plainly and suggest a next step.
PROMPT;
return PromptLoader::load('chat-system', [
'user_name' => $user?->name ?? 'the user',
'company_name' => $company?->name ?? 'this company',
'today' => Carbon::now()->toDateString(),
]);
}
/**

View File

@@ -4,6 +4,7 @@ namespace App\Services\Ai;
use App\Services\AiConfigurationService;
use App\Support\Ai\AiException;
use App\Support\Ai\PromptLoader;
/**
* Stateless one-shot text generation for the WYSIWYG popup.
@@ -67,13 +68,14 @@ class AiTextGenerationService
* Keep the framing terse — text-generation output should not be padded
* with "Here is the text you requested:" preambles. The instruction is
* always placed last so the model gives it the most weight.
*
* The static preamble lives at resources/ai/prompts/text-generation.md.
* The conditional context + instruction appending stays here because it
* has different structure depending on whether `context` is present.
*/
protected function buildPrompt(string $prompt, ?string $context): string
{
$system = "You are a helpful writing assistant embedded in an invoicing application. Generate text based on the user's instruction. Rules:\n"
.'- Return only the requested text. No preamble, no explanation, no markdown code fences.'."\n"
.'- Match the tone and language implied by the instruction.'."\n"
.'- Be concise unless explicitly asked for length.';
$system = PromptLoader::load('text-generation');
if ($context !== null && trim($context) !== '') {
return $system."\n\n"

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Support\Ai;
use RuntimeException;
/**
* Loads LLM prompt templates from disk and performs lightweight
* placeholder substitution.
*
* Prompts live as plain markdown files under `resources/ai/prompts/`
* so they can be edited without touching PHP service classes, get
* syntax-highlighted in editors, and produce clean PR diffs. Keeping
* the templates outside PHP also sidesteps heredoc indentation
* rules and interpolation quirks (dollar signs, curly braces).
*
* Placeholders use the `{{name}}` double-brace form — a common
* template convention that doesn't collide with markdown syntax and
* is trivial to substitute via strtr(). We deliberately do NOT use
* Blade for this: Blade's `{{ $var }}` HTML-escapes ampersands and
* quotes, which would corrupt prompts that reference names like
* "Smith & Co" (the model would see "Smith &amp; Co").
*/
class PromptLoader
{
/**
* Load a prompt template and substitute any placeholders.
*
* @param string $name Template filename without extension (e.g. 'chat-system').
* @param array<string, scalar|\Stringable> $vars Placeholder => value map.
*
* @throws RuntimeException when the template file does not exist.
*/
public static function load(string $name, array $vars = []): string
{
$path = resource_path("ai/prompts/{$name}.md");
if (! is_file($path)) {
throw new RuntimeException("Missing AI prompt template: {$name} (expected at {$path})");
}
$template = (string) file_get_contents($path);
if ($vars === []) {
return trim($template);
}
$replacements = [];
foreach ($vars as $key => $value) {
$replacements['{{'.$key.'}}'] = (string) $value;
}
return trim(strtr($template, $replacements));
}
}

View File

@@ -0,0 +1,11 @@
You are the InvoiceShelf assistant, embedded in an invoicing application. You help {{user_name}} answer questions about **{{company_name}}**'s data: invoices, estimates, payments, expenses, customers, and items.
Today is {{today}}.
Rules:
- Use the provided tools whenever the user asks about specific records. Do not invent data.
- Only answer questions about {{company_name}}'s data. If asked about another company or unrelated topics, politely decline.
- You cannot modify data — you are read-only. If the user asks you to create/update/delete something, explain that they need to do it in the UI.
- Be concise. Format responses in Markdown (headings, **bold**, bullet lists, tables when comparing multiple records).
- Use emoji sparingly as visual cues on status and totals: ✅ paid, 🟡 partially paid, ⚠️ overdue, 📝 draft, 📤 sent, 👁️ viewed, ❌ declined/rejected, 💰 totals, 📅 dates, 📊 stats, 💡 tips. One emoji per bullet is plenty — don't decorate every sentence.
- If a tool returns an error or empty result, say so plainly and suggest a next step.

View File

@@ -0,0 +1,4 @@
You are a helpful writing assistant embedded in an invoicing application. Generate text based on the user's instruction. Rules:
- Return only the requested text. No preamble, no explanation, no markdown code fences.
- Match the tone and language implied by the instruction.
- Be concise unless explicitly asked for length.

View File

@@ -0,0 +1,36 @@
<?php
use App\Support\Ai\PromptLoader;
test('load substitutes placeholders in the chat-system template', function () {
$prompt = PromptLoader::load('chat-system', [
'user_name' => 'Jane Doe',
'company_name' => 'Acme Corp',
'today' => '2026-04-11',
]);
// Substituted values present
expect($prompt)->toContain('Jane Doe');
expect($prompt)->toContain('Acme Corp');
expect($prompt)->toContain('2026-04-11');
// Raw placeholders are gone
expect($prompt)->not->toContain('{{user_name}}');
expect($prompt)->not->toContain('{{company_name}}');
expect($prompt)->not->toContain('{{today}}');
});
test('load returns a trimmed template when no placeholders are provided', function () {
$prompt = PromptLoader::load('text-generation');
expect($prompt)->not->toBe('');
// No leading/trailing whitespace — trim() stripped the trailing newline.
expect($prompt)->toBe(trim($prompt));
// Sanity-check a word known to appear in the preamble.
expect($prompt)->toContain('writing assistant');
});
test('load throws when the template file is missing', function () {
expect(fn () => PromptLoader::load('definitely-not-a-real-prompt'))
->toThrow(RuntimeException::class, 'Missing AI prompt template: definitely-not-a-real-prompt');
});