The AI chat assistant scoped tool queries by company but ignored the
per-user Bouncer abilities the rest of the app enforces, so any `use ai`
holder could read customers, invoices, payments, and company financials
their role couldn't otherwise see. Each AiTool now declares a required
ability (entity-aligned); the registry hides unauthorized tools from the
model and refuses to execute them as a backstop.
Separately, admin/owner-supplied URLs were fetched server-side with no
guard against private/reserved targets (SSRF): the AI base URL, the
CurrencyConverter "DEDICATED" exchange-rate URL, and S3/Spaces file-disk
endpoints. A shared PrivateNetworkGuard now backs a PublicHttpUrl
validation rule (save-time) and runtime guards in each driver.
- AiTool::requiredAbility() + mapping across all 12 tools
- AiToolRegistry filters schemas() by ability and re-checks in execute()
- PrivateNetworkGuard / BlockedUrlException / PublicHttpUrl rule (new)
- Rule wired into AI config (service + 3 controllers), exchange-rate,
and file-disk endpoints; runtime guards in OpenRouterDriver,
CurrencyConverterDriver, and FileDiskService
- Tests for ability filtering, the guard, the rule, and 422 rejections
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.
The assistant's output looked perfectly structured — headings, bold,
lists — but had zero visual cues. Sonnet 4.6 defaults to restrained
prose and won't decorate without a nudge. Users reviewing a list of
invoices or an overdue summary benefit from status icons (✅ paid,
⚠️ overdue, 📝 draft) scanning across many records.
Adds an emoji guideline to buildSystemPrompt() with a vocabulary
mapped to the domain model's statuses (paid / partially paid /
overdue / draft / sent / viewed / declined) plus totals, dates,
stats and tips. Capped at one emoji per bullet so responses don't
turn into decoration soup.
Also expands the Markdown rule to explicitly mention headings, bold,
bullet lists and tables — the model was already producing these but
the prompt only vaguely said "Format in Markdown", which left tables
off the table (pun intended) when comparing multiple records.
Second phase of the AI feature. Users can now open a slide-in chat drawer from the SiteHeader and ask natural-language questions about their company's invoices, customers, payments, and expenses. The LLM invokes pre-defined read-only tool functions (scoped to the current company at execute time) to fetch data and synthesize answers.
**Database** — new ai_conversations and ai_messages tables. Messages are stored in OpenAI's chat format so AiAssistantService serializes a conversation into an API request with zero translation. Columns: role, content, tool_call_id, tool_calls JSON, model, tokens_in, tokens_out. Conversations are scoped (company_id, user_id) — one user's chats are invisible to everyone else, even inside the same company. Foreign-key cascade deletes.
**Tool infrastructure** — AiTool abstract base + AiToolRegistry singleton (registered in a new AiServiceProvider). The base class enforces the security rule: every tool's execute() receives companyId and userId as injected parameters; tools' JSON schemas NEVER include a company_id field. An LLM physically cannot pass a company_id and escape tenancy. Modules can register their own tools by resolving the registry from their own ServiceProvider::boot().
**Nine built-in tools**: search_invoices, get_invoice, search_customers, get_customer, list_recent_payments, list_overdue_invoices, get_company_stats (aggregates for named periods), search_items, list_expense_categories. All read-only; no mutations. Each returns JSON-encodable data the LLM can parse.
**AiAssistantService orchestration loop** — the heart of Phase 2. Flow: persist user message → build payload from system prompt + recent history (40-message window) + new user message → call driver.chatCompletion with tools → if tool_calls, execute each one via the registry (with injected scope), persist tool result, loop → if plain text, persist and return. Hard cap at 5 iterations to prevent runaway LLMs. System prompt pins the assistant to this company's data and forbids mutation.
**Controllers + policy + rate limit** — POST /api/v1/ai/chat runs the orchestration loop. GET/PATCH/DELETE /api/v1/ai/conversations for CRUD. AiConversationPolicy enforces user_id+company_id match on every action. A new 'ai' RateLimiter in RouteServiceProvider throttles to 30 req/min per (user, company). New 'use ai' Gate defined in AppServiceProvider returns true for any authenticated user — the per-company kill-switch still goes through AiConfigurationService::resolveForCompany.
**Frontend** — new features/company/ai/ folder with a Pinia store (ai-chat.store.ts) holding drawer state, current conversation, messages, and loading flags. AiChatDrawer.vue is a slide-in panel teleported to <body>, mounted globally in CompanyLayout.vue when bootstrap reports ai.enabled && ai.chat_enabled. Sub-components: AiChatMessage (user bubbles vs assistant bubbles), AiChatMessageInput (Enter submits, Shift+Enter newline), AiChatConversationList (sidebar with 'new chat' button, rename, delete). A SparklesIcon button in SiteHeader toggles the drawer.
**Driver test double** — tests use a ScriptedAiDriver registered via AiDriverFactory::register('scripted', ...) that returns pre-queued AiChatResponse objects. Feature tests cover: happy path (new conversation + message persistence), tool-call loop (multi-round-trip with search_invoices), runaway-loop cap, driver-throws path, ai_enabled=NO rejection, chat role disabled rejection, per-user conversation visibility, cross-user policy enforcement, cascade delete.
388 tests pass (was 372, +16 new). Pint clean. npm run build clean. Phase 3 (WYSIWYG text generation popup) is the remaining follow-up.