feat(ai): Phase 2 — chat assistant with tool-calling

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.
This commit is contained in:
Darko Gjorgjijoski
2026-04-12 08:00:00 +02:00
parent c7fab5d52f
commit e861fc1fc1
39 changed files with 2776 additions and 1 deletions

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Company\Ai;
use App\Http\Controllers\Controller;
use App\Models\AiConversation;
use App\Services\Ai\AiAssistantService;
use App\Support\Ai\AiException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class ChatController extends Controller
{
public function __construct(
private readonly AiAssistantService $assistant,
) {}
/**
* Send a message into a conversation and get the assistant's reply.
*
* If `conversation_id` is omitted (or belongs to a conversation the user
* doesn't own), we start a fresh conversation scoped to the current
* (company_id, user_id). This matches the "new chat" UX where the user
* opens the drawer and starts typing immediately.
*
* @throws ValidationException
*/
public function __invoke(Request $request): JsonResponse
{
$this->authorize('use ai');
$validated = $this->validate($request, [
'conversation_id' => 'nullable|integer',
'message' => 'required|string|max:10000',
]);
$companyId = (int) $request->header('company');
$userId = (int) $request->user()->id;
$conversation = $this->resolveConversation(
$validated['conversation_id'] ?? null,
$companyId,
$userId,
$validated['message'],
);
try {
$assistantMessage = $this->assistant->chat($conversation, $validated['message']);
} catch (AiException $e) {
return response()->json([
'error' => $e->errorKey,
'message' => $e->getMessage(),
], 422);
}
$conversation->refresh();
return response()->json([
'conversation' => [
'id' => $conversation->id,
'title' => $conversation->title,
'model' => $conversation->model,
'updated_at' => $conversation->updated_at,
],
'message' => [
'id' => $assistantMessage->id,
'role' => $assistantMessage->role,
'content' => $assistantMessage->content,
'created_at' => $assistantMessage->created_at,
],
]);
}
/**
* Pick an existing conversation the user owns, or create a new one.
*/
protected function resolveConversation(
?int $conversationId,
int $companyId,
int $userId,
string $firstMessage,
): AiConversation {
if ($conversationId !== null) {
$existing = AiConversation::query()
->where('id', $conversationId)
->where('company_id', $companyId)
->where('user_id', $userId)
->first();
if ($existing) {
return $existing;
}
}
return $this->assistant->startConversation($companyId, $userId, $firstMessage);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Company\Ai;
use App\Http\Controllers\Controller;
use App\Models\AiConversation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class ConversationController extends Controller
{
/**
* List the current user's conversations for the current company.
*/
public function index(Request $request): JsonResponse
{
$this->authorize('use ai');
$conversations = AiConversation::query()
->where('company_id', $request->header('company'))
->where('user_id', $request->user()->id)
->latest('updated_at')
->limit(50)
->get(['id', 'title', 'model', 'updated_at', 'created_at']);
return response()->json(['conversations' => $conversations]);
}
/**
* Show a single conversation with its full message history.
*/
public function show(Request $request, int $id): JsonResponse
{
$this->authorize('use ai');
$conversation = AiConversation::query()
->where('id', $id)
->where('company_id', $request->header('company'))
->firstOrFail();
$this->authorize('view', $conversation);
$messages = $conversation->messages()
->whereIn('role', ['user', 'assistant'])
->get(['id', 'role', 'content', 'created_at']);
return response()->json([
'conversation' => [
'id' => $conversation->id,
'title' => $conversation->title,
'model' => $conversation->model,
'created_at' => $conversation->created_at,
'updated_at' => $conversation->updated_at,
],
'messages' => $messages,
]);
}
/**
* Rename a conversation.
*
* @throws ValidationException
*/
public function update(Request $request, int $id): JsonResponse
{
$this->authorize('use ai');
$conversation = AiConversation::query()
->where('id', $id)
->where('company_id', $request->header('company'))
->firstOrFail();
$this->authorize('update', $conversation);
$validated = $this->validate($request, [
'title' => 'required|string|max:255',
]);
$conversation->update(['title' => $validated['title']]);
return response()->json(['success' => true]);
}
/**
* Delete a conversation (cascades to messages via DB foreign key).
*/
public function destroy(Request $request, int $id): JsonResponse
{
$this->authorize('use ai');
$conversation = AiConversation::query()
->where('id', $id)
->where('company_id', $request->header('company'))
->firstOrFail();
$this->authorize('delete', $conversation);
$conversation->delete();
return response()->json(['success' => true]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* A single chat-assistant thread, scoped to (company_id, user_id).
*
* One user in one company owns many conversations; messages belong to a
* conversation. Conversations are deleted when either the company or the
* user is deleted (cascade at the DB level).
*/
class AiConversation extends Model
{
use HasFactory;
protected $guarded = ['id'];
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function messages(): HasMany
{
return $this->hasMany(AiMessage::class, 'conversation_id')->orderBy('created_at');
}
}

51
app/Models/AiMessage.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One message in an AI conversation.
*
* Column shapes match OpenAI's chat message format exactly so that
* AiAssistantService can serialize a conversation into an API request
* payload with no translation layer. Supported roles:
*
* - user — human-authored prompt
* - assistant — model-generated reply (may carry tool_calls instead of content)
* - tool — result of a tool invocation, tied to an assistant's tool_call_id
* - system — persisted system prompts (not typically stored; reserved for future)
*/
class AiMessage extends Model
{
use HasFactory;
public const ROLE_USER = 'user';
public const ROLE_ASSISTANT = 'assistant';
public const ROLE_TOOL = 'tool';
public const ROLE_SYSTEM = 'system';
public const UPDATED_AT = null; // created_at only — messages are immutable once written
protected $guarded = ['id'];
protected function casts(): array
{
return [
'tool_calls' => 'array',
'tokens_in' => 'integer',
'tokens_out' => 'integer',
'created_at' => 'datetime',
];
}
public function conversation(): BelongsTo
{
return $this->belongsTo(AiConversation::class, 'conversation_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Policies;
use App\Models\AiConversation;
use App\Models\User;
/**
* Conversation visibility is strictly per-user per-company.
*
* Every action requires (a) the user is a member of the conversation's
* company AND (b) the user IS the conversation's owner. We do not leak
* conversations between users — the whole point of the per-user scope
* is privacy within a shared company workspace.
*/
class AiConversationPolicy
{
public function view(User $user, AiConversation $conversation): bool
{
return $this->owns($user, $conversation);
}
public function update(User $user, AiConversation $conversation): bool
{
return $this->owns($user, $conversation);
}
public function delete(User $user, AiConversation $conversation): bool
{
return $this->owns($user, $conversation);
}
protected function owns(User $user, AiConversation $conversation): bool
{
return $conversation->user_id === $user->id
&& $user->hasCompany($conversation->company_id);
}
}

View File

@@ -55,6 +55,15 @@ class SettingsPolicy
return false;
}
/**
* Any authenticated user with an active company context can use AI features,
* subject to the per-company kill-switch in AiConfigurationService.
*/
public function useAi(User $user)
{
return $user !== null;
}
public function managePDFConfig(User $user)
{
if ($user->isSuperAdmin()) {

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Providers;
use App\Services\Ai\AiToolRegistry;
use App\Services\Ai\Tools\GetCompanyStatsTool;
use App\Services\Ai\Tools\GetCustomerTool;
use App\Services\Ai\Tools\GetInvoiceTool;
use App\Services\Ai\Tools\ListExpenseCategoriesTool;
use App\Services\Ai\Tools\ListOverdueInvoicesTool;
use App\Services\Ai\Tools\ListRecentPaymentsTool;
use App\Services\Ai\Tools\SearchCustomersTool;
use App\Services\Ai\Tools\SearchInvoicesTool;
use App\Services\Ai\Tools\SearchItemsTool;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
/**
* Binds the AI tool registry as a singleton and registers every built-in
* read-only tool the chat assistant can call.
*
* Modules that ship additional tools should extend the registry from their
* own ServiceProvider::boot() by resolving AiToolRegistry from the container
* and calling register() on it.
*/
class AiServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(AiToolRegistry::class, function (Application $app): AiToolRegistry {
$registry = new AiToolRegistry;
// Built-in read-only tools (order is presentation-only; the LLM picks).
$registry->register(new SearchInvoicesTool);
$registry->register(new GetInvoiceTool);
$registry->register(new ListOverdueInvoicesTool);
$registry->register(new SearchCustomersTool);
$registry->register(new GetCustomerTool);
$registry->register(new ListRecentPaymentsTool);
$registry->register(new SearchItemsTool);
$registry->register(new ListExpenseCategoriesTool);
$registry->register(new GetCompanyStatsTool);
return $registry;
});
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Models\AiConversation;
use App\Policies\AiConversationPolicy;
use App\Policies\CompanyPolicy;
use App\Policies\CustomerPolicy;
use App\Policies\DashboardPolicy;
@@ -64,6 +66,7 @@ class AppServiceProvider extends ServiceProvider
}
Gate::policy(Role::class, RolePolicy::class);
Gate::policy(AiConversation::class, AiConversationPolicy::class);
View::addNamespace('pdf_templates', storage_path('app/templates/pdf'));
@@ -144,6 +147,7 @@ class AppServiceProvider extends ServiceProvider
Gate::define('manage file disk', [SettingsPolicy::class, 'manageFileDisk']);
Gate::define('manage email config', [SettingsPolicy::class, 'manageEmailConfig']);
Gate::define('manage ai config', [SettingsPolicy::class, 'manageAiConfig']);
Gate::define('use ai', [SettingsPolicy::class, 'useAi']);
Gate::define('manage pdf config', [SettingsPolicy::class, 'managePDFConfig']);
Gate::define('manage notes', [NotePolicy::class, 'manageNotes']);
Gate::define('view notes', [NotePolicy::class, 'viewNotes']);

View File

@@ -53,5 +53,15 @@ class RouteServiceProvider extends ServiceProvider
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60);
});
// AI endpoints — per user per company, fairly generous for chat use
// but low enough to contain runaway clients / loops.
RateLimiter::for('ai', function (Request $request) {
$user = $request->user();
$companyId = $request->header('company') ?? 'noop';
$key = $user ? "{$user->id}:{$companyId}" : $request->ip();
return Limit::perMinute(30)->by($key);
});
}
}

View File

@@ -0,0 +1,331 @@
<?php
namespace App\Services\Ai;
use App\Models\AiConversation;
use App\Models\AiMessage;
use App\Models\Company;
use App\Models\User;
use App\Services\AiConfigurationService;
use App\Support\Ai\AiChatResponse;
use App\Support\Ai\AiException;
use Carbon\Carbon;
use InvalidArgumentException;
use Throwable;
/**
* Orchestrates a single turn of the chat assistant.
*
* The public surface is `chat(conversation, userMessage)` — it persists the
* user's message, runs the LLM → tool-calls → LLM loop until the model emits
* a plain-text reply (or we hit the hard cap), persists everything along the
* way, and returns the final assistant AiMessage.
*
* Per-phase-2 plan this is NON-STREAMING: the caller waits for the final
* response. Streaming is a future polish refactor — the storage shape and
* return type will be the same.
*/
class AiAssistantService
{
/** Hard cap on tool-call iterations per turn. Prevents runaway loops. */
public const MAX_TOOL_ITERATIONS = 5;
/** Cap on messages loaded from history to fit the model context window. */
private const HISTORY_WINDOW = 40;
public function __construct(
private readonly AiConfigurationService $aiConfiguration,
private readonly AiToolRegistry $toolRegistry,
) {}
/**
* Start a new conversation for the given user in the given company.
*/
public function startConversation(int $companyId, int $userId, ?string $firstMessage = null): AiConversation
{
return AiConversation::create([
'company_id' => $companyId,
'user_id' => $userId,
'title' => $firstMessage !== null ? $this->titleFromMessage($firstMessage) : null,
]);
}
/**
* Process one user message within an existing conversation.
*
* Returns the final assistant AiMessage that should be shown to the user.
*
* @throws AiException When AI is disabled or the driver call fails unrecoverably.
*/
public function chat(AiConversation $conversation, string $userMessage): AiMessage
{
$driver = $this->aiConfiguration->makeDriver($conversation->company_id);
if ($driver === null) {
throw new AiException('AI is not enabled for this company', 'ai_disabled');
}
$resolved = $this->aiConfiguration->resolveForCompany($conversation->company_id);
if (empty($resolved['chat_enabled'])) {
throw new AiException('Chat is not enabled for this company', 'chat_disabled');
}
$model = (string) ($resolved['ai_chat_model'] ?? '');
if ($model === '') {
throw new AiException('No chat model configured', 'missing_model');
}
// Auto-title on first message.
if ($conversation->title === null) {
$conversation->title = $this->titleFromMessage($userMessage);
$conversation->model = $model;
}
// Persist the user's message first so it shows even if the LLM call fails.
$userRow = AiMessage::create([
'conversation_id' => $conversation->id,
'role' => AiMessage::ROLE_USER,
'content' => $userMessage,
]);
$conversation->touch(); // bump updated_at for "recent" ordering
$messages = $this->buildMessagesPayload($conversation);
$tools = $this->toolRegistry->schemas();
for ($iteration = 0; $iteration < self::MAX_TOOL_ITERATIONS; $iteration++) {
try {
$response = $driver->chatCompletion($messages, $model, $tools);
} catch (AiException $e) {
// Persist the error as an assistant message so the UI can render it.
return AiMessage::create([
'conversation_id' => $conversation->id,
'role' => AiMessage::ROLE_ASSISTANT,
'content' => "Error: {$e->getMessage()}",
'model' => $model,
]);
}
// Tool calls requested → execute each, append their results, loop.
if ($response->hasToolCalls()) {
$this->persistAssistantToolCallTurn($conversation, $response, $model);
$messages[] = $this->assistantToolCallMessage($response);
foreach ($response->toolCalls as $call) {
$toolResult = $this->safelyExecuteTool(
name: $call['name'],
arguments: $call['arguments'] ?? [],
companyId: $conversation->company_id,
userId: $conversation->user_id,
);
$resultJson = json_encode($toolResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
AiMessage::create([
'conversation_id' => $conversation->id,
'role' => AiMessage::ROLE_TOOL,
'content' => $resultJson,
'tool_call_id' => $call['id'] ?? null,
]);
$messages[] = [
'role' => 'tool',
'tool_call_id' => $call['id'] ?? '',
'content' => $resultJson,
];
}
// Loop — the LLM will receive the tool results on the next iteration.
continue;
}
// Plain text response — persist and return.
return AiMessage::create([
'conversation_id' => $conversation->id,
'role' => AiMessage::ROLE_ASSISTANT,
'content' => $response->message ?? '',
'model' => $model,
'tokens_in' => $response->usage['tokens_in'] ?? null,
'tokens_out' => $response->usage['tokens_out'] ?? null,
]);
}
// Exceeded the loop cap.
return AiMessage::create([
'conversation_id' => $conversation->id,
'role' => AiMessage::ROLE_ASSISTANT,
'content' => 'The assistant could not complete this request within the tool-call budget. Please try rephrasing.',
'model' => $model,
]);
}
/**
* Build the OpenAI-format messages payload for the next LLM call.
*
* Starts with a fresh system prompt, then the recent message history
* trimmed to HISTORY_WINDOW items, then the message(s) that will drive
* the upcoming round-trip (already persisted by chat()).
*
* @return array<int, array<string, mixed>>
*/
protected function buildMessagesPayload(AiConversation $conversation): array
{
$payload = [
[
'role' => 'system',
'content' => $this->buildSystemPrompt($conversation),
],
];
$history = AiMessage::query()
->where('conversation_id', $conversation->id)
->orderByDesc('created_at')
->limit(self::HISTORY_WINDOW)
->get()
->reverse()
->values();
foreach ($history as $msg) {
$payload[] = $this->formatMessageForDriver($msg);
}
return $payload;
}
/**
* @return array<string, mixed>
*/
protected function formatMessageForDriver(AiMessage $msg): array
{
$base = ['role' => $msg->role];
if ($msg->role === AiMessage::ROLE_TOOL) {
$base['tool_call_id'] = $msg->tool_call_id ?? '';
$base['content'] = $msg->content ?? '';
return $base;
}
if ($msg->role === AiMessage::ROLE_ASSISTANT && $msg->tool_calls) {
$base['content'] = $msg->content;
$base['tool_calls'] = array_map(function (array $call): array {
return [
'id' => $call['id'] ?? '',
'type' => 'function',
'function' => [
'name' => $call['name'] ?? '',
'arguments' => json_encode($call['arguments'] ?? [], JSON_UNESCAPED_UNICODE),
],
];
}, $msg->tool_calls);
return $base;
}
$base['content'] = $msg->content ?? '';
return $base;
}
/**
* Compose the system prompt with company context.
*/
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.
- If a tool returns an error or empty result, say so plainly and suggest a next step.
PROMPT;
}
/**
* Persist the assistant's tool_calls-turn message (the one that requested tools).
*/
protected function persistAssistantToolCallTurn(
AiConversation $conversation,
AiChatResponse $response,
string $model,
): void {
AiMessage::create([
'conversation_id' => $conversation->id,
'role' => AiMessage::ROLE_ASSISTANT,
'content' => $response->message, // usually null on a tool_calls turn
'tool_calls' => $response->toolCalls,
'model' => $model,
'tokens_in' => $response->usage['tokens_in'] ?? null,
'tokens_out' => $response->usage['tokens_out'] ?? null,
]);
}
/**
* Format the assistant tool-call turn for the NEXT driver call, in OpenAI format.
*
* @return array<string, mixed>
*/
protected function assistantToolCallMessage(AiChatResponse $response): array
{
return [
'role' => 'assistant',
'content' => $response->message,
'tool_calls' => array_map(function (array $call): array {
return [
'id' => $call['id'] ?? '',
'type' => 'function',
'function' => [
'name' => $call['name'] ?? '',
'arguments' => json_encode($call['arguments'] ?? [], JSON_UNESCAPED_UNICODE),
],
];
}, $response->toolCalls),
];
}
/**
* Call a tool and trap any exception — we want tool failures to be visible
* to the LLM as structured errors, not to crash the whole turn.
*
* @param array<string, mixed> $arguments
*/
protected function safelyExecuteTool(
string $name,
array $arguments,
int $companyId,
int $userId,
): mixed {
try {
return $this->toolRegistry->execute($name, $arguments, $companyId, $userId);
} catch (InvalidArgumentException $e) {
return ['error' => 'unknown_tool', 'message' => $e->getMessage()];
} catch (Throwable $e) {
return ['error' => 'tool_execution_failed', 'message' => $e->getMessage()];
}
}
/**
* Derive a short conversation title from the first user message.
*/
protected function titleFromMessage(string $message): string
{
$trimmed = trim(preg_replace('/\s+/', ' ', $message) ?? '');
if (mb_strlen($trimmed) <= 60) {
return $trimmed;
}
return rtrim(mb_substr($trimmed, 0, 57)).'...';
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Services\Ai;
use App\Services\Ai\Tools\AiTool;
use InvalidArgumentException;
/**
* In-memory registry of AiTool instances.
*
* Register tools from a service provider (see `App\Providers\AiServiceProvider`)
* at app boot; the AiAssistantService reads `schemas()` to populate the LLM's
* tool-calling payload and calls `execute()` when the model returns a tool_call.
*
* The registry is intentionally a singleton: tools themselves are stateless
* dispatchers, so one instance shared across the request is safe. Modules can
* register their own tools by resolving this service and calling `register()`
* from their own ServiceProvider::boot().
*
* $this->app->resolving(AiToolRegistry::class, function (AiToolRegistry $registry) {
* $registry->register(new MyCustomTool);
* });
*/
class AiToolRegistry
{
/**
* @var array<string, AiTool>
*/
protected array $tools = [];
public function register(AiTool $tool): void
{
$this->tools[$tool->name()] = $tool;
}
/**
* @return array<string, AiTool>
*/
public function all(): array
{
return $this->tools;
}
public function get(string $name): ?AiTool
{
return $this->tools[$name] ?? null;
}
/**
* Export all registered tools as the `tools` array for an OpenAI-style chat request.
*
* @return array<int, array<string, mixed>>
*/
public function schemas(): array
{
return array_values(array_map(
fn (AiTool $tool): array => $tool->toOpenAiToolSchema(),
$this->tools,
));
}
/**
* Execute a tool by name, injecting company + user scope from the caller's session.
*
* The AiAssistantService is the only place this should be called from — that's
* how we guarantee the `$companyId` and `$userId` arguments are session-authoritative
* and never influenced by LLM output.
*
* @param array<string, mixed> $arguments
*
* @throws InvalidArgumentException When the tool name is not registered.
*/
public function execute(string $name, array $arguments, int $companyId, int $userId): mixed
{
$tool = $this->get($name);
if ($tool === null) {
throw new InvalidArgumentException("Unknown AI tool: {$name}");
}
return $tool->execute($arguments, $companyId, $userId);
}
/**
* Test-only: reset the registry between tests that exercise different tool sets.
*/
public function flush(): void
{
$this->tools = [];
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Services\Ai\Tools;
/**
* Abstract base class for read-only AI tools invokable by the chat assistant.
*
* Tools are what the LLM "calls" to fetch data when answering a user question.
* Every tool is a small, well-typed PHP function:
*
* - `name()` — snake_case identifier passed to the LLM
* - `description()` — one-sentence description; this is how the LLM decides
* when to invoke the tool. Write it like documentation, not marketing.
* - `parameterSchema()` — JSON schema for the parameters the LLM can pass.
* Must NEVER include a `company_id` field — scoping is injected at execute
* time from the caller's session. This is the v1 prompt-injection defense.
* - `execute()` — actually runs the query. Receives the resolved `$companyId`
* and `$userId` from the session, plus the arguments the LLM chose.
*
* Tools are **read-only** by contract. There is intentionally no mutation
* surface in v1 — the chat assistant cannot create, update, or delete
* anything, no matter what the LLM is told. Any attempt to add a mutation
* tool should go through a separate design review.
*/
abstract class AiTool
{
/** Stable identifier sent to the LLM. Use snake_case. */
abstract public function name(): string;
/**
* Natural-language description the LLM uses to decide when to call this tool.
*
* Keep it one sentence, written in imperative mood, describing what you get
* back. Good examples:
*
* - "Search invoices by customer, status, or free-text query."
* - "Fetch full details for a customer by ID, including their address and totals."
*
* Avoid marketing language and hedging. The LLM pays close attention to
* this string.
*/
abstract public function description(): string;
/**
* JSON schema for the arguments the LLM may pass.
*
* Shape matches OpenAI's function-calling parameters schema — an object
* with a `properties` map and a `required` array. Tools that take no
* arguments should return a bare `['type' => 'object', 'properties' => (object) []]`.
*
* Critical: do NOT include `company_id` or `user_id` in the schema.
* Scoping is the host's responsibility and is injected at execute time.
*
* @return array<string, mixed>
*/
abstract public function parameterSchema(): array;
/**
* Run the tool with the given arguments, scoped to the caller's session.
*
* Implementations MUST query within the given `$companyId` and never trust
* any company/user identifier the LLM tries to sneak into `$arguments`.
*
* @param array<string, mixed> $arguments Parsed from the LLM's tool_call JSON
* @param int $companyId Injected from the current session — authoritative
* @param int $userId Injected from the current session
* @return mixed Anything JSON-encodable; will be serialized and sent back to the LLM
*/
abstract public function execute(array $arguments, int $companyId, int $userId): mixed;
/**
* Convert this tool into an OpenAI-style tools array entry.
*
* @return array<string, mixed>
*/
public function toOpenAiToolSchema(): array
{
return [
'type' => 'function',
'function' => [
'name' => $this->name(),
'description' => $this->description(),
'parameters' => $this->parameterSchema(),
],
];
}
/**
* Normalize a model date field (which may be a string OR a Carbon instance
* depending on model casts) to a YYYY-MM-DD string for tool output.
*
* Many InvoiceShelf models store dates as raw strings; others cast to Carbon.
* Centralizing this avoids cast-guessing in every tool.
*/
protected function asDate(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
if (is_object($value) && method_exists($value, 'toDateString')) {
return $value->toDateString();
}
// String like '2026-04-11' or '2026-04-11 00:00:00' — keep the date part only.
if (is_string($value)) {
return substr($value, 0, 10);
}
return null;
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Services\Ai\Tools;
use App\Models\Expense;
use App\Models\Invoice;
use App\Models\Payment;
use Carbon\Carbon;
/**
* Aggregate stats for the current company over a time window.
*
* Use this when the user asks "how much did we make/spend/invoice in <period>".
* Much cheaper than fetching full invoice lists and summing client-side.
*/
class GetCompanyStatsTool extends AiTool
{
private const PERIODS = [
'today',
'this_week',
'this_month',
'last_month',
'this_quarter',
'this_year',
'last_year',
];
public function name(): string
{
return 'get_company_stats';
}
public function description(): string
{
return "Aggregate stats for the current company over a named time period: invoice count and total, payment count and total, expense count and total. Use this for 'how much did we earn/spend' questions.";
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => [
'period' => [
'type' => 'string',
'enum' => self::PERIODS,
'description' => 'Named time window.',
],
],
'required' => ['period'],
];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$period = (string) ($arguments['period'] ?? 'this_month');
if (! in_array($period, self::PERIODS, true)) {
return ['error' => 'invalid_period', 'valid' => self::PERIODS];
}
[$start, $end] = $this->rangeFor($period);
$invoiceCount = Invoice::query()
->where('company_id', $companyId)
->whereBetween('invoice_date', [$start, $end])
->count();
$invoiceTotal = (float) Invoice::query()
->where('company_id', $companyId)
->whereBetween('invoice_date', [$start, $end])
->sum('total');
$paymentCount = Payment::query()
->where('company_id', $companyId)
->whereBetween('payment_date', [$start, $end])
->count();
$paymentTotal = (float) Payment::query()
->where('company_id', $companyId)
->whereBetween('payment_date', [$start, $end])
->sum('amount');
$expenseCount = Expense::query()
->where('company_id', $companyId)
->whereBetween('expense_date', [$start, $end])
->count();
$expenseTotal = (float) Expense::query()
->where('company_id', $companyId)
->whereBetween('expense_date', [$start, $end])
->sum('amount');
return [
'period' => $period,
'start' => $start->toDateString(),
'end' => $end->toDateString(),
'invoices' => ['count' => $invoiceCount, 'total' => $invoiceTotal],
'payments' => ['count' => $paymentCount, 'total' => $paymentTotal],
'expenses' => ['count' => $expenseCount, 'total' => $expenseTotal],
];
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
private function rangeFor(string $period): array
{
$now = Carbon::now();
return match ($period) {
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
'this_week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()],
'this_month' => [$now->copy()->startOfMonth(), $now->copy()->endOfMonth()],
'last_month' => [
$now->copy()->subMonthNoOverflow()->startOfMonth(),
$now->copy()->subMonthNoOverflow()->endOfMonth(),
],
'this_quarter' => [$now->copy()->startOfQuarter(), $now->copy()->endOfQuarter()],
'this_year' => [$now->copy()->startOfYear(), $now->copy()->endOfYear()],
'last_year' => [
$now->copy()->subYearNoOverflow()->startOfYear(),
$now->copy()->subYearNoOverflow()->endOfYear(),
],
};
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Services\Ai\Tools;
use App\Models\Customer;
use App\Models\Invoice;
class GetCustomerTool extends AiTool
{
public function name(): string
{
return 'get_customer';
}
public function description(): string
{
return 'Fetch full details for a single customer by ID, including contact info, billing/shipping address, and aggregate totals (invoice count, outstanding balance).';
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => [
'customer_id' => [
'type' => 'integer',
'description' => 'The customer ID.',
],
],
'required' => ['customer_id'],
];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$customer = Customer::query()
->where('company_id', $companyId)
->where('id', (int) ($arguments['customer_id'] ?? 0))
->with(['billingAddress', 'shippingAddress'])
->first();
if (! $customer) {
return ['error' => 'customer_not_found'];
}
// Aggregate totals — done with lightweight queries rather than loading every invoice.
$invoiceCount = Invoice::query()
->where('company_id', $companyId)
->where('customer_id', $customer->id)
->count();
$outstanding = (float) Invoice::query()
->where('company_id', $companyId)
->where('customer_id', $customer->id)
->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID'])
->sum('due_amount');
return [
'customer' => [
'id' => $customer->id,
'name' => $customer->name,
'display_name' => $customer->display_name,
'email' => $customer->email,
'phone' => $customer->phone,
'contact_name' => $customer->contact_name,
'company_name' => $customer->company_name,
'website' => $customer->website,
'enable_portal' => (bool) $customer->enable_portal,
'billing_address' => $customer->billingAddress,
'shipping_address' => $customer->shippingAddress,
'totals' => [
'invoice_count' => $invoiceCount,
'outstanding_amount' => $outstanding,
],
],
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Services\Ai\Tools;
use App\Models\Invoice;
/**
* Fetch one invoice's full details by invoice_number, including items and taxes.
*/
class GetInvoiceTool extends AiTool
{
public function name(): string
{
return 'get_invoice';
}
public function description(): string
{
return 'Fetch full details for a single invoice by its invoice_number, including line items, taxes, totals, customer info, and dates. Use this after search_invoices when the user wants more detail on a specific invoice.';
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => [
'invoice_number' => [
'type' => 'string',
'description' => 'The invoice_number to look up (e.g. "INV-000001").',
],
],
'required' => ['invoice_number'],
];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$invoice = Invoice::query()
->where('company_id', $companyId)
->where('invoice_number', (string) ($arguments['invoice_number'] ?? ''))
->with(['customer:id,name,email,phone', 'items', 'taxes'])
->first();
if (! $invoice) {
return ['error' => 'invoice_not_found'];
}
return [
'invoice' => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'reference_number' => $invoice->reference_number,
'status' => $invoice->status,
'paid_status' => $invoice->paid_status,
'invoice_date' => $this->asDate($invoice->invoice_date),
'due_date' => $this->asDate($invoice->due_date),
'sub_total' => $invoice->sub_total,
'tax' => $invoice->tax,
'discount' => $invoice->discount,
'total' => $invoice->total,
'due_amount' => $invoice->due_amount,
'overdue' => (bool) $invoice->overdue,
'notes' => $invoice->notes,
'customer' => $invoice->customer ? [
'id' => $invoice->customer->id,
'name' => $invoice->customer->name,
'email' => $invoice->customer->email,
'phone' => $invoice->customer->phone,
] : null,
'items' => $invoice->items->map(fn ($item): array => [
'name' => $item->name,
'description' => $item->description,
'quantity' => $item->quantity,
'price' => $item->price,
'total' => $item->total,
])->all(),
'taxes' => $invoice->taxes->map(fn ($tax): array => [
'name' => $tax->name,
'percent' => $tax->percent,
'amount' => $tax->amount,
])->all(),
],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Services\Ai\Tools;
use App\Models\ExpenseCategory;
class ListExpenseCategoriesTool extends AiTool
{
public function name(): string
{
return 'list_expense_categories';
}
public function description(): string
{
return 'List all expense categories defined for the current company.';
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => (object) [],
'required' => [],
];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$categories = ExpenseCategory::query()
->where('company_id', $companyId)
->orderBy('name')
->get(['id', 'name', 'description']);
return [
'categories' => $categories->map(fn ($c): array => [
'id' => $c->id,
'name' => $c->name,
'description' => $c->description,
])->all(),
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Services\Ai\Tools;
use App\Models\Invoice;
class ListOverdueInvoicesTool extends AiTool
{
public function name(): string
{
return 'list_overdue_invoices';
}
public function description(): string
{
return 'List all invoices for the current company that are currently overdue (past their due date and unpaid or partially paid). Sorted by oldest-due-first.';
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => (object) [],
'required' => [],
];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$invoices = Invoice::query()
->where('company_id', $companyId)
->where('overdue', true)
->with('customer:id,name')
->orderBy('due_date')
->limit(100)
->get();
$totalOutstanding = (float) $invoices->sum('due_amount');
return [
'count' => $invoices->count(),
'total_outstanding' => $totalOutstanding,
'invoices' => $invoices->map(fn (Invoice $inv): array => [
'id' => $inv->id,
'invoice_number' => $inv->invoice_number,
'customer_id' => $inv->customer_id,
'customer_name' => $inv->customer?->name,
'due_date' => $this->asDate($inv->due_date),
'due_amount' => $inv->due_amount,
'total' => $inv->total,
])->all(),
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Services\Ai\Tools;
use App\Models\Payment;
use Carbon\Carbon;
class ListRecentPaymentsTool extends AiTool
{
private const DEFAULT_DAYS = 30;
private const MAX_DAYS = 365;
private const DEFAULT_LIMIT = 20;
private const MAX_LIMIT = 100;
public function name(): string
{
return 'list_recent_payments';
}
public function description(): string
{
return 'List payments received in the last N days for the current company, sorted most recent first. Returns payment number, customer, amount, payment date, and payment method.';
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => [
'days' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => self::MAX_DAYS,
'description' => 'How many days back to look (default 30, max 365).',
],
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => self::MAX_LIMIT,
'description' => 'Max rows to return (default 20, max 100).',
],
],
'required' => [],
];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$days = min((int) ($arguments['days'] ?? self::DEFAULT_DAYS), self::MAX_DAYS);
$limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT);
$since = Carbon::now()->subDays($days)->startOfDay();
$payments = Payment::query()
->where('company_id', $companyId)
->where('payment_date', '>=', $since)
->with(['customer:id,name', 'paymentMethod:id,name'])
->latest('payment_date')
->limit($limit)
->get();
return [
'since' => $since->toDateString(),
'payments' => $payments->map(fn (Payment $p): array => [
'id' => $p->id,
'payment_number' => $p->payment_number,
'payment_date' => $this->asDate($p->payment_date),
'amount' => $p->amount,
'customer_id' => $p->customer_id,
'customer_name' => $p->customer?->name,
'invoice_id' => $p->invoice_id,
'payment_method' => $p->paymentMethod?->name,
])->all(),
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Services\Ai\Tools;
use App\Models\Customer;
class SearchCustomersTool extends AiTool
{
private const DEFAULT_LIMIT = 10;
private const MAX_LIMIT = 50;
public function name(): string
{
return 'search_customers';
}
public function description(): string
{
return 'Search customers for the current company by free-text query (matches name, display_name, email, company_name, contact_name). Returns a compact list with ids, names, and contact info.';
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'Free-text search against name, email, and related fields.',
],
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => self::MAX_LIMIT,
],
],
'required' => [],
];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT);
$query = Customer::query()
->where('company_id', $companyId)
->orderBy('name')
->limit($limit);
if (! empty($arguments['query'])) {
$q = $arguments['query'];
$query->where(function ($qb) use ($q) {
$qb->where('name', 'like', "%{$q}%")
->orWhere('display_name', 'like', "%{$q}%")
->orWhere('email', 'like', "%{$q}%")
->orWhere('company_name', 'like', "%{$q}%")
->orWhere('contact_name', 'like', "%{$q}%");
});
}
return [
'customers' => $query->get()->map(fn (Customer $c): array => [
'id' => $c->id,
'name' => $c->name,
'display_name' => $c->display_name,
'email' => $c->email,
'phone' => $c->phone,
'company_name' => $c->company_name,
])->all(),
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Services\Ai\Tools;
use App\Models\Invoice;
/**
* Search invoices by free text, status, and/or customer, scoped to the current company.
*
* Returns a compact list — no line items, no taxes. For full details the LLM
* should follow up with GetInvoiceTool using the returned invoice_number.
*/
class SearchInvoicesTool extends AiTool
{
private const DEFAULT_LIMIT = 10;
private const MAX_LIMIT = 50;
public function name(): string
{
return 'search_invoices';
}
public function description(): string
{
return 'Search invoices for the current company. Filter by free-text query (matches invoice number and reference), status, or customer_id. Returns a compact list with invoice numbers, customers, dates, totals, and statuses.';
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'Optional free-text search against invoice_number and reference_number.',
],
'status' => [
'type' => 'string',
'enum' => ['DRAFT', 'SENT', 'VIEWED', 'COMPLETED', 'UNPAID', 'PARTIALLY_PAID', 'PAID', 'OVERDUE'],
'description' => 'Optional status filter.',
],
'customer_id' => [
'type' => 'integer',
'description' => 'Optional customer ID to restrict to a specific customer.',
],
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => self::MAX_LIMIT,
'description' => 'Max rows to return (default 10, max 50).',
],
],
'required' => [],
];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT);
$query = Invoice::query()
->where('company_id', $companyId)
->with('customer:id,name')
->latest('invoice_date')
->limit($limit);
if (! empty($arguments['query'])) {
$q = $arguments['query'];
$query->where(function ($qb) use ($q) {
$qb->where('invoice_number', 'like', "%{$q}%")
->orWhere('reference_number', 'like', "%{$q}%");
});
}
if (! empty($arguments['status'])) {
$status = strtoupper((string) $arguments['status']);
// 'PAID' / 'UNPAID' / 'PARTIALLY_PAID' live on paid_status; the rest on status.
if (in_array($status, ['PAID', 'UNPAID', 'PARTIALLY_PAID'], true)) {
$query->where('paid_status', $status);
} elseif ($status === 'OVERDUE') {
$query->where('overdue', true);
} else {
$query->where('status', $status);
}
}
if (! empty($arguments['customer_id'])) {
$query->where('customer_id', (int) $arguments['customer_id']);
}
return [
'invoices' => $query->get()->map(fn (Invoice $inv): array => [
'id' => $inv->id,
'invoice_number' => $inv->invoice_number,
'customer_id' => $inv->customer_id,
'customer_name' => $inv->customer?->name,
'invoice_date' => $this->asDate($inv->invoice_date),
'due_date' => $this->asDate($inv->due_date),
'status' => $inv->status,
'paid_status' => $inv->paid_status,
'total' => $inv->total,
'due_amount' => $inv->due_amount,
'overdue' => (bool) $inv->overdue,
])->all(),
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Services\Ai\Tools;
use App\Models\Item;
class SearchItemsTool extends AiTool
{
private const DEFAULT_LIMIT = 10;
private const MAX_LIMIT = 50;
public function name(): string
{
return 'search_items';
}
public function description(): string
{
return 'Search catalog items (products/services) for the current company by free-text query (matches name and description). Returns id, name, unit price, and description.';
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'Free-text search against name and description.',
],
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => self::MAX_LIMIT,
],
],
'required' => [],
];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT);
$query = Item::query()
->where('company_id', $companyId)
->orderBy('name')
->limit($limit);
if (! empty($arguments['query'])) {
$q = $arguments['query'];
$query->where(function ($qb) use ($q) {
$qb->where('name', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
return [
'items' => $query->get()->map(fn (Item $item): array => [
'id' => $item->id,
'name' => $item->name,
'description' => $item->description,
'price' => $item->price,
])->all(),
];
}
}