Compare commits
2 Commits
v1.2.0
...
1df697afce
| Author | SHA1 | Date | |
|---|---|---|---|
| 1df697afce | |||
| 313e01ad24 |
236
ARCHITECTURE.md
236
ARCHITECTURE.md
@@ -113,6 +113,29 @@ typed against `ModelNotFoundException` itself would never match) that
|
|||||||
redirects to `operator.queue`/`client.dashboard` instead, for any
|
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 30–60 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.
|
||||||
|
|||||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -3,6 +3,75 @@
|
|||||||
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.2] - 2026-07-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Subcategories can now be reordered within their category — up/down arrow
|
||||||
|
buttons next to "Edytuj" in Admin > Kategorie, right beside each
|
||||||
|
subcategory's edit/delete buttons. The order set there is used everywhere a
|
||||||
|
subcategory list is shown (subcategory pickers, admin listings, etc.), not
|
||||||
|
just the admin panel itself.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
43
CLAUDE.md
43
CLAUDE.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
50
README.md
50
README.md
@@ -40,13 +40,19 @@ 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. Subcategories within a category can be reordered with
|
||||||
|
up/down arrows in Admin > Kategorie; the order set there is what clients/operators
|
||||||
|
see everywhere a subcategory picker is shown.
|
||||||
- **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 +139,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 +221,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
|
||||||
|
|||||||
48
install.md
48
install.md
@@ -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
|
||||||
|
|||||||
@@ -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.2
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
|
|||||||
35
src/app/Console/Commands/BookstackTagContent.php
Normal file
35
src/app/Console/Commands/BookstackTagContent.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/Console/Commands/RunAiTicketAutomation.php
Normal file
35
src/app/Console/Commands/RunAiTicketAutomation.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -270,10 +316,55 @@ class Panel extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Category::query()->find($categoryId)?->subcategories()->create(['name' => $name]);
|
$category = Category::query()->find($categoryId);
|
||||||
|
|
||||||
|
if (! $category) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextPosition = ((int) $category->subcategories()->max('sort_order')) + 1;
|
||||||
|
$category->subcategories()->create(['name' => $name, 'sort_order' => $nextPosition]);
|
||||||
$this->newSubNames[$categoryId] = '';
|
$this->newSubNames[$categoryId] = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function moveSubcategoryUp(int $subcategoryId): void
|
||||||
|
{
|
||||||
|
$this->swapSubcategoryOrder($subcategoryId, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function moveSubcategoryDown(int $subcategoryId): void
|
||||||
|
{
|
||||||
|
$this->swapSubcategoryOrder($subcategoryId, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function swapSubcategoryOrder(int $subcategoryId, int $direction): void
|
||||||
|
{
|
||||||
|
$sub = Subcategory::query()->find($subcategoryId);
|
||||||
|
|
||||||
|
if (! $sub) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siblings = Subcategory::query()
|
||||||
|
->where('category_id', $sub->category_id)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$idx = $siblings->search(fn (Subcategory $s) => $s->id === $sub->id);
|
||||||
|
$swapIdx = $idx + $direction;
|
||||||
|
|
||||||
|
if ($idx === false || $swapIdx < 0 || $swapIdx >= $siblings->count()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$other = $siblings[$swapIdx];
|
||||||
|
[$order, $otherOrder] = [$sub->sort_order, $other->sort_order];
|
||||||
|
|
||||||
|
$sub->update(['sort_order' => $otherOrder]);
|
||||||
|
$other->update(['sort_order' => $order]);
|
||||||
|
}
|
||||||
|
|
||||||
public function openSubcategoryEditForm(int $subcategoryId): void
|
public function openSubcategoryEditForm(int $subcategoryId): void
|
||||||
{
|
{
|
||||||
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);
|
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);
|
||||||
@@ -1348,6 +1439,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 +1538,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 +1591,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 +1618,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 --------
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
public function subcategories(): HasMany
|
public function subcategories(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Subcategory::class);
|
return $this->hasMany(Subcategory::class)->orderBy('sort_order');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['category_id', 'name', 'description', 'default_priority_key'])]
|
#[Fillable(['category_id', 'name', 'description', 'default_priority_key', 'sort_order'])]
|
||||||
class Subcategory extends Model
|
class Subcategory extends Model
|
||||||
{
|
{
|
||||||
public function category(): BelongsTo
|
public function category(): BelongsTo
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
src/app/Services/AiClient.php
Normal file
95
src/app/Services/AiClient.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
*/
|
*/
|
||||||
|
|||||||
260
src/app/Services/BookStackContentTagger.php
Normal file
260
src/app/Services/BookStackContentTagger.php
Normal 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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/app/Services/TicketAiSummaryService.php
Normal file
139
src/app/Services/TicketAiSummaryService.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/app/Services/TicketAiTriageService.php
Normal file
311
src/app/Services/TicketAiTriageService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('subcategories', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('sort_order')->default(0)->after('default_priority_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (DB::table('subcategories')->orderBy('category_id')->orderBy('id')->get() as $position => $sub) {
|
||||||
|
DB::table('subcategories')->where('id', $sub->id)->update(['sort_order' => $position]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('subcategories', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('sort_order');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -85,7 +85,7 @@ $tabGroups = [
|
|||||||
<table class="table" style="margin:0;border-top:none">
|
<table class="table" style="margin:0;border-top:none">
|
||||||
<thead><tr><th>Podkategoria</th><th></th></tr></thead>
|
<thead><tr><th>Podkategoria</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach ($cat->subcategories as $sub)
|
@foreach ($cat->subcategories as $i => $sub)
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;flex-direction:column;gap:2px">
|
<div style="display:flex;flex-direction:column;gap:2px">
|
||||||
@@ -97,7 +97,9 @@ $tabGroups = [
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
<div style="display:flex;gap:6px;justify-content:flex-end;align-items:center">
|
||||||
|
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryUp({{ $sub->id }})" @disabled($i === 0) style="padding:2px;width:24px;height:24px" title="Przesuń wyżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span></button>
|
||||||
|
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryDown({{ $sub->id }})" @disabled($i === $cat->subcategories->count() - 1) style="padding:2px;width:24px;height:24px" title="Przesuń niżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span></button>
|
||||||
<button class="btn btn-ghost" type="button" wire:click="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button>
|
<button class="btn btn-ghost" type="button" wire:click="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button>
|
||||||
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
|
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -638,6 +640,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 +722,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 → 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 → 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 +800,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 +834,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 są 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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
61
src/tests/Feature/AdminAiIntegrationConfigTest.php
Normal file
61
src/tests/Feature/AdminAiIntegrationConfigTest.php
Normal 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');
|
||||||
|
});
|
||||||
74
src/tests/Feature/AdminAiTriageConfigTest.php
Normal file
74
src/tests/Feature/AdminAiTriageConfigTest.php
Normal 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);
|
||||||
|
});
|
||||||
54
src/tests/Feature/AdminBookstackTaggingButtonsTest.php
Normal file
54
src/tests/Feature/AdminBookstackTaggingButtonsTest.php
Normal 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');
|
||||||
|
});
|
||||||
@@ -45,3 +45,59 @@ test('admin can add a description to a subcategory via the edit dialog', functio
|
|||||||
|
|
||||||
expect($sub->fresh()->description)->toBe('Problemy z połączeniem VPN.');
|
expect($sub->fresh()->description)->toBe('Problemy z połączeniem VPN.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('admin can reorder subcategories with up/down arrows', function () {
|
||||||
|
$admin = adminUser();
|
||||||
|
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||||
|
$vpn = $category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
|
||||||
|
$wifi = $category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
|
||||||
|
$drukarki = $category->subcategories()->create(['name' => 'Drukarki', 'sort_order' => 2]);
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class)
|
||||||
|
->call('setTab', 'categories')
|
||||||
|
->call('moveSubcategoryUp', $drukarki->id)
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect($category->fresh()->subcategories->pluck('name')->all())
|
||||||
|
->toBe(['VPN', 'Drukarki', 'WiFi']);
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class)
|
||||||
|
->call('setTab', 'categories')
|
||||||
|
->call('moveSubcategoryDown', $vpn->id)
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect($category->fresh()->subcategories->pluck('name')->all())
|
||||||
|
->toBe(['Drukarki', 'VPN', 'WiFi']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving the top subcategory up or the bottom one down is a no-op', function () {
|
||||||
|
$admin = adminUser();
|
||||||
|
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||||
|
$vpn = $category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
|
||||||
|
$wifi = $category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class)
|
||||||
|
->call('setTab', 'categories')
|
||||||
|
->call('moveSubcategoryUp', $vpn->id)
|
||||||
|
->call('moveSubcategoryDown', $wifi->id)
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect($category->fresh()->subcategories->pluck('name')->all())
|
||||||
|
->toBe(['VPN', 'WiFi']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new subcategory is appended to the end of the display order', function () {
|
||||||
|
$admin = adminUser();
|
||||||
|
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||||
|
$category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
|
||||||
|
$category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)->test(Panel::class)
|
||||||
|
->call('setTab', 'categories')
|
||||||
|
->set('newSubNames.'.$category->id, 'Drukarki')
|
||||||
|
->call('addSubcategory', $category->id)
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect($category->fresh()->subcategories->pluck('name')->all())
|
||||||
|
->toBe(['VPN', 'WiFi', 'Drukarki']);
|
||||||
|
});
|
||||||
|
|||||||
66
src/tests/Feature/AiClientTest.php
Normal file
66
src/tests/Feature/AiClientTest.php
Normal 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');
|
||||||
|
});
|
||||||
154
src/tests/Feature/BookStackContentTaggerTest.php
Normal file
154
src/tests/Feature/BookStackContentTaggerTest.php
Normal 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'));
|
||||||
|
});
|
||||||
54
src/tests/Feature/BookStackSearchTagQueryTest.php
Normal file
54
src/tests/Feature/BookStackSearchTagQueryTest.php
Normal 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'));
|
||||||
|
});
|
||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
45
src/tests/Feature/RefreshBadgesConfiguredIntervalTest.php
Normal file
45
src/tests/Feature/RefreshBadgesConfiguredIntervalTest.php
Normal 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"');
|
||||||
|
});
|
||||||
43
src/tests/Feature/RefreshIntervalsConfigTest.php
Normal file
43
src/tests/Feature/RefreshIntervalsConfigTest.php
Normal 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');
|
||||||
|
});
|
||||||
36
src/tests/Feature/RunAiTicketAutomationCommandTest.php
Normal file
36
src/tests/Feature/RunAiTicketAutomationCommandTest.php
Normal 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,');
|
||||||
|
});
|
||||||
42
src/tests/Feature/SettingsCronExpressionTest.php
Normal file
42
src/tests/Feature/SettingsCronExpressionTest.php
Normal 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();
|
||||||
|
});
|
||||||
126
src/tests/Feature/TicketAiSummaryServiceTest.php
Normal file
126
src/tests/Feature/TicketAiSummaryServiceTest.php
Normal 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();
|
||||||
|
});
|
||||||
245
src/tests/Feature/TicketAiTriageServiceTest.php
Normal file
245
src/tests/Feature/TicketAiTriageServiceTest.php
Normal 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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user