- 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:
2026-07-24 13:38:39 +02:00
parent 0d116dfd98
commit 313e01ad24
46 changed files with 3224 additions and 150 deletions

View File

@@ -0,0 +1,245 @@
<?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();
});