diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4ea6762..3e49064 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,6 +113,29 @@ typed against `ModelNotFoundException` itself would never match) that redirects to `operator.queue`/`client.dashboard` instead, for any authenticated request under `operator/*`/`client/*`. +That global handler only ever sees a full HTTP request (a page load/reload), +not Livewire's own AJAX update endpoint (`/livewire/update`, which doesn't +match the `operator/*`/`client/*` path check) — so it doesn't cover an +operator who already has a ticket open when it's deleted, or whose team gets +reassigned (by anyone, including via their own action — see "Teams" in +[README.md](README.md)) to one outside their visible scope +(`Ticket::isVisibleToOperator()`) mid-session. `Operator\TicketShow` handles +that case itself: a Livewire component's typed public model property +(`public Ticket $ticket`) is re-fetched by id on every subsequent request via +`firstOrFail()` (`Livewire\Features\SupportModels\ModelSynth::hydrate()`), +which throws `ModelNotFoundException` *before* any of the component's own +method code runs if the row is gone — too early for an ordinary try/catch +inside an action method to ever catch. The component instead defines +Livewire's `exception($e, $stopPropagation)` lifecycle hook (called for any +exception raised anywhere in the component's request lifecycle, hydration +included) to catch that case and redirect. The narrower case — ticket still +exists but is no longer visible, e.g. after a team reassignment — doesn't +throw at all, so it's caught separately: `refreshOrRedirectAway()` re-checks +`isVisibleToOperator()` after every live-update refresh +(`onQueueChanged()`/`refreshTicketData()`) and after the operator's own +`setTeam()` call, redirecting immediately rather than leaving them on a +ticket they can no longer legitimately keep viewing. + ## Roles & permissions `$user->roles` reads/writes as a plain array (`['client', 'operator']`), but @@ -252,9 +275,19 @@ if "nothing updates live" ever comes back: As a defense against a dropped websocket connection (backgrounded tab, network blip), the operator queue and both ticket-detail views also poll -themselves every 30–60 seconds via a small Alpine countdown calling -`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is -best-effort, not the only way these views ever update. +themselves via a small Alpine countdown calling `$wire.refreshQueue()` / +`$wire.refreshTicketData()` — broadcasting is best-effort, not the only way +these views ever update. The countdown badge is also clickable +(`x-on:click="remaining = total; $wire.refresh...()"` on the same element +the `x-init="setInterval(...)"` already lives on) to fetch immediately and +reset the countdown, rather than only ever firing on its own schedule. Its +interval — like the notification bell's `wire:poll` and the 4 scheduled +commands below — reads from `Settings` (`refresh_queue_seconds`/ +`refresh_ticket_view_seconds`/`refresh_notifications_seconds`, admin-editable +in Konfiguracja) rather than a hardcoded number: `wire:poll.{{ $seconds }}s` +and Alpine's `x-data="{ remaining: {{ $seconds }}, ... }"` both just +interpolate to plain text in the rendered HTML, so a `Settings`-sourced value +works exactly like a literal one would. A third private channel, **`App.Models.User.{id}`** (Laravel's default per-notifiable convention, kept verbatim rather than a shorter alias), @@ -274,8 +307,9 @@ fires while the tab is open, same limitation as the other Echo listeners here. `SlaRule` holds per-priority response/resolution targets in minutes. The scheduled command `tickets:check-sla-breaches` (registered in -`routes/console.php`, run every 15 minutes via `schedule:run`) flags overdue -tickets and can notify the assigned operator — see [install.md](install.md) for +`routes/console.php`, default every 15 minutes, interval admin-configurable — +see "Configurable scheduled-command intervals" below) flags overdue tickets +and can notify the assigned operator — see [install.md](install.md) for why this requires an external cron entry (the Docker image ships no cron/supervisor of its own). @@ -285,8 +319,9 @@ cron/supervisor of its own). `scope_subcategory_id`/`scope_team_id`, `action_type` + `action_value`) lets an admin configure "if a ticket has been silent for N minutes, change its priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The -scheduled command `automation:run-rules` (also every 15 minutes) evaluates -every enabled rule against `Ticket.last_customer_activity_at` (falling back to +scheduled command `automation:run-rules` (default also every 15 minutes, +independently configurable) evaluates every enabled rule against +`Ticket.last_customer_activity_at` (falling back to `created_at` if never set — mirrors how `resolutionDeadline()` treats a missing `SlaRule` as "no SLA" rather than backfilling one), and applies a match through the same `TicketService` setters a manual operator action would @@ -301,6 +336,29 @@ that closes the ticket doesn't block earlier-ordered rules already applied this run, but a later rule's own query naturally excludes an already-closed ticket. +## Configurable scheduled-command intervals + +All 4 scheduled commands (`tickets:check-sla-breaches`, `automation:run-rules`, +`emails:fetch-imap`, `ai:run-ticket-automation`) have an admin-configurable +interval (Admin > Konfiguracja — `schedule_sla_check_minutes`/ +`schedule_automation_rules_minutes`/`schedule_imap_fetch_minutes`/ +`schedule_ai_automation_minutes`), defaulting to their previous hardcoded +values (15/15/5/5 minutes). `routes/console.php` registers all 4 as +`->everyMinute()->when(fn () => Settings::dueEveryMinutes($key, $default))` +rather than an eagerly-built `->cron('*/N * * * *')` string — this is a +deliberate choice, not just a style preference: `routes/console.php` is +`require`'d on **every** artisan boot (`migrate`, `tinker`, `php artisan +test`, not just `schedule:run`, since it's wired in via `bootstrap/app.php`'s +`commands:` key), so anything at its *top level* that queries the database +would run before a fresh/test database necessarily has the `settings` table +yet — an early version of this feature that built the cron string eagerly at +the top level broke exactly this way. A closure passed to `->when()` is only +ever evaluated later, when `schedule:run` actually processes due events, so +`Settings::dueEveryMinutes()` never runs at boot. One visible side effect: +`php artisan schedule:list` shows `* * * * *` for all four regardless of +their actual configured interval, since the real interval only exists inside +the closure — expected, not a bug. + ## IMAP e-mail intake Optional, off by default (`ImapMailbox.enabled` per row — there is no single @@ -366,10 +424,10 @@ singleton). Split across three layers, mirroring the plan that shipped it: the ticket number; ticket view: per-message in the thread, plus a tag next to the ticket number in the header). - **`emails:fetch-imap`** (`app/Console/Commands/FetchImapEmails.php`), - registered in `routes/console.php` as - `Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping()` - — the one scheduled command in this app that opts into - `withoutOverlapping()` (SLA/automation don't), given IMAP I/O latency. + registered in `routes/console.php` with `->withoutOverlapping()` (like + `ai:run-ticket-automation`, unlike the SLA-check/automation-rules + commands — both make real outbound HTTP/IMAP calls per record, so a slow + run risks overlapping the next tick in a way a pure-DB command doesn't). Early-returns if no `ImapMailbox` is enabled. Also callable directly per mailbox from Admin > Poczta's "Pobierz teraz" button (`ImapMailboxFetcher::fetchMailbox()`, bypassing the enabled-only @@ -392,26 +450,140 @@ tighter per-IP limit for unauthenticated requests generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API reference in-repo. +## Generic AI integration + +`App\Services\AiClient` is a small, feature-agnostic wrapper around an +OpenAI-compatible `/chat/completions` endpoint (`chat(array $messages, array +$options = []): ?string`) — works against Groq, OpenAI itself, or a +self-hosted Ollama instance, whichever `ai_base_url` points at. +`Settings`-driven like everything else here: `ai_enabled`, `ai_base_url`, +`ai_api_key` (encrypted, optional — deliberately not required by `enabled()`, +since a self-hosted Ollama instance typically has no auth at all), +`ai_model`, `ai_verify_ssl`. Every call is wrapped in `try/catch(\Throwable)` +and returns `null` on any failure (network, non-2xx, unexpected shape), +matching `BookStackClient`'s safe-default convention — callers are expected +to treat `null` as "AI unavailable" and degrade gracefully rather than throw. +Not tied to any single feature: `BookStackContentTagger`, +`TicketAiTriageService` and `TicketAiSummaryService` (below) are just its +first three consumers, each with their own prompt-building/parsing logic +layered on top rather than baked into the client itself. + ## BookStack integration -`App\Services\BookStackClient` is the only outbound HTTP client in the -codebase (Laravel's `Http` facade) — everything else here only ever receives -requests. It's entirely `Settings`-driven, no `.env`/`config()` involved: -`bookstack_enabled`, `bookstack_base_url`, `bookstack_token_id`/ -`bookstack_token_secret` (encrypted, same as the LDAP/SMTP passwords), -`bookstack_verify_ssl`, `bookstack_search_types` ('both'|'page'|'book'), and -**two independent** allow-lists of BookStack shelf IDs — -`bookstack_allowed_shelf_ids_creation` (ticket-wizard suggestions) and -`bookstack_allowed_shelf_ids_ticket_view` (the operator's sidebar on an -existing ticket) — `search()` takes a `$context` (`CONTEXT_CREATION` / -`CONTEXT_TICKET_VIEW`) that selects which one applies. **An empty allow-list -means "search nothing"**, not "search everything" — nothing is ever -suggested until an admin explicitly opts shelves in, independently per -context. BookStack has no "which shelf is this book on" field in its own -search response, so `BookStackClient` fetches `/api/shelves` + -`/api/shelves/{id}` once (cached 30 min) into a shelf→book-ids map, used both -to resolve the allow-list to book IDs and to build the "Shelf > Book" -breadcrumb shown next to each suggestion. Per-query search results are cached -10 minutes, keyed on the query text **and** the active allow-list, so toggling -which shelves are allowed is reflected immediately instead of serving a -pre-change result for up to 10 minutes. +`App\Services\BookStackClient` is one of two outbound HTTP clients in the +codebase (Laravel's `Http` facade), alongside `AiClient` above — everything +else here only ever receives requests. It's entirely `Settings`-driven, no +`.env`/`config()` involved: `bookstack_enabled`, `bookstack_base_url`, +`bookstack_token_id`/`bookstack_token_secret` (encrypted, same as the +LDAP/SMTP passwords), `bookstack_verify_ssl`, and **two independent** +allow-lists of BookStack shelf IDs — `bookstack_allowed_shelf_ids_creation` +(ticket-wizard suggestions) and `bookstack_allowed_shelf_ids_ticket_view` +(the operator's sidebar on an existing ticket) — `search()` takes a +`$context` (`CONTEXT_CREATION` / `CONTEXT_TICKET_VIEW`) that selects which +one applies. **An empty allow-list means "search nothing"**, not "search +everything" — nothing is ever suggested until an admin explicitly opts +shelves in, independently per context. BookStack has no "which shelf is this +book on" field in its own search response, so `BookStackClient` fetches +`/api/shelves` + `/api/shelves/{id}` once (cached 30 min) into a +shelf→book-ids map, used both to resolve the allow-list to book IDs and to +build the "Shelf > Book" breadcrumb shown next to each suggestion. Per-query +search results are cached 10 minutes, keyed on the query text **and** the +active allow-list, so toggling which shelves are allowed is reflected +immediately instead of serving a pre-change result for up to 10 minutes. + +**Content-type filter and "search by" mode**: `bookstack_search_types` is a +comma-separated subset of `BookStackClient::SEARCH_TYPES` (`book`, `page`, +`chapter` — checkboxes in the admin UI, no more single-select "both/page/book" +dropdown), combined into BookStack's own `{type:a|b}` query syntax. +`bookstack_search_by` (`'name'`/`'tags'`/`'both'`) picks between matching the +title (`{in_name:...}`) and matching a tag whose name equals the query +(`[...]` — see BookStack content auto-tagging below for what actually writes +those tags); `'both'` runs one request per mode and merges/dedupes the +results, since BookStack's own query syntax ANDs filters together rather than +OR-ing them, so there's no single-request way to ask for "name OR tag". +`search()` takes both a `$query` (full "Category Subcategory" text, used for +the name-match variant) and an optional `$tagQuery` (bare subcategory name, +used for the tag-match variant) — the two differ because a tag is expected to +hold just the subcategory name, not the combined category+subcategory text. + +## BookStack content auto-tagging + +`App\Services\BookStackContentTagger` (used by the "Otaguj nową +treść"/"Otaguj wszystko ponownie" buttons on the BookStack admin card and by +`php artisan bookstack:tag-content`) is the reason the tag-based search mode +above has anything to match: it walks every book/chapter/page via +`BookStackClient::listAll()`/`detail()`, builds a Polish prompt naming the +current, live `Subcategory` list as the only allowed vocabulary, and asks +`AiClient` (above) to return which subcategory name(s) fit each item — a +single response per batch of 20 items, to keep prompt size/cost down. +Defensive JSON parsing (`parseAssignments()`) regex-extracts the first +`{...}` block before decoding, so a chatty or malformed response fails just +that one batch (`failed_batches` in the run summary) instead of crashing the +whole pass; every returned label is matched case-insensitively against the +real subcategory list before being trusted, so a hallucinated name is +silently dropped rather than written as a tag. Idempotent by default — an +item already carrying a tag matching a current subcategory name is skipped +unless `--force`/the "wszystko ponownie" button is used — and new tags are +merged into an item's existing tags (`updateTags()` PUTs the whole array; +BookStack has no "append a tag" endpoint), never overwriting unrelated ones. + +## AI ticket triage & summary + +Two independent services, both consuming `AiClient` above, both run from a +single scheduled command (`ai:run-ticket-automation`) — **never +synchronously at ticket creation**, so an LLM call never adds latency to a +live customer submitting a ticket: + +- **`App\Services\TicketAiTriageService`** — a one-shot classification pass + per ticket, gated by 5 independent toggles + (`ai_triage_category_when_missing`/`subcategory_when_category_only`/ + `recheck_categorized`/`fix_subject`/`set_priority`). `buildPrompt()` picks + one of 3 mutually-exclusive category scenarios from the ticket's *current* + state (no category/subcategory at all → assign both; category but no + subcategory → pick one within it; already has a subcategory → recheck and + possibly correct), independently of the subject/priority toggles. Every + scanned ticket gets `tickets.ai_triaged_at` stamped exactly once — this is + a one-shot pass, not a continuous recheck, and there's deliberately no + manual per-ticket re-trigger. Resolution is fail-closed the same way as the + BookStack tagger: every value the model returns is matched against the + real category/subcategory/priority vocabulary before being trusted: a + hallucinated or out-of-scope value (e.g. a subcategory claimed under the + wrong category) is silently dropped. Applying changes goes through a new + `TicketService::applyAiTriage(Ticket $ticket, array $changes, array + $historyLines)` — a single `$ticket->update()` for whichever + category/subcategory/subject/priority fields actually changed, one + specific history line per changed field plus a final "Automatyzacja: + klasyfikacja AI" attribution line (mirrors how `RunAutomationRules` logs + its own SLA-automation changes), and `notify()`/`TriggerEngine::handle()` + fired only for the fields that actually changed — deliberately not + composed from the existing `setPriority()`/`updateDetails()` setters, since + one AI pass can touch several fields at once and those would each write + their own generic line and fire notifications per-field instead of once + per pass. +- **`App\Services\TicketAiSummaryService`** — a summary + suggested next + action for **every** ticket (gated by a single `ai_summary_enabled` + toggle), cached on `tickets.ai_summary`/`ai_suggested_action`/ + `ai_summary_generated_at` and shown only in the operator ticket view (a + "Podsumowanie AI" sidebar card, lazy-loaded via `wire:init` like the + BookStack suggestions card next to it — never live-called from the ticket + page itself, only ever displaying whatever the scheduled command last + computed). Regenerates whenever a ticket's latest message postdates its + last summary — deliberately compared against `ticket_messages.created_at`, + not `tickets.updated_at` (which also changes on unrelated actions like a + status/priority edit, which would otherwise trigger spurious + re-summarization on every tick for an active ticket). Unlike the triage + service, a malformed AI response here leaves the previous summary + untouched rather than stamping "done" — the ticket stays in the "stale" + set and gets retried next run, since this feature is meant to keep + refreshing indefinitely, not run once. The system prompt is + admin-editable (`ai_summary_prompt` setting, plain textarea with a + "Resetuj" button restoring `Settings::default('ai_summary_prompt')` — + same pattern as the e-mail footer editor) and asks the model for a small + JSON object (`{"summary": "...", "suggested_action": "..."}`), parsed with + the same defensive regex-extract-then-decode approach used throughout + these AI services. + +Its own interval (`ai:run-ticket-automation`) is admin-configurable the same +way the other 3 scheduled commands are — see "Configurable scheduled-command +intervals" above for the mechanism and a boot-time trap worth knowing about +before touching `routes/console.php` again. diff --git a/CHANGELOG.md b/CHANGELOG.md index fa25f5b..e01f163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,65 @@ All notable changes to this project are documented in this file. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.2.1] - 2026-07-24 + +### Added + +- **Generic AI integration** (Admin > Integracje > "Integracja AI"), optional + and off by default — an OpenAI-compatible `/chat/completions` client (works + against Groq, OpenAI itself, or a self-hosted Ollama instance) configured by + base URL, optional API key, model name, and an SSL-verification toggle for + self-signed local endpoints. Not tied to any one feature — it's the shared + foundation for the two AI-driven features below, and for anything else that + wants an LLM call in the future. +- **BookStack automatic content tagging (AI)** — a "Otaguj nową treść"/"Otaguj + wszystko ponownie (force)" button pair in the BookStack card, plus + `php artisan bookstack:tag-content` (`--dry-run`/`--force`/`--limit=N`) for + the command line. Uses the AI integration above to classify every + book/chapter/page's title+content against the current list of helpdesk + subcategories and tags matching ones by name — idempotent by default + (skips already-tagged content), so re-running after adding a few pages is + cheap. This is what gives the BookStack "search by tags" option below + something to actually match against. +- **BookStack search refinement** — "Przeszukuj" is now three independent + checkboxes (Książki / Strony / Rozdziały) instead of a single dropdown with + no chapter option, plus a new "Szukaj po" setting: słowa kluczowe w nazwie / + tagi / oba. Tag matching uses the bare subcategory name (e.g. "Drukarki i + skanery"), matching what the auto-tagging feature above writes. +- **AI-driven ticket triage + summary** (Admin > Integracje > + "Automatyzacja AI dla zgłoszeń", runs via a new scheduled + `ai:run-ticket-automation`) — five independent toggles: assign a + category/subcategory when a ticket has neither, pick a subcategory when it + only has a category, recheck and possibly correct an already-categorized + ticket, rewrite an unclear subject, and set a priority based on content. + Runs once per ticket in the background (never synchronously at submission, + so it adds no latency for a client), and every applied change leaves a + specific line plus a "Automatyzacja: klasyfikacja AI" entry in the ticket's + history, same convention as the existing SLA automation rules. Separately, + an AI-generated summary + suggested next action for **every** ticket, shown + only to operators in a new "Podsumowanie AI" sidebar card, refreshed + whenever the thread gets a new message — its system prompt is admin-editable + as a plain-text field with a "Resetuj" button back to the shipped default. +- Operators can now reassign a ticket to **any** team, not just one they + belong to (previously the dropdown only ever offered the operator's own + teams). +- The auto-refresh countdown badges (ticket view, operator queue) are now + **clickable** — fetch immediately and reset the countdown, instead of only + ever refreshing on their own fixed schedule. +- **All 7 "cyclical" intervals** in the app are now configurable from + Admin > Konfiguracja instead of fixed in code: the 3 browser auto-refresh + countdowns (ticket view, operator queue), the notification bell's poll, + and the 4 background scheduled commands (SLA breach check, SLA automation + rules, IMAP fetch, AI ticket automation). Defaults match the previous + hardcoded values, so nothing changes until an admin edits them. + +### Fixed + +- An operator viewing a ticket that gets deleted by someone else, or whose + team changes to one outside the operator's own scope (including via their + own reassignment above), is now redirected back to the operator queue + instead of hitting an error mid-session. + ## [1.2.0] - 2026-07-23 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 58e2bc1..876606f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,26 +63,43 @@ with no rebuild or restart: ## Scheduled commands need a host crontab entry The Docker image ships no cron/supervisor of its own (see [install.md](install.md)), -so `tickets:check-sla-breaches`, `automation:run-rules`, and `emails:fetch-imap` -(all registered in `routes/console.php` via `Schedule::command(...)`) only ever -run if something outside the container calls `php artisan schedule:run` on a -timer. **As of 2026-07-23 this is configured** — root's crontab on the host -runs, every minute: +so `tickets:check-sla-breaches`, `automation:run-rules`, `emails:fetch-imap`, +and `ai:run-ticket-automation` (all registered in `routes/console.php` via +`Schedule::command(...)`) only ever run if something outside the container +calls `php artisan schedule:run` on a timer. **As of 2026-07-23 this is +configured** — root's crontab on the host runs, every minute: ```cron * * * * * cd /mnt/rabbit-containers/servicedesk && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1 ``` (`sudo crontab -l -u root` to inspect/edit — it previously did not exist at all, -which meant none of the three scheduled commands above had ever run +which meant none of the four scheduled commands above had ever run automatically; ask before changing this again, since removing it silently -breaks SLA checks, automation rules and IMAP fetching, and confusingly not the -IMAP feature alone if you're only debugging that one.) IMAP-specific activity -(connect attempts, per-message accept/reject decisions, created/replied ticket -ids) is logged separately from the app's normal `LOG_LEVEL` to -`storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) — -check there first when a mailbox isn't behaving as expected, before assuming -the scheduler itself isn't firing. +breaks SLA checks, automation rules, IMAP fetching and AI ticket automation, +and confusingly not the IMAP feature alone if you're only debugging that one.) +IMAP-specific activity (connect attempts, per-message accept/reject decisions, +created/replied ticket ids) is logged separately from the app's normal +`LOG_LEVEL` to `storage/logs/imap-*.log` (see the `imap` channel in +`config/logging.php`) — check there first when a mailbox isn't behaving as +expected, before assuming the scheduler itself isn't firing. + +All four commands' intervals are admin-configurable (Admin > Konfiguracja — +`schedule_sla_check_minutes`/`schedule_automation_rules_minutes`/ +`schedule_imap_fetch_minutes`/`schedule_ai_automation_minutes`), which is why +`routes/console.php` registers them as `->everyMinute()->when(fn () => +Settings::dueEveryMinutes(...))` instead of a plain `->everyFifteenMinutes()`/ +`->cron(...)` call — **never build a cron expression (or otherwise read +`Settings`) at that file's top level**. `routes/console.php` is `require`'d on +every artisan boot (`migrate`, `tinker`, `php artisan test`, not just +`schedule:run` — it's wired in via `bootstrap/app.php`'s `commands:` key), so +a top-level `Settings::get(...)` call runs before a fresh/test database +necessarily has the `settings` table, and crashes every single artisan +invocation, not just the scheduler. A `->when($closure)` guard is the fix — +the closure is only ever evaluated later, when `schedule:run` processes due +events. One visible side effect: `php artisan schedule:list` shows +`* * * * *` for all four regardless of the actual configured interval, since +that only exists inside the closure — expected, not a bug worth chasing. ## Apache `/icons/` alias trap diff --git a/README.md b/README.md index e906c84..3e4db9a 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,17 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides. (operator and client) update live over WebSockets (Laravel Reverb): new tickets, status/priority/team/assignee changes, and new replies ("live chat") all show up without a manual refresh. A periodic fallback refresh (with a - visible countdown) covers a dropped websocket connection. + visible, clickable countdown badge — click it to fetch immediately and reset + the countdown) covers a dropped websocket connection. All of the refresh/poll + intervals in the app, browser-side and the background scheduled commands + alike, are configurable from Admin > Konfiguracja (see below). - **Categories & custom fields** — admin-defined categories/subcategories, each with its own set of custom fields (text/textarea/select/checkbox/date/number) and an optional default priority. - **Teams** — subcategories auto-route to a team; operators only see their own team's queue (plus unrouted tickets and anything assigned to them) unless they're - an admin. + an admin. Reassigning a ticket to a team, though, is unrestricted — an operator + can route a ticket to any team, not just one they belong to. - **Templates** — canned response snippets for the reply box, admin-configurable "quick actions" (send + transition status in one click), and HTML e-mail templates for every ticket lifecycle event (created, status/priority/category/ @@ -133,10 +137,33 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides. both operators and clients (with a copy-link button for operators). Loads in after the page's first paint rather than blocking it. Configured entirely from Admin > Integracje: connection + API token, optional SSL-verification - bypass for self-signed instances, page/book search-type filter, and two - independent per-shelf allow-lists (nothing is searched until an admin opts - specific shelves in, separately for ticket-creation suggestions vs. the - operator/client ticket-view sidebar). + bypass for self-signed instances, a content-type filter (books/pages/ + chapters, independently toggleable) and a "search by" mode (name / tags / + both), and two independent per-shelf allow-lists (nothing is searched until + an admin opts specific shelves in, separately for ticket-creation + suggestions vs. the operator/client ticket-view sidebar). A pair of + "Otaguj nową treść"/"Otaguj wszystko ponownie" buttons (also available as + `php artisan bookstack:tag-content`) use the AI integration below to + auto-tag every book/chapter/page with matching subcategory names, so the + tag-based search mode has something to find. +- **Generic AI integration** (Admin > Integracje > "Integracja AI") *(optional, + 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. Not tied to a single + feature — it backs the BookStack auto-tagging above and the AI ticket + triage/summary below, and is meant to be reused by anything that needs an + LLM call in the future. +- **AI-driven ticket triage + summary** (Admin > Integracje > + "Automatyzacja AI dla zgłoszeń") *(optional, off by default, requires the AI + integration above)* — five independent toggles run once per new ticket, in + the background (`ai:run-ticket-automation`, never synchronously at + submission): assign a category/subcategory when missing, pick a + subcategory when only a category is set, recheck/correct an + already-categorized ticket, rewrite an unclear subject, and set a priority + from the ticket's content. Every applied change is logged in the ticket's + history. Separately, an AI-generated summary + suggested next action for + every ticket, shown to operators only, refreshed as the thread grows, with + an admin-editable prompt (reset-to-default button included). ## Tech stack @@ -192,9 +219,12 @@ src/ Laravel application app/Livewire/ Client/Operator/Admin Livewire components app/Models/ Eloquent models app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted) - app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch) + app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch, + AI ticket triage/summary) + bookstack:tag-content app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient, - ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic) + ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic), + AiClient (generic LLM client), BookStackContentTagger, + TicketAiTriageService, TicketAiSummaryService app/Ldap/ LDAP user model + sync handlers database/migrations/ Schema (one file per table group, final shape) database/seeders/ DatabaseSeeder — reference data, no ticket data diff --git a/install.md b/install.md index 3a7fe61..7c6f753 100644 --- a/install.md +++ b/install.md @@ -74,7 +74,7 @@ APP_LOCALE=pl APP_FALLBACK_LOCALE=pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji -VERSION=1.1.3 # widoczne w Admin > O aplikacji +VERSION=1.2.1 # widoczne w Admin > O aplikacji DB_CONNECTION=mysql DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1 @@ -265,14 +265,19 @@ docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`. -### 1.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) i kolejka +### 1.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP, AI) i kolejka `routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules` -co 15 minut oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem — -patrz Admin > Poczta) co 5 minut, ale **obraz Dockera nie ma wbudowanego -cron/supervisora** — bez dodatkowego kroku żadne z tych zadań nigdy się nie -uruchomi (poczta IMAP nadal da się sprawdzić ręcznie przyciskiem „Pobierz teraz”, -ale bez crona nic nie dzieje się samo). Najprościej dodać wpis crona **na hoście**: +co 15 minut, oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem — +patrz Admin > Poczta) i `ai:run-ticket-automation` (opcjonalna automatyczna +kategoryzacja/podsumowania AI zgłoszeń — patrz Admin > Integracje) co 5 minut, +ale **obraz Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego +kroku żadne z tych zadań nigdy się nie uruchomi (poczta IMAP nadal da się +sprawdzić ręcznie przyciskiem „Pobierz teraz”, ale bez crona nic nie dzieje się +samo). Wszystkie cztery interwały są też konfigurowalne z poziomu **Admin > +Konfiguracja** (bez potrzeby edycji kodu czy restartu — nowa wartość obowiązuje +od najbliższego tyknięcia harmonogramu). Najprościej dodać wpis crona **na +hoście**: ```cron * * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1 @@ -301,15 +306,27 @@ użytku: 3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz polegać na logowaniu przez katalog. -### Integracje opcjonalne (BookStack) +### Integracje opcjonalne (BookStack, AI) Podpowiedzi artykułów z bazy wiedzy BookStack (przy tworzeniu zgłoszenia i w panelu operatora) są **domyślnie wyłączone** i nie wymagają żadnej zmiennej w -`.env` — całość konfiguruje się w **Admin > Konfiguracja**: adres instancji, +`.env` — całość konfiguruje się w **Admin > Integracje**: adres instancji, Token ID/Secret (rola/użytkownik właściciela tokenu musi mieć w BookStacku -uprawnienie „Access System API”), oraz osobne listy dozwolonych półek dla -podpowiedzi przy tworzeniu zgłoszenia i dla panelu operatora — dopóki żadna -półka nie jest zaznaczona, wyszukiwanie nic nie zwraca. +uprawnienie „Access System API”), filtr typu treści (książki/strony/rozdziały, +niezależne checkboxy), tryb wyszukiwania (po nazwie / po tagach / oba), oraz +osobne listy dozwolonych półek dla podpowiedzi przy tworzeniu zgłoszenia i dla +panelu operatora — dopóki żadna półka nie jest zaznaczona, wyszukiwanie nic +nie zwraca. + +Integracja AI (Admin > Integracje > „Integracja AI”) jest **domyślnie +wyłączona** i tak samo nie wymaga żadnej zmiennej w `.env` — adres API +(dowolny dostawca kompatybilny z OpenAI: Groq, OpenAI, lokalny Ollama), +opcjonalny klucz API, nazwa modelu i przełącznik weryfikacji SSL. Sama w +sobie nic nie robi — dopiero po jej włączeniu można włączyć automatyczne +tagowanie treści BookStack (przyciski przy integracji BookStack) oraz +automatyczną kategoryzację/podsumowania AI zgłoszeń (Admin > Integracje > +„Automatyzacja AI dla zgłoszeń”, wymaga też wpisu crona z kroku 1.6/2.6 +powyżej — to ten sam harmonogram co SLA/automatyzacje/IMAP). --- @@ -352,7 +369,7 @@ APP_LOCALE=pl APP_FALLBACK_LOCALE=pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl -VERSION=1.1.3 +VERSION=1.2.1 DB_CONNECTION=mysql DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB @@ -460,10 +477,11 @@ server { } ``` -### 2.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) i kolejka +### 2.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP, AI) i kolejka Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`) — obsługuje też -`automation:run-rules` i `emails:fetch-imap` (patrz 1.6 wyżej): +`automation:run-rules`, `emails:fetch-imap` i `ai:run-ticket-automation` +(patrz 1.6 wyżej, w tym konfigurowalne interwały w Admin > Konfiguracja): ```cron * * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1 diff --git a/src/.env.example b/src/.env.example index af4ba8d..d79eca6 100644 --- a/src/.env.example +++ b/src/.env.example @@ -5,7 +5,7 @@ APP_DEBUG=false APP_URL=http://localhost AUTHOR_CONTACT=helpdesk@kzbikowski.pl -VERSION=1.1.4 +VERSION=1.2.1 APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/src/app/Console/Commands/BookstackTagContent.php b/src/app/Console/Commands/BookstackTagContent.php new file mode 100644 index 0000000..504fa3e --- /dev/null +++ b/src/app/Console/Commands/BookstackTagContent.php @@ -0,0 +1,35 @@ +run( + dryRun: (bool) $this->option('dry-run'), + force: (bool) $this->option('force'), + limit: $this->option('limit') !== null ? (int) $this->option('limit') : null, + ); + + $this->info(sprintf( + 'BookStack tagging: przeskanowano %d, otagowano %d, pominięto %d, nieudanych paczek %d.', + $totals['scanned'], + $totals['tagged'], + $totals['skipped'], + $totals['failed_batches'], + )); + + return self::SUCCESS; + } +} diff --git a/src/app/Console/Commands/RunAiTicketAutomation.php b/src/app/Console/Commands/RunAiTicketAutomation.php new file mode 100644 index 0000000..a4b30a7 --- /dev/null +++ b/src/app/Console/Commands/RunAiTicketAutomation.php @@ -0,0 +1,35 @@ +run(); + $summaryTotals = $summary->run(); + + $this->info(sprintf( + 'AI triage: scanned %d, changed %d, failed %d.', + $triageTotals['scanned'], + $triageTotals['changed'], + $triageTotals['failed'], + )); + $this->info(sprintf( + 'AI summaries: scanned %d, updated %d, failed %d.', + $summaryTotals['scanned'], + $summaryTotals['updated'], + $summaryTotals['failed'], + )); + + return self::SUCCESS; + } +} diff --git a/src/app/Livewire/Admin/Panel.php b/src/app/Livewire/Admin/Panel.php index 3157236..621bddd 100644 --- a/src/app/Livewire/Admin/Panel.php +++ b/src/app/Livewire/Admin/Panel.php @@ -17,7 +17,9 @@ use App\Models\Team; use App\Models\Ticket; use App\Models\User; use App\Models\UserField; +use App\Services\AiClient; use App\Services\BookStackClient; +use App\Services\BookStackContentTagger; use App\Services\LdapUserProvisioner; use App\Support\Settings; use Illuminate\Support\Collection; @@ -152,6 +154,24 @@ class Panel extends Component public ?string $bookstackTestMessage = null; + public ?array $bookstackTagResult = null; + + public ?string $bookstackTagError = null; + + public array $aiConfig = []; + + public ?string $aiTestResult = null; + + public ?string $aiTestMessage = null; + + public array $aiTriageConfig = []; + + public bool $aiSummaryEnabled = false; + + public string $aiSummaryPrompt = ''; + + public int $aiSummaryPromptVersion = 0; + // ---- generic pending-delete confirm ---- public ?string $pendingDeleteType = null; @@ -179,6 +199,13 @@ class Panel extends Component 'ticketNumberPrefix' => Settings::get('ticket_number_prefix'), 'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'), 'ticketNumberMinLength' => Settings::get('ticket_number_min_length'), + 'refreshTicketViewSeconds' => Settings::get('refresh_ticket_view_seconds'), + 'refreshQueueSeconds' => Settings::get('refresh_queue_seconds'), + 'refreshNotificationsSeconds' => Settings::get('refresh_notifications_seconds'), + 'scheduleSlaCheckMinutes' => Settings::get('schedule_sla_check_minutes'), + 'scheduleAutomationRulesMinutes' => Settings::get('schedule_automation_rules_minutes'), + 'scheduleImapFetchMinutes' => Settings::get('schedule_imap_fetch_minutes'), + 'scheduleAiAutomationMinutes' => Settings::get('schedule_ai_automation_minutes'), ]; $this->ldapConfig = [ @@ -202,10 +229,29 @@ class Panel extends Component 'tokenSecret' => Settings::get('bookstack_token_secret'), 'verifySsl' => Settings::bool('bookstack_verify_ssl'), 'showToGuests' => Settings::bool('bookstack_show_to_guests'), - 'searchTypes' => Settings::get('bookstack_search_types', 'both'), + 'searchTypes' => BookStackClient::normalizeSearchTypes(Settings::get('bookstack_search_types', '')), + 'searchBy' => in_array($searchBy = Settings::get('bookstack_search_by', 'both'), BookStackClient::SEARCH_BY_OPTIONS, true) ? $searchBy : 'both', 'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')), 'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')), ]; + + $this->aiConfig = [ + 'enabled' => Settings::bool('ai_enabled'), + 'baseUrl' => Settings::get('ai_base_url'), + 'apiKey' => Settings::get('ai_api_key'), + 'model' => Settings::get('ai_model'), + 'verifySsl' => Settings::bool('ai_verify_ssl'), + ]; + + $this->aiTriageConfig = [ + 'categoryWhenMissing' => Settings::bool('ai_triage_category_when_missing'), + 'subcategoryWhenCategoryOnly' => Settings::bool('ai_triage_subcategory_when_category_only'), + 'recheckCategorized' => Settings::bool('ai_triage_recheck_categorized'), + 'fixSubject' => Settings::bool('ai_triage_fix_subject'), + 'setPriority' => Settings::bool('ai_triage_set_priority'), + ]; + $this->aiSummaryEnabled = Settings::bool('ai_summary_enabled'); + $this->aiSummaryPrompt = Settings::get('ai_summary_prompt'); } public function setTab(string $tab): void @@ -1348,6 +1394,14 @@ class Panel extends Component Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix'])); Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0'); Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength'])); + + Settings::set('refresh_ticket_view_seconds', (string) max(1, (int) $this->systemConfig['refreshTicketViewSeconds'])); + Settings::set('refresh_queue_seconds', (string) max(1, (int) $this->systemConfig['refreshQueueSeconds'])); + Settings::set('refresh_notifications_seconds', (string) max(1, (int) $this->systemConfig['refreshNotificationsSeconds'])); + Settings::set('schedule_sla_check_minutes', (string) max(1, (int) $this->systemConfig['scheduleSlaCheckMinutes'])); + Settings::set('schedule_automation_rules_minutes', (string) max(1, (int) $this->systemConfig['scheduleAutomationRulesMinutes'])); + Settings::set('schedule_imap_fetch_minutes', (string) max(1, (int) $this->systemConfig['scheduleImapFetchMinutes'])); + Settings::set('schedule_ai_automation_minutes', (string) max(1, (int) $this->systemConfig['scheduleAiAutomationMinutes'])); } /** @@ -1439,8 +1493,10 @@ class Panel extends Component Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0'); Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '1' : '0'); - if (in_array($this->bookstackConfig['searchTypes'], ['both', 'page', 'book'], true)) { - Settings::set('bookstack_search_types', $this->bookstackConfig['searchTypes']); + Settings::set('bookstack_search_types', implode(',', BookStackClient::normalizeSearchTypes($this->bookstackConfig['searchTypes']))); + + if (in_array($this->bookstackConfig['searchBy'], BookStackClient::SEARCH_BY_OPTIONS, true)) { + Settings::set('bookstack_search_by', $this->bookstackConfig['searchBy']); } Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation'])); @@ -1490,6 +1546,15 @@ class Panel extends Component : [...$ids, $id]; } + public function toggleBookstackSearchType(string $type): void + { + $types = $this->bookstackConfig['searchTypes']; + + $this->bookstackConfig['searchTypes'] = in_array($type, $types, true) + ? array_values(array_diff($types, [$type])) + : [...$types, $type]; + } + public function testBookstackConnection(): void { $cfg = $this->bookstackConfig; @@ -1508,6 +1573,93 @@ class Panel extends Component $this->bookstackTestMessage = $result['message']; } + /** + * Runs synchronously in the request (no queue worker runs in this + * deployment — see CLAUDE.md — so a dispatched job would just sit in the + * `jobs` table). Safe to click again if it times out on a large wiki: + * every write is idempotent, so a re-run just skips whatever already got + * tagged (or, for the --force variant, re-classifies from scratch). + */ + public function runBookstackTagging(bool $force = false): void + { + if (! app(BookStackClient::class)->enabled() || ! app(AiClient::class)->enabled()) { + $this->bookstackTagResult = null; + $this->bookstackTagError = 'Włącz i skonfiguruj obie integracje — BookStack oraz AI — przed uruchomieniem tagowania.'; + + return; + } + + $this->bookstackTagError = null; + + set_time_limit(0); + $this->bookstackTagResult = app(BookStackContentTagger::class)->run(force: $force); + } + + public function runBookstackTaggingForce(): void + { + $this->runBookstackTagging(force: true); + } + + // ===================== AI CONFIG ===================== + + public function saveAiConfig(): void + { + Settings::set('ai_enabled', $this->aiConfig['enabled'] ? '1' : '0'); + Settings::set('ai_base_url', $this->aiConfig['baseUrl']); + + if ($this->aiConfig['apiKey']) { + Settings::set('ai_api_key', $this->aiConfig['apiKey']); + } + + Settings::set('ai_model', $this->aiConfig['model']); + Settings::set('ai_verify_ssl', $this->aiConfig['verifySsl'] ? '1' : '0'); + + $this->aiTestResult = null; + $this->aiTestMessage = null; + } + + public function testAiConnection(): void + { + $cfg = $this->aiConfig; + + if (empty($cfg['baseUrl']) || empty($cfg['model'])) { + $this->aiTestResult = 'error'; + $this->aiTestMessage = 'Uzupełnij adres API i nazwę modelu.'; + + return; + } + + $apiKey = $cfg['apiKey'] ?: Settings::get('ai_api_key'); + $result = app(AiClient::class)->testConnection($cfg['baseUrl'], $apiKey ?? '', $cfg['model'], (bool) $cfg['verifySsl']); + + $this->aiTestResult = $result['ok'] ? 'ok' : 'error'; + $this->aiTestMessage = $result['message']; + } + + public function saveAiTriageConfig(): void + { + Settings::set('ai_triage_category_when_missing', $this->aiTriageConfig['categoryWhenMissing'] ? '1' : '0'); + Settings::set('ai_triage_subcategory_when_category_only', $this->aiTriageConfig['subcategoryWhenCategoryOnly'] ? '1' : '0'); + Settings::set('ai_triage_recheck_categorized', $this->aiTriageConfig['recheckCategorized'] ? '1' : '0'); + Settings::set('ai_triage_fix_subject', $this->aiTriageConfig['fixSubject'] ? '1' : '0'); + Settings::set('ai_triage_set_priority', $this->aiTriageConfig['setPriority'] ? '1' : '0'); + Settings::set('ai_summary_enabled', $this->aiSummaryEnabled ? '1' : '0'); + } + + public function saveAiSummaryPrompt(string $value): void + { + Settings::set('ai_summary_prompt', $value); + $this->aiSummaryPrompt = $value; + } + + public function resetAiSummaryPrompt(): void + { + $default = Settings::default('ai_summary_prompt'); + Settings::set('ai_summary_prompt', $default); + $this->aiSummaryPrompt = $default; + $this->aiSummaryPromptVersion++; + } + // ===================== GENERIC DELETE CONFIRM ===================== public function requestDelete(string $type, mixed $id, string $message): void diff --git a/src/app/Livewire/Client/NewTicket.php b/src/app/Livewire/Client/NewTicket.php index 47ea3fb..6e3c4b8 100644 --- a/src/app/Livewire/Client/NewTicket.php +++ b/src/app/Livewire/Client/NewTicket.php @@ -82,8 +82,9 @@ class NewTicket extends Component } $query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); + $tagQuery = trim($this->selectedSubcategory?->name ?? ''); - return app(BookStackClient::class)->search($query); + return app(BookStackClient::class)->search($query, tagQuery: $tagQuery); } public function backToCategory(): void diff --git a/src/app/Livewire/Client/TicketShow.php b/src/app/Livewire/Client/TicketShow.php index 8f4edf1..9548f71 100644 --- a/src/app/Livewire/Client/TicketShow.php +++ b/src/app/Livewire/Client/TicketShow.php @@ -122,8 +122,9 @@ class TicketShow extends Component $subcategory = $this->ticket->subcategory; $query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? '')); + $tagQuery = trim($subcategory?->name ?? ''); - return app(BookStackClient::class)->search($query); + return app(BookStackClient::class)->search($query, tagQuery: $tagQuery); } public function updatedAttachments(): void diff --git a/src/app/Livewire/Landing.php b/src/app/Livewire/Landing.php index fcbb348..71f5740 100644 --- a/src/app/Livewire/Landing.php +++ b/src/app/Livewire/Landing.php @@ -100,8 +100,9 @@ class Landing extends Component } $query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); + $tagQuery = trim($this->selectedSubcategory?->name ?? ''); - return app(BookStackClient::class)->search($query); + return app(BookStackClient::class)->search($query, tagQuery: $tagQuery); } public function backToCategory(): void diff --git a/src/app/Livewire/Operator/NewTicket.php b/src/app/Livewire/Operator/NewTicket.php index c73da9a..81ffe9e 100644 --- a/src/app/Livewire/Operator/NewTicket.php +++ b/src/app/Livewire/Operator/NewTicket.php @@ -91,8 +91,9 @@ class NewTicket extends Component } $query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); + $tagQuery = trim($this->selectedSubcategory?->name ?? ''); - return app(BookStackClient::class)->search($query); + return app(BookStackClient::class)->search($query, tagQuery: $tagQuery); } public function backToCategory(): void diff --git a/src/app/Livewire/Operator/TicketShow.php b/src/app/Livewire/Operator/TicketShow.php index db7a615..6c608db 100644 --- a/src/app/Livewire/Operator/TicketShow.php +++ b/src/app/Livewire/Operator/TicketShow.php @@ -16,6 +16,7 @@ use App\Models\User; use App\Services\BookStackClient; use App\Services\TicketService; use App\Support\Settings; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Computed; use Livewire\Attributes\On; @@ -78,6 +79,18 @@ class TicketShow extends Component $this->suggestedArticlesLoaded = true; } + // Same wire:init-deferred pattern — the AI summary card just displays + // whatever the scheduled ai:run-ticket-automation command last computed + // (no live AI call from the ticket page), but refreshing the ticket here + // picks up a summary the command generated after this page's initial load. + public bool $aiSummaryLoaded = false; + + public function loadAiSummary(): void + { + $this->aiSummaryLoaded = true; + $this->ticket->refresh(); + } + public function mount(Ticket $ticket): void { abort_unless($ticket->isVisibleToOperator(Auth::user()), 403); @@ -91,6 +104,24 @@ class TicketShow extends Component $this->ticket->resumeTimer(); } + /** + * Livewire lifecycle hook, called for any exception raised while + * handling a request for this component — including one thrown while + * re-hydrating the typed $ticket property itself (Livewire re-fetches + * it by id on every request), which happens before any of this + * component's own methods run and so can't be caught locally the way + * refreshOrRedirectAway() catches it during an explicit refresh(). + * Covers a ticket deleted by someone else while an operator still has + * it open — sends them back to their queue instead of a hard error. + */ + public function exception(\Throwable $e, $stopPropagation): void + { + if ($e instanceof ModelNotFoundException) { + $this->redirect(route('operator.queue'), navigate: true); + $stopPropagation(); + } + } + #[Computed] public function isWatching(): bool { @@ -209,6 +240,36 @@ class TicketShow extends Component unset($this->publicMessages, $this->internalMessages); } + /** + * Re-fetches the ticket and, for a non-admin operator, sends them back + * to their queue instead of leaving them stuck on a page that can no + * longer legitimately show anything — either because the ticket was + * deleted (refresh() throws ModelNotFoundException, same as + * Model::findOrFail() internally) or because a team/assignee change + * (by this operator or anyone else) moved it out of their visible + * scope. Returns false when it redirected, so callers can bail out of + * whatever they were doing instead of continuing to operate on a + * ticket that's about to disappear from under them. + */ + protected function refreshOrRedirectAway(): bool + { + try { + $this->ticket->refresh(); + } catch (ModelNotFoundException) { + $this->redirect(route('operator.queue'), navigate: true); + + return false; + } + + if (! $this->ticket->isVisibleToOperator(Auth::user())) { + $this->redirect(route('operator.queue'), navigate: true); + + return false; + } + + return true; + } + /** * Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js * and Queue::onQueueChanged()) — lets a status/priority/team/assignee @@ -222,7 +283,7 @@ class TicketShow extends Component return; } - $this->ticket->refresh(); + $this->refreshOrRedirectAway(); } /** @@ -232,8 +293,11 @@ class TicketShow extends Component */ public function refreshTicketData(): void { + if (! $this->refreshOrRedirectAway()) { + return; + } + unset($this->publicMessages, $this->internalMessages); - $this->ticket->refresh(); } #[Computed] @@ -266,24 +330,22 @@ class TicketShow extends Component $subcategory = $this->ticket->subcategory; $query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? '')); + $tagQuery = trim($subcategory?->name ?? ''); - return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW); + return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW, $tagQuery); } /** - * A non-admin operator can only reassign a ticket to one of their own - * teams (mirrors the visibility scoping in Operator\Queue). + * Every team, regardless of the viewing operator's own membership — + * unlike ticket *visibility* (Operator\Queue, scoped to an operator's + * own teams), reassignment isn't restricted: an operator working a + * ticket needs to be able to route it to whichever team actually owns + * the problem, even one they don't personally belong to. */ #[Computed] public function teams() { - $query = Team::query(); - - if (! Auth::user()->isAdmin()) { - $query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id())); - } - - return $query->get(); + return Team::query()->get(); } #[Computed] @@ -357,7 +419,12 @@ class TicketShow extends Component public function setTeam(string $id): void { app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null); - $this->ticket->refresh(); + + // Reassigning to a team the operator doesn't belong to can move the + // ticket out of their own visible scope (see Ticket::isVisibleToOperator()) + // — send them back to their queue rather than leaving them on a + // ticket they can no longer legitimately keep viewing. + $this->refreshOrRedirectAway(); } // -------- reporter -------- diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php index 87a4d33..8c203ea 100644 --- a/src/app/Models/Ticket.php +++ b/src/app/Models/Ticket.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\DB; 'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', 'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at', + 'ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at', ])] class Ticket extends Model { @@ -44,6 +45,8 @@ class Ticket extends Model 'timer_started_at' => 'datetime', 'csat_rating' => 'integer', 'csat_rated_at' => 'datetime', + 'ai_triaged_at' => 'datetime', + 'ai_summary_generated_at' => 'datetime', ]; } diff --git a/src/app/Services/AiClient.php b/src/app/Services/AiClient.php new file mode 100644 index 0000000..f4c21c3 --- /dev/null +++ b/src/app/Services/AiClient.php @@ -0,0 +1,95 @@ + $messages + * @return string|null the assistant message content, or null on any failure + */ + public function chat(array $messages, array $options = []): ?string + { + if (! $this->enabled()) { + return null; + } + + try { + $response = $this->client()->post('/chat/completions', [ + 'model' => Settings::get('ai_model'), + 'messages' => $messages, + ...$options, + ]); + + if (! $response->successful()) { + return null; + } + + return $response->json('choices.0.message.content'); + } catch (\Throwable) { + return null; + } + } + + /** + * Tests unsaved admin-form values directly, rather than whatever's + * currently stored — mirrors BookStackClient::testConnection(). + * + * @return array{ok: bool, message: ?string} + */ + public function testConnection(string $baseUrl, string $apiKey, string $model, bool $verifySsl = true): array + { + try { + $http = Http::withOptions(['verify' => $verifySsl]) + ->timeout(10) + ->baseUrl(rtrim($baseUrl, '/')); + + if ($apiKey !== '') { + $http = $http->withToken($apiKey); + } + + $response = $http->post('/chat/completions', [ + 'model' => $model, + 'messages' => [['role' => 'user', 'content' => 'ping']], + 'max_tokens' => 1, + ]); + + if ($response->successful()) { + return ['ok' => true, 'message' => null]; + } + + return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())]; + } catch (\Throwable $e) { + return ['ok' => false, 'message' => $e->getMessage()]; + } + } + + protected function client() + { + $http = Http::withOptions(['verify' => Settings::bool('ai_verify_ssl')]) + ->timeout(60) + ->baseUrl(rtrim(Settings::get('ai_base_url'), '/')); + + $apiKey = Settings::get('ai_api_key'); + + return $apiKey ? $http->withToken($apiKey) : $http; + } +} diff --git a/src/app/Services/BookStackClient.php b/src/app/Services/BookStackClient.php index fd7b34f..b608f52 100644 --- a/src/app/Services/BookStackClient.php +++ b/src/app/Services/BookStackClient.php @@ -23,6 +23,31 @@ class BookStackClient self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view', ]; + /** + * Content types selectable via the admin's "Przeszukuj" checkboxes. + * Deliberately excludes 'bookshelf' — shelves are only ever a filter + * (dozwolone półki), never a suggestion result in their own right. + */ + public const SEARCH_TYPES = ['book', 'page', 'chapter']; + + /** + * How the query text is matched, via the admin's "Szukaj po" option — + * 'name' restricts to the title ({in_name:...}), 'tags' matches a tag + * whose name equals the query (expected to hold the helpdesk + * category/subcategory name, e.g. a "Drukarki" tag on the relevant + * BookStack pages), 'both' runs both and merges the results (BookStack's + * query syntax ANDs filters together, so there's no single-query way to + * express "name OR tag"). + */ + public const SEARCH_BY_OPTIONS = ['name', 'tags', 'both']; + + /** + * Content types the bulk-tagging command operates over — same set as + * SEARCH_TYPES (book/page/chapter, no bookshelf), named separately since + * the two consts serve different features that happen to share a domain. + */ + public const CONTENT_TYPES = self::SEARCH_TYPES; + public function enabled(): bool { return Settings::bool('bookstack_enabled') @@ -39,44 +64,56 @@ class BookStackClient * before any content is ever suggested, independently per context). * Cached briefly since the same category/subcategory query repeats * across every ticket created/viewed with that combination. Respects the - * admin-configured bookstack_search_types setting ('both'|'page'|'book') - * via BookStack's own `{type:x}` query syntax. The cache key folds in the - * allowed-shelf list so changing it in Admin > Konfiguracja is reflected - * immediately, instead of possibly serving a pre-change result for up to - * 10 minutes. + * admin-configured bookstack_search_types (subset of SEARCH_TYPES, via + * BookStack's `{type:a|b}` syntax) and bookstack_search_by ('name'| + * 'tags'|'both', via `{in_name:...}`/`[...]`) settings. The cache key + * folds in the allowed-shelf list so changing it in Admin > Konfiguracja + * is reflected immediately, instead of possibly serving a pre-change + * result for up to 10 minutes. + * + * $tagQuery is the text matched by the 'tags' variant, separate from + * $query (matched by the 'name' variant) — callers pass the bare + * subcategory name here (what bookstack:tag-content actually writes as + * a tag), while $query stays the fuller "Category Subcategory" text + * that's more useful for a plain title/body search. Defaults to $query + * so existing call sites that don't pass it keep working. * * @return array */ - public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION): array + public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION, ?string $tagQuery = null): array { $query = trim($query); + $tagQuery = trim($tagQuery ?? $query); $allowedShelfIds = $this->allowedShelfIds($context); if (! $this->enabled() || $query === '' || ! $allowedShelfIds) { return []; } - $typeFilter = Settings::get('bookstack_search_types', 'both'); + $searchBy = $this->searchBy(); + $bookstackQueries = $searchBy === 'both' + ? [$this->buildQuery($query, 'name'), $this->buildQuery($tagQuery, 'tags')] + : [$this->buildQuery($searchBy === 'tags' ? $tagQuery : $query, $searchBy)]; - if (in_array($typeFilter, ['page', 'book'], true)) { - $query .= " {type:{$typeFilter}}"; - } + $cacheKey = 'bookstack:search:'.md5(implode('||', $bookstackQueries).'|'.$limit.'|'.implode(',', $allowedShelfIds)); - $cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds)); - - return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) { + return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($bookstackQueries, $limit, $allowedShelfIds) { try { - $response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]); - - if (! $response->successful()) { - return []; - } - $shelfMap = $this->shelfBookMap(); $allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds); $bookShelfNames = $this->bookShelfNames($shelfMap); - return collect($response->json('data', [])) + $items = collect(); + + foreach ($bookstackQueries as $bookstackQuery) { + $response = $this->client()->get('/api/search', ['query' => $bookstackQuery, 'count' => $limit]); + + if ($response->successful()) { + $items = $items->concat($response->json('data', [])); + } + } + + return $items ->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) { $type = $item['type'] ?? null; @@ -103,6 +140,8 @@ class BookStackClient ]; }) ->filter(fn (array $item) => $item['name'] !== '') + ->unique(fn (array $item) => $item['url'] ?? $item['name']) + ->take($limit) ->values() ->all(); } catch (\Throwable) { @@ -111,6 +150,68 @@ class BookStackClient }); } + /** + * Builds one BookStack search-syntax query string for $query, restricted + * to $by ('name' -> `{in_name:...}`, 'tags' -> `[...]`) and to the + * configured content types (`{type:a|b}`, omitted if all types are + * allowed since that's equivalent to no filter). + */ + protected function buildQuery(string $query, string $by): string + { + $parts = [$by === 'tags' ? "[{$query}]" : "{in_name:{$query}}"]; + + $types = $this->searchTypes(); + + if (array_diff(self::SEARCH_TYPES, $types)) { + $parts[] = '{type:'.implode('|', $types).'}'; + } + + return implode(' ', $parts); + } + + /** + * @return string[] non-empty subset of SEARCH_TYPES + */ + protected function searchTypes(): array + { + return self::normalizeSearchTypes(Settings::get('bookstack_search_types', '')); + } + + /** + * Normalizes bookstack_search_types storage into a non-empty subset of + * SEARCH_TYPES — shared with Admin\Panel so the checkbox UI and the + * actual search agree on the same format. Also understands the legacy + * single-value 'both'/'page'/'book' storage from before the setting + * became a checkbox list, so existing configuration keeps working. + * + * @param string[]|string $raw + * @return string[] + */ + public static function normalizeSearchTypes(array|string $raw): array + { + $legacy = ['both' => self::SEARCH_TYPES, 'page' => ['page'], 'book' => ['book']]; + + if (is_string($raw) && isset($legacy[$raw])) { + return $legacy[$raw]; + } + + $types = collect(is_array($raw) ? $raw : explode(',', $raw)) + ->map(fn ($v) => trim((string) $v)) + ->filter(fn ($v) => in_array($v, self::SEARCH_TYPES, true)) + ->unique() + ->values() + ->all(); + + return $types ?: self::SEARCH_TYPES; + } + + protected function searchBy(): string + { + $raw = Settings::get('bookstack_search_by', 'both'); + + return in_array($raw, self::SEARCH_BY_OPTIONS, true) ? $raw : 'both'; + } + /** * Drops the cached shelf list and shelf>book membership map — used by * the admin's "Odśwież listę półek" button so a shelf renamed/added/ @@ -155,6 +256,103 @@ class BookStackClient }); } + /** + * Every book/chapter/page of $type across the whole BookStack instance — + * NOT filtered by the allowed-shelf settings, unlike search(). Those only + * gate which suggestions are ever shown to a client/operator; the bulk + * tagger is meant to cover every piece of content regardless. Paginates + * through BookStack's count/offset list endpoints (count capped at 500, + * the API's own per-page maximum). Uncached — this is a one-shot batch + * read, not a repeated request-path lookup. + * + * @return array + */ + public function listAll(string $type): array + { + if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) { + return []; + } + + $items = []; + $offset = 0; + + try { + do { + $response = $this->client()->get("/api/{$type}s", ['count' => 500, 'offset' => $offset]); + + if (! $response->successful()) { + break; + } + + $page = $response->json('data', []); + $items = [...$items, ...$page]; + $offset += 500; + $total = $response->json('total', 0); + } while (count($page) > 0 && count($items) < $total); + } catch (\Throwable) { + return $items; + } + + return $items; + } + + /** + * Full detail for a single book/chapter/page — its current tags (needed + * to merge rather than clobber when the tagger writes new ones) and the + * text used to classify it (a page's markdown source, or a book/ + * chapter's description). Null if $type is invalid or the item can't be + * fetched. + * + * @return array{id: int, name: string, tags: array, content: string}|null + */ + public function detail(string $type, int $id): ?array + { + if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) { + return null; + } + + try { + $response = $this->client()->get("/api/{$type}s/{$id}"); + + if (! $response->successful()) { + return null; + } + + $data = $response->json(); + + return [ + 'id' => $data['id'], + 'name' => $data['name'] ?? '', + 'tags' => $data['tags'] ?? [], + 'content' => $data['markdown'] ?? $data['description'] ?? '', + ]; + } catch (\Throwable) { + return null; + } + } + + /** + * Overwrites just the tags field on a book/chapter/page — BookStack + * treats every field on its update endpoints as optional, so this never + * touches the item's name/content/other attributes. Callers are + * responsible for merging in any tags they want to keep (this replaces + * the whole array, it doesn't append). + * + * @param array $tags + */ + public function updateTags(string $type, int $id, array $tags): bool + { + if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) { + return false; + } + + try { + return $this->client()->put("/api/{$type}s/{$id}", ['tags' => $tags])->successful(); + } catch (\Throwable) { + return false; + } + } + /** * @return int[] */ diff --git a/src/app/Services/BookStackContentTagger.php b/src/app/Services/BookStackContentTagger.php new file mode 100644 index 0000000..c76a3f4 --- /dev/null +++ b/src/app/Services/BookStackContentTagger.php @@ -0,0 +1,260 @@ + 0, 'tagged' => 0, 'skipped' => 0, 'failed_batches' => 0]; + + if (! $this->bookstack->enabled() || ! $this->ai->enabled()) { + return $totals; + } + + $vocabulary = $this->subcategoryVocabulary(); + + if (! $vocabulary) { + return $totals; + } + + foreach (BookStackClient::CONTENT_TYPES as $type) { + foreach ($this->itemsToProcess($type, $vocabulary, $force, $limit, $totals) as $batch) { + $this->processBatch($type, $batch, $vocabulary, $dryRun, $totals); + + if ($limit !== null && $totals['scanned'] >= $limit) { + break 2; + } + } + } + + return $totals; + } + + /** + * @return string[] current subcategory names, deduped case-insensitively + */ + protected function subcategoryVocabulary(): array + { + return Subcategory::query() + ->pluck('name') + ->filter() + ->unique(fn (string $name) => Str::lower($name)) + ->values() + ->all(); + } + + /** + * Yields item detail records (id, name, tags, content) in chunks of + * BATCH_SIZE, skipping already-tagged items unless $force. Detail is + * fetched per item since BookStack's list endpoints don't include + * tags/content — acceptable here since this is an offline batch job, not + * a request-path call. + * + * @param string[] $vocabulary + * @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals + * @return \Generator> + */ + protected function itemsToProcess(string $type, array $vocabulary, bool $force, ?int $limit, array &$totals): \Generator + { + $batch = []; + + foreach ($this->bookstack->listAll($type) as $summary) { + if ($limit !== null && $totals['scanned'] >= $limit) { + return; + } + + $detail = $this->bookstack->detail($type, $summary['id']); + + if (! $detail) { + continue; + } + + $totals['scanned']++; + + if (! $force && $this->alreadyTagged($detail['tags'], $vocabulary)) { + $totals['skipped']++; + + continue; + } + + $batch[] = $detail; + + if (count($batch) >= self::BATCH_SIZE) { + yield $batch; + $batch = []; + } + } + + if ($batch) { + yield $batch; + } + } + + /** + * @param array $tags + * @param string[] $vocabulary + */ + protected function alreadyTagged(array $tags, array $vocabulary): bool + { + $existing = collect($tags)->map(fn (array $t) => Str::lower($t['name'] ?? '')); + $vocabLower = collect($vocabulary)->map(fn (string $v) => Str::lower($v)); + + return $existing->intersect($vocabLower)->isNotEmpty(); + } + + /** + * @param array $batch + * @param string[] $vocabulary + * @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals + */ + protected function processBatch(string $type, array $batch, array $vocabulary, bool $dryRun, array &$totals): void + { + $prompt = $this->buildPrompt($batch, $vocabulary); + + $raw = $this->ai->chat([ + ['role' => 'system', 'content' => $prompt['system']], + ['role' => 'user', 'content' => $prompt['user']], + ], ['temperature' => 0]); + + $assignments = $this->parseAssignments($raw, $vocabulary); + + if ($assignments === null) { + $totals['failed_batches']++; + + return; + } + + foreach ($batch as $item) { + $labels = $assignments[(string) $item['id']] ?? []; + + if (! $labels) { + continue; + } + + if ($dryRun) { + $totals['tagged']++; + + continue; + } + + $mergedTags = $this->mergeTags($item['tags'], $labels); + + if ($this->bookstack->updateTags($type, $item['id'], $mergedTags)) { + $totals['tagged']++; + } + } + } + + /** + * @param array $batch + * @param string[] $vocabulary + * @return array{system: string, user: string} + */ + protected function buildPrompt(array $batch, array $vocabulary): array + { + $system = 'Klasyfikujesz artykuły bazy wiedzy do kategorii zgłoszeń helpdesk. ' + .'Dostępne kategorie (użyj DOKŁADNIE tej pisowni): '.implode(', ', $vocabulary).'. ' + .'Dla każdego elementu przypisz 0, 1 lub więcej pasujących kategorii — nie zgaduj ' + .'kategorii, jeśli żadna sensownie nie pasuje, zwróć pustą tablicę. ' + .'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania, ' + .'gdzie klucz to id elementu (jako string), a wartość to tablica dopasowanych nazw ' + .'kategorii. Przykład: {"12": ["Drukarki i skanery"], "13": []}'; + + $user = collect($batch)->map(function (array $item) { + $excerpt = Str::limit(strip_tags($item['content']), self::CONTENT_EXCERPT_CHARS, ''); + + return "id={$item['id']} nazwa=\"{$item['name']}\"\n{$excerpt}"; + })->implode("\n---\n"); + + return ['system' => $system, 'user' => $user]; + } + + /** + * Defensively parses the LLM's JSON response — not every OpenAI- + * compatible provider (esp. self-hosted Ollama models) reliably honors a + * "respond with only JSON" instruction, so this first tries to pull out + * the first {...} block (in case the model wrapped it in prose or a + * markdown code fence) before decoding. A malformed/non-JSON response + * fails just this one batch (caller counts it in failed_batches) rather + * than aborting the whole run. Only vocabulary-matching labels survive + * (case-insensitive); anything else the model returns is discarded. + * + * @param string[] $vocabulary + * @return array|null + */ + protected function parseAssignments(?string $raw, array $vocabulary): ?array + { + if (! $raw) { + return null; + } + + if (! preg_match('/\{.*\}/s', $raw, $matches)) { + return null; + } + + $decoded = json_decode($matches[0], true); + + if (! is_array($decoded)) { + return null; + } + + $vocabByLower = collect($vocabulary)->mapWithKeys(fn (string $v) => [Str::lower($v) => $v]); + $result = []; + + foreach ($decoded as $id => $labels) { + if (! is_array($labels)) { + continue; + } + + $result[(string) $id] = collect($labels) + ->map(fn ($l) => $vocabByLower[Str::lower((string) $l)] ?? null) + ->filter() + ->unique() + ->values() + ->all(); + } + + return $result; + } + + /** + * @param array $existing + * @param string[] $newLabels + * @return array + */ + protected function mergeTags(array $existing, array $newLabels): array + { + $existingLower = collect($existing)->map(fn (array $t) => Str::lower($t['name'] ?? '')); + + $additions = collect($newLabels) + ->reject(fn (string $label) => $existingLower->contains(Str::lower($label))) + ->map(fn (string $label) => ['name' => $label, 'value' => '']); + + return [...$existing, ...$additions->values()->all()]; + } +} diff --git a/src/app/Services/TicketAiSummaryService.php b/src/app/Services/TicketAiSummaryService.php new file mode 100644 index 0000000..af9f893 --- /dev/null +++ b/src/app/Services/TicketAiSummaryService.php @@ -0,0 +1,139 @@ + 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, + ]; + } +} diff --git a/src/app/Services/TicketAiTriageService.php b/src/app/Services/TicketAiTriageService.php new file mode 100644 index 0000000..9da59c8 --- /dev/null +++ b/src/app/Services/TicketAiTriageService.php @@ -0,0 +1,311 @@ + 0, 'changed' => 0, 'failed' => 0]; + + if (! $this->ai->enabled() || ! $this->anyToggleEnabled()) { + return $totals; + } + + $vocabulary = $this->buildVocabulary(); + $priorities = Priority::query()->orderBy('sort_order')->pluck('label', 'key')->all(); + + Ticket::query()->whereNull('ai_triaged_at') + ->orderBy('id') + ->limit($limit ?? self::BATCH_LIMIT) + ->get() + ->each(function (Ticket $ticket) use ($vocabulary, $priorities, &$totals) { + $totals['scanned']++; + $this->triageOne($ticket, $vocabulary, $priorities, $totals); + }); + + return $totals; + } + + protected function anyToggleEnabled(): bool + { + return Settings::bool('ai_triage_category_when_missing') + || Settings::bool('ai_triage_subcategory_when_category_only') + || Settings::bool('ai_triage_recheck_categorized') + || Settings::bool('ai_triage_fix_subject') + || Settings::bool('ai_triage_set_priority'); + } + + /** + * @param array}> $vocabulary + * @param array $priorities + * @param array{scanned: int, changed: int, failed: int} $totals + */ + protected function triageOne(Ticket $ticket, array $vocabulary, array $priorities, array &$totals): void + { + $prompt = $this->buildPrompt($ticket, $vocabulary, $priorities); + + if ($prompt === null) { + $ticket->update(['ai_triaged_at' => now()]); + + return; + } + + $raw = $this->ai->chat([ + ['role' => 'system', 'content' => $prompt['system']], + ['role' => 'user', 'content' => $prompt['user']], + ], ['temperature' => 0]); + + $parsed = $this->parseResponse($raw); + + // A response came back but couldn't be parsed — not fatal to the + // run, just means this ticket wasn't classified this time. + // ai_triaged_at is still stamped below so a persistently-bad + // response doesn't get retried forever. + if ($raw !== null && $parsed === null) { + $totals['failed']++; + } + + [$changes, $historyLines] = $parsed + ? $this->resolveChanges($ticket, $parsed, $vocabulary, $priorities, $prompt['scope']) + : [[], []]; + + if ($changes) { + $this->tickets->applyAiTriage($ticket, $changes, $historyLines); + $totals['changed']++; + } + + $ticket->update(['ai_triaged_at' => now()]); + } + + /** + * @return array}> + */ + protected function buildVocabulary(): array + { + return Category::query()->with('subcategories')->get() + ->map(fn (Category $c) => [ + 'id' => $c->id, + 'name' => $c->name, + 'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->all(), + ]) + ->all(); + } + + /** + * Determines which of the 3 mutually-exclusive category scenarios (if + * any) applies to $ticket's current state, and whether subject/priority + * are also in scope, then builds the prompt around exactly that. Returns + * null when nothing is applicable/enabled for this ticket, so the caller + * can skip straight to stamping ai_triaged_at without an AI call. + * + * @param array $vocabulary + * @param array $priorities + * @return array{system: string, user: string, scope: ?string}|null + */ + protected function buildPrompt(Ticket $ticket, array $vocabulary, array $priorities): ?array + { + $scope = null; + + if (! $ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_category_when_missing')) { + $scope = 'missing'; + } elseif ($ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_subcategory_when_category_only')) { + $scope = 'category_only'; + } elseif ($ticket->subcategory_id && Settings::bool('ai_triage_recheck_categorized')) { + $scope = 'recheck'; + } + + $wantSubject = Settings::bool('ai_triage_fix_subject'); + $wantPriority = Settings::bool('ai_triage_set_priority'); + + if ($scope === null && ! $wantSubject && ! $wantPriority) { + return null; + } + + $parts = ['Klasyfikujesz zgłoszenia helpdesku na podstawie tematu i treści.']; + + if ($scope === 'missing') { + $parts[] = 'To zgłoszenie nie ma jeszcze przypisanej kategorii ani podkategorii. Wybierz najlepiej ' + .'pasującą kategorię z listy poniżej (użyj DOKŁADNIE tej pisowni) i, jeśli to możliwe, także ' + .'konkretną podkategorię w jej ramach. Jeśli żadna kategoria sensownie nie pasuje, zwróć null dla obu pól.'; + } elseif ($scope === 'category_only') { + $parts[] = "To zgłoszenie ma już przypisaną kategorię \"{$ticket->category->name}\", ale brak konkretnej " + .'podkategorii. Wybierz najlepiej pasującą podkategorię z listy poniżej (należącą do tej kategorii, ' + .'użyj DOKŁADNIE tej pisowni). Jeśli żadna nie pasuje dobrze, zwróć null.'; + } elseif ($scope === 'recheck') { + $parts[] = "To zgłoszenie ma już przypisaną podkategorię \"{$ticket->subcategory->label()}\". Sprawdź, " + .'czy to nadal najlepsze dopasowanie na podstawie treści. Jeśli tak — zwróć null (nic nie zmieniaj). ' + .'Jeśli lepiej pasuje inna kategoria/podkategoria z listy poniżej, zwróć ją.'; + } + + if ($scope !== null) { + $parts[] = "Dostępne kategorie i podkategorie:\n".$this->vocabularyText($vocabulary, $scope, $ticket); + } + + if ($wantSubject) { + $parts[] = 'Jeśli obecny temat zgłoszenia jest niejasny lub mylący, zaproponuj lepszy, zwięzły temat po ' + .'polsku w polu "subject" (w przeciwnym razie null).'; + } + + if ($wantPriority) { + $priorityList = collect($priorities)->map(fn ($label, $key) => "{$key} ({$label})")->implode(', '); + $parts[] = 'Na podstawie treści oceń priorytet zgłoszenia i zwróć jego klucz w polu "priority" — ' + ."dostępne klucze: {$priorityList}."; + } + + $parts[] = 'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania: ' + .'{"category": "...", "subcategory": "...", "subject": "...", "priority": "..."} — pola, o które nie ' + .'proszono powyżej, ustaw na null.'; + + $user = "Temat: {$ticket->subject}\n\nTreść:\n".Str::limit(strip_tags($ticket->body), self::BODY_EXCERPT_CHARS); + + return ['system' => implode("\n\n", $parts), 'user' => $user, 'scope' => $scope]; + } + + /** + * @param array}> $vocabulary + */ + protected function vocabularyText(array $vocabulary, string $scope, Ticket $ticket): string + { + $categories = $scope === 'category_only' + ? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id) + : collect($vocabulary); + + return $categories + ->map(fn (array $c) => "- {$c['name']}: ".collect($c['subcategories'])->pluck('name')->implode(', ')) + ->implode("\n"); + } + + /** + * Same defensive-parsing shape as BookStackContentTagger::parseAssignments() + * — extracts the first {...} block before decoding, so prose-wrapped or + * malformed responses fail gracefully instead of crashing the run. + */ + protected function parseResponse(?string $raw): ?array + { + if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) { + return null; + } + + $decoded = json_decode($matches[0], true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * Fail-closed resolution: every value from the model is matched against + * the real vocabulary/priority list before being trusted — an + * unmatched/hallucinated category, a subcategory outside its claimed + * category, or an unknown priority key is silently dropped rather than + * written to the ticket. Only fields whose resolved value actually + * differs from the ticket's current value produce a change + history + * line, so e.g. a "recheck" that confirms the existing subcategory + * leaves no trace. + * + * @param array}> $vocabulary + * @param array $priorities + * @return array{0: array, 1: string[]} + */ + protected function resolveChanges(Ticket $ticket, array $parsed, array $vocabulary, array $priorities, ?string $scope): array + { + $changes = []; + $historyLines = []; + + if ($scope !== null) { + $resolved = $this->resolveCategory($parsed, $vocabulary, $scope, $ticket); + + if ($resolved) { + [$categoryId, $subcategoryId, $label] = $resolved; + + if ($categoryId !== $ticket->category_id || $subcategoryId !== $ticket->subcategory_id) { + $changes['category_id'] = $categoryId; + $changes['subcategory_id'] = $subcategoryId; + $historyLines[] = "Kategoria zmieniona na: {$label}"; + } + } + } + + if (Settings::bool('ai_triage_fix_subject') && ! empty($parsed['subject']) && is_string($parsed['subject'])) { + $newSubject = Str::limit(trim($parsed['subject']), 255, ''); + + if ($newSubject !== '' && $newSubject !== $ticket->subject) { + $changes['subject'] = $newSubject; + $historyLines[] = "Temat zmieniony na: „{$newSubject}”"; + } + } + + if (Settings::bool('ai_triage_set_priority') && ! empty($parsed['priority']) && is_string($parsed['priority'])) { + $key = collect($priorities)->keys()->first(fn ($k) => Str::lower($k) === Str::lower($parsed['priority'])); + + if ($key !== null && $key !== $ticket->priority_key) { + $changes['priority_key'] = $key; + $historyLines[] = 'Priorytet zmieniony na: '.Priority::labelFor($key); + } + } + + return [$changes, $historyLines]; + } + + /** + * @param array}> $vocabulary + * @return array{0: ?int, 1: ?int, 2: string}|null [category_id, subcategory_id, display label] + */ + protected function resolveCategory(array $parsed, array $vocabulary, string $scope, Ticket $ticket): ?array + { + $categories = $scope === 'category_only' + ? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id) + : collect($vocabulary); + + $subName = $parsed['subcategory'] ?? null; + + if (is_string($subName) && $subName !== '') { + foreach ($categories as $category) { + foreach ($category['subcategories'] as $sub) { + if (Str::lower($sub['name']) === Str::lower($subName)) { + return [null, $sub['id'], "{$category['name']} / {$sub['name']}"]; + } + } + } + } + + $catName = $parsed['category'] ?? null; + + if (is_string($catName) && $catName !== '') { + foreach ($categories as $category) { + if (Str::lower($category['name']) === Str::lower($catName)) { + return [$category['id'], null, $category['name']]; + } + } + } + + return null; + } +} diff --git a/src/app/Services/TicketService.php b/src/app/Services/TicketService.php index 97eab45..be04145 100644 --- a/src/app/Services/TicketService.php +++ b/src/app/Services/TicketService.php @@ -161,6 +161,52 @@ class TicketService TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id()); } + /** + * Applies one AI-triage pass's changes (see TicketAiTriageService) in a + * single update, rather than composing setPriority()/updateDetails() — + * a pass can touch category, subcategory, subject and priority + * together, and those would each write their own generic history line + * and fire notify()/TriggerEngine::handle() per field instead of once + * per pass, fragmenting one semantic AI decision into several + * unrelated-looking edits. $historyLines carries one mechanical + * "X changed to: Y" line per changed field (built by the caller, since + * it already knows the human-readable labels); this always appends one + * more attribution line on top, mirroring how RunAutomationRules logs + * "Automatyzacja: {label}" after its own field-change lines. + * + * @param array $changes column => value, only the fields that actually changed + * @param string[] $historyLines + */ + public function applyAiTriage(Ticket $ticket, array $changes, array $historyLines): void + { + if (! $changes) { + return; + } + + $categoryChanged = array_key_exists('category_id', $changes) || array_key_exists('subcategory_id', $changes); + $priorityChanged = array_key_exists('priority_key', $changes); + + $ticket->update($changes); + + foreach ($historyLines as $line) { + $ticket->addHistory($line); + } + $ticket->addHistory('Automatyzacja: klasyfikacja AI'); + + if ($categoryChanged) { + $this->notify($ticket, 'category_changed'); + app(TriggerEngine::class)->handle($ticket, 'category_changed'); + } + + if ($priorityChanged) { + $this->notify($ticket, 'priority_changed'); + app(TriggerEngine::class)->handle($ticket, 'priority_changed'); + } + + app(TriggerEngine::class)->handle($ticket, 'ticket_updated'); + TicketQueueChanged::dispatch($ticket->id, 'ai_triage', Auth::id()); + } + public function setReporter(Ticket $ticket, User $customer): void { $ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]); diff --git a/src/app/Support/Settings.php b/src/app/Support/Settings.php index e1b2c90..53556de 100644 --- a/src/app/Support/Settings.php +++ b/src/app/Support/Settings.php @@ -25,6 +25,13 @@ class Settings 'ticket_number_prefix' => '#', 'ticket_number_obfuscate' => '0', 'ticket_number_min_length' => '4', + 'refresh_ticket_view_seconds' => '30', + 'refresh_queue_seconds' => '60', + 'refresh_notifications_seconds' => '30', + 'schedule_sla_check_minutes' => '15', + 'schedule_automation_rules_minutes' => '15', + 'schedule_imap_fetch_minutes' => '5', + 'schedule_ai_automation_minutes' => '5', 'ldap_enabled' => '1', 'ldap_host' => '', 'ldap_port' => '389', @@ -48,9 +55,28 @@ class Settings 'bookstack_token_secret' => '', 'bookstack_verify_ssl' => '1', 'bookstack_show_to_guests' => '0', - 'bookstack_search_types' => 'both', + 'bookstack_search_types' => 'book,page,chapter', + 'bookstack_search_by' => 'both', 'bookstack_allowed_shelf_ids_creation' => '', 'bookstack_allowed_shelf_ids_ticket_view' => '', + 'ai_enabled' => '0', + 'ai_base_url' => '', + 'ai_api_key' => '', + 'ai_model' => '', + 'ai_verify_ssl' => '1', + 'ai_triage_category_when_missing' => '0', + 'ai_triage_subcategory_when_category_only' => '0', + 'ai_triage_recheck_categorized' => '0', + 'ai_triage_fix_subject' => '0', + 'ai_triage_set_priority' => '0', + 'ai_summary_enabled' => '0', + 'ai_summary_prompt' => 'Jesteś asystentem operatora helpdesku. Otrzymujesz temat, treść oraz historię ' + .'wiadomości zgłoszenia. Podsumuj sprawę rzeczowo po polsku (2-3 zdania, czego dotyczy problem i na ' + .'jakim jest etapie — np. czeka na odpowiedź klienta czy na działanie operatora) i zaproponuj krótką, ' + .'konkretną kolejną akcję (jedno zdanie), np. "Poproś klienta o zrzut ekranu błędu" albo "Zamknij ' + .'zgłoszenie — klient potwierdził rozwiązanie". Odpowiedz WYŁĄCZNIE obiektem JSON, bez innego tekstu: ' + .'{"summary": "...", "suggested_action": "..."}. Jeśli nie da się ocenić kolejnego kroku, ustaw ' + .'"suggested_action" na pusty string.', 'email_footer' => '

Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.

', 'accent_color' => '#7c6fd6', 'login_notice_type' => 'info', @@ -63,7 +89,7 @@ class Settings .'', ]; - protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret']; + protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret', 'ai_api_key']; public static function get(string $key, ?string $default = null): ?string { @@ -209,6 +235,24 @@ class Settings : static::$defaults['timezone']; } + /** + * Whether an admin-configurable "every N minutes" scheduled command + * (schedule_*_minutes, routes/console.php) is due to run this minute. + * Used inside a Schedule::command(...)->when(...) closure rather than + * building a cron string up front — a closure is only evaluated when + * schedule:run actually processes due events, whereas eagerly reading + * Settings (which queries the DB) at routes/console.php's top level + * would run on every artisan boot (migrate, tinker, tests, ...), before + * the settings table necessarily even exists. Clamped to a minimum of 1 + * so a blank/zero/negative stored value can never busy-loop. + */ + public static function dueEveryMinutes(string $key, int $default): bool + { + $minutes = max(1, (int) static::get($key, (string) $default)); + + return now()->minute % $minutes === 0; + } + /** * Wraps a single e-mail template's rendered HTML body in the fixed * "box" layout — company name, ticket content and footer — so every diff --git a/src/database/migrations/2026_07_24_000156_add_ai_triage_and_summary_to_tickets.php b/src/database/migrations/2026_07_24_000156_add_ai_triage_and_summary_to_tickets.php new file mode 100644 index 0000000..64f4fe5 --- /dev/null +++ b/src/database/migrations/2026_07_24_000156_add_ai_triage_and_summary_to_tickets.php @@ -0,0 +1,35 @@ +timestamp('ai_triaged_at')->nullable()->after('source'); + $table->text('ai_summary')->nullable()->after('ai_triaged_at'); + $table->text('ai_suggested_action')->nullable()->after('ai_summary'); + $table->timestamp('ai_summary_generated_at')->nullable()->after('ai_suggested_action'); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn(['ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at']); + }); + } +}; diff --git a/src/resources/views/livewire/admin/panel.blade.php b/src/resources/views/livewire/admin/panel.blade.php index 39916da..3225f8e 100644 --- a/src/resources/views/livewire/admin/panel.blade.php +++ b/src/resources/views/livewire/admin/panel.blade.php @@ -638,6 +638,31 @@ $tabGroups = [ +
+

Częstotliwość odświeżania i harmonogramu

+ +
Odświeżanie w przeglądarce
+
+
+
+
+
+

Sekundy między automatycznymi odświeżeniami w przeglądarce (niezależnie od odświeżenia na żądanie po kliknięciu w licznik).

+ +
+ +
Zadania w tle
+
+
+
+
+
+
+
+
+

Minuty między uruchomieniami zadań w tle. Zmiana obowiązuje od najbliższego uruchomienia harmonogramu (co minutę), bez potrzeby restartu.

+
+
@@ -695,12 +720,35 @@ $tabGroups = [

Token API generuje się w BookStack: Profil → Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.

-
- + Książki + + + +
+ @if (empty($bookstackConfig['searchTypes'])) +

Nic nie zaznaczono — przy zapisie zostaną użyte wszystkie typy.

+ @endif + + +
+ + +

„Tagi” dopasowują artykuły oznaczone w BookStack tagiem o nazwie zgodnej z kategorią/podkategorią zgłoszenia (np. tag „Drukarki” dla podkategorii „Drukarki”).

@@ -750,6 +798,26 @@ $tabGroups = [ Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA. +
+ +

Używa integracji AI (skonfigurowanej w karcie „Integracja AI” obok) do otagowania książek/stron/rozdziałów nazwami pasujących podkategorii helpdesku — bez tego wyszukiwanie „po tagach” nic nie znajdzie. „Otaguj nową treść” pomija już otagowane pozycje; „Otaguj wszystko ponownie” klasyfikuje od nowa całą wiki (dłużej, więcej zapytań do AI).

+
+ + +
+ @if ($bookstackTagResult !== null) +
Przeskanowano {{ $bookstackTagResult['scanned'] }}, otagowano {{ $bookstackTagResult['tagged'] }}, pominięto {{ $bookstackTagResult['skipped'] }}, nieudanych paczek {{ $bookstackTagResult['failed_batches'] }}.
+ @elseif ($bookstackTagError) +
error{{ $bookstackTagError }}
+ @endif +
+
@@ -764,6 +832,68 @@ $tabGroups = [ @endif +
+

Integracja AI

+ Ogólne połączenie z dostawcą modelu językowego (API kompatybilne z OpenAI — Groq, OpenAI, lokalny Ollama itp.), wykorzystywane m.in. do automatycznego tagowania treści w BookStack. + + + @if ($aiConfig['enabled']) +
+
+

Zostaw puste dla lokalnych instancji bez autoryzacji (np. Ollama).

+
+ + + Wyłącz tylko jeśli instancja (np. lokalny Ollama) korzysta z certyfikatu self-signed / z prywatnego CA. + +
+ + + @if ($aiTestResult === 'ok') +
check_circlePołączenie OK
+ @elseif ($aiTestResult === 'error') +
errorBłąd połączenia{{ $aiTestMessage ? ': '.$aiTestMessage : '' }}
+ @endif +
+ @else + + @endif +
+ +
+

Automatyzacja AI dla zgłoszeń

+ Wymaga włączonej integracji AI (karta obok). Zgłoszenia są przetwarzane w tle, cyklicznie co kilka minut — nie spowalnia to tworzenia zgłoszenia przez klienta. + +
+ + + + + + +

Każde zgłoszenie jest sprawdzane tylko raz — zastosowane zmiany trafiają do historii zgłoszenia z adnotacją „Automatyzacja: klasyfikacja AI”.

+
+ +
+ +
+ + +

Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Odświeżane automatycznie, gdy w wątku pojawi się nowa wiadomość.

+
+ +
+
+ + +
+ +

Model musi zwrócić obiekt JSON z kluczami "summary" i "suggested_action" — nie zmieniaj tego wymogu, chyba że wiadomo, że nowy dostawca/model obsłuży to inaczej.

+
+ + +
+
@endif diff --git a/src/resources/views/livewire/client/ticket-show.blade.php b/src/resources/views/livewire/client/ticket-show.blade.php index 657bfad..2b9ca11 100644 --- a/src/resources/views/livewire/client/ticket-show.blade.php +++ b/src/resources/views/livewire/client/ticket-show.blade.php @@ -7,13 +7,18 @@ {{-- Live updates arrive via broadcasting, but websocket connections can drop silently — this is a periodic fallback refresh, with a visible - countdown so it's clear the thread is still refreshing on its own. --}} + countdown so it's clear the thread is still refreshing on its own. + Also clickable, to fetch immediately and reset the countdown. --}} + @php + $refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds')); + @endphp
schedule diff --git a/src/resources/views/livewire/notification-bell.blade.php b/src/resources/views/livewire/notification-bell.blade.php index 4942f04..09a43a4 100644 --- a/src/resources/views/livewire/notification-bell.blade.php +++ b/src/resources/views/livewire/notification-bell.blade.php @@ -1,4 +1,4 @@ -