diff --git a/app/Services/Ai/AiAssistantService.php b/app/Services/Ai/AiAssistantService.php index 2e0007bf..44ec524e 100644 --- a/app/Services/Ai/AiAssistantService.php +++ b/app/Services/Ai/AiAssistantService.php @@ -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 << $user?->name ?? 'the user', + 'company_name' => $company?->name ?? 'this company', + 'today' => Carbon::now()->toDateString(), + ]); } /** diff --git a/app/Services/Ai/AiTextGenerationService.php b/app/Services/Ai/AiTextGenerationService.php index 1c5db25e..eb8f3e15 100644 --- a/app/Services/Ai/AiTextGenerationService.php +++ b/app/Services/Ai/AiTextGenerationService.php @@ -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" diff --git a/app/Support/Ai/PromptLoader.php b/app/Support/Ai/PromptLoader.php new file mode 100644 index 00000000..c1ab7c65 --- /dev/null +++ b/app/Support/Ai/PromptLoader.php @@ -0,0 +1,55 @@ + $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)); + } +} diff --git a/resources/ai/prompts/chat-system.md b/resources/ai/prompts/chat-system.md new file mode 100644 index 00000000..4abbdccf --- /dev/null +++ b/resources/ai/prompts/chat-system.md @@ -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. diff --git a/resources/ai/prompts/text-generation.md b/resources/ai/prompts/text-generation.md new file mode 100644 index 00000000..7de959e9 --- /dev/null +++ b/resources/ai/prompts/text-generation.md @@ -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. diff --git a/tests/Unit/PromptLoaderTest.php b/tests/Unit/PromptLoaderTest.php new file mode 100644 index 00000000..2136fdb7 --- /dev/null +++ b/tests/Unit/PromptLoaderTest.php @@ -0,0 +1,36 @@ + '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'); +});