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(); });