Co nowego: - Podgląd logów w panelu admina (Admin > Logi) — pliki storage/logs/*.log bez potrzeby dostępu do kontenera, z filtrami poziomu/tekstu/liczby wpisów i auto-odświeżaniem. - Filtr „Bez kategorii” w kolejce operatora — izoluje zgłoszenia bez przypisanej kategorii/podkategorii. - Narzędzie importu z Heska: już nie tworzy automatycznie kont klientów dla nieznanych e-maili (pomija takie zgłoszenia zamiast zakładać konto), łączy odpowiedzi/właścicieli zgłoszeń z realnymi kontami operatorów po e-mailu, nowe flagi --assign-operators i --fix-closed-dates do donaprawiania wcześniejszych importów, dedykowany log storage/logs/hesk-import.log. - Poprawka: pulpit statystyk operatora (rozkład wg kategorii/podkategorii i filtr kategorii) pomijał zgłoszenia przypisane do samej kategorii bez podkategorii (np. z poczty IMAP) — teraz liczone poprawnie. - Poprawka: błąd JS i zawieszone w tle liczniki przy nawigacji z widoku z aktywnym licznikiem (najbardziej odczuwalne w liczniku czasu pracy operatora). - Porządki w bazie: usunięte niewykorzystywane kolumny (users.remember_token, users.email_verified_at, email_templates.trigger_label); wartości pól dodatkowych, stan triage/podsumowania AI i powiązany sprzęt Snipe-IT przeniesione z tabeli tickets do osobnych tabel (ticket_field_values, ticket_ai_summaries, ticket_snipeit_assets) — bez zmiany zachowania, ale pola dodatkowe są teraz efektywnie przeszukiwalne; dodane brakujące indeksy na 4 tabelach pivot; tickets.source/ticket_messages.source walidowane względem znanego zestawu wartości. Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md, CHANGELOG.md, wiki/admin, wiki/operator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
7.0 KiB
PHP
189 lines
7.0 KiB
PHP
<?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\Facades\Log;
|
|
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 BODY_EXCERPT_CHARS = 4000;
|
|
|
|
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']++;
|
|
});
|
|
|
|
Log::channel('ai')->info(sprintf(
|
|
'Podsumowania: przeskanowano %d, zaktualizowano %d, błędów %d.',
|
|
$totals['scanned'],
|
|
$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.
|
|
*
|
|
* ai_summary_generated_at now lives on the related ticket_ai_summaries
|
|
* row (see Ticket::aiSummary()), so this joins to it directly rather
|
|
* than going through the model relation — a plain whereNull() on the
|
|
* left-joined column covers "no row yet" the same way it used to cover
|
|
* "column is null" when it lived on tickets itself.
|
|
*/
|
|
protected function staleQuery(): Builder
|
|
{
|
|
return Ticket::query()
|
|
->leftJoin('ticket_ai_summaries', 'ticket_ai_summaries.ticket_id', '=', 'tickets.id')
|
|
->where(function (Builder $q) {
|
|
$q->whereNull('ticket_ai_summaries.summary_generated_at')
|
|
->orWhere(function (Builder $q2) {
|
|
$q2->whereNotNull('ticket_ai_summaries.summary_generated_at')
|
|
->whereColumn('ticket_ai_summaries.summary_generated_at', '<', DB::raw(
|
|
'(select max(ticket_messages.created_at) from ticket_messages where ticket_messages.ticket_id = tickets.id)'
|
|
));
|
|
});
|
|
})
|
|
->select('tickets.*')
|
|
->orderBy('tickets.id');
|
|
}
|
|
|
|
/**
|
|
* Regenerates the summary for a single ticket right now, bypassing the
|
|
* staleness check — used by the manual "regenerate" button and the
|
|
* on-new-message hook, as opposed to run()'s scheduled batch sweep.
|
|
*/
|
|
public function generateFor(Ticket $ticket): bool
|
|
{
|
|
if (! $this->ai->enabled() || ! Settings::bool('ai_summary_enabled')) {
|
|
return false;
|
|
}
|
|
|
|
return $this->summarizeOne($ticket);
|
|
}
|
|
|
|
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.
|
|
Log::channel('ai')->warning("Podsumowanie zgłoszenia #{$ticket->number}: nie udało się wygenerować (brak lub niepoprawna odpowiedź modelu).");
|
|
|
|
return false;
|
|
}
|
|
|
|
$ticket->update([
|
|
'ai_summary' => $parsed['summary'],
|
|
'ai_suggested_action' => $parsed['suggested_action'],
|
|
'ai_summary_generated_at' => now(),
|
|
]);
|
|
|
|
Log::channel('ai')->debug("Podsumowanie zgłoszenia #{$ticket->number}: zaktualizowane.");
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Includes tickets.body explicitly (the opening description, separate
|
|
* from ticket_messages) rather than relying on it showing up as the
|
|
* thread's first message — that row falls outside the last-N transcript
|
|
* window on any ticket with more than TRANSCRIPT_MESSAGE_LIMIT messages,
|
|
* which would otherwise silently drop the original request from long
|
|
* threads. Mirrors TicketAiTriageService's own subject+body framing.
|
|
*/
|
|
protected function buildTranscript(Ticket $ticket): string
|
|
{
|
|
$lines = [
|
|
"Temat: {$ticket->subject}",
|
|
"Treść:\n".Str::limit(strip_tags($ticket->body), self::BODY_EXCERPT_CHARS),
|
|
];
|
|
|
|
$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,
|
|
];
|
|
}
|
|
}
|