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