v1.2.1
- Generic AI integration (Admin > Integracje > "Integracja AI"), optional and off by default: an OpenAI-compatible /chat/completions client (Groq, OpenAI, or a self-hosted Ollama instance) configured by base URL, optional API key, model, and an SSL-verification toggle. Foundation for the two AI features below and anything else that wants an LLM call in the future. - BookStack automatic content tagging (AI): "Otaguj nową treść"/"Otaguj wszystko ponownie" buttons plus `php artisan bookstack:tag-content` (--dry-run/--force/--limit=N) tag every book/chapter/page with matching helpdesk subcategory names, idempotent by default. - BookStack search refinement: "Przeszukuj" is now three independent checkboxes (Książki/Strony/Rozdziały) instead of a single dropdown, plus a new "Szukaj po" setting (nazwa/tagi/oba) — tag matching uses the bare subcategory name, matching what auto-tagging writes. - AI-driven ticket triage + summary (Admin > Integracje > "Automatyzacja AI dla zgłoszeń", via new scheduled ai:run-ticket-automation): five toggles auto-assign/correct category+subcategory, rewrite an unclear subject, and set priority from content, once per ticket in the background; every change is logged in the ticket's history. Separately, an AI summary + suggested action for every ticket, shown to operators only, with an admin-editable prompt. - Operators can now reassign a ticket to any team, not just one they belong to. - The auto-refresh countdown badges (ticket view, operator queue) are now clickable — fetch immediately and reset the countdown. - All 7 "cyclical" intervals (3 browser refresh countdowns, the notification bell poll, and the 4 background scheduled commands) are now configurable from Admin > Konfiguracja instead of fixed in code. - Fixed: an operator viewing a ticket that's deleted or moved outside their team scope mid-session is now redirected to the operator queue instead of hitting an error. - Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
126
src/tests/Feature/TicketAiSummaryServiceTest.php
Normal file
126
src/tests/Feature/TicketAiSummaryServiceTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?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('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();
|
||||
});
|
||||
Reference in New Issue
Block a user