- Generic AI integration (Admin > Integracje > "Integracja AI"), optional and
  off by default: an OpenAI-compatible /chat/completions client (Groq, OpenAI,
  or a self-hosted Ollama instance) configured by base URL, optional API key,
  model, and an SSL-verification toggle. Foundation for the two AI features
  below and anything else that wants an LLM call in the future.
- BookStack automatic content tagging (AI): "Otaguj nową treść"/"Otaguj
  wszystko ponownie" buttons plus `php artisan bookstack:tag-content`
  (--dry-run/--force/--limit=N) tag every book/chapter/page with matching
  helpdesk subcategory names, idempotent by default.
- BookStack search refinement: "Przeszukuj" is now three independent
  checkboxes (Książki/Strony/Rozdziały) instead of a single dropdown, plus a
  new "Szukaj po" setting (nazwa/tagi/oba) — tag matching uses the bare
  subcategory name, matching what auto-tagging writes.
- AI-driven ticket triage + summary (Admin > Integracje > "Automatyzacja AI
  dla zgłoszeń", via new scheduled ai:run-ticket-automation): five toggles
  auto-assign/correct category+subcategory, rewrite an unclear subject, and
  set priority from content, once per ticket in the background; every change
  is logged in the ticket's history. Separately, an AI summary + suggested
  action for every ticket, shown to operators only, with an admin-editable
  prompt.
- Operators can now reassign a ticket to any team, not just one they belong
  to.
- The auto-refresh countdown badges (ticket view, operator queue) are now
  clickable — fetch immediately and reset the countdown.
- All 7 "cyclical" intervals (3 browser refresh countdowns, the notification
  bell poll, and the 4 background scheduled commands) are now configurable
  from Admin > Konfiguracja instead of fixed in code.
- Fixed: an operator viewing a ticket that's deleted or moved outside their
  team scope mid-session is now redirected to the operator queue instead of
  hitting an error.
- Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 13:38:39 +02:00
parent 0d116dfd98
commit 313e01ad24
46 changed files with 3224 additions and 150 deletions

View File

@@ -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 3060 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.