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, ]; } }