- 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>
246 lines
9.5 KiB
PHP
246 lines
9.5 KiB
PHP
<?php
|
|
|
|
use App\Models\Category;
|
|
use App\Models\Priority;
|
|
use App\Models\Subcategory;
|
|
use App\Models\Ticket;
|
|
use App\Services\TicketAiTriageService;
|
|
use App\Support\Settings;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
function enableAiForTriage(): void
|
|
{
|
|
Settings::set('ai_enabled', '1');
|
|
Settings::set('ai_base_url', 'https://ai.test');
|
|
Settings::set('ai_model', 'llama-3.3-70b-versatile');
|
|
}
|
|
|
|
/** @return array{cat1: Category, sub1: Subcategory, sub2: Subcategory, cat2: Category} */
|
|
function seedCategoriesForTriage(): array
|
|
{
|
|
$cat1 = Category::query()->create(['name' => 'IT-Pomoc']);
|
|
$sub1 = $cat1->subcategories()->create(['name' => 'Drukarki i skanery']);
|
|
$sub2 = $cat1->subcategories()->create(['name' => 'VPN']);
|
|
$cat2 = Category::query()->create(['name' => 'Zamówienia']);
|
|
$cat2->subcategories()->create(['name' => 'Nowe zamówienie']);
|
|
|
|
return compact('cat1', 'sub1', 'sub2', 'cat2');
|
|
}
|
|
|
|
function seedPrioritiesForTriage(): void
|
|
{
|
|
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#000', 'sort_order' => 4]);
|
|
Priority::query()->create(['key' => 'medium', 'label' => 'Średni', 'color' => '#000', 'sort_order' => 3]);
|
|
Priority::query()->create(['key' => 'high', 'label' => 'Wysoki', 'color' => '#000', 'sort_order' => 2]);
|
|
Priority::query()->create(['key' => 'critical', 'label' => 'Krytyczny', 'color' => '#000', 'sort_order' => 1]);
|
|
}
|
|
|
|
function triageTicket(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 HP w biurze nie drukuje od rana, pokazuje błąd papieru mimo że jest papier.',
|
|
'status_key' => 'new',
|
|
'priority_key' => 'medium',
|
|
'custom_fields' => [],
|
|
], $overrides));
|
|
}
|
|
|
|
function fakeAiChat(string $content): void
|
|
{
|
|
Http::fake(['ai.test/*' => Http::response(['choices' => [['message' => ['content' => $content]]]])]);
|
|
}
|
|
|
|
test('category_when_missing assigns category+subcategory to a fully unclassified ticket', function () {
|
|
enableAiForTriage();
|
|
['sub1' => $sub1] = seedCategoriesForTriage();
|
|
Settings::set('ai_triage_category_when_missing', '1');
|
|
fakeAiChat('{"category": "IT-Pomoc", "subcategory": "Drukarki i skanery", "subject": null, "priority": null}');
|
|
|
|
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => null]);
|
|
|
|
$totals = app(TicketAiTriageService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 1, 'changed' => 1, 'failed' => 0]);
|
|
$ticket->refresh();
|
|
expect($ticket->subcategory_id)->toBe($sub1->id);
|
|
expect($ticket->category_id)->toBeNull();
|
|
expect($ticket->ai_triaged_at)->not->toBeNull();
|
|
expect($ticket->histories()->pluck('text')->all())->toBe([
|
|
'Kategoria zmieniona na: IT-Pomoc / Drukarki i skanery',
|
|
'Automatyzacja: klasyfikacja AI',
|
|
]);
|
|
});
|
|
|
|
test('subcategory_when_category_only restricts the prompt to the ticket\'s existing category and picks within it', function () {
|
|
enableAiForTriage();
|
|
['cat1' => $cat1, 'sub2' => $sub2] = seedCategoriesForTriage();
|
|
Settings::set('ai_triage_subcategory_when_category_only', '1');
|
|
fakeAiChat('{"category": null, "subcategory": "VPN", "subject": null, "priority": null}');
|
|
|
|
$ticket = triageTicket(['category_id' => $cat1->id, 'subcategory_id' => null]);
|
|
|
|
app(TicketAiTriageService::class)->run();
|
|
|
|
$ticket->refresh();
|
|
expect($ticket->subcategory_id)->toBe($sub2->id);
|
|
expect($ticket->category_id)->toBeNull();
|
|
|
|
Http::assertSent(function ($request) {
|
|
$content = $request['messages'][0]['content'] ?? '';
|
|
|
|
return str_contains($content, 'Drukarki i skanery')
|
|
&& str_contains($content, 'VPN')
|
|
&& ! str_contains($content, 'Nowe zamówienie');
|
|
});
|
|
});
|
|
|
|
test('recheck_categorized moves an already-categorized ticket to a better-matching subcategory', function () {
|
|
enableAiForTriage();
|
|
['sub1' => $sub1, 'sub2' => $sub2] = seedCategoriesForTriage();
|
|
Settings::set('ai_triage_recheck_categorized', '1');
|
|
fakeAiChat('{"category": null, "subcategory": "VPN", "subject": null, "priority": null}');
|
|
|
|
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub1->id]);
|
|
|
|
app(TicketAiTriageService::class)->run();
|
|
|
|
expect($ticket->refresh()->subcategory_id)->toBe($sub2->id);
|
|
});
|
|
|
|
test('recheck_categorized confirming the existing subcategory leaves no history and no changed count', function () {
|
|
enableAiForTriage();
|
|
['sub1' => $sub1] = seedCategoriesForTriage();
|
|
Settings::set('ai_triage_recheck_categorized', '1');
|
|
fakeAiChat('{"category": null, "subcategory": "Drukarki i skanery", "subject": null, "priority": null}');
|
|
|
|
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub1->id]);
|
|
|
|
$totals = app(TicketAiTriageService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 1, 'changed' => 0, 'failed' => 0]);
|
|
expect($ticket->refresh()->subcategory_id)->toBe($sub1->id);
|
|
expect($ticket->histories()->count())->toBe(0);
|
|
expect($ticket->ai_triaged_at)->not->toBeNull();
|
|
});
|
|
|
|
test('fix_subject rewrites an unclear subject', function () {
|
|
enableAiForTriage();
|
|
Settings::set('ai_triage_fix_subject', '1');
|
|
fakeAiChat('{"category": null, "subcategory": null, "subject": "Awaria drukarki HP w biurze", "priority": null}');
|
|
|
|
$ticket = triageTicket(['subject' => 'pomocy!!!']);
|
|
|
|
app(TicketAiTriageService::class)->run();
|
|
|
|
$ticket->refresh();
|
|
expect($ticket->subject)->toBe('Awaria drukarki HP w biurze');
|
|
expect($ticket->histories()->pluck('text')->all())->toBe([
|
|
'Temat zmieniony na: „Awaria drukarki HP w biurze”',
|
|
'Automatyzacja: klasyfikacja AI',
|
|
]);
|
|
});
|
|
|
|
test('set_priority assigns a priority based on content', function () {
|
|
enableAiForTriage();
|
|
seedPrioritiesForTriage();
|
|
Settings::set('ai_triage_set_priority', '1');
|
|
fakeAiChat('{"category": null, "subcategory": null, "subject": null, "priority": "high"}');
|
|
|
|
$ticket = triageTicket(['priority_key' => 'medium']);
|
|
|
|
app(TicketAiTriageService::class)->run();
|
|
|
|
$ticket->refresh();
|
|
expect($ticket->priority_key)->toBe('high');
|
|
expect($ticket->histories()->pluck('text')->all())->toBe([
|
|
'Priorytet zmieniony na: Wysoki',
|
|
'Automatyzacja: klasyfikacja AI',
|
|
]);
|
|
});
|
|
|
|
test('a multi-field change writes one mechanical line per changed field plus a single attribution line', function () {
|
|
enableAiForTriage();
|
|
seedPrioritiesForTriage();
|
|
['sub1' => $sub1, 'sub2' => $sub2] = seedCategoriesForTriage();
|
|
Settings::set('ai_triage_recheck_categorized', '1');
|
|
Settings::set('ai_triage_set_priority', '1');
|
|
// ticket currently sits under sub2 (VPN); AI moves it to sub1 (Drukarki i
|
|
// skanery) AND bumps priority — both fields change in the same pass.
|
|
fakeAiChat('{"category": null, "subcategory": "Drukarki i skanery", "subject": null, "priority": "critical"}');
|
|
|
|
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub2->id, 'priority_key' => 'medium']);
|
|
|
|
app(TicketAiTriageService::class)->run();
|
|
|
|
$ticket->refresh();
|
|
expect($ticket->subcategory_id)->toBe($sub1->id);
|
|
expect($ticket->priority_key)->toBe('critical');
|
|
expect($ticket->histories()->pluck('text')->all())->toBe([
|
|
'Kategoria zmieniona na: IT-Pomoc / Drukarki i skanery',
|
|
'Priorytet zmieniony na: Krytyczny',
|
|
'Automatyzacja: klasyfikacja AI',
|
|
]);
|
|
});
|
|
|
|
test('idempotency: a second run does not rescan an already-triaged ticket', function () {
|
|
enableAiForTriage();
|
|
Settings::set('ai_triage_set_priority', '1');
|
|
seedPrioritiesForTriage();
|
|
fakeAiChat('{"category": null, "subcategory": null, "subject": null, "priority": "high"}');
|
|
|
|
triageTicket();
|
|
|
|
app(TicketAiTriageService::class)->run();
|
|
$second = app(TicketAiTriageService::class)->run();
|
|
|
|
expect($second)->toBe(['scanned' => 0, 'changed' => 0, 'failed' => 0]);
|
|
});
|
|
|
|
test('a malformed AI response changes nothing but still stamps ai_triaged_at and counts as failed', function () {
|
|
enableAiForTriage();
|
|
Settings::set('ai_triage_set_priority', '1');
|
|
seedPrioritiesForTriage();
|
|
fakeAiChat('to nie jest JSON');
|
|
|
|
$ticket = triageTicket(['priority_key' => 'medium']);
|
|
|
|
$totals = app(TicketAiTriageService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 1, 'changed' => 0, 'failed' => 1]);
|
|
$ticket->refresh();
|
|
expect($ticket->priority_key)->toBe('medium');
|
|
expect($ticket->ai_triaged_at)->not->toBeNull();
|
|
});
|
|
|
|
test('a hallucinated category name is silently dropped rather than applied', function () {
|
|
enableAiForTriage();
|
|
seedCategoriesForTriage();
|
|
Settings::set('ai_triage_category_when_missing', '1');
|
|
fakeAiChat('{"category": "Kategoria Zmyślona Przez Model", "subcategory": null, "subject": null, "priority": null}');
|
|
|
|
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => null]);
|
|
|
|
$totals = app(TicketAiTriageService::class)->run();
|
|
|
|
expect($totals['changed'])->toBe(0);
|
|
$ticket->refresh();
|
|
expect($ticket->category_id)->toBeNull();
|
|
expect($ticket->subcategory_id)->toBeNull();
|
|
});
|
|
|
|
test('with every triage toggle off, run makes no AI calls at all', function () {
|
|
enableAiForTriage();
|
|
triageTicket(['category_id' => null, 'subcategory_id' => null]);
|
|
|
|
Http::fake();
|
|
|
|
$totals = app(TicketAiTriageService::class)->run();
|
|
|
|
expect($totals)->toBe(['scanned' => 0, 'changed' => 0, 'failed' => 0]);
|
|
Http::assertNothingSent();
|
|
});
|