- Snipe-IT asset inventory integration (Admin > Integracje), optional and off by default: connect by API address + personal token (+ SSL-verification bypass). Three independent toggles: client can pick which of their own Snipe-IT assets a ticket concerns (scoped to admin-selected subcategories, empty = never shows), operator sees the requester's assets in a ticket-view sidebar, operator can search the whole inventory from that same sidebar (not a separate page) for shared equipment. Assets shown as "numer środka - numer seryjny - producent model" + category; a linked asset's live status is fetched fresh on the ticket page, and unlinking stays available to an operator even with both view/search toggles off. - AI summary: a "Wygeneruj teraz" button for an immediate on-demand refresh, plus a new admin toggle to regenerate right after every new reply/note instead of only on the next scheduled sweep. The transcript sent to the model now also includes the ticket's own opening body, fixing summaries missing the original request on long threads. - Fixed: the status dropdown in the operator ticket view could keep showing the pre-change status after a status-changing quick action until the next page load (Livewire/Alpine-morph quirk for wire:change-bound selects). - Docs: README/ARCHITECTURE/CHANGELOG/install/wiki updated for all of the above, including correcting the AI-summary refresh description left over from the 1.2.1 release notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
171 lines
6.7 KiB
PHP
171 lines
6.7 KiB
PHP
<?php
|
|
|
|
use App\Models\Ticket;
|
|
use App\Services\TicketAiSummaryService;
|
|
use App\Support\Settings;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
function enableAiSummary(): void
|
|
{
|
|
Settings::set('ai_enabled', '1');
|
|
Settings::set('ai_base_url', 'https://ai.test');
|
|
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
|
Settings::set('ai_summary_enabled', '1');
|
|
}
|
|
|
|
function summaryTicket(array $overrides = []): Ticket
|
|
{
|
|
return Ticket::query()->create(array_merge([
|
|
'number' => (string) random_int(100000, 999999),
|
|
'email' => 'client@example.com',
|
|
'name' => 'Test Client',
|
|
'subject' => 'Problem z drukarką',
|
|
'body' => 'Drukarka nie działa od rana.',
|
|
'status_key' => 'new',
|
|
'priority_key' => 'medium',
|
|
'custom_fields' => [],
|
|
], $overrides));
|
|
}
|
|
|
|
function fakeAiSummaryChat(string $content): void
|
|
{
|
|
Http::fake(['ai.test/*' => Http::response(['choices' => [['message' => ['content' => $content]]]])]);
|
|
}
|
|
|
|
test('generates and stores a summary + suggested action for a fresh ticket', function () {
|
|
enableAiSummary();
|
|
fakeAiSummaryChat('{"summary": "Klient zgłasza awarię drukarki.", "suggested_action": "Poproś o model drukarki."}');
|
|
|
|
$ticket = summaryTicket();
|
|
|
|
$totals = app(TicketAiSummaryService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
|
|
$ticket->refresh();
|
|
expect($ticket->ai_summary)->toBe('Klient zgłasza awarię drukarki.');
|
|
expect($ticket->ai_suggested_action)->toBe('Poproś o model drukarki.');
|
|
expect($ticket->ai_summary_generated_at)->not->toBeNull();
|
|
});
|
|
|
|
test('a ticket with a newer message than its last summary is picked up again', function () {
|
|
enableAiSummary();
|
|
$ticket = summaryTicket();
|
|
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Pierwsza wiadomość.']);
|
|
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => now()->subDay()]);
|
|
|
|
// A message created "now" postdates the day-old summary.
|
|
$ticket->messages()->create(['author_name' => 'Operator', 'body' => 'Nowa odpowiedź operatora.']);
|
|
|
|
fakeAiSummaryChat('{"summary": "Zaktualizowane podsumowanie.", "suggested_action": null}');
|
|
|
|
$totals = app(TicketAiSummaryService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
|
|
expect($ticket->refresh()->ai_summary)->toBe('Zaktualizowane podsumowanie.');
|
|
});
|
|
|
|
test('a ticket whose summary is already newer than its latest message is not reprocessed', function () {
|
|
enableAiSummary();
|
|
$ticket = summaryTicket();
|
|
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
|
|
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
|
|
|
|
Http::fake();
|
|
|
|
$totals = app(TicketAiSummaryService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('an unrelated update() that only touches updated_at does not trigger re-summarization', function () {
|
|
enableAiSummary();
|
|
$ticket = summaryTicket();
|
|
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
|
|
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
|
|
|
|
// Simulates e.g. a priority/status change touching tickets.updated_at
|
|
// without any new ticket_messages row.
|
|
$ticket->update(['priority_key' => 'high']);
|
|
|
|
Http::fake();
|
|
|
|
$totals = app(TicketAiSummaryService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('a malformed AI response leaves the previous summary untouched and keeps the ticket stale', function () {
|
|
enableAiSummary();
|
|
$ticket = summaryTicket();
|
|
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => null]);
|
|
fakeAiSummaryChat('to nie jest JSON');
|
|
|
|
$totals = app(TicketAiSummaryService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 1, 'updated' => 0, 'failed' => 1]);
|
|
$ticket->refresh();
|
|
expect($ticket->ai_summary)->toBe('stare podsumowanie');
|
|
expect($ticket->ai_summary_generated_at)->toBeNull();
|
|
});
|
|
|
|
test('the transcript sent to the AI includes the ticket subject, its own body, and every message tagged by role', function () {
|
|
enableAiSummary();
|
|
$ticket = summaryTicket(['subject' => 'Problem z drukarką', 'body' => 'Drukarka nie działa od rana.']);
|
|
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Dodatkowy szczegół.'])->attachAuthor(null, 'client');
|
|
$ticket->messages()->create(['author_name' => 'Operator', 'body' => 'Sprawdzam sprawę.'])->attachAuthor(null, 'operator');
|
|
|
|
fakeAiSummaryChat('{"summary": "ok", "suggested_action": null}');
|
|
|
|
app(TicketAiSummaryService::class)->run();
|
|
|
|
Http::assertSent(function ($request) {
|
|
$userMessage = collect($request->data()['messages'])->firstWhere('role', 'user')['content'];
|
|
|
|
return str_contains($userMessage, 'Temat: Problem z drukarką')
|
|
&& str_contains($userMessage, 'Treść:')
|
|
&& str_contains($userMessage, 'Drukarka nie działa od rana.')
|
|
&& str_contains($userMessage, '[klient] Test Client: Dodatkowy szczegół.')
|
|
&& str_contains($userMessage, '[operator] Operator: Sprawdzam sprawę.');
|
|
});
|
|
});
|
|
|
|
test('generateFor() regenerates a single ticket immediately, ignoring the staleness check', function () {
|
|
enableAiSummary();
|
|
$ticket = summaryTicket();
|
|
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
|
|
|
|
fakeAiSummaryChat('{"summary": "Świeże podsumowanie.", "suggested_action": null}');
|
|
|
|
$result = app(TicketAiSummaryService::class)->generateFor($ticket);
|
|
|
|
expect($result)->toBeTrue();
|
|
expect($ticket->refresh()->ai_summary)->toBe('Świeże podsumowanie.');
|
|
});
|
|
|
|
test('generateFor() returns false without calling the AI when the integration is disabled', function () {
|
|
Settings::set('ai_enabled', '0');
|
|
$ticket = summaryTicket();
|
|
|
|
Http::fake();
|
|
|
|
expect(app(TicketAiSummaryService::class)->generateFor($ticket))->toBeFalse();
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('ai_summary_enabled=0 makes no AI calls', function () {
|
|
Settings::set('ai_enabled', '1');
|
|
Settings::set('ai_base_url', 'https://ai.test');
|
|
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
|
Settings::set('ai_summary_enabled', '0');
|
|
summaryTicket();
|
|
|
|
Http::fake();
|
|
|
|
$totals = app(TicketAiSummaryService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
|
|
Http::assertNothingSent();
|
|
});
|