- 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 redirects to `operator.queue`/`client.dashboard` instead, for any
authenticated request under `operator/*`/`client/*`. 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 ## Roles & permissions
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but `$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, As a defense against a dropped websocket connection (backgrounded tab,
network blip), the operator queue and both ticket-detail views also poll network blip), the operator queue and both ticket-detail views also poll
themselves every 3060 seconds via a small Alpine countdown calling themselves via a small Alpine countdown calling `$wire.refreshQueue()` /
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is `$wire.refreshTicketData()` — broadcasting is best-effort, not the only way
best-effort, not the only way these views ever update. 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 A third private channel, **`App.Models.User.{id}`** (Laravel's default
per-notifiable convention, kept verbatim rather than a shorter alias), 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 `SlaRule` holds per-priority response/resolution targets in minutes. The
scheduled command `tickets:check-sla-breaches` (registered in scheduled command `tickets:check-sla-breaches` (registered in
`routes/console.php`, run every 15 minutes via `schedule:run`) flags overdue `routes/console.php`, default every 15 minutes, interval admin-configurable —
tickets and can notify the assigned operator — see [install.md](install.md) for 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 why this requires an external cron entry (the Docker image ships no
cron/supervisor of its own). 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 `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 admin configure "if a ticket has been silent for N minutes, change its
priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The
scheduled command `automation:run-rules` (also every 15 minutes) evaluates scheduled command `automation:run-rules` (default also every 15 minutes,
every enabled rule against `Ticket.last_customer_activity_at` (falling back to 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 `created_at` if never set — mirrors how `resolutionDeadline()` treats a
missing `SlaRule` as "no SLA" rather than backfilling one), and applies a missing `SlaRule` as "no SLA" rather than backfilling one), and applies a
match through the same `TicketService` setters a manual operator action would 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 this run, but a later rule's own query naturally excludes an already-closed
ticket. 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 ## IMAP e-mail intake
Optional, off by default (`ImapMailbox.enabled` per row — there is no single 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 the ticket number; ticket view: per-message in the thread, plus a tag next
to the ticket number in the header). to the ticket number in the header).
- **`emails:fetch-imap`** (`app/Console/Commands/FetchImapEmails.php`), - **`emails:fetch-imap`** (`app/Console/Commands/FetchImapEmails.php`),
registered in `routes/console.php` as registered in `routes/console.php` with `->withoutOverlapping()` (like
`Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping()` `ai:run-ticket-automation`, unlike the SLA-check/automation-rules
— the one scheduled command in this app that opts into commands — both make real outbound HTTP/IMAP calls per record, so a slow
`withoutOverlapping()` (SLA/automation don't), given IMAP I/O latency. 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 Early-returns if no `ImapMailbox` is enabled. Also callable directly per
mailbox from Admin > Poczta's "Pobierz teraz" button mailbox from Admin > Poczta's "Pobierz teraz" button
(`ImapMailboxFetcher::fetchMailbox()`, bypassing the enabled-only (`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 generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
reference in-repo. 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 ## BookStack integration
`App\Services\BookStackClient` is the only outbound HTTP client in the `App\Services\BookStackClient` is one of two outbound HTTP clients in the
codebase (Laravel's `Http` facade) — everything else here only ever receives codebase (Laravel's `Http` facade), alongside `AiClient` above — everything
requests. It's entirely `Settings`-driven, no `.env`/`config()` involved: else here only ever receives requests. It's entirely `Settings`-driven, no
`bookstack_enabled`, `bookstack_base_url`, `bookstack_token_id`/ `.env`/`config()` involved: `bookstack_enabled`, `bookstack_base_url`,
`bookstack_token_secret` (encrypted, same as the LDAP/SMTP passwords), `bookstack_token_id`/`bookstack_token_secret` (encrypted, same as the
`bookstack_verify_ssl`, `bookstack_search_types` ('both'|'page'|'book'), and LDAP/SMTP passwords), `bookstack_verify_ssl`, and **two independent**
**two independent** allow-lists of BookStack shelf IDs — allow-lists of BookStack shelf IDs — `bookstack_allowed_shelf_ids_creation`
`bookstack_allowed_shelf_ids_creation` (ticket-wizard suggestions) and (ticket-wizard suggestions) and `bookstack_allowed_shelf_ids_ticket_view`
`bookstack_allowed_shelf_ids_ticket_view` (the operator's sidebar on an (the operator's sidebar on an existing ticket) — `search()` takes a
existing ticket) — `search()` takes a `$context` (`CONTEXT_CREATION` / `$context` (`CONTEXT_CREATION` / `CONTEXT_TICKET_VIEW`) that selects which
`CONTEXT_TICKET_VIEW`) that selects which one applies. **An empty allow-list one applies. **An empty allow-list means "search nothing"**, not "search
means "search nothing"**, not "search everything" — nothing is ever everything" nothing is ever suggested until an admin explicitly opts
suggested until an admin explicitly opts shelves in, independently per shelves in, independently per context. BookStack has no "which shelf is this
context. BookStack has no "which shelf is this book on" field in its own book on" field in its own search response, so `BookStackClient` fetches
search response, so `BookStackClient` fetches `/api/shelves` + `/api/shelves` + `/api/shelves/{id}` once (cached 30 min) into a
`/api/shelves/{id}` once (cached 30 min) into a shelf→book-ids map, used both shelf→book-ids map, used both to resolve the allow-list to book IDs and to
to resolve the allow-list to book IDs and to build the "Shelf > Book" build the "Shelf > Book" breadcrumb shown next to each suggestion. Per-query
breadcrumb shown next to each suggestion. Per-query search results are cached search results are cached 10 minutes, keyed on the query text **and** the
10 minutes, keyed on the query text **and** the active allow-list, so toggling active allow-list, so toggling which shelves are allowed is reflected
which shelves are allowed is reflected immediately instead of serving a immediately instead of serving a pre-change result for up to 10 minutes.
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.

View File

@@ -3,6 +3,65 @@
All notable changes to this project are documented in this file. Format loosely All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 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 ## [1.2.0] - 2026-07-23
### Added ### Added

View File

@@ -63,26 +63,43 @@ with no rebuild or restart:
## Scheduled commands need a host crontab entry ## Scheduled commands need a host crontab entry
The Docker image ships no cron/supervisor of its own (see [install.md](install.md)), 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` so `tickets:check-sla-breaches`, `automation:run-rules`, `emails:fetch-imap`,
(all registered in `routes/console.php` via `Schedule::command(...)`) only ever and `ai:run-ticket-automation` (all registered in `routes/console.php` via
run if something outside the container calls `php artisan schedule:run` on a `Schedule::command(...)`) only ever run if something outside the container
timer. **As of 2026-07-23 this is configured** — root's crontab on the host calls `php artisan schedule:run` on a timer. **As of 2026-07-23 this is
runs, every minute: configured** — root's crontab on the host runs, every minute:
```cron ```cron
* * * * * cd /mnt/rabbit-containers/servicedesk && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1 * * * * * 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, (`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 automatically; ask before changing this again, since removing it silently
breaks SLA checks, automation rules and IMAP fetching, and confusingly not the breaks SLA checks, automation rules, IMAP fetching and AI ticket automation,
IMAP feature alone if you're only debugging that one.) IMAP-specific activity and confusingly not the IMAP feature alone if you're only debugging that one.)
(connect attempts, per-message accept/reject decisions, created/replied ticket IMAP-specific activity (connect attempts, per-message accept/reject decisions,
ids) is logged separately from the app's normal `LOG_LEVEL` to created/replied ticket ids) is logged separately from the app's normal
`storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) — `LOG_LEVEL` to `storage/logs/imap-*.log` (see the `imap` channel in
check there first when a mailbox isn't behaving as expected, before assuming `config/logging.php`) — check there first when a mailbox isn't behaving as
the scheduler itself isn't firing. 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 ## Apache `/icons/` alias trap

View File

@@ -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 (operator and client) update live over WebSockets (Laravel Reverb): new
tickets, status/priority/team/assignee changes, and new replies ("live chat") tickets, status/priority/team/assignee changes, and new replies ("live chat")
all show up without a manual refresh. A periodic fallback refresh (with a 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 - **Categories & custom fields** — admin-defined categories/subcategories, each with
its own set of custom fields (text/textarea/select/checkbox/date/number) and an its own set of custom fields (text/textarea/select/checkbox/date/number) and an
optional default priority. optional default priority.
- **Teams** — subcategories auto-route to a team; operators only see their own - **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 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 - **Templates** — canned response snippets for the reply box, admin-configurable
"quick actions" (send + transition status in one click), and HTML e-mail "quick actions" (send + transition status in one click), and HTML e-mail
templates for every ticket lifecycle event (created, status/priority/category/ 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 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 after the page's first paint rather than blocking it. Configured entirely
from Admin > Integracje: connection + API token, optional SSL-verification from Admin > Integracje: connection + API token, optional SSL-verification
bypass for self-signed instances, page/book search-type filter, and two bypass for self-signed instances, a content-type filter (books/pages/
independent per-shelf allow-lists (nothing is searched until an admin opts chapters, independently toggleable) and a "search by" mode (name / tags /
specific shelves in, separately for ticket-creation suggestions vs. the both), and two independent per-shelf allow-lists (nothing is searched until
operator/client ticket-view sidebar). 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 ## Tech stack
@@ -192,9 +219,12 @@ src/ Laravel application
app/Livewire/ Client/Operator/Admin Livewire components app/Livewire/ Client/Operator/Admin Livewire components
app/Models/ Eloquent models app/Models/ Eloquent models
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted) 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, 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 app/Ldap/ LDAP user model + sync handlers
database/migrations/ Schema (one file per table group, final shape) database/migrations/ Schema (one file per table group, final shape)
database/seeders/ DatabaseSeeder — reference data, no ticket data database/seeders/ DatabaseSeeder — reference data, no ticket data

View File

@@ -74,7 +74,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji 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_CONNECTION=mysql
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1 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/`. 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` `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 — 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 patrz Admin > Poczta) i `ai:run-ticket-automation` (opcjonalna automatyczna
cron/supervisora** — bez dodatkowego kroku żadne z tych zadań nigdy się nie kategoryzacja/podsumowania AI zgłoszeń — patrz Admin > Integracje) co 5 minut,
uruchomi (poczta IMAP nadal da się sprawdzić ręcznie przyciskiem „Pobierz teraz”, ale **obraz Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego
ale bez crona nic nie dzieje się samo). Najprościej dodać wpis crona **na hoście**: 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 ```cron
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1 * * * * * 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 3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
polegać na logowaniu przez katalog. 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 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 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 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 uprawnienie „Access System API”), filtr typu treści (książki/strony/rozdziały,
podpowiedzi przy tworzeniu zgłoszenia i dla panelu operatora — dopóki żadna niezależne checkboxy), tryb wyszukiwania (po nazwie / po tagach / oba), oraz
półka nie jest zaznaczona, wyszukiwanie nic nie zwraca. 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 APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl
VERSION=1.1.3 VERSION=1.2.1
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB 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ż 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 ```cron
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1 * * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1

View File

@@ -5,7 +5,7 @@ APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.1.4 VERSION=1.2.1
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Services\BookStackContentTagger;
use Illuminate\Console\Command;
class BookstackTagContent extends Command
{
protected $signature = 'bookstack:tag-content
{--dry-run : Klasyfikuj i pokaż wynik bez zapisu tagów do BookStack}
{--force : Klasyfikuj ponownie elementy, które już mają tag podkategorii}
{--limit= : Zatrzymaj się po przetworzeniu N elementów}';
protected $description = 'Otaguj każdą książkę/rozdział/stronę w BookStack pasującymi nazwami podkategorii helpdesku, z pomocą skonfigurowanego dostawcy AI';
public function handle(BookStackContentTagger $tagger): int
{
$totals = $tagger->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;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Services\TicketAiSummaryService;
use App\Services\TicketAiTriageService;
use Illuminate\Console\Command;
class RunAiTicketAutomation extends Command
{
protected $signature = 'ai:run-ticket-automation';
protected $description = 'Run AI-driven ticket triage (categorize/prioritize new tickets) and refresh AI ticket summaries for the operator view';
public function handle(TicketAiTriageService $triage, TicketAiSummaryService $summary): int
{
$triageTotals = $triage->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;
}
}

View File

@@ -17,7 +17,9 @@ use App\Models\Team;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Models\UserField; use App\Models\UserField;
use App\Services\AiClient;
use App\Services\BookStackClient; use App\Services\BookStackClient;
use App\Services\BookStackContentTagger;
use App\Services\LdapUserProvisioner; use App\Services\LdapUserProvisioner;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -152,6 +154,24 @@ class Panel extends Component
public ?string $bookstackTestMessage = null; 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 ---- // ---- generic pending-delete confirm ----
public ?string $pendingDeleteType = null; public ?string $pendingDeleteType = null;
@@ -179,6 +199,13 @@ class Panel extends Component
'ticketNumberPrefix' => Settings::get('ticket_number_prefix'), 'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'), 'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
'ticketNumberMinLength' => Settings::get('ticket_number_min_length'), '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 = [ $this->ldapConfig = [
@@ -202,10 +229,29 @@ class Panel extends Component
'tokenSecret' => Settings::get('bookstack_token_secret'), 'tokenSecret' => Settings::get('bookstack_token_secret'),
'verifySsl' => Settings::bool('bookstack_verify_ssl'), 'verifySsl' => Settings::bool('bookstack_verify_ssl'),
'showToGuests' => Settings::bool('bookstack_show_to_guests'), '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', '')), 'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')), '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 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_prefix', trim((string) $this->systemConfig['ticketNumberPrefix']));
Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0'); 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('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_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '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', implode(',', BookStackClient::normalizeSearchTypes($this->bookstackConfig['searchTypes'])));
Settings::set('bookstack_search_types', $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'])); Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
@@ -1490,6 +1546,15 @@ class Panel extends Component
: [...$ids, $id]; : [...$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 public function testBookstackConnection(): void
{ {
$cfg = $this->bookstackConfig; $cfg = $this->bookstackConfig;
@@ -1508,6 +1573,93 @@ class Panel extends Component
$this->bookstackTestMessage = $result['message']; $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 ===================== // ===================== GENERIC DELETE CONFIRM =====================
public function requestDelete(string $type, mixed $id, string $message): void public function requestDelete(string $type, mixed $id, string $message): void

View File

@@ -82,8 +82,9 @@ class NewTicket extends Component
} }
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); $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 public function backToCategory(): void

View File

@@ -122,8 +122,9 @@ class TicketShow extends Component
$subcategory = $this->ticket->subcategory; $subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? '')); $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 public function updatedAttachments(): void

View File

@@ -100,8 +100,9 @@ class Landing extends Component
} }
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); $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 public function backToCategory(): void

View File

@@ -91,8 +91,9 @@ class NewTicket extends Component
} }
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); $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 public function backToCategory(): void

View File

@@ -16,6 +16,7 @@ use App\Models\User;
use App\Services\BookStackClient; use App\Services\BookStackClient;
use App\Services\TicketService; use App\Services\TicketService;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\On; use Livewire\Attributes\On;
@@ -78,6 +79,18 @@ class TicketShow extends Component
$this->suggestedArticlesLoaded = true; $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 public function mount(Ticket $ticket): void
{ {
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403); abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
@@ -91,6 +104,24 @@ class TicketShow extends Component
$this->ticket->resumeTimer(); $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] #[Computed]
public function isWatching(): bool public function isWatching(): bool
{ {
@@ -209,6 +240,36 @@ class TicketShow extends Component
unset($this->publicMessages, $this->internalMessages); 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 * Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
* and Queue::onQueueChanged()) lets a status/priority/team/assignee * and Queue::onQueueChanged()) lets a status/priority/team/assignee
@@ -222,7 +283,7 @@ class TicketShow extends Component
return; return;
} }
$this->ticket->refresh(); $this->refreshOrRedirectAway();
} }
/** /**
@@ -232,8 +293,11 @@ class TicketShow extends Component
*/ */
public function refreshTicketData(): void public function refreshTicketData(): void
{ {
if (! $this->refreshOrRedirectAway()) {
return;
}
unset($this->publicMessages, $this->internalMessages); unset($this->publicMessages, $this->internalMessages);
$this->ticket->refresh();
} }
#[Computed] #[Computed]
@@ -266,24 +330,22 @@ class TicketShow extends Component
$subcategory = $this->ticket->subcategory; $subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? '')); $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 * Every team, regardless of the viewing operator's own membership
* teams (mirrors the visibility scoping in Operator\Queue). * 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] #[Computed]
public function teams() public function teams()
{ {
$query = Team::query(); return Team::query()->get();
if (! Auth::user()->isAdmin()) {
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
}
return $query->get();
} }
#[Computed] #[Computed]
@@ -357,7 +419,12 @@ class TicketShow extends Component
public function setTeam(string $id): void public function setTeam(string $id): void
{ {
app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null); 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 -------- // -------- reporter --------

View File

@@ -17,6 +17,7 @@ use Illuminate\Support\Facades\DB;
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', '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', 'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_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 class Ticket extends Model
{ {
@@ -44,6 +45,8 @@ class Ticket extends Model
'timer_started_at' => 'datetime', 'timer_started_at' => 'datetime',
'csat_rating' => 'integer', 'csat_rating' => 'integer',
'csat_rated_at' => 'datetime', 'csat_rated_at' => 'datetime',
'ai_triaged_at' => 'datetime',
'ai_summary_generated_at' => 'datetime',
]; ];
} }

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Services;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
/**
* Generic OpenAI-compatible chat-completions client works against Groq,
* OpenAI itself, or a self-hosted Ollama instance's OpenAI-compat endpoint,
* whichever the admin points ai_base_url at. Not BookStack-specific; the
* BookStack content tagger is just the first consumer.
*/
class AiClient
{
public function enabled(): bool
{
// Deliberately no api-key requirement here — a self-hosted Ollama
// instance typically has no auth at all.
return Settings::bool('ai_enabled')
&& Settings::get('ai_base_url')
&& Settings::get('ai_model');
}
/**
* @param array<int, array{role: string, content: string}> $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;
}
}

View File

@@ -23,6 +23,31 @@ class BookStackClient
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view', 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 public function enabled(): bool
{ {
return Settings::bool('bookstack_enabled') return Settings::bool('bookstack_enabled')
@@ -39,44 +64,56 @@ class BookStackClient
* before any content is ever suggested, independently per context). * before any content is ever suggested, independently per context).
* Cached briefly since the same category/subcategory query repeats * Cached briefly since the same category/subcategory query repeats
* across every ticket created/viewed with that combination. Respects the * across every ticket created/viewed with that combination. Respects the
* admin-configured bookstack_search_types setting ('both'|'page'|'book') * admin-configured bookstack_search_types (subset of SEARCH_TYPES, via
* via BookStack's own `{type:x}` query syntax. The cache key folds in the * BookStack's `{type:a|b}` syntax) and bookstack_search_by ('name'|
* allowed-shelf list so changing it in Admin > Konfiguracja is reflected * 'tags'|'both', via `{in_name:...}`/`[...]`) settings. The cache key
* immediately, instead of possibly serving a pre-change result for up to * folds in the allowed-shelf list so changing it in Admin > Konfiguracja
* 10 minutes. * 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<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}> * @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
*/ */
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); $query = trim($query);
$tagQuery = trim($tagQuery ?? $query);
$allowedShelfIds = $this->allowedShelfIds($context); $allowedShelfIds = $this->allowedShelfIds($context);
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) { if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
return []; 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)) { $cacheKey = 'bookstack:search:'.md5(implode('||', $bookstackQueries).'|'.$limit.'|'.implode(',', $allowedShelfIds));
$query .= " {type:{$typeFilter}}";
}
$cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds)); return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($bookstackQueries, $limit, $allowedShelfIds) {
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) {
try { try {
$response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]);
if (! $response->successful()) {
return [];
}
$shelfMap = $this->shelfBookMap(); $shelfMap = $this->shelfBookMap();
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds); $allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
$bookShelfNames = $this->bookShelfNames($shelfMap); $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) { ->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
$type = $item['type'] ?? null; $type = $item['type'] ?? null;
@@ -103,6 +140,8 @@ class BookStackClient
]; ];
}) })
->filter(fn (array $item) => $item['name'] !== '') ->filter(fn (array $item) => $item['name'] !== '')
->unique(fn (array $item) => $item['url'] ?? $item['name'])
->take($limit)
->values() ->values()
->all(); ->all();
} catch (\Throwable) { } 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 * 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/ * 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<int, array{id: int, name: string}>
*/
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<int, array{name: string, value: string}>, 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<int, array{name: string, value: string}> $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[] * @return int[]
*/ */

View File

@@ -0,0 +1,260 @@
<?php
namespace App\Services;
use App\Models\Subcategory;
use Illuminate\Support\Str;
/**
* Bulk-assigns BookStack tags to every book/chapter/page, using the
* configured AiClient to classify each item's title+content against the
* helpdesk's current subcategory vocabulary — so BookStackClient::search()'s
* 'tags'/'both' mode has something to actually match against. Idempotent by
* default: an item already carrying a tag matching a current subcategory
* name is skipped, so re-running after adding a handful of new pages is
* cheap; $force re-classifies everything.
*/
class BookStackContentTagger
{
protected const BATCH_SIZE = 20;
protected const CONTENT_EXCERPT_CHARS = 2000;
public function __construct(
protected BookStackClient $bookstack,
protected AiClient $ai,
) {}
/**
* @return array{scanned: int, tagged: int, skipped: int, failed_batches: int}
*/
public function run(bool $dryRun = false, bool $force = false, ?int $limit = null): array
{
$totals = ['scanned' => 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<int, array<int, array{id: int, name: string, tags: array, content: string}>>
*/
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<int, array{name: string, value: string}> $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<int, array{id: int, name: string, tags: array, content: string}> $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<int, array{id: int, name: string, tags: array, content: string}> $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<string, string[]>|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<int, array{name: string, value: string}> $existing
* @param string[] $newLabels
* @return array<int, array{name: string, value: string}>
*/
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()];
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Services;
use App\Models\Ticket;
use App\Support\Settings;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Generates an AI summary + suggested next action for every ticket, cached
* on the ticket row and shown only in the operator view (see
* TicketAiTriageService's docblock for why this runs from the scheduled
* ai:run-ticket-automation command rather than live on page load). Stays
* reasonably fresh by regenerating whenever a ticket's latest message
* postdates its last summary, not on every scheduler tick for every ticket.
*/
class TicketAiSummaryService
{
protected const BATCH_LIMIT = 25;
protected const MESSAGE_EXCERPT_CHARS = 1500;
protected const TRANSCRIPT_MESSAGE_LIMIT = 30;
public function __construct(protected AiClient $ai) {}
/**
* @return array{scanned: int, updated: int, failed: int}
*/
public function run(?int $limit = null): array
{
$totals = ['scanned' => 0, 'updated' => 0, 'failed' => 0];
if (! $this->ai->enabled() || ! Settings::bool('ai_summary_enabled')) {
return $totals;
}
$this->staleQuery()
->limit($limit ?? self::BATCH_LIMIT)
->get()
->each(function (Ticket $ticket) use (&$totals) {
$totals['scanned']++;
$this->summarizeOne($ticket) ? $totals['updated']++ : $totals['failed']++;
});
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,
];
}
}

View File

@@ -0,0 +1,311 @@
<?php
namespace App\Services;
use App\Models\Category;
use App\Models\Priority;
use App\Models\Ticket;
use App\Support\Settings;
use Illuminate\Support\Str;
/**
* One-shot AI classification of new tickets, gated by 5 independent
* settings toggles (ai_triage_category_when_missing/
* subcategory_when_category_only/recheck_categorized/fix_subject/
* set_priority). Runs from the scheduled ai:run-ticket-automation command,
* never synchronously at ticket creation, so it never adds LLM latency to a
* live customer submitting a ticket. Every scanned ticket gets
* ai_triaged_at stamped exactly once this is a one-shot pass per ticket,
* not a continuous recheck, and there's no manual re-trigger by design.
*/
class TicketAiTriageService
{
protected const BATCH_LIMIT = 25;
protected const BODY_EXCERPT_CHARS = 4000;
public function __construct(
protected AiClient $ai,
protected TicketService $tickets,
) {}
/**
* @return array{scanned: int, changed: int, failed: int}
*/
public function run(?int $limit = null): array
{
$totals = ['scanned' => 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<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
* @param array<string, string> $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<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}>
*/
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<int, array{id: int, name: string, subcategories: array}> $vocabulary
* @param array<string, string> $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<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $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<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
* @param array<string, string> $priorities
* @return array{0: array<string, mixed>, 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<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $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;
}
}

View File

@@ -161,6 +161,52 @@ class TicketService
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id()); 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<string, mixed> $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 public function setReporter(Ticket $ticket, User $customer): void
{ {
$ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]); $ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]);

View File

@@ -25,6 +25,13 @@ class Settings
'ticket_number_prefix' => '#', 'ticket_number_prefix' => '#',
'ticket_number_obfuscate' => '0', 'ticket_number_obfuscate' => '0',
'ticket_number_min_length' => '4', '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_enabled' => '1',
'ldap_host' => '', 'ldap_host' => '',
'ldap_port' => '389', 'ldap_port' => '389',
@@ -48,9 +55,28 @@ class Settings
'bookstack_token_secret' => '', 'bookstack_token_secret' => '',
'bookstack_verify_ssl' => '1', 'bookstack_verify_ssl' => '1',
'bookstack_show_to_guests' => '0', '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_creation' => '',
'bookstack_allowed_shelf_ids_ticket_view' => '', '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' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>', 'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
'accent_color' => '#7c6fd6', 'accent_color' => '#7c6fd6',
'login_notice_type' => 'info', 'login_notice_type' => 'info',
@@ -63,7 +89,7 @@ class Settings
.'</div>', .'</div>',
]; ];
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 public static function get(string $key, ?string $default = null): ?string
{ {
@@ -209,6 +235,24 @@ class Settings
: static::$defaults['timezone']; : 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 * Wraps a single e-mail template's rendered HTML body in the fixed
* "box" layout company name, ticket content and footer so every * "box" layout company name, ticket content and footer so every

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* ai_triaged_at marks that the AI auto-triage pass has run for this
* ticket (regardless of whether it changed anything) never reset, so
* the scheduled command's query is just "tickets where this is null".
*
* ai_summary/ai_suggested_action/ai_summary_generated_at cache the AI
* ticket summary shown to operators; generated_at lets the summary
* command cheaply tell whether a ticket's summary is stale relative to
* its latest message, without re-summarizing every ticket every run.
*/
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -638,6 +638,31 @@ $tabGroups = [
</div> </div>
</div> </div>
<div class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Częstotliwość odświeżania i harmonogramu</h4>
<div class="card-kicker">Odświeżanie w przeglądarce</div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Widok zgłoszenia (klient i operator)</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshTicketViewSeconds"></div>
<div class="field" style="flex:1"><label>Lista zgłoszeń operatora</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshQueueSeconds"></div>
</div>
<div class="field"><label>Dzwonek powiadomień</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshNotificationsSeconds"></div>
<p class="text-muted" style="font-size:12px;margin:0">Sekundy między automatycznymi odświeżeniami w przeglądarce (niezależnie od odświeżenia na żądanie po kliknięciu w licznik).</p>
<div class="hr"></div>
<div class="card-kicker">Zadania w tle</div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Sprawdzanie naruszeń SLA</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleSlaCheckMinutes"></div>
<div class="field" style="flex:1"><label>Reguły automatyzacji</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleAutomationRulesMinutes"></div>
</div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Pobieranie e-maili (IMAP)</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleImapFetchMinutes"></div>
<div class="field" style="flex:1"><label>Automatyczna kategoryzacja i podsumowania AI</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleAiAutomationMinutes"></div>
</div>
<p class="text-muted" style="font-size:12px;margin:0">Minuty między uruchomieniami zadań w tle. Zmiana obowiązuje od najbliższego uruchomienia harmonogramu (co minutę), bez potrzeby restartu.</p>
</div>
<div style="grid-column:1/-1;display:flex"> <div style="grid-column:1/-1;display:flex">
<button type="submit" class="btn btn-primary">Zapisz</button> <button type="submit" class="btn btn-primary">Zapisz</button>
</div> </div>
@@ -695,12 +720,35 @@ $tabGroups = [
<div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div> <div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil &rarr; Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p> <p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil &rarr; Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
<div class="field"><label>Przeszukuj</label> <div class="field">
<select class="input" style="width:auto" wire:model="bookstackConfig.searchTypes"> <label>Przeszukuj</label>
<option value="both">Strony i książki</option> <div style="display:flex;gap:16px;flex-wrap:wrap">
<option value="page">Tylko strony</option> <label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
<option value="book">Tylko książki</option> <input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('book', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('book')">
Książki
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('page', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('page')">
Strony
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('chapter', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('chapter')">
Rozdziały
</label>
</div>
@if (empty($bookstackConfig['searchTypes']))
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Nic nie zaznaczono przy zapisie zostaną użyte wszystkie typy.</p>
@endif
</div>
<div class="field">
<label>Szukaj po</label>
<select class="input" style="width:auto" wire:model="bookstackConfig.searchBy">
<option value="both">Słowa kluczowe w nazwie lub tagi</option>
<option value="name">Tylko słowa kluczowe w nazwie</option>
<option value="tags">Tylko tagi</option>
</select> </select>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">„Tagi” dopasowują artykuły oznaczone w BookStack tagiem o nazwie zgodnej z kategorią/podkategorią zgłoszenia (np. tag „Drukarki” dla podkategorii „Drukarki”).</p>
</div> </div>
<div style="display:flex;justify-content:flex-end"> <div style="display:flex;justify-content:flex-end">
@@ -750,6 +798,26 @@ $tabGroups = [
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL instancji BookStack</label> <label class="radio"><input type="checkbox" wire:model="bookstackConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL instancji BookStack</label>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA.</span> <span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA.</span>
<div class="field">
<label>Automatyczne tagowanie treści (AI)</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">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).</p>
<div style="display:flex;gap:10px;margin-top:6px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="runBookstackTagging" wire:loading.attr="disabled" wire:target="runBookstackTagging,runBookstackTaggingForce">
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle" wire:loading.class="spin" wire:target="runBookstackTagging">sell</span>
Otaguj nową treść
</button>
<button type="button" class="btn btn-secondary" wire:click="runBookstackTaggingForce" wire:loading.attr="disabled" wire:target="runBookstackTagging,runBookstackTaggingForce">
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle" wire:loading.class="spin" wire:target="runBookstackTaggingForce">refresh</span>
Otaguj wszystko ponownie (force)
</button>
</div>
@if ($bookstackTagResult !== null)
<div class="text-muted" style="font-size:11.5px;margin-top:6px">Przeskanowano {{ $bookstackTagResult['scanned'] }}, otagowano {{ $bookstackTagResult['tagged'] }}, pominięto {{ $bookstackTagResult['skipped'] }}, nieudanych paczek {{ $bookstackTagResult['failed_batches'] }}.</div>
@elseif ($bookstackTagError)
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:11.5px;margin-top:6px"><span class="material-symbols-outlined" style="font-size:16px">error</span>{{ $bookstackTagError }}</div>
@endif
</div>
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap"> <div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button> <button type="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button>
<button type="submit" class="btn btn-primary">Zapisz</button> <button type="submit" class="btn btn-primary">Zapisz</button>
@@ -764,6 +832,68 @@ $tabGroups = [
@endif @endif
</form> </form>
<form wire:submit="saveAiConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Integracja AI</h4>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">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.</span>
<label class="radio"><input type="checkbox" wire:model="aiConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację AI</strong></label>
@if ($aiConfig['enabled'])
<div class="field"><label>Adres API (Base URL)</label><input class="input" placeholder="https://api.groq.com/openai/v1" wire:model="aiConfig.baseUrl"></div>
<div class="field"><label>Klucz API</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="aiConfig.apiKey"></div>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Zostaw puste dla lokalnych instancji bez autoryzacji (np. Ollama).</p>
<div class="field"><label>Model</label><input class="input" placeholder="np. llama-3.3-70b-versatile" wire:model="aiConfig.model"></div>
<label class="radio"><input type="checkbox" wire:model="aiConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL</label>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja (np. lokalny Ollama) korzysta z certyfikatu self-signed / z prywatnego CA.</span>
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="testAiConnection">Testuj połączenie</button>
<button type="submit" class="btn btn-primary">Zapisz</button>
@if ($aiTestResult === 'ok')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
@elseif ($aiTestResult === 'error')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $aiTestMessage ? ': '.$aiTestMessage : '' }}</div>
@endif
</div>
@else
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
@endif
</form>
<form wire:submit="saveAiTriageConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Automatyzacja AI dla zgłoszeń</h4>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wymaga włączonej integracji AI (karta obok). Zgłoszenia przetwarzane w tle, cyklicznie co kilka minut nie spowalnia to tworzenia zgłoszenia przez klienta.</span>
<div class="field">
<label>Automatyczna kategoryzacja nowych zgłoszeń</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.categoryWhenMissing" style="position:static;opacity:1;width:auto;height:auto">Przypisz kategorię/podkategorię, gdy zgłoszenie nie ma żadnej</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.subcategoryWhenCategoryOnly" style="position:static;opacity:1;width:auto;height:auto">Dobierz podkategorię, gdy zgłoszenie ma tylko kategorię</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.recheckCategorized" style="position:static;opacity:1;width:auto;height:auto">Zweryfikuj i ewentualnie popraw już przypisaną podkategorię</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.fixSubject" style="position:static;opacity:1;width:auto;height:auto">Popraw temat zgłoszenia, jeśli jest niejasny</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.setPriority" style="position:static;opacity:1;width:auto;height:auto">Ustaw priorytet na podstawie treści</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Każde zgłoszenie jest sprawdzane tylko raz zastosowane zmiany trafiają do historii zgłoszenia z adnotacją „Automatyzacja: klasyfikacja AI”.</p>
</div>
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
<div class="field">
<label>Podsumowanie AI dla operatora</label>
<label class="radio"><input type="checkbox" wire:model="aiSummaryEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Generuj podsumowanie i sugerowaną akcję dla każdego zgłoszenia</strong></label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Odświeżane automatycznie, gdy w wątku pojawi się nowa wiadomość.</p>
</div>
<div class="field">
<div style="display:flex;justify-content:space-between;align-items:center">
<label>Prompt systemowy podsumowania</label>
<button type="button" class="btn btn-ghost" style="padding:2px 8px;font-size:12px" wire:click="resetAiSummaryPrompt" wire:confirm="Przywrócić domyślny prompt? Obecna treść zostanie zastąpiona.">Resetuj</button>
</div>
<textarea wire:key="ai-summary-prompt-{{ $aiSummaryPromptVersion }}" class="input" rows="6" wire:change="saveAiSummaryPrompt($event.target.value)">{{ $aiSummaryPrompt }}</textarea>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">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.</p>
</div>
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
</form>
</div> </div>
@endif @endif

View File

@@ -7,13 +7,18 @@
{{-- Live updates arrive via broadcasting, but websocket connections can {{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible 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
<div <div
class="btn btn-secondary" class="btn btn-secondary"
style="cursor:default;gap:6px" style="cursor:pointer;gap:6px"
x-data="{ remaining: 30, total: 30 }" x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)" x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie" x-on:click="remaining = total; $wire.refreshTicketData()"
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
> >
<span class="material-symbols-outlined" style="font-size:18px">schedule</span> <span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span> <span x-text="remaining + 's'"></span>

View File

@@ -1,4 +1,4 @@
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.30s="$refresh"> <div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.{{ max(1, (int) \App\Support\Settings::get('refresh_notifications_seconds')) }}s="$refresh">
<button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px"> <button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px">
<span class="material-symbols-outlined" style="font-size:18px">notifications</span> <span class="material-symbols-outlined" style="font-size:18px">notifications</span>
@if ($this->unreadCount) @if ($this->unreadCount)

View File

@@ -120,13 +120,18 @@
{{-- Live updates arrive via broadcasting, but websocket connections can {{-- Live updates arrive via broadcasting, but websocket connections can
drop silently (backgrounded tab, network blip) this is a periodic drop silently (backgrounded tab, network blip) this is a periodic
fallback refresh, with a visible countdown so it's clear the queue fallback refresh, with a visible countdown so it's clear the queue
is still refreshing itself rather than just stuck. --}} is still refreshing itself rather than just stuck. Also clickable,
to fetch immediately and reset the countdown. --}}
@php
$refreshQueueSeconds = max(1, (int) \App\Support\Settings::get('refresh_queue_seconds'));
@endphp
<div <div
class="btn btn-secondary" class="btn btn-secondary"
style="cursor:default;gap:6px" style="cursor:pointer;gap:6px"
x-data="{ remaining: 60, total: 60 }" x-data="{ remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }} }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)" x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
title="Kolejka odświeża się automatycznie co minutę" x-on:click="remaining = total; $wire.refreshQueue()"
title="Kolejka odświeża się automatycznie — kliknij, aby odświeżyć teraz"
> >
<span class="material-symbols-outlined" style="font-size:18px">schedule</span> <span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span> <span x-text="remaining + 's'"></span>

View File

@@ -19,13 +19,18 @@
{{-- Live updates arrive via broadcasting, but websocket connections can {{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible 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
<div <div
class="btn btn-secondary" class="btn btn-secondary"
style="cursor:default;gap:6px" style="cursor:pointer;gap:6px"
x-data="{ remaining: 30, total: 30 }" x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)" x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie" x-on:click="remaining = total; $wire.refreshTicketData()"
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
> >
<span class="material-symbols-outlined" style="font-size:18px">schedule</span> <span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span> <span x-text="remaining + 's'"></span>
@@ -329,6 +334,25 @@
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" /> <x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
</div> </div>
@if (\App\Support\Settings::bool('ai_summary_enabled'))
<div wire:init="loadAiSummary" class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Podsumowanie AI</div>
@if ($aiSummaryLoaded)
@if ($ticket->ai_summary)
<div style="font-size:13px;line-height:1.5">{{ $ticket->ai_summary }}</div>
@if ($ticket->ai_suggested_action)
<div style="margin-top:6px;padding-top:8px;border-top:1px solid var(--color-divider);font-size:13px">
<strong>Sugerowana akcja:</strong> {{ $ticket->ai_suggested_action }}
</div>
@endif
<div class="text-muted" style="font-size:11px;margin-top:4px">Zaktualizowano: {{ $ticket->ai_summary_generated_at?->diffForHumans() }}</div>
@else
<p class="text-muted" style="font-size:12.5px;margin:0">Podsumowanie pojawi się po najbliższym cyklu automatyzacji AI.</p>
@endif
@endif
</div>
@endif
<div class="card" style="padding:16px;gap:8px"> <div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">SLA</div> <div class="card-kicker">SLA</div>
<div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div> <div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div>

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Support\Settings;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
@@ -8,6 +9,16 @@ Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes(); // Intervals are admin-configurable (Admin > Konfiguracja). Each command is
Schedule::command('automation:run-rules')->everyFifteenMinutes(); // considered every minute but the ->when() closure (evaluated lazily by
Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping(); // schedule:run, never at boot) decides whether the configured interval has
// actually elapsed — see Settings::dueEveryMinutes() for why this can't be
// an eagerly-built cron string instead.
Schedule::command('tickets:check-sla-breaches')->everyMinute()
->when(fn () => Settings::dueEveryMinutes('schedule_sla_check_minutes', 15));
Schedule::command('automation:run-rules')->everyMinute()
->when(fn () => Settings::dueEveryMinutes('schedule_automation_rules_minutes', 15));
Schedule::command('emails:fetch-imap')->everyMinute()->withoutOverlapping()
->when(fn () => Settings::dueEveryMinutes('schedule_imap_fetch_minutes', 5));
Schedule::command('ai:run-ticket-automation')->everyMinute()->withoutOverlapping()
->when(fn () => Settings::dueEveryMinutes('schedule_ai_automation_minutes', 5));

View File

@@ -0,0 +1,61 @@
<?php
use App\Livewire\Admin\Panel;
use App\Models\Setting;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
test('the Integracje tab shows the Integracja AI card', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->assertSee('Integracja AI');
});
test('saving the AI config persists settings and encrypts the api key at rest', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiConfig.enabled', true)
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
->set('aiConfig.apiKey', 'super-secret-key')
->set('aiConfig.model', 'llama-3.3-70b-versatile')
->set('aiConfig.verifySsl', true)
->call('saveAiConfig')
->assertOk();
expect(Settings::get('ai_base_url'))->toBe('https://api.groq.test/openai/v1');
expect(Settings::get('ai_model'))->toBe('llama-3.3-70b-versatile');
expect(Settings::get('ai_api_key'))->toBe('super-secret-key');
$stored = Setting::query()->where('key', 'ai_api_key')->value('value');
expect($stored)->not->toBe('super-secret-key');
});
test('leaving the api key field blank on save keeps the previously stored key', function () {
Settings::set('ai_api_key', 'already-stored-key');
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiConfig.enabled', true)
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
->set('aiConfig.model', 'llama-3.3-70b-versatile')
->call('saveAiConfig')
->assertOk();
expect(Settings::get('ai_api_key'))->toBe('already-stored-key');
});
test('testAiConnection reports the result of a live probe using unsaved form values', function () {
Http::fake(['api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'pong']]]])]);
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiConfig.enabled', true)
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
->set('aiConfig.apiKey', 'key')
->set('aiConfig.model', 'llama-3.3-70b-versatile')
->call('testAiConnection')
->assertSet('aiTestResult', 'ok');
});

View File

@@ -0,0 +1,74 @@
<?php
use App\Livewire\Admin\Panel;
use App\Support\Settings;
use Livewire\Livewire;
test('the Integracje tab shows the Automatyzacja AI dla zgłoszeń card', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->assertSee('Automatyzacja AI dla zgłoszeń');
});
test('saving the triage config persists all 5 toggles plus the summary toggle', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiTriageConfig.categoryWhenMissing', true)
->set('aiTriageConfig.subcategoryWhenCategoryOnly', true)
->set('aiTriageConfig.recheckCategorized', true)
->set('aiTriageConfig.fixSubject', true)
->set('aiTriageConfig.setPriority', true)
->set('aiSummaryEnabled', true)
->call('saveAiTriageConfig')
->assertOk();
expect(Settings::bool('ai_triage_category_when_missing'))->toBeTrue();
expect(Settings::bool('ai_triage_subcategory_when_category_only'))->toBeTrue();
expect(Settings::bool('ai_triage_recheck_categorized'))->toBeTrue();
expect(Settings::bool('ai_triage_fix_subject'))->toBeTrue();
expect(Settings::bool('ai_triage_set_priority'))->toBeTrue();
expect(Settings::bool('ai_summary_enabled'))->toBeTrue();
});
test('unchecking every toggle and saving turns them all back off', function () {
Settings::set('ai_triage_category_when_missing', '1');
Settings::set('ai_summary_enabled', '1');
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiTriageConfig.categoryWhenMissing', false)
->set('aiSummaryEnabled', false)
->call('saveAiTriageConfig')
->assertOk();
expect(Settings::bool('ai_triage_category_when_missing'))->toBeFalse();
expect(Settings::bool('ai_summary_enabled'))->toBeFalse();
});
test('saveAiSummaryPrompt persists custom prompt text', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('saveAiSummaryPrompt', 'Mój niestandardowy prompt.')
->assertOk();
expect(Settings::get('ai_summary_prompt'))->toBe('Mój niestandardowy prompt.');
});
test('resetAiSummaryPrompt restores the default prompt after it was customized', function () {
$default = Settings::default('ai_summary_prompt');
$admin = adminUser();
$component = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('saveAiSummaryPrompt', 'Coś zupełnie innego.')
->assertSet('aiSummaryPrompt', 'Coś zupełnie innego.');
expect(Settings::get('ai_summary_prompt'))->toBe('Coś zupełnie innego.');
$component->call('resetAiSummaryPrompt')
->assertSet('aiSummaryPrompt', $default);
expect(Settings::get('ai_summary_prompt'))->toBe($default);
});

View File

@@ -0,0 +1,54 @@
<?php
use App\Livewire\Admin\Panel;
use App\Support\Settings;
use Livewire\Livewire;
test('the tagging buttons are visible once BookStack is enabled', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('bookstackConfig.enabled', true)
->assertSee('Otaguj nową treść')
->assertSee('Otaguj wszystko ponownie');
});
test('clicking the normal button runs a non-force tagging pass and shows the summary', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [10 => [], 11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
aiContent: '{"10": ["Drukarki i skanery"], "11": ["Drukarki i skanery"]}',
);
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('runBookstackTagging')
->assertSet('bookstackTagResult', ['scanned' => 2, 'tagged' => 1, 'skipped' => 1, 'failed_batches' => 0])
->assertSee('Przeskanowano 2, otagowano 1, pominięto 1');
});
test('clicking the force button reclassifies already-tagged content too', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
aiContent: '{"11": ["Drukarki i skanery"]}',
);
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('runBookstackTaggingForce')
->assertSet('bookstackTagResult', ['scanned' => 1, 'tagged' => 1, 'skipped' => 0, 'failed_batches' => 0]);
});
test('running tagging without a configured AI integration shows a helpful error instead of a silent no-op', function () {
enableBookstackAndAiForTagging();
Settings::set('ai_enabled', '0');
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('runBookstackTagging')
->assertSet('bookstackTagResult', null)
->assertSee('Włącz i skonfiguruj obie integracje');
});

View File

@@ -0,0 +1,66 @@
<?php
use App\Services\AiClient;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
test('enabled is false unless ai_enabled, ai_base_url and ai_model are all set — api key is optional', function () {
expect(app(AiClient::class)->enabled())->toBeFalse();
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
expect(app(AiClient::class)->enabled())->toBeTrue();
});
test('chat returns the assistant message content on success', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Http::fake([
'api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'hello']]]]),
]);
expect(app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]))->toBe('hello');
});
test('chat returns null on a non-successful response instead of throwing', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Http::fake(['api.groq.test/*' => Http::response(['error' => 'nope'], 500)]);
expect(app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]))->toBeNull();
});
test('chat sends no Authorization header when no api key is configured (self-hosted Ollama style)', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'http://ollama.test/v1');
Settings::set('ai_model', 'llama3');
Http::fake(['ollama.test/*' => Http::response(['choices' => [['message' => ['content' => 'ok']]]])]);
app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]);
Http::assertSent(fn ($request) => ! $request->hasHeader('Authorization'));
});
test('testConnection reports ok on a successful response', function () {
Http::fake(['api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'pong']]]])]);
$ok = app(AiClient::class)->testConnection('https://api.groq.test/openai/v1', 'key', 'llama3', true);
expect($ok)->toBe(['ok' => true, 'message' => null]);
});
test('testConnection reports the provider error message on failure', function () {
Http::fake(['api.groq.test/*' => Http::response(['error' => ['message' => 'bad model']], 400)]);
$error = app(AiClient::class)->testConnection('https://api.groq.test/openai/v1', 'key', 'bad-model', true);
expect($error['ok'])->toBeFalse();
expect($error['message'])->toBe('bad model');
});

View File

@@ -0,0 +1,154 @@
<?php
use App\Models\Category;
use App\Services\BookStackContentTagger;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
function seedTaggerSubcategory(): void
{
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$category->subcategories()->create(['name' => 'Drukarki i skanery']);
}
function enableBookstackAndAiForTagging(): void
{
Settings::set('bookstack_enabled', '1');
Settings::set('bookstack_base_url', 'https://wiki.test');
Settings::set('bookstack_token_id', 'id');
Settings::set('bookstack_token_secret', 'secret');
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
}
/**
* @param array<int, array> $pageTags page id => existing tags array
*/
function fakeTaggerBookstackAndAi(array $pageTags, string $aiContent): void
{
Http::fake([
'wiki.test/api/books*' => Http::response(['data' => [], 'total' => 0]),
'wiki.test/api/chapters*' => Http::response(['data' => [], 'total' => 0]),
'wiki.test/api/pages*' => function ($request) use ($pageTags) {
$path = parse_url($request->url(), PHP_URL_PATH);
if ($request->method() === 'GET' && Str::endsWith($path, '/api/pages')) {
return Http::response([
'data' => collect($pageTags)->keys()->map(fn ($id) => ['id' => $id, 'name' => "Page {$id}"])->values()->all(),
'total' => count($pageTags),
]);
}
$id = (int) basename($path);
if ($request->method() === 'GET') {
return Http::response([
'id' => $id,
'name' => "Page {$id}",
'tags' => $pageTags[$id] ?? [],
'markdown' => 'Treść o drukarkach i skanerach.',
]);
}
if ($request->method() === 'PUT') {
return Http::response(['id' => $id]);
}
return Http::response([], 404);
},
'ai.test/*' => Http::response(['choices' => [['message' => ['content' => $aiContent]]]]),
]);
}
test('normal run tags untagged content and skips already-tagged content', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [10 => [], 11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
aiContent: '{"10": ["Drukarki i skanery"], "11": ["Drukarki i skanery"]}',
);
$totals = app(BookStackContentTagger::class)->run();
expect($totals)->toBe(['scanned' => 2, 'tagged' => 1, 'skipped' => 1, 'failed_batches' => 0]);
Http::assertSent(fn ($r) => $r->method() === 'PUT'
&& str_contains((string) $r->url(), '/api/pages/10')
&& collect($r->data()['tags'])->contains(fn ($t) => $t['name'] === 'Drukarki i skanery'));
Http::assertNotSent(fn ($r) => $r->method() === 'PUT' && str_contains((string) $r->url(), '/api/pages/11'));
// the already-tagged page never even makes it into the AI prompt
Http::assertSent(fn ($r) => str_contains((string) $r->url(), 'ai.test')
? (str_contains($r['messages'][1]['content'], 'id=10') && ! str_contains($r['messages'][1]['content'], 'id=11'))
: true);
});
test('--dry-run classifies but never calls PUT', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [10 => []],
aiContent: '{"10": ["Drukarki i skanery"]}',
);
$totals = app(BookStackContentTagger::class)->run(dryRun: true);
expect($totals['tagged'])->toBe(1);
Http::assertNotSent(fn ($r) => $r->method() === 'PUT');
});
test('--force re-classifies and re-writes already-tagged content', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
aiContent: '{"11": ["Drukarki i skanery"]}',
);
$totals = app(BookStackContentTagger::class)->run(force: true);
expect($totals)->toBe(['scanned' => 1, 'tagged' => 1, 'skipped' => 0, 'failed_batches' => 0]);
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_contains((string) $r->url(), '/api/pages/11'));
});
test('tag matching is case-insensitive when deciding whether content is already tagged', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [11 => [['name' => 'drukarki i skanery', 'value' => '']]],
aiContent: '{"11": []}',
);
$totals = app(BookStackContentTagger::class)->run();
expect($totals['skipped'])->toBe(1);
expect($totals['scanned'])->toBe(1);
});
test('a malformed AI response fails only that batch, without writing any tags', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [10 => []],
aiContent: 'this is not json at all',
);
$totals = app(BookStackContentTagger::class)->run();
expect($totals)->toBe(['scanned' => 1, 'tagged' => 0, 'skipped' => 0, 'failed_batches' => 1]);
Http::assertNotSent(fn ($r) => $r->method() === 'PUT');
});
test('the tagger never touches bookshelves', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(pageTags: [], aiContent: '{}');
app(BookStackContentTagger::class)->run();
Http::assertNotSent(fn ($r) => str_contains((string) $r->url(), '/api/shelves'));
});

View File

@@ -0,0 +1,54 @@
<?php
use App\Services\BookStackClient;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
function enableBookstackForSearch(string $searchBy = 'both'): void
{
Settings::set('bookstack_enabled', '1');
Settings::set('bookstack_base_url', 'https://wiki.test');
Settings::set('bookstack_token_id', 'id');
Settings::set('bookstack_token_secret', 'secret');
Settings::set('bookstack_search_by', $searchBy);
Settings::set('bookstack_allowed_shelf_ids_creation', '1');
Http::fake([
'wiki.test/api/shelves/1' => Http::response(['books' => [['id' => 5]]]),
'wiki.test/api/shelves*' => Http::response(['data' => [['id' => 1, 'name' => 'IT']]]),
'wiki.test/api/search*' => Http::response(['data' => []]),
]);
}
test('with search_by=both, the tags-variant query uses tagQuery while the name-variant keeps the full query', function () {
enableBookstackForSearch('both');
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery', 5, BookStackClient::CONTEXT_CREATION, 'Drukarki i skanery');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& ($request['query'] ?? '') === '{in_name:IT-Pomoc Drukarki i skanery}');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& ($request['query'] ?? '') === '[Drukarki i skanery]');
});
test('omitting tagQuery falls back to the full query for backward compatibility', function () {
enableBookstackForSearch('tags');
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& ($request['query'] ?? '') === '[IT-Pomoc Drukarki i skanery]');
});
test('search_by=tags alone sends only the tag-form query built from tagQuery, never the plain category+subcategory text', function () {
enableBookstackForSearch('tags');
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery', 5, BookStackClient::CONTEXT_CREATION, 'Drukarki i skanery');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& ($request['query'] ?? '') === '[Drukarki i skanery]');
Http::assertNotSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& str_contains($request['query'] ?? '', 'IT-Pomoc'));
});

View File

@@ -91,7 +91,7 @@ test('a non-admin operator can still open a ticket outside their team if it is p
->assertOk(); ->assertOk();
}); });
test('the team reassignment dropdown on a ticket only offers a non-admin operator their own teams', function () { test('the team reassignment dropdown on a ticket offers a non-admin operator every team, not just their own', function () {
seedStatusesAndPriorities(); seedStatusesAndPriorities();
$operator = operatorUser('scoped-5@example.com'); $operator = operatorUser('scoped-5@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']); $myTeam = Team::query()->create(['name' => 'Infrastruktura']);
@@ -101,9 +101,25 @@ test('the team reassignment dropdown on a ticket only offers a non-admin operato
$ticket = makeTicket(['number' => '5001', 'team_id' => $myTeam->id]); $ticket = makeTicket(['number' => '5001', 'team_id' => $myTeam->id]);
$teamNames = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) $teamNames = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->instance()->teams->pluck('name')->all(); ->instance()->teams->pluck('name')->sort()->values()->all();
expect($teamNames)->toBe(['Infrastruktura']); expect($teamNames)->toBe(['Aplikacje', 'Infrastruktura']);
});
test('a non-admin operator can reassign a ticket to a team they do not belong to', function () {
seedStatusesAndPriorities();
$operator = operatorUser('scoped-7@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$ticket = makeTicket(['number' => '5002', 'team_id' => $myTeam->id]);
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->call('setTeam', (string) $otherTeam->id)
->assertOk();
expect($ticket->fresh()->team_id)->toBe($otherTeam->id);
}); });
test('merging cannot pull in a ticket outside the operators scope via a crafted selection', function () { test('merging cannot pull in a ticket outside the operators scope via a crafted selection', function () {

View File

@@ -0,0 +1,94 @@
<?php
use App\Livewire\Operator\TicketShow;
use App\Models\Team;
use App\Models\Ticket;
use Livewire\Livewire;
test('an operator viewing a ticket is sent to the queue instead of erroring when it is deleted by someone else', function () {
seedStatusesAndPriorities();
$operator = operatorUser('redirect-1@example.com');
$ticket = makeTicket(['number' => '6001', 'team_id' => null]);
$component = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]);
Ticket::query()->whereKey($ticket->id)->delete();
$component->call('onQueueChanged', $ticket->id)
->assertRedirect(route('operator.queue'));
});
test('an operator is sent to the queue when a live update moves the ticket to a team outside their scope', function () {
seedStatusesAndPriorities();
$operator = operatorUser('redirect-2@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$ticket = makeTicket(['number' => '6002', 'team_id' => $myTeam->id]);
$component = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]);
$ticket->update(['team_id' => $otherTeam->id]);
$component->call('onQueueChanged', $ticket->id)
->assertRedirect(route('operator.queue'));
});
test('an operator is not redirected by a live update for a ticket still personally assigned to them', function () {
seedStatusesAndPriorities();
$operator = operatorUser('redirect-3@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$ticket = makeTicket(['number' => '6003', 'team_id' => $myTeam->id, 'assignee_id' => $operator->id]);
$component = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]);
$ticket->update(['team_id' => $otherTeam->id]);
$component->call('onQueueChanged', $ticket->id)
->assertNoRedirect();
});
test('an operator who reassigns a ticket to a team outside their own scope is redirected immediately', function () {
seedStatusesAndPriorities();
$operator = operatorUser('redirect-4@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$otherTeam = Team::query()->create(['name' => 'Aplikacje']);
$operator->teams()->attach($myTeam->id);
$ticket = makeTicket(['number' => '6004', 'team_id' => $myTeam->id]);
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->call('setTeam', (string) $otherTeam->id)
->assertRedirect(route('operator.queue'));
expect($ticket->fresh()->team_id)->toBe($otherTeam->id);
});
test('an operator who reassigns a ticket to their own team is not redirected', function () {
seedStatusesAndPriorities();
$operator = operatorUser('redirect-5@example.com');
$myTeam = Team::query()->create(['name' => 'Infrastruktura']);
$operator->teams()->attach($myTeam->id);
$ticket = makeTicket(['number' => '6005', 'team_id' => null]);
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->call('setTeam', (string) $myTeam->id)
->assertNoRedirect();
expect($ticket->fresh()->team_id)->toBe($myTeam->id);
});
test('a direct link to a since-deleted ticket redirects an operator to their queue instead of a 404', function () {
seedStatusesAndPriorities();
$operator = operatorUser('redirect-6@example.com');
$ticket = makeTicket(['number' => '6006']);
$url = route('operator.ticket', $ticket);
$ticket->delete();
$this->actingAs($operator)->get($url)->assertRedirect(route('operator.queue'));
});

View File

@@ -0,0 +1,45 @@
<?php
use App\Livewire\Client\TicketShow as ClientTicketShow;
use App\Livewire\NotificationBell;
use App\Livewire\Operator\Queue;
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
use App\Models\User;
use App\Support\Settings;
use Livewire\Livewire;
test('operator ticket view renders the configured interval and a working click-to-refresh handler', function () {
seedStatusesAndPriorities();
Settings::set('refresh_ticket_view_seconds', '45');
$ticket = makeTicket(['number' => '7001']);
Livewire::actingAs(operatorUser())->test(OperatorTicketShow::class, ['ticket' => $ticket])
->assertSeeHtml('remaining: 45, total: 45')
->assertSeeHtml('remaining = total; $wire.refreshTicketData()');
});
test('client ticket view renders the configured interval and a working click-to-refresh handler', function () {
seedStatusesAndPriorities();
Settings::set('refresh_ticket_view_seconds', '50');
$client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'client-refresh@example.com', 'roles' => ['client']]);
$ticket = makeTicket(['number' => '7002', 'customer_id' => $client->id]);
Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])
->assertSeeHtml('remaining: 50, total: 50')
->assertSeeHtml('remaining = total; $wire.refreshTicketData()');
});
test('operator queue renders the configured interval and a working click-to-refresh handler', function () {
Settings::set('refresh_queue_seconds', '75');
Livewire::actingAs(operatorUser())->test(Queue::class)
->assertSeeHtml('remaining: 75, total: 75')
->assertSeeHtml('remaining = total; $wire.refreshQueue()');
});
test('notification bell polls at the configured interval', function () {
Settings::set('refresh_notifications_seconds', '15');
Livewire::actingAs(operatorUser())->test(NotificationBell::class)
->assertSeeHtml('wire:poll.15s="$refresh"');
});

View File

@@ -0,0 +1,43 @@
<?php
use App\Livewire\Admin\Panel;
use App\Support\Settings;
use Livewire\Livewire;
test('admin can save custom refresh and schedule intervals from the Konfiguracja tab', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'config')
->set('systemConfig.refreshTicketViewSeconds', '45')
->set('systemConfig.refreshQueueSeconds', '90')
->set('systemConfig.refreshNotificationsSeconds', '20')
->set('systemConfig.scheduleSlaCheckMinutes', '10')
->set('systemConfig.scheduleAutomationRulesMinutes', '10')
->set('systemConfig.scheduleImapFetchMinutes', '2')
->set('systemConfig.scheduleAiAutomationMinutes', '2')
->call('saveSystemConfig')
->assertOk();
expect(Settings::get('refresh_ticket_view_seconds'))->toBe('45');
expect(Settings::get('refresh_queue_seconds'))->toBe('90');
expect(Settings::get('refresh_notifications_seconds'))->toBe('20');
expect(Settings::get('schedule_sla_check_minutes'))->toBe('10');
expect(Settings::get('schedule_automation_rules_minutes'))->toBe('10');
expect(Settings::get('schedule_imap_fetch_minutes'))->toBe('2');
expect(Settings::get('schedule_ai_automation_minutes'))->toBe('2');
});
test('saving a zero or negative interval clamps it to 1', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'config')
->set('systemConfig.refreshQueueSeconds', '0')
->set('systemConfig.scheduleImapFetchMinutes', '-3')
->call('saveSystemConfig')
->assertOk();
expect(Settings::get('refresh_queue_seconds'))->toBe('1');
expect(Settings::get('schedule_imap_fetch_minutes'))->toBe('1');
});

View File

@@ -0,0 +1,36 @@
<?php
use App\Models\Priority;
use App\Models\Ticket;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
test('the scheduled command runs both triage and summary in one pass', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Settings::set('ai_triage_set_priority', '1');
Settings::set('ai_summary_enabled', '1');
Priority::query()->create(['key' => 'high', 'label' => 'Wysoki', 'color' => '#000', 'sort_order' => 1]);
Ticket::query()->create([
'number' => '900100',
'email' => 'client@example.com',
'name' => 'Test Client',
'subject' => 'Test',
'body' => 'Treść zgłoszenia.',
'status_key' => 'new',
'priority_key' => 'medium',
'custom_fields' => [],
]);
Http::fake(['ai.test/*' => Http::response(['choices' => [
['message' => ['content' => '{"category": null, "subcategory": null, "subject": null, "priority": "high"}']],
]])]);
$this->artisan('ai:run-ticket-automation')
->assertSuccessful()
->expectsOutputToContain('AI triage: scanned 1, changed 1, failed 0.')
->expectsOutputToContain('AI summaries: scanned 1,');
});

View File

@@ -0,0 +1,42 @@
<?php
use App\Support\Settings;
use Illuminate\Support\Carbon;
test('dueEveryMinutes is true on a minute that is a multiple of the configured interval', function () {
Carbon::setTestNow(Carbon::parse('2026-01-01 12:14:00'));
Settings::set('schedule_imap_fetch_minutes', '7');
expect(Settings::dueEveryMinutes('schedule_imap_fetch_minutes', 5))->toBeTrue();
Carbon::setTestNow();
});
test('dueEveryMinutes is false on a minute that is not a multiple of the configured interval', function () {
Carbon::setTestNow(Carbon::parse('2026-01-01 12:15:00'));
Settings::set('schedule_imap_fetch_minutes', '7');
expect(Settings::dueEveryMinutes('schedule_imap_fetch_minutes', 5))->toBeFalse();
Carbon::setTestNow();
});
test('dueEveryMinutes falls back to the given default when unset', function () {
Carbon::setTestNow(Carbon::parse('2026-01-01 12:15:00'));
expect(Settings::dueEveryMinutes('schedule_ai_automation_minutes', 5))->toBeTrue();
Carbon::setTestNow();
});
test('dueEveryMinutes clamps a zero or negative stored value to 1, so it is always due', function () {
Carbon::setTestNow(Carbon::parse('2026-01-01 12:13:00'));
Settings::set('schedule_sla_check_minutes', '0');
expect(Settings::dueEveryMinutes('schedule_sla_check_minutes', 15))->toBeTrue();
Settings::set('schedule_sla_check_minutes', '-4');
expect(Settings::dueEveryMinutes('schedule_sla_check_minutes', 15))->toBeTrue();
Carbon::setTestNow();
});

View File

@@ -0,0 +1,126 @@
<?php
use App\Models\Ticket;
use App\Services\TicketAiSummaryService;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
function enableAiSummary(): void
{
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Settings::set('ai_summary_enabled', '1');
}
function summaryTicket(array $overrides = []): Ticket
{
return Ticket::query()->create(array_merge([
'number' => (string) random_int(100000, 999999),
'email' => 'client@example.com',
'name' => 'Test Client',
'subject' => 'Problem z drukarką',
'body' => 'Drukarka nie działa od rana.',
'status_key' => 'new',
'priority_key' => 'medium',
'custom_fields' => [],
], $overrides));
}
function fakeAiSummaryChat(string $content): void
{
Http::fake(['ai.test/*' => Http::response(['choices' => [['message' => ['content' => $content]]]])]);
}
test('generates and stores a summary + suggested action for a fresh ticket', function () {
enableAiSummary();
fakeAiSummaryChat('{"summary": "Klient zgłasza awarię drukarki.", "suggested_action": "Poproś o model drukarki."}');
$ticket = summaryTicket();
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
$ticket->refresh();
expect($ticket->ai_summary)->toBe('Klient zgłasza awarię drukarki.');
expect($ticket->ai_suggested_action)->toBe('Poproś o model drukarki.');
expect($ticket->ai_summary_generated_at)->not->toBeNull();
});
test('a ticket with a newer message than its last summary is picked up again', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Pierwsza wiadomość.']);
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => now()->subDay()]);
// A message created "now" postdates the day-old summary.
$ticket->messages()->create(['author_name' => 'Operator', 'body' => 'Nowa odpowiedź operatora.']);
fakeAiSummaryChat('{"summary": "Zaktualizowane podsumowanie.", "suggested_action": null}');
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 1, 'updated' => 1, 'failed' => 0]);
expect($ticket->refresh()->ai_summary)->toBe('Zaktualizowane podsumowanie.');
});
test('a ticket whose summary is already newer than its latest message is not reprocessed', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
Http::fake();
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
Http::assertNothingSent();
});
test('an unrelated update() that only touches updated_at does not trigger re-summarization', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Jedyna wiadomość.']);
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
// Simulates e.g. a priority/status change touching tickets.updated_at
// without any new ticket_messages row.
$ticket->update(['priority_key' => 'high']);
Http::fake();
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
Http::assertNothingSent();
});
test('a malformed AI response leaves the previous summary untouched and keeps the ticket stale', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->update(['ai_summary' => 'stare podsumowanie', 'ai_summary_generated_at' => null]);
fakeAiSummaryChat('to nie jest JSON');
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 1, 'updated' => 0, 'failed' => 1]);
$ticket->refresh();
expect($ticket->ai_summary)->toBe('stare podsumowanie');
expect($ticket->ai_summary_generated_at)->toBeNull();
});
test('ai_summary_enabled=0 makes no AI calls', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Settings::set('ai_summary_enabled', '0');
summaryTicket();
Http::fake();
$totals = app(TicketAiSummaryService::class)->run();
expect($totals)->toBe(['scanned' => 0, 'updated' => 0, 'failed' => 0]);
Http::assertNothingSent();
});

View File

@@ -0,0 +1,245 @@
<?php
use App\Models\Category;
use App\Models\Priority;
use App\Models\Subcategory;
use App\Models\Ticket;
use App\Services\TicketAiTriageService;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
function enableAiForTriage(): void
{
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
}
/** @return array{cat1: Category, sub1: Subcategory, sub2: Subcategory, cat2: Category} */
function seedCategoriesForTriage(): array
{
$cat1 = Category::query()->create(['name' => 'IT-Pomoc']);
$sub1 = $cat1->subcategories()->create(['name' => 'Drukarki i skanery']);
$sub2 = $cat1->subcategories()->create(['name' => 'VPN']);
$cat2 = Category::query()->create(['name' => 'Zamówienia']);
$cat2->subcategories()->create(['name' => 'Nowe zamówienie']);
return compact('cat1', 'sub1', 'sub2', 'cat2');
}
function seedPrioritiesForTriage(): void
{
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#000', 'sort_order' => 4]);
Priority::query()->create(['key' => 'medium', 'label' => 'Średni', 'color' => '#000', 'sort_order' => 3]);
Priority::query()->create(['key' => 'high', 'label' => 'Wysoki', 'color' => '#000', 'sort_order' => 2]);
Priority::query()->create(['key' => 'critical', 'label' => 'Krytyczny', 'color' => '#000', 'sort_order' => 1]);
}
function triageTicket(array $overrides = []): Ticket
{
return Ticket::query()->create(array_merge([
'number' => (string) random_int(100000, 999999),
'email' => 'client@example.com',
'name' => 'Test Client',
'subject' => 'Problem z drukarką',
'body' => 'Drukarka HP w biurze nie drukuje od rana, pokazuje błąd papieru mimo że jest papier.',
'status_key' => 'new',
'priority_key' => 'medium',
'custom_fields' => [],
], $overrides));
}
function fakeAiChat(string $content): void
{
Http::fake(['ai.test/*' => Http::response(['choices' => [['message' => ['content' => $content]]]])]);
}
test('category_when_missing assigns category+subcategory to a fully unclassified ticket', function () {
enableAiForTriage();
['sub1' => $sub1] = seedCategoriesForTriage();
Settings::set('ai_triage_category_when_missing', '1');
fakeAiChat('{"category": "IT-Pomoc", "subcategory": "Drukarki i skanery", "subject": null, "priority": null}');
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => null]);
$totals = app(TicketAiTriageService::class)->run();
expect($totals)->toBe(['scanned' => 1, 'changed' => 1, 'failed' => 0]);
$ticket->refresh();
expect($ticket->subcategory_id)->toBe($sub1->id);
expect($ticket->category_id)->toBeNull();
expect($ticket->ai_triaged_at)->not->toBeNull();
expect($ticket->histories()->pluck('text')->all())->toBe([
'Kategoria zmieniona na: IT-Pomoc / Drukarki i skanery',
'Automatyzacja: klasyfikacja AI',
]);
});
test('subcategory_when_category_only restricts the prompt to the ticket\'s existing category and picks within it', function () {
enableAiForTriage();
['cat1' => $cat1, 'sub2' => $sub2] = seedCategoriesForTriage();
Settings::set('ai_triage_subcategory_when_category_only', '1');
fakeAiChat('{"category": null, "subcategory": "VPN", "subject": null, "priority": null}');
$ticket = triageTicket(['category_id' => $cat1->id, 'subcategory_id' => null]);
app(TicketAiTriageService::class)->run();
$ticket->refresh();
expect($ticket->subcategory_id)->toBe($sub2->id);
expect($ticket->category_id)->toBeNull();
Http::assertSent(function ($request) {
$content = $request['messages'][0]['content'] ?? '';
return str_contains($content, 'Drukarki i skanery')
&& str_contains($content, 'VPN')
&& ! str_contains($content, 'Nowe zamówienie');
});
});
test('recheck_categorized moves an already-categorized ticket to a better-matching subcategory', function () {
enableAiForTriage();
['sub1' => $sub1, 'sub2' => $sub2] = seedCategoriesForTriage();
Settings::set('ai_triage_recheck_categorized', '1');
fakeAiChat('{"category": null, "subcategory": "VPN", "subject": null, "priority": null}');
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub1->id]);
app(TicketAiTriageService::class)->run();
expect($ticket->refresh()->subcategory_id)->toBe($sub2->id);
});
test('recheck_categorized confirming the existing subcategory leaves no history and no changed count', function () {
enableAiForTriage();
['sub1' => $sub1] = seedCategoriesForTriage();
Settings::set('ai_triage_recheck_categorized', '1');
fakeAiChat('{"category": null, "subcategory": "Drukarki i skanery", "subject": null, "priority": null}');
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub1->id]);
$totals = app(TicketAiTriageService::class)->run();
expect($totals)->toBe(['scanned' => 1, 'changed' => 0, 'failed' => 0]);
expect($ticket->refresh()->subcategory_id)->toBe($sub1->id);
expect($ticket->histories()->count())->toBe(0);
expect($ticket->ai_triaged_at)->not->toBeNull();
});
test('fix_subject rewrites an unclear subject', function () {
enableAiForTriage();
Settings::set('ai_triage_fix_subject', '1');
fakeAiChat('{"category": null, "subcategory": null, "subject": "Awaria drukarki HP w biurze", "priority": null}');
$ticket = triageTicket(['subject' => 'pomocy!!!']);
app(TicketAiTriageService::class)->run();
$ticket->refresh();
expect($ticket->subject)->toBe('Awaria drukarki HP w biurze');
expect($ticket->histories()->pluck('text')->all())->toBe([
'Temat zmieniony na: „Awaria drukarki HP w biurze”',
'Automatyzacja: klasyfikacja AI',
]);
});
test('set_priority assigns a priority based on content', function () {
enableAiForTriage();
seedPrioritiesForTriage();
Settings::set('ai_triage_set_priority', '1');
fakeAiChat('{"category": null, "subcategory": null, "subject": null, "priority": "high"}');
$ticket = triageTicket(['priority_key' => 'medium']);
app(TicketAiTriageService::class)->run();
$ticket->refresh();
expect($ticket->priority_key)->toBe('high');
expect($ticket->histories()->pluck('text')->all())->toBe([
'Priorytet zmieniony na: Wysoki',
'Automatyzacja: klasyfikacja AI',
]);
});
test('a multi-field change writes one mechanical line per changed field plus a single attribution line', function () {
enableAiForTriage();
seedPrioritiesForTriage();
['sub1' => $sub1, 'sub2' => $sub2] = seedCategoriesForTriage();
Settings::set('ai_triage_recheck_categorized', '1');
Settings::set('ai_triage_set_priority', '1');
// ticket currently sits under sub2 (VPN); AI moves it to sub1 (Drukarki i
// skanery) AND bumps priority — both fields change in the same pass.
fakeAiChat('{"category": null, "subcategory": "Drukarki i skanery", "subject": null, "priority": "critical"}');
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => $sub2->id, 'priority_key' => 'medium']);
app(TicketAiTriageService::class)->run();
$ticket->refresh();
expect($ticket->subcategory_id)->toBe($sub1->id);
expect($ticket->priority_key)->toBe('critical');
expect($ticket->histories()->pluck('text')->all())->toBe([
'Kategoria zmieniona na: IT-Pomoc / Drukarki i skanery',
'Priorytet zmieniony na: Krytyczny',
'Automatyzacja: klasyfikacja AI',
]);
});
test('idempotency: a second run does not rescan an already-triaged ticket', function () {
enableAiForTriage();
Settings::set('ai_triage_set_priority', '1');
seedPrioritiesForTriage();
fakeAiChat('{"category": null, "subcategory": null, "subject": null, "priority": "high"}');
triageTicket();
app(TicketAiTriageService::class)->run();
$second = app(TicketAiTriageService::class)->run();
expect($second)->toBe(['scanned' => 0, 'changed' => 0, 'failed' => 0]);
});
test('a malformed AI response changes nothing but still stamps ai_triaged_at and counts as failed', function () {
enableAiForTriage();
Settings::set('ai_triage_set_priority', '1');
seedPrioritiesForTriage();
fakeAiChat('to nie jest JSON');
$ticket = triageTicket(['priority_key' => 'medium']);
$totals = app(TicketAiTriageService::class)->run();
expect($totals)->toBe(['scanned' => 1, 'changed' => 0, 'failed' => 1]);
$ticket->refresh();
expect($ticket->priority_key)->toBe('medium');
expect($ticket->ai_triaged_at)->not->toBeNull();
});
test('a hallucinated category name is silently dropped rather than applied', function () {
enableAiForTriage();
seedCategoriesForTriage();
Settings::set('ai_triage_category_when_missing', '1');
fakeAiChat('{"category": "Kategoria Zmyślona Przez Model", "subcategory": null, "subject": null, "priority": null}');
$ticket = triageTicket(['category_id' => null, 'subcategory_id' => null]);
$totals = app(TicketAiTriageService::class)->run();
expect($totals['changed'])->toBe(0);
$ticket->refresh();
expect($ticket->category_id)->toBeNull();
expect($ticket->subcategory_id)->toBeNull();
});
test('with every triage toggle off, run makes no AI calls at all', function () {
enableAiForTriage();
triageTicket(['category_id' => null, 'subcategory_id' => null]);
Http::fake();
$totals = app(TicketAiTriageService::class)->run();
expect($totals)->toBe(['scanned' => 0, 'changed' => 0, 'failed' => 0]);
Http::assertNothingSent();
});

View File

@@ -2,9 +2,9 @@
Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu: Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu:
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje
(reguły SLA, wyzwalacze), użytkownicy i zespoły, treści (szablony, szybkie (reguły SLA, wyzwalacze, automatyzacja AI zgłoszeń), użytkownicy i zespoły,
akcje, e-maile), wygląd/branding oraz integracje (LDAP, poczta SMTP/IMAP, treści (szablony, szybkie akcje, e-maile), wygląd/branding oraz integracje
BookStack, API). (LDAP, poczta SMTP/IMAP, BookStack, AI, API).
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
panelu Administratora przez menu profilu (prawy górny róg). panelu Administratora przez menu profilu (prawy górny róg).
@@ -173,6 +173,14 @@ ważne + treść HTML).
numerem przestaje działać. REST API (`/api/v1/...`) tego nie dotyczy — numerem przestaje działać. REST API (`/api/v1/...`) tego nie dotyczy —
tam zgłoszenia zawsze identyfikuje się po `id`, niezależnie od tego tam zgłoszenia zawsze identyfikuje się po `id`, niezależnie od tego
ustawienia. ustawienia.
- **Częstotliwość odświeżania i harmonogramu** — dwie grupy pól:
- **Odświeżanie w przeglądarce** — co ile sekund odświeża się (poza
aktualizacjami na żywo) widok zgłoszenia (klient i operator), lista
zgłoszeń operatora, i dzwonek powiadomień.
- **Zadania w tle** — co ile minut uruchamiają się sprawdzanie naruszeń
SLA, reguły automatyzacji, pobieranie e-maili (IMAP) i automatyzacja AI
zgłoszeń. Zmiana obowiązuje od najbliższego tyknięcia harmonogramu (co
minutę), bez potrzeby restartu czy redeployu.
SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **Poczta**, przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **Poczta**,
@@ -248,7 +256,12 @@ razem, zamiast być rozrzucone po różnych zakładkach.
zapytania kończą się błędem 403 mimo poprawnych danych logowania. zapytania kończą się błędem 403 mimo poprawnych danych logowania.
- **Weryfikuj certyfikat SSL** — włączone domyślnie; wyłącz tylko jeśli - **Weryfikuj certyfikat SSL** — włączone domyślnie; wyłącz tylko jeśli
instancja BookStack korzysta z certyfikatu self-signed/prywatnego CA. instancja BookStack korzysta z certyfikatu self-signed/prywatnego CA.
- **Przeszukuj** — strony i książki / tylko strony / tylko książki. - **Przeszukuj** — trzy niezależne checkboxy: Książki / Strony / Rozdziały
(dowolna kombinacja).
- **Szukaj po** — słowa kluczowe w nazwie / tagi / oba. Wyszukiwanie po
tagach dopasowuje artykuły oznaczone w BookStacku tagiem o nazwie zgodnej
z podkategorią zgłoszenia (np. tag „Drukarki i skanery”) — patrz
automatyczne tagowanie niżej, żeby nie robić tego ręcznie dla całej wiki.
- **Dozwolone półki** — dwie **niezależne** checklisty: jedna dla podpowiedzi - **Dozwolone półki** — dwie **niezależne** checklisty: jedna dla podpowiedzi
przy tworzeniu zgłoszenia (klient, operator, formularz gościa na stronie przy tworzeniu zgłoszenia (klient, operator, formularz gościa na stronie
głównej), druga dla panelu bocznego operatora na widoku istniejącego głównej), druga dla panelu bocznego operatora na widoku istniejącego
@@ -263,6 +276,43 @@ razem, zamiast być rozrzucone po różnych zakładkach.
- Przycisk **„Testuj połączenie”** sprawdza niezapisane wartości formularza - Przycisk **„Testuj połączenie”** sprawdza niezapisane wartości formularza
(analogicznie do LDAP/SMTP) i pokazuje dokładny komunikat błędu z (analogicznie do LDAP/SMTP) i pokazuje dokładny komunikat błędu z
BookStacka, jeśli połączenie się nie powiedzie. BookStacka, jeśli połączenie się nie powiedzie.
- **Automatyczne tagowanie treści (AI)** — przyciski **„Otaguj nową
treść”** (pomija już otagowane pozycje) i **„Otaguj wszystko ponownie”**
(klasyfikuje od nowa całą wiki) używają integracji AI (niżej) do
otagowania każdej książki/strony/rozdziału nazwami pasujących podkategorii
helpdesku — bez tego wyszukiwanie „po tagach” wyżej nic nie znajdzie.
Wymaga wcześniej skonfigurowanej i włączonej integracji AI. Dostępne też
z linii poleceń: `php artisan bookstack:tag-content` (`--dry-run`,
`--force`, `--limit=N`).
- **Integracja AI** — opcjonalna, **domyślnie wyłączona**, ogólne połączenie z
dostawcą modelu językowego (nie tylko dla BookStacka — patrz
„Automatyzacja AI dla zgłoszeń” niżej). Pola: **adres API** (dowolny
dostawca kompatybilny z OpenAI — np. Groq, OpenAI, lokalny Ollama),
**klucz API** (opcjonalny — zostaw puste dla lokalnych instancji bez
autoryzacji), **model**, **weryfikacja SSL** (wyłącz tylko dla instancji z
certyfikatem self-signed, np. lokalny Ollama). Przycisk **„Testuj
połączenie”** jak przy pozostałych integracjach.
- **Automatyzacja AI dla zgłoszeń** — wymaga włączonej integracji AI powyżej.
Zgłoszenia przetwarzane są w tle, cyklicznie (`ai:run-ticket-automation`,
interwał konfigurowalny w Konfiguracji) — nie synchronicznie przy
składaniu zgłoszenia, więc nie spowalnia to klienta.
- **Automatyczna kategoryzacja** — pięć niezależnych przełączników: przypisz
kategorię/podkategorię, gdy zgłoszenie nie ma żadnej; dobierz podkategorię,
gdy ma tylko kategorię; zweryfikuj i ewentualnie popraw już przypisaną
podkategorię; popraw temat zgłoszenia, jeśli jest niejasny; ustaw
priorytet na podstawie treści. Każde zgłoszenie jest sprawdzane **tylko
raz** — zmiany trafiają do historii zgłoszenia z adnotacją
„Automatyzacja: klasyfikacja AI” (patrz „Historia” w przewodniku
operatora).
- **Podsumowanie AI dla operatora** — osobny przełącznik generuje krótkie
podsumowanie + sugerowaną kolejną akcję dla **każdego** zgłoszenia,
widoczne tylko operatorowi (panel boczny „Podsumowanie AI” w widoku
zgłoszenia), odświeżane automatycznie, gdy w wątku pojawi się nowa
wiadomość.
- **Prompt systemowy podsumowania** — edytowalne pole tekstowe z gotową
wartością domyślną i przyciskiem **„Resetuj”**.
## API ## API

View File

@@ -54,8 +54,9 @@ Otwórz dowolne zgłoszenie, by zobaczyć:
Wszystko na tej stronie aktualizuje się **na żywo** — jeśli operator odpowie Wszystko na tej stronie aktualizuje się **na żywo** — jeśli operator odpowie
albo zmieni status/przypisanie, zobaczysz to bez odświeżania strony. Mały albo zmieni status/przypisanie, zobaczysz to bez odświeżania strony. Mały
licznik przy przycisku „Wróć do listy” to niezależny, okresowy fallback (co licznik przy przycisku „Wróć do listy” to niezależny, okresowy fallback
ok. 30 s), na wypadek gdyby połączenie w tle się zerwało. (domyślnie co ok. 30 s), na wypadek gdyby połączenie w tle się zerwało
kliknięcie licznika od razu odświeża stronę i resetuje odliczanie.
## Odpowiadanie ## Odpowiadanie

View File

@@ -60,16 +60,24 @@ konkretnej wiadomości w wątku zgłoszenia.
Kolejka aktualizuje się **na żywo** — nowe zgłoszenie, zmiana statusu/priorytetu/ Kolejka aktualizuje się **na żywo** — nowe zgłoszenie, zmiana statusu/priorytetu/
przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok
przycisku „Kolumny” widać mały licznik odliczający do zera — to niezależny od przycisku „Kolumny” widać mały licznik odliczający do zera — to niezależny od
połączenia na żywo, okresowy fallback (co ok. 60 s), na wypadek gdyby połączenia na żywo, okresowy fallback (interwał ustawia administrator w
połączenie sieciowe w tle się zerwało. Konfiguracji, domyślnie co ok. 60 s), na wypadek gdyby połączenie sieciowe w
tle się zerwało. **Licznik jest też klikalny** — kliknięcie od razu odświeża
listę i resetuje odliczanie, zamiast czekać na naturalny koniec.
## Praca ze zgłoszeniem ## Praca ze zgłoszeniem
Widok zgłoszenia też aktualizuje się na żywo — nowa wiadomość klienta pojawia Widok zgłoszenia też aktualizuje się na żywo — nowa wiadomość klienta pojawia
się od razu (bez odświeżania), podobnie jak zmiana statusu/priorytetu/zespołu się od razu (bez odświeżania), podobnie jak zmiana statusu/priorytetu/zespołu
zrobiona przez innego operatora albo przez regułę automatyzacji SLA. Licznik zrobiona przez innego operatora, przez regułę automatyzacji SLA albo przez
przy przycisku „Wróć do listy” to taki sam fallbackowy zegar jak w kolejce automatyzację AI (patrz „Historia” niżej). Licznik przy przycisku „Wróć do
(co ok. 30 s). listy” to taki sam fallbackowy, klikalny zegar jak w kolejce (domyślnie co
ok. 30 s, konfigurowalny przez administratora).
Jeśli zgłoszenie, które akurat oglądasz, zostanie usunięte przez kogoś
innego, albo trafi (przez zmianę zespołu) poza Twój zakres widoczności —
zostaniesz automatycznie przeniesiony z powrotem do swojej kolejki, zamiast
zobaczyć błąd.
W widoku pojedynczego zgłoszenia: W widoku pojedynczego zgłoszenia:
@@ -78,7 +86,10 @@ W widoku pojedynczego zgłoszenia:
„Obserwowane zgłoszenia” w Twoich preferencjach powiadomień „Obserwowane zgłoszenia” w Twoich preferencjach powiadomień
(`/settings/notifications`). (`/settings/notifications`).
- **Zmiana statusu / priorytetu / zespołu / przypisanego operatora** — z listy - **Zmiana statusu / priorytetu / zespołu / przypisanego operatora** — z listy
rozwijanej; „Przypisz do mnie” to skrót jednym kliknięciem. rozwijanej; „Przypisz do mnie” to skrót jednym kliknięciem. Zespół można
zmienić na **dowolny**, nie tylko taki, do którego sam należysz — jeśli
przeniesiesz zgłoszenie do zespołu spoza swojego zakresu widoczności,
zostaniesz automatycznie przeniesiony do swojej kolejki.
- **Odpowiedź publiczna** — widoczna dla klienta; można wybrać **szablon - **Odpowiedź publiczna** — widoczna dla klienta; można wybrać **szablon
odpowiedzi** (wstawia gotowy tekst do edycji) i wysłać razem ze zmianą statusu odpowiedzi** (wstawia gotowy tekst do edycji) i wysłać razem ze zmianą statusu
jedną **szybką akcją** (np. „Wyślij i zamknij”) zamiast dwóch osobnych kroków. jedną **szybką akcją** (np. „Wyślij i zamknij”) zamiast dwóch osobnych kroków.
@@ -91,6 +102,10 @@ W widoku pojedynczego zgłoszenia:
boczny podpowiada artykuły pasujące do kategorii/podkategorii zgłoszenia; boczny podpowiada artykuły pasujące do kategorii/podkategorii zgłoszenia;
kliknięcie otwiera artykuł, przycisk „Kopiuj link" kopiuje adres bez kliknięcie otwiera artykuł, przycisk „Kopiuj link" kopiuje adres bez
wychodzenia ze zgłoszenia (np. do wklejenia w odpowiedzi). wychodzenia ze zgłoszenia (np. do wklejenia w odpowiedzi).
- **Podsumowanie AI** (jeśli administrator włączył automatyzację AI
zgłoszeń) — panel boczny widoczny tylko w panelu operatora, pokazuje krótkie
podsumowanie sprawy i sugerowaną kolejną akcję; odświeża się samo, gdy w
wątku pojawi się nowa wiadomość — nie trzeba go ręcznie odświeżać.
- **Ocena obsługi** — jeśli klient już ocenił zgłoszenie, ocena (gwiazdki + - **Ocena obsługi** — jeśli klient już ocenił zgłoszenie, ocena (gwiazdki +
ewentualny komentarz) pokazuje się w panelu bocznym, tylko do odczytu. ewentualny komentarz) pokazuje się w panelu bocznym, tylko do odczytu.
- **Licznik czasu pracy** — start/stop/reset przy zgłoszeniu; czas zapisuje się - **Licznik czasu pracy** — start/stop/reset przy zgłoszeniu; czas zapisuje się
@@ -100,9 +115,12 @@ W widoku pojedynczego zgłoszenia:
po jego zamknięciu; wcześniej naliczony czas można wciąż ręcznie skorygować. po jego zamknięciu; wcześniej naliczony czas można wciąż ręcznie skorygować.
- **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe; - **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
zmiana kategorii może wysłać powiadomienie do klienta. zmiana kategorii może wysłać powiadomienie do klienta.
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z - **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie,
datą; wpis zaczynający się od „Automatyzacja: …” oznacza, że zmianę wykonała kategoria/podkategoria, temat) z datą; wpis „Automatyzacja: nazwa reguły”
reguła automatyzacji SLA (Admin > Automatyzacja SLA), nie operator ręcznie. oznacza regułę automatyzacji SLA (Admin > Automatyzacja SLA), a
„Automatyzacja: klasyfikacja AI” — automatyczną kategoryzację/priorytet
ustawione przez integrację AI (Admin > Integracje) — w obu przypadkach nie
operator ręcznie.
## Statystyki (`/operator/stats`) ## Statystyki (`/operator/stats`)