Files
InvoiceShelf/app/Services/Ai/Tools/Concerns/ResolvesPeriod.php
Darko Gjorgjijoski 3df2a01832 feat(ai): add customer/item/expense ranking tools to the chat assistant
Adds three new read-only tools the chat LLM can call to answer
"who/what did the most X" questions that previously fell through
the cracks:

- rank_top_customers — ranks customers by invoiced_total, paid_total,
  invoice_count, or outstanding_balance over a named time period
- rank_top_items — ranks catalog items by quantity_sold or revenue
- rank_expense_categories — ranks expense categories by total spend

All three share a new ResolvesPeriod trait that centralizes the
period-name → [start, end] logic. GetCompanyStatsTool is refactored
onto the same trait (identical public schema — the 'all_time' option
is only exposed on the new ranking tools, where an unbounded window
makes sense; stats over "all time" collapses every record into one
giant bucket and is rarely useful).

Each tool follows the existing pattern: snake_case name, one-sentence
description tuned for LLM tool selection, JSON-schema parameters
with injected company scoping (never trusting LLM-supplied company
IDs), and JSON-encodable output. outstanding_balance on the customer
tool explicitly ignores the period param since it's a current-state
snapshot.

Multi-company scoping tests lock down the session-authoritative
boundary on every new tool. Per-metric ordering tests verify the
aggregate queries actually rank correctly, and an ad-hoc-item
exclusion test verifies rank_top_items skips invoice lines where
item_id is null (free-typed entries that have no catalog row to
rank by id).

15 new tests added (tests/Feature/Ai/Tools/); test suite grows from
398 to 413 passing. LLM tool count goes from 9 to 12 — the model
will discover the new tools automatically via the function-calling
schema with no prompt changes required.
2026-04-11 21:54:39 +02:00

66 lines
2.3 KiB
PHP

<?php
namespace App\Services\Ai\Tools\Concerns;
use Carbon\Carbon;
/**
* Shared period-name → [start, end] resolution for AI tools.
*
* Several tools (stats, rankings) accept a named time window and need the
* same match logic. Rather than duplicate the Carbon juggling in every
* tool, they `use ResolvesPeriod` and call `rangeFor()` consistently.
*
* `all_time` is included in the superset of period names so ranking tools
* can offer an unbounded window — in that case `rangeFor()` returns null
* and the caller skips any `whereBetween` filter. `GetCompanyStatsTool`
* deliberately does NOT expose `all_time` in its own enum (stats over all
* time drops every record into one giant bucket and is rarely useful).
*/
trait ResolvesPeriod
{
/** Superset of period names supported by the trait. */
protected const ALL_PERIODS = [
'all_time',
'today',
'this_week',
'this_month',
'last_month',
'this_quarter',
'this_year',
'last_year',
];
/**
* Resolve a named period to a start/end Carbon pair.
*
* Returns null for `all_time` (meaning: "no date filter — use every
* record regardless of date"). Callers are expected to branch on the
* null and skip any `whereBetween` clause.
*
* @return array{0: Carbon, 1: Carbon}|null
*/
protected function rangeFor(string $period): ?array
{
$now = Carbon::now();
return match ($period) {
'all_time' => null,
'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(),
],
default => null,
};
}
}