mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-16 13:55:21 +00:00
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:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
|
||||
55
app/Support/Ai/PromptLoader.php
Normal file
55
app/Support/Ai/PromptLoader.php
Normal 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 & 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));
|
||||
}
|
||||
}
|
||||
11
resources/ai/prompts/chat-system.md
Normal file
11
resources/ai/prompts/chat-system.md
Normal 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.
|
||||
4
resources/ai/prompts/text-generation.md
Normal file
4
resources/ai/prompts/text-generation.md
Normal 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.
|
||||
36
tests/Unit/PromptLoaderTest.php
Normal file
36
tests/Unit/PromptLoaderTest.php
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user