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:
139
src/app/Services/TicketAiSummaryService.php
Normal file
139
src/app/Services/TicketAiSummaryService.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Generates an AI summary + suggested next action for every ticket, cached
|
||||
* on the ticket row and shown only in the operator view (see
|
||||
* TicketAiTriageService's docblock for why this runs from the scheduled
|
||||
* ai:run-ticket-automation command rather than live on page load). Stays
|
||||
* reasonably fresh by regenerating whenever a ticket's latest message
|
||||
* postdates its last summary, not on every scheduler tick for every ticket.
|
||||
*/
|
||||
class TicketAiSummaryService
|
||||
{
|
||||
protected const BATCH_LIMIT = 25;
|
||||
|
||||
protected const MESSAGE_EXCERPT_CHARS = 1500;
|
||||
|
||||
protected const TRANSCRIPT_MESSAGE_LIMIT = 30;
|
||||
|
||||
public function __construct(protected AiClient $ai) {}
|
||||
|
||||
/**
|
||||
* @return array{scanned: int, updated: int, failed: int}
|
||||
*/
|
||||
public function run(?int $limit = null): array
|
||||
{
|
||||
$totals = ['scanned' => 0, 'updated' => 0, 'failed' => 0];
|
||||
|
||||
if (! $this->ai->enabled() || ! Settings::bool('ai_summary_enabled')) {
|
||||
return $totals;
|
||||
}
|
||||
|
||||
$this->staleQuery()
|
||||
->limit($limit ?? self::BATCH_LIMIT)
|
||||
->get()
|
||||
->each(function (Ticket $ticket) use (&$totals) {
|
||||
$totals['scanned']++;
|
||||
|
||||
$this->summarizeOne($ticket) ? $totals['updated']++ : $totals['failed']++;
|
||||
});
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tickets with no summary yet, or whose latest message postdates the
|
||||
* last summary generation. Deliberately compares against
|
||||
* ticket_messages.created_at rather than tickets.updated_at — the
|
||||
* latter also changes on unrelated actions (status/priority/timer
|
||||
* edits), which would otherwise trigger spurious re-summarization on
|
||||
* every scheduler tick for an active ticket.
|
||||
*/
|
||||
protected function staleQuery(): Builder
|
||||
{
|
||||
return Ticket::query()->where(function (Builder $q) {
|
||||
$q->whereNull('ai_summary_generated_at')
|
||||
->orWhere(function (Builder $q2) {
|
||||
$q2->whereNotNull('ai_summary_generated_at')
|
||||
->whereColumn('ai_summary_generated_at', '<', DB::raw(
|
||||
'(select max(ticket_messages.created_at) from ticket_messages where ticket_messages.ticket_id = tickets.id)'
|
||||
));
|
||||
});
|
||||
})->orderBy('id');
|
||||
}
|
||||
|
||||
protected function summarizeOne(Ticket $ticket): bool
|
||||
{
|
||||
$raw = $this->ai->chat([
|
||||
['role' => 'system', 'content' => Settings::get('ai_summary_prompt')],
|
||||
['role' => 'user', 'content' => $this->buildTranscript($ticket)],
|
||||
], ['temperature' => 0.2]);
|
||||
|
||||
$parsed = $this->parseResponse($raw);
|
||||
|
||||
if ($parsed === null) {
|
||||
// Leaves any prior summary untouched and generated_at unchanged,
|
||||
// so the ticket stays in the stale set and gets retried next run
|
||||
// rather than silently losing a working summary.
|
||||
return false;
|
||||
}
|
||||
|
||||
$ticket->update([
|
||||
'ai_summary' => $parsed['summary'],
|
||||
'ai_suggested_action' => $parsed['suggested_action'],
|
||||
'ai_summary_generated_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function buildTranscript(Ticket $ticket): string
|
||||
{
|
||||
$lines = ["Temat: {$ticket->subject}"];
|
||||
|
||||
$ticket->messages()->latest('created_at')->limit(self::TRANSCRIPT_MESSAGE_LIMIT)->get()
|
||||
->sortBy('created_at')
|
||||
->each(function ($message) use (&$lines) {
|
||||
$role = $message->internal ? 'notatka wewnętrzna' : ($message->role === 'client' ? 'klient' : 'operator');
|
||||
$body = Str::limit(strip_tags($message->body), self::MESSAGE_EXCERPT_CHARS, '');
|
||||
$lines[] = "[{$role}] {$message->author_name}: {$body}";
|
||||
});
|
||||
|
||||
return implode("\n\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same defensive-parsing shape used elsewhere in this app's AI services
|
||||
* (BookStackContentTagger, TicketAiTriageService) — extracts the first
|
||||
* {...} block before decoding.
|
||||
*
|
||||
* @return array{summary: string, suggested_action: ?string}|null
|
||||
*/
|
||||
protected function parseResponse(?string $raw): ?array
|
||||
{
|
||||
if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($matches[0], true);
|
||||
|
||||
if (! is_array($decoded) || empty($decoded['summary']) || ! is_string($decoded['summary'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => trim($decoded['summary']),
|
||||
'suggested_action' => ! empty($decoded['suggested_action']) && is_string($decoded['suggested_action'])
|
||||
? trim($decoded['suggested_action'])
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user