support_desk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +998 -14
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +23 -1
- data/app/helpers/support_desk/engine_helper.rb +16 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
- data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/show.html.erb +5 -0
- data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
- data/config/locales/support_desk.console.en.yml +63 -0
- data/config/locales/support_desk.console.es.yml +65 -0
- data/config/locales/support_desk.en.yml +15 -0
- data/config/locales/support_desk.es.yml +23 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
- data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
- data/lib/generators/support_desk/templates/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +12 -2
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +552 -2
- data/lib/support_desk/console.rb +290 -8
- data/lib/support_desk/context_card.rb +10 -1
- data/lib/support_desk/doctor.rb +144 -1
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +37 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +14 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/requester.rb +18 -0
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +334 -37
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/test_helpers.rb +152 -0
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +108 -0
- data/lib/tasks/support_desk.rake +46 -0
- metadata +30 -8
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 🎫 `support_desk` - Customer support for your Rails app, as conversations
|
|
1
|
+
# 🎫 `support_desk` - Customer support for your Rails app, as conversations — answered by your team and by AI agents on a leash
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/support_desk) [](https://github.com/rameerez/support_desk/actions)
|
|
4
4
|
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
`support_desk` gives your Rails app a **support desk**: tickets that are real conversations. Somebody asks for help about something in your app (a ride, an order, a withdrawal) or about nothing in particular, your desk answers, humans sign the answers, and your team works a queue.
|
|
9
9
|
|
|
10
|
+
**AI agents are first-class citizens of that desk.** An assistant is an agent with a policy: a seat, a name, a turn budget and a level (`observe · draft · reply · resolve`) that says what she may *produce* on a case. At the default level she proposes and a person sends, signed by them; raise her level per topic and she answers customers herself, hands off when she is unsure, and can never touch a case a human holds, a money topic you capped, or a customer who asked for a person. The gem ships the guardrails — policy, drafts and review, the turn that makes a late model answer harmless, disclosure, the two exits, the sweep that catches a dead harness — and **no LLM**: bring any provider, any prompt, any retrieval, in a job of about ten lines. [→ Assistants](#-assistants)
|
|
11
|
+
|
|
10
12
|
Here is the whole thing — the kind of support desk a DoorDash, an Uber Eats or a Grab needs — running on a made-up delivery app called Pepperbox. These are the **bundled views**, unmodified, themed by the host with a handful of CSS variables:
|
|
11
13
|
|
|
12
14
|
| One row, every case | Pick a topic | Which order? |
|
|
@@ -19,7 +21,19 @@ Here is the whole thing — the kind of support desk a DoorDash, an Uber Eats or
|
|
|
19
21
|
|
|
20
22
|
It is a product gem on the [`chats`](https://github.com/rameerez/chats) kernel: chats owns the transcript, realtime, attachments, read state and moderation; `support_desk` owns the case — topics, assignment, SLA clocks, events and the console API.
|
|
21
23
|
|
|
22
|
-
Every app eventually needs a support inbox, and everyone rebuilds the same ticket table, the same "assigned to me" tab, the same "which order is this about?" picker and the same email bridge. `support_desk` is that whole rebuild, done once, done right, on top of the messaging you already have.
|
|
24
|
+
Every app eventually needs a support inbox, and everyone rebuilds the same ticket table, the same "assigned to me" tab, the same "which order is this about?" picker and the same email bridge — and, lately, the same "let the model answer, but not *that*" rules. `support_desk` is that whole rebuild, done once, done right, on top of the messaging you already have.
|
|
25
|
+
|
|
26
|
+
What "AI-native" means here, concretely:
|
|
27
|
+
|
|
28
|
+
- **One verb for the harness.** `ticket.respond!(text, by: rose, turn:)` — policy decides whether it is sent, drafted for a person, or withheld with the reason on the record. The job never encodes the rules.
|
|
29
|
+
- **Bounded authority, in the model.** Levels, topic caps, a per-case cap, pause, and floors for closed / human-held / human-requested cases — enforced inside every transition, not in a prompt or a button.
|
|
30
|
+
- **A turn, not a lock-free hope.** Every message and transition moves `assistant_turn`; every assistant action requires and consumes it, so a late, retried or redelivered job writes nothing.
|
|
31
|
+
- **Human in the loop by default.** Proposals a person sends verbatim or edited — as *their* message — or rejects with a reason you can raise her level on.
|
|
32
|
+
- **Two exits, always.** She escalates; the customer has a door to a person that never disappears.
|
|
33
|
+
- **Disclosure is your explicit choice**, and the record tells the truth in every mode.
|
|
34
|
+
- **Context as data.** `ticket.brief` and `ticket.transcript` — facts, roles, `may` / `may_not` — for any provider.
|
|
35
|
+
|
|
36
|
+
**Contents:** [Example](#-example) · [Quickstart](#quickstart) · [Configuration reference](#configuration-reference) · [Topics](#topics) · [Model macros](#the-model-macros) · [Tickets](#tickets) · [Queues and presenters](#queues-and-presenters) · [The requester experience](#the-requester-experience) · [The agent console](#the-agent-console) · [Writing first](#writing-first) · [Assistants](#-assistants) · [The wizard](#the-wizard) · [Events](#events) · [Errors](#errors) · [Locales](#locales) · [Doctor](#doctor) · [Compatibility](#compatibility) · [Testing](#testing) · [Module-level API](#module-level-api)
|
|
23
37
|
|
|
24
38
|
## 👨💻 Example
|
|
25
39
|
|
|
@@ -42,9 +56,14 @@ ticket.reply!("We're on it", by: lucia) # you answ
|
|
|
42
56
|
ticket.close!(by: lucia)
|
|
43
57
|
|
|
44
58
|
lucia.open_support_conversation_with!(alice, "We saw your refund bounced", about: order) # you write first
|
|
59
|
+
|
|
60
|
+
config.assistant(:rose) { |rose| rose.autonomy = :draft; rose.disclosure = :signature } # an AI agent, on a leash
|
|
61
|
+
rose = SupportDesk.assistant(:rose)
|
|
62
|
+
ticket.respond!(answer_from_your_model, by: rose, turn: ticket.assistant_turn) # she proposes; policy decides
|
|
63
|
+
ticket.pending_draft.send!(by: lucia, seen_turn: ticket.assistant_turn) # a person sends it, signed by them
|
|
45
64
|
```
|
|
46
65
|
|
|
47
|
-
That's a ticket, a conversation, an assignment history, an append-only audit trail and
|
|
66
|
+
That's a ticket, a conversation, an assignment history, an append-only audit trail, an AI agent whose every action is policy-checked under the row lock, and a dozen events your app can subscribe to.
|
|
48
67
|
|
|
49
68
|
## Quickstart
|
|
50
69
|
|
|
@@ -69,6 +88,9 @@ workers, and backfill before starting 0.2 traffic. This is not a rolling
|
|
|
69
88
|
upgrade: old assignment writers cannot handle new support-opened cases.
|
|
70
89
|
See the CHANGELOG for the complete cutover and rollback procedure.
|
|
71
90
|
|
|
91
|
+
Already on 0.2? The same generator copies the 0.3 assistants migration,
|
|
92
|
+
which is additive and needs no drain — see [Assistants](#-assistants).
|
|
93
|
+
|
|
72
94
|
Three model lines and one route line:
|
|
73
95
|
|
|
74
96
|
```ruby
|
|
@@ -110,6 +132,129 @@ Check your work any time with `SupportDesk.doctor.print`.
|
|
|
110
132
|
|
|
111
133
|
Desk records are memoised for the life of the process, so anything that has to change everywhere at once belongs in this initializer rather than in a desk's `settings` column.
|
|
112
134
|
|
|
135
|
+
## Configuration reference
|
|
136
|
+
|
|
137
|
+
Everything lives in `config/initializers/support_desk.rb` (the install
|
|
138
|
+
generator writes an annotated one). Two rules, shared with the rest of the
|
|
139
|
+
gem ecosystem: class names are stored as **strings** and constantized lazily,
|
|
140
|
+
so the initializer can name app classes before they load and everything
|
|
141
|
+
survives reloads; and every setter **validates on assignment** and raises
|
|
142
|
+
`SupportDesk::ConfigurationError` with the fix in the message — a
|
|
143
|
+
configuration mistake is a boot failure, never a 3 a.m. `NoMethodError`.
|
|
144
|
+
|
|
145
|
+
### Installation settings
|
|
146
|
+
|
|
147
|
+
| setting | default | what it decides |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `requester_class` | `"User"` | the model with `has_support_tickets`; it must also be a chats messager |
|
|
150
|
+
| `parent_controller` | `"::ApplicationController"` | what the requester-facing engine inherits: your layout, auth, helpers, locale |
|
|
151
|
+
| `console_parent_controller` | your admin's base controller | what the mounted `ConsoleEngine` and the generated console inherit |
|
|
152
|
+
| `current_requester_method` | `:current_user` | how the engine finds the person asking |
|
|
153
|
+
| `current_agent_method` | `:current_user` | how the console finds the person answering (or define `current_agent` in your controller) |
|
|
154
|
+
| `authenticate_method` | `:authenticate_user!` | your own filter, run before every requester screen |
|
|
155
|
+
| `visible_desks_for` | `nil` (every desk) | `->(agent) { … }` returning the desks this agent may work; scopes the whole console |
|
|
156
|
+
| `authorize_console` | `nil` (allow) | `->(agent, ticket, action) { … }`; asked before every console action, `ticket` is nil for `index`, `new`, `next` and `open_conversation`; a hook that raises **denies** |
|
|
157
|
+
| `assistant(key) { … }` | — | declare an assistant (see [Assistants](#-assistants)); `config.assistants`, `config.assistant?(key)` read them back |
|
|
158
|
+
| `default_assistant` | the only one declared | which assistant the `:default` desk gets when more than one exists |
|
|
159
|
+
| `on(event) { … }` | — | subscribe to an event (see [Events](#events)); pass `key:` from code that reloads |
|
|
160
|
+
|
|
161
|
+
### Desk settings
|
|
162
|
+
|
|
163
|
+
Top-level setters configure the `:default` desk. Every other desk inherits
|
|
164
|
+
whatever it does not state:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
config.desk :billing do |desk|
|
|
168
|
+
desk.name = "Billing"
|
|
169
|
+
desk.reply_within = 8.hours
|
|
170
|
+
desk.topics { topic :invoice, about: Invoice; other }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
SupportDesk.desk # the :default Desk record, memoised for the process
|
|
174
|
+
SupportDesk.desk(:billing) # another one (nil if nobody configured it)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
| setting | default | values / meaning |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| `name` | the key, humanized | what requesters see as the counterpart |
|
|
180
|
+
| `avatar` | `nil` | anything `image_tag` takes, or `->(desk) { … }` |
|
|
181
|
+
| `email` | `nil` | the address the email channel will answer from (channel lands in a later release) |
|
|
182
|
+
| `agents { … }` | — | a block or lambda returning the agent pool: notified while a case is unheld, offered in the assign picker |
|
|
183
|
+
| `assistant` | the default one | the key of the assistant that works this desk; an explicit `nil` **disables** her here rather than inheriting (see [Assistants](#-assistants)) |
|
|
184
|
+
| `topics do … end` | `other` only | the topic tree (see [Topics](#topics)) |
|
|
185
|
+
| `reply_policy` | `:anyone` | `:anyone` (a drop-in posts, signed; an unheld case is taken by whoever answers) · `:take_over` (replying reassigns) · `:assignee_only` (raises `NotAllowed`) |
|
|
186
|
+
| `announce_assignments` | `:first_only` | `:first_only` ("Lucía is taking care of your request" once) · `:always` (hand-offs too) · `:never` |
|
|
187
|
+
| `closed_tickets` | `:reopen_on_reply` | `:reopen_on_reply` (a requester writing reopens the case) · `:locked` (composer replaced by a notice) |
|
|
188
|
+
| `reply_within` | `24.hours` | the SLA breach threshold **and** the promise the requester reads |
|
|
189
|
+
| `at_risk_after` | `4.hours` | when a waiting case starts showing as at risk |
|
|
190
|
+
| `open_rate_limit` | `{ to: 5, within: 1.hour }` | how often one requester may **ask**; `nil` disables. Counts only cases the requester opened |
|
|
191
|
+
| `max_open_tickets` | `5` | how many cases one requester may have open; `nil` disables. Same rule |
|
|
192
|
+
| `inbox_entry` | `:always` | when the desk shows in an inbox with no cases yet: `:always` · `:when_tickets` · `:never` |
|
|
193
|
+
| `routing` | `:manual` | new cases are unassigned and the first "take" wins (`:round_robin` / `:least_loaded` are reserved and refused until they ship) |
|
|
194
|
+
| `mirror_replies_by_email` | `:when_away` | reserved for the email channel: `:always` · `:when_away` · `:never` |
|
|
195
|
+
| `auto_close_after` | `nil` | reserved for the sweep job: a duration after which an answered case closes itself |
|
|
196
|
+
| `opening_line` | `nil` | the system line a requester-opened thread starts with (see [Writing first](#writing-first)) |
|
|
197
|
+
| `opening_line_from_support` | the gem's I18n copy | the same line for a case the desk opened |
|
|
198
|
+
| `find_requester { \|query\| … }` | `nil` | how a console turns what an agent typed into a requester record |
|
|
199
|
+
|
|
200
|
+
`SupportDesk.doctor.print` checks all of it against a running app (see
|
|
201
|
+
[Doctor](#doctor)).
|
|
202
|
+
|
|
203
|
+
## Topics
|
|
204
|
+
|
|
205
|
+
Topics are a **tree defined in code** and stored on the ticket as a stable
|
|
206
|
+
path (`"billing/invoice"`), so they can carry behaviour — which models they
|
|
207
|
+
attach to, which picker, which desk — and be reviewed and versioned like
|
|
208
|
+
everything else. Labels are I18n keys by default, so copy is a locale edit.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
config.topics do
|
|
212
|
+
topic :order, about: Order, subject: :required, icon: "package"
|
|
213
|
+
topic :billing, priority: :high do
|
|
214
|
+
topic :invoice, about: Invoice, ask: "Which invoice?"
|
|
215
|
+
topic :refund, prefill: "Hi, I'd like a refund for ", desk: :billing
|
|
216
|
+
end
|
|
217
|
+
topic :beta, only: ->(requester) { requester.beta? }
|
|
218
|
+
topic :legacy, retired: true # old cases keep their label; nobody can open a new one
|
|
219
|
+
other # the free-form leaf every desk should have
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
| option | meaning |
|
|
224
|
+
|---|---|
|
|
225
|
+
| `about:` | the `supportable` class(es) this topic is about; the wizard offers the requester's own records |
|
|
226
|
+
| `subject:` | `:required` (must pick one) · `:optional` (offers "none of these") · `:none` (free-form) |
|
|
227
|
+
| `candidates:` | `->(requester) { … }` overriding which records the picker shows |
|
|
228
|
+
| `ask:`, `placeholder:`, `prefill:`, `label:` | copy, when you'd rather not use the locale keys; `prefill:` may be a `->(subject) { … }` |
|
|
229
|
+
| `only:` | `->(requester) { … }` — who is offered this topic in the wizard (agents may still file onto it) |
|
|
230
|
+
| `priority:` | `:normal` · `:high` · `:urgent` — sorts the queue |
|
|
231
|
+
| `desk:` / `route_to:` | send cases on this topic to another desk |
|
|
232
|
+
| `retired:` | hidden from the wizard, still readable on old cases |
|
|
233
|
+
| `icon:` | a key your views may render; the gem never does |
|
|
234
|
+
| `assistant:` | the most an assistant may produce on this branch (see [Assistants](#-assistants)) |
|
|
235
|
+
|
|
236
|
+
`about`, `candidates`, `desk`, `route_to`, `priority`, `only` and `retired`
|
|
237
|
+
are inherited down the branch; copy never is. `assistant:` is the one option
|
|
238
|
+
that is neither inherited nor overridden: `Topic#assistant_cap` is the
|
|
239
|
+
**minimum** over the node and every ancestor, so a child can only tighten
|
|
240
|
+
what a parent allowed. Labels come from
|
|
241
|
+
`support_desk.topics.<path>.label` in your locale files (`ask` and `hint`
|
|
242
|
+
alongside).
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
ticket.topic # a SupportDesk::Topic value object
|
|
246
|
+
ticket.topic.path # "billing/invoice"
|
|
247
|
+
ticket.topic.label # "Invoice"
|
|
248
|
+
ticket.topic.full_label # "Billing › Invoice"
|
|
249
|
+
ticket.topic.under?(:billing) # true
|
|
250
|
+
ticket.topic.free_form? .subject_required? .retired? .priority .about .icon .assistant_cap
|
|
251
|
+
SupportDesk.find_topic("billing/invoice") # across every desk; nil, never a raise
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Agents can refile a case (`ticket.change_topic!(to: "billing/refund", by:)`)
|
|
255
|
+
and point a free-form one at the record it turned out to be about
|
|
256
|
+
(`ticket.attach_subject!(order, by:)`); both are events.
|
|
257
|
+
|
|
113
258
|
## The model macros
|
|
114
259
|
|
|
115
260
|
### `has_support_tickets(desk: :default, as: nil, if: nil)`
|
|
@@ -144,6 +289,8 @@ Makes a domain record something people can ask about. Every method has a working
|
|
|
144
289
|
|
|
145
290
|
Makes someone able to answer. Agents are never chats participants — the desk sends, the agent *authors* — so this needs no messaging setup at all. It adds `support_agent?`, `support_agent_name`, `support_agent_avatar`, `on_duty?`, `support_capacity`, `support_queue` and exactly one verb — `open_support_conversation_with!`, the only agent action with no ticket yet (see [Writing first](#writing-first)). Everywhere else the ticket is the subject of the sentence.
|
|
146
291
|
|
|
292
|
+
`kind:` is `:human` or `:ai`, and anything else is a boot failure. Declaring one of your own models `kind: :ai` does **not** hand it an agent's authority: every support write by it, or to it, is refused with `SupportDesk::NotAnAssistant`, and `doctor` warns about it. The only machine that may act on a case is the desk's own `SupportDesk::Assistant` — see [Assistants](#-assistants).
|
|
293
|
+
|
|
147
294
|
## Tickets
|
|
148
295
|
|
|
149
296
|
```ruby
|
|
@@ -169,16 +316,51 @@ ticket.attach_subject!(order, by: lucia)
|
|
|
169
316
|
|
|
170
317
|
Every transition takes `by:` (falling back to `SupportDesk::Current.actor`), runs under the ticket's row lock, writes exactly one event row, and emits its events after the transaction commits. Repeating one that already happened returns `self` and writes nothing.
|
|
171
318
|
|
|
319
|
+
More of what a ticket knows:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
ticket.opened_by ticket.opened_by_requester? ticket.opened_by_support? # who wrote first
|
|
323
|
+
ticket.opened_via # :in_app | :email | :intercom | :api
|
|
324
|
+
ticket.channels # every channel the case can be answered through
|
|
325
|
+
ticket.channels_summary # "in app · email", in the reader's language
|
|
326
|
+
ticket.requester ticket.assignee ticket.desk ticket.conversation ticket.messages
|
|
327
|
+
ticket.assigned_to?(lucia) ticket.about?(order) ticket.reopened? ticket.unassigned?
|
|
328
|
+
ticket.assignments # the history of who held it; .open for the current seat
|
|
329
|
+
ticket.assignment_history # the same, oldest first
|
|
330
|
+
ticket.events ticket.notes # the append-only timeline, and just the internal notes
|
|
331
|
+
ticket.waiting_since ticket.first_agent_reply_at ticket.last_requester_message_at ticket.last_agent_message_at
|
|
332
|
+
ticket.export # a GDPR-friendly Hash: the requester's transcript and the events they saw, never notes
|
|
333
|
+
ticket.notification_title # "Support · new message" — safe for a lock screen
|
|
334
|
+
ticket.notification_body # the label — for an authenticated feed
|
|
335
|
+
ticket.register!(message) # fold a chats message into the clocks by hand (imports); idempotent, and what the chats subscriber calls
|
|
336
|
+
ticket.desk_config # this desk's slice of the configuration
|
|
337
|
+
|
|
338
|
+
SupportDesk::Ticket.find_by_reference!("t-ab12cd") # forgives case, the prefix and Crockford lookalikes (O→0, I/L→1)
|
|
339
|
+
SupportDesk::Ticket.for_conversation(conversation) # the case behind a chats conversation, or nil
|
|
340
|
+
SupportDesk::Current.actor = lucia # the fallback for every `by:` (the console sets it per request)
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
An **assignment** row records `agent`, `assigned_by`, `reason` (`taken`
|
|
344
|
+
`assigned` `handed_off` `routed` `drop_in_takeover` `escalated` `reopened`
|
|
345
|
+
`opened`), `note`, `assigned_at`, `released_at` and `release_reason`
|
|
346
|
+
(`handed_off` `released` `shift_end` `closed` `escalated`). An **event** row
|
|
347
|
+
has a `kind` (`opened` `assigned` `handed_off` `released` `drop_in` `closed`
|
|
348
|
+
`reopened` `topic_changed` `subject_attached` `note` `escalated`
|
|
349
|
+
`human_requested` `assistant_paused` `assistant_resumed` `draft_sent`
|
|
350
|
+
`draft_rejected` `assistant_withheld` — plus kinds reserved for later
|
|
351
|
+
releases), an `actor` and a `payload`, and is read-only once written.
|
|
352
|
+
|
|
172
353
|
### Scopes
|
|
173
354
|
|
|
174
355
|
```ruby
|
|
175
356
|
SupportDesk::Ticket
|
|
176
357
|
.open .closed .not_closed .assigned .unassigned .assigned_to(lucia)
|
|
177
358
|
.awaiting_reply .awaiting_requester
|
|
359
|
+
.opened_by_requester .opened_by_support
|
|
178
360
|
.waiting_over(4.hours) .at_risk .overdue
|
|
179
361
|
.about(order) .about_any(Order) .on_topic(:billing)
|
|
180
|
-
.for_desk(:billing) .opened_via(:email) .opened_between(range)
|
|
181
|
-
.most_urgent_first .recent_activity_first .newest_first
|
|
362
|
+
.for_desk(:billing) .opened_via(:email) .opened_between(range) .closed_between(range)
|
|
363
|
+
.most_urgent_first .recent_activity_first .newest_first .oldest_first
|
|
182
364
|
.find_by_reference("T-AB12CD")
|
|
183
365
|
```
|
|
184
366
|
|
|
@@ -188,11 +370,12 @@ Bring your own UI on the agent side. Everything the console needs is plain Ruby:
|
|
|
188
370
|
|
|
189
371
|
```ruby
|
|
190
372
|
q = lucia.support_queue
|
|
191
|
-
q.mine q.unassigned q.awaiting q.open q.closed
|
|
192
|
-
q.counts
|
|
193
|
-
q.badge
|
|
194
|
-
q.next
|
|
195
|
-
q.tabs
|
|
373
|
+
q.mine q.unassigned q.awaiting q.open q.closed q.needs_human # relations
|
|
374
|
+
q.counts # { awaiting: 4, mine: 2, … } in ONE query
|
|
375
|
+
q.badge # the nav number, cached 30s per agent
|
|
376
|
+
q.next # the most urgent thing this agent could pick up
|
|
377
|
+
q.tabs # [[:awaiting, "Needs a reply", 4], …]
|
|
378
|
+
q.visible_tabs # which ones this desk has any use for
|
|
196
379
|
|
|
197
380
|
ticket.context_card # title, status, the host's own context pairs, the requester
|
|
198
381
|
ticket.summary # one line for a list row, Slack, or a digest
|
|
@@ -200,6 +383,19 @@ ticket.timeline # messages ⨉ events merged by time; .print in a conso
|
|
|
200
383
|
ticket.actions_for(lucia) # exactly the buttons to render
|
|
201
384
|
```
|
|
202
385
|
|
|
386
|
+
```ruby
|
|
387
|
+
card = ticket.context_card
|
|
388
|
+
card.title card.status card.pairs card.subject_url card.topic_label
|
|
389
|
+
card.requester_name card.requester_avatar card.requester_since card.requester_open_tickets
|
|
390
|
+
card.opened_by_label # "Support · Lucía G." for a case the desk opened; "not recorded" for a 0.1 row awaiting backfill
|
|
391
|
+
card.to_h
|
|
392
|
+
|
|
393
|
+
ticket.summary.to_s # "T-AB12CD · Order SO1 · Alice · awaiting reply (12 minutes)"
|
|
394
|
+
ticket.summary.state # "awaiting reply" | "awaiting requester" | "closed" | "open", translated
|
|
395
|
+
ticket.timeline.entries # Timeline::Entry: .kind (:message or the event kind), .at, .actor, .body
|
|
396
|
+
ticket.timeline.print # in a console
|
|
397
|
+
```
|
|
398
|
+
|
|
203
399
|
## The requester experience
|
|
204
400
|
|
|
205
401
|
Mounting the engine is the whole user side: four screens, ejectable views,
|
|
@@ -258,6 +454,22 @@ first would lose to it.
|
|
|
258
454
|
Both surfaces are pushed screens, never modals: every wizard step is a real
|
|
259
455
|
URL, so the back gesture and cold-boot deep links work.
|
|
260
456
|
|
|
457
|
+
|
|
458
|
+
### Every helper the requester views can use
|
|
459
|
+
|
|
460
|
+
| helper | what it renders |
|
|
461
|
+
|---|---|
|
|
462
|
+
| `link_to_support(about:, text:, **html)` | the door into the wizard (nothing when there is nobody to ask, or nothing they may ask about) |
|
|
463
|
+
| `support_unread_badge` | unread support messages, counted against the chats read horizon |
|
|
464
|
+
| `support_desk_styles` | the bundled stylesheet tag for your `<head>` |
|
|
465
|
+
| `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` |
|
|
466
|
+
| `support_ticket_state(ticket)` | "We're on it" · "We replied" · "We wrote to you" · "Closed" |
|
|
467
|
+
| `support_inbox_door?(viewer)` | whether the inbox should show the door instead of a row (`inbox_entry`) |
|
|
468
|
+
| `support_desk_record(key)`, `support_desk_avatar`, `support_desk_badge` | the desk as a counterpart: record, avatar (initials fallback), verified badge |
|
|
469
|
+
| `support_thread_path(ticket)` | where a case is read: its chats conversation |
|
|
470
|
+
| `open_support_ticket_about(record, requester)` | the open case about a record, or nil — what makes a door lead to the existing conversation |
|
|
471
|
+
| `support_human_door(ticket)` | the way to a person on a case an assistant is or was on, and the status line once one has been asked for (see [Assistants](#-assistants)) |
|
|
472
|
+
|
|
261
473
|
### Restyling
|
|
262
474
|
|
|
263
475
|
The views ship with a small bundled stylesheet and semantic classes (the list
|
|
@@ -298,8 +510,11 @@ end
|
|
|
298
510
|
```
|
|
299
511
|
|
|
300
512
|
That adds member `reply take assign hand_off release close reopen note
|
|
301
|
-
change_topic
|
|
302
|
-
|
|
513
|
+
change_topic send_draft reject_draft pause_assistant resume_assistant` and
|
|
514
|
+
collection `next` and `open_conversation`. The last four do nothing until an
|
|
515
|
+
assistant is configured, and the console never offers them before that. It
|
|
516
|
+
has to sit inside a `resources` block, since that is what those routes hang
|
|
517
|
+
off.
|
|
303
518
|
`new` stays yours: add it to `only:` when you render the form behind
|
|
304
519
|
`open_conversation` ("Write to someone").
|
|
305
520
|
|
|
@@ -483,6 +698,56 @@ screen, an order) and says so:
|
|
|
483
698
|
config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
|
|
484
699
|
```
|
|
485
700
|
|
|
701
|
+
#### What the compose form posts, and what comes back
|
|
702
|
+
|
|
703
|
+
The form is yours (`new.html.erb` — the generated one is a fine start); the
|
|
704
|
+
concern owns the request. `new` fills the draft it renders, `open_conversation`
|
|
705
|
+
sends it. Every field is read by name:
|
|
706
|
+
|
|
707
|
+
| param | what it is |
|
|
708
|
+
|---|---|
|
|
709
|
+
| `requester` | a GlobalID from one of your own pages — **authoritative**: a bad one is a refusal, never a fallback to the typed query |
|
|
710
|
+
| `requester_query` | what an agent typed, resolved by `config.find_requester` when no `requester` was supplied |
|
|
711
|
+
| `about` | a GlobalID of a `supportable` record; the topic comes with it |
|
|
712
|
+
| `topic` | a free-form topic path when there is no subject |
|
|
713
|
+
| `body`, `files[]` | the message: text, uploads, or Active Storage signed blob ids — text **or** an attachment is enough |
|
|
714
|
+
| `desk` | the desk key the agent is working; an explicit key that isn't a visible desk is refused, never silently swapped for the default |
|
|
715
|
+
|
|
716
|
+
GlobalIDs resolve only inside the classes that declared themselves
|
|
717
|
+
(`has_support_tickets`, `supportable`), so a token is an identifier and never
|
|
718
|
+
permission to call `find` on whatever it names. The concern sets `@requester`,
|
|
719
|
+
`@about`, `@topic`, `@body`, `@files` and `@requester_query` **before** it
|
|
720
|
+
looks anything up, so every refusal re-renders your `new` as a **422 with the
|
|
721
|
+
draft still in it** (for Turbo too — a stream refresh would throw it away);
|
|
722
|
+
success is a **303** to the case, with the flash `message_sent` whether the
|
|
723
|
+
case was opened or the message joined one the person already had open. The
|
|
724
|
+
refusals are distinct and each has its own copy under
|
|
725
|
+
`support_desk.console.errors`: `unknown_requester`, `not_a_requester` (a
|
|
726
|
+
closed account), `invalid_requester`, `invalid_subject`, `blank_message`,
|
|
727
|
+
`invalid_input`, `no_requester_lookup`, `writing_to_yourself`, `off_duty`.
|
|
728
|
+
|
|
729
|
+
Two named seams, for hosts that look people up their own way (a multi-tenant
|
|
730
|
+
host scopes **both**, since `find_requester` only guards the typed path):
|
|
731
|
+
|
|
732
|
+
```ruby
|
|
733
|
+
def support_conversation_requester # the person, or nil; raise SupportDesk::Console::InvalidInput, :invalid_requester to refuse
|
|
734
|
+
def support_conversation_subject # what it's about, or nil; the model re-checks supportable_by? at the write
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
Helpers your `new` template can read: `support_conversation_topics` (the
|
|
738
|
+
free-form leaves), `support_conversation_offered?` (show the door at all: on
|
|
739
|
+
duty, and `config.authorize_console` says yes for `:open_conversation` with a
|
|
740
|
+
nil ticket), `support_conversation_sendable?` (render the send button), and
|
|
741
|
+
`support_desk_record` (the desk being written as). When the message turns out
|
|
742
|
+
to be a reply into an open case, `config.authorize_console(agent, ticket,
|
|
743
|
+
:reply)` and `ticket.actions_for(agent)` are asked again, **under that case's
|
|
744
|
+
row lock**, before anything is written.
|
|
745
|
+
|
|
746
|
+
The verbs live in one table the router reads:
|
|
747
|
+
`SupportDesk::Console::MEMBER_VERBS` (reply take assign hand_off release close
|
|
748
|
+
reopen note change_topic) and `SupportDesk::Console::COLLECTION_VERBS`
|
|
749
|
+
(`next: :get, open_conversation: :post`).
|
|
750
|
+
|
|
486
751
|
### Who can be written to
|
|
487
752
|
|
|
488
753
|
`has_support_tickets if: :kept?` is the whole policy. When it turns false
|
|
@@ -491,6 +756,598 @@ the model, a direct chats write — and nothing is hidden or deleted: the
|
|
|
491
756
|
transcript stays readable, the case stays in the queue, and agents can
|
|
492
757
|
still take notes and close it.
|
|
493
758
|
|
|
759
|
+
### Upgrading to 0.2
|
|
760
|
+
|
|
761
|
+
```bash
|
|
762
|
+
rails generate support_desk:upgrade # copies the additive opened_by migration (the same file a fresh install runs)
|
|
763
|
+
rails db:migrate # backfills every existing case to its requester
|
|
764
|
+
rake support_desk:backfill_opened_by # the idempotent catch-up, once the old processes are gone
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
**This is a drained cutover, not a rolling deploy.** 0.1 processes revalidate
|
|
768
|
+
`Assignment#reason` on `release!`/`close!`/`hand_off!` and reject the new
|
|
769
|
+
`opened` reason, so a case the desk opened must not exist while 0.1 code can
|
|
770
|
+
still touch it: migrate, pause support writes, stop and drain all old web
|
|
771
|
+
requests **and workers**, run the catch-up, verify
|
|
772
|
+
`SupportDesk::Ticket.where(opened_by_id: nil).count == 0` and
|
|
773
|
+
`SupportDesk.doctor` (its `provenance` check says exactly that), then start
|
|
774
|
+
only 0.2. A NULL `opened_by` is read as requester-opened in the meantime, so
|
|
775
|
+
quotas, labels and metrics stay right before the catch-up. Rolling back is a
|
|
776
|
+
code rollback, never a schema one; with staff-opened cases live, prefer
|
|
777
|
+
dropping `new`/`open_conversation` from your routes over downgrading. If you
|
|
778
|
+
would rather avoid the window, ship a `0.1.4` that only adds `opened` to
|
|
779
|
+
`Assignment::REASONS` first.
|
|
780
|
+
|
|
781
|
+
Requesters see one more state in their list — `support_desk.tickets.state
|
|
782
|
+
.opened_by_support` ("We wrote to you") — until they answer, and every error
|
|
783
|
+
the gem raises still inherits `SupportDesk::Error`; the new one is
|
|
784
|
+
`SupportDesk::NotARequester` (no `has_support_tickets`, or its `if:` said no).
|
|
785
|
+
`SupportDesk.humanize_duration` is where "1 day" / "4 horas" now comes from
|
|
786
|
+
(`Wizard.humanize_duration` still delegates to it).
|
|
787
|
+
|
|
788
|
+
## 🤖 Assistants
|
|
789
|
+
|
|
790
|
+
An assistant is an agent that happens to be a machine. She has a seat, a
|
|
791
|
+
name, a turn budget and a **level** that says what she may produce on this
|
|
792
|
+
case — and at the default level that is a proposal a person reads and sends,
|
|
793
|
+
signed by them.
|
|
794
|
+
|
|
795
|
+
The gem does not call a model. It emits one event and accepts a handful of
|
|
796
|
+
verbs; which provider you use, what you put in a prompt and what you spend
|
|
797
|
+
are yours. What lives here is the part nobody should write twice: who may
|
|
798
|
+
say what to whom, what happens when the customer writes again mid-answer,
|
|
799
|
+
and how a person takes over.
|
|
800
|
+
|
|
801
|
+
**Nothing in this section is on until you turn it on.** Without
|
|
802
|
+
`config.assistant`, `support_desk` behaves exactly as it did in 0.2.
|
|
803
|
+
|
|
804
|
+
### Two minutes
|
|
805
|
+
|
|
806
|
+
```bash
|
|
807
|
+
rails g support_desk:assistant Rose --disclosure signature
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
It writes `app/jobs/support/rose_turn_job.rb` and
|
|
811
|
+
`app/services/support/rose.rb`, and it **prints** the rest — it never edits
|
|
812
|
+
your initializer, because `config.assistant` is a policy decision and a
|
|
813
|
+
generator that wrote one would turn "let me look at this" into a live
|
|
814
|
+
assistant. Paste the stanza:
|
|
815
|
+
|
|
816
|
+
```ruby
|
|
817
|
+
# config/initializers/support_desk.rb
|
|
818
|
+
config.assistant :rose do |rose|
|
|
819
|
+
rose.name = "Rose"
|
|
820
|
+
rose.autonomy = :draft # she proposes; a person sends
|
|
821
|
+
rose.disclosure = :signature # REQUIRED — see below
|
|
822
|
+
rose.max_turns = 6
|
|
823
|
+
rose.responds_within = 3.minutes
|
|
824
|
+
end
|
|
825
|
+
config.default_assistant = :rose
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
Wire the job to the one event the gem emits:
|
|
829
|
+
|
|
830
|
+
```ruby
|
|
831
|
+
SupportDesk.on(:assistant_turn, key: "support.rose.turn") do |ticket, assistant, _message, turn:|
|
|
832
|
+
Support::RoseTurnJob.set(wait: 20.seconds).perform_later(ticket.id, assistant.key, turn)
|
|
833
|
+
end
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
And the job, which is the whole harness contract in nine lines:
|
|
837
|
+
|
|
838
|
+
```ruby
|
|
839
|
+
def perform(ticket_id, assistant_key, turn)
|
|
840
|
+
ticket = SupportDesk::Ticket.find(ticket_id)
|
|
841
|
+
return unless ticket.assistant_turn == turn # the case moved on while we waited
|
|
842
|
+
|
|
843
|
+
rose = SupportDesk.assistant(assistant_key)
|
|
844
|
+
return unless ticket.assistant_policy(rose).may_observe?
|
|
845
|
+
|
|
846
|
+
answer = Support::Rose.answer(ticket.brief, ticket.transcript)
|
|
847
|
+
ticket.respond!(answer.text, by: rose, turn: turn, confidence: answer.confidence)
|
|
848
|
+
end
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
What a person sees next: a card at the top of the case that says "Propuesta
|
|
852
|
+
de Rose", the text, a confidence pill, the sources she cited, and three
|
|
853
|
+
buttons — **Enviar**, **Editar**, **Descartar**. The sent message is
|
|
854
|
+
*theirs*, signed with their name. The customer sees an answer from a human,
|
|
855
|
+
because it is one.
|
|
856
|
+
|
|
857
|
+
### Levels
|
|
858
|
+
|
|
859
|
+
`autonomy` is the ceiling she may ever work at. Every level is the one below
|
|
860
|
+
it plus its own verbs:
|
|
861
|
+
|
|
862
|
+
| level | she may | what it feels like |
|
|
863
|
+
|---|---|---|
|
|
864
|
+
| `:off` | nothing | configured, switched off |
|
|
865
|
+
| `:observe` | `note`, `escalate`, `release` | she reads and can leave a staff note or hand the case to a person; she never writes to the customer |
|
|
866
|
+
| `:draft` | the above + `draft` | **the default.** Every word goes through a person |
|
|
867
|
+
| `:reply` | the above + `reply`, `take` | she answers the customer and holds the case |
|
|
868
|
+
| `:resolve` | the above + `close` | she can close a case she holds once the customer has been answered |
|
|
869
|
+
|
|
870
|
+
```ruby
|
|
871
|
+
ticket.assistant_policy.level # => :draft
|
|
872
|
+
ticket.assistant_policy.because # => "topic payments caps rose at draft"
|
|
873
|
+
ticket.assistant_policy.may_reply? # => false
|
|
874
|
+
ticket.assistant_policy.allowed_verbs # => [:note, :escalate, :release, :draft]
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
### Ceilings and floors
|
|
878
|
+
|
|
879
|
+
A **ceiling** lowers what she may ever produce here. The lowest one wins,
|
|
880
|
+
and `because` names the single rule that decided it — so any refusal traces
|
|
881
|
+
back to one line of configuration:
|
|
882
|
+
|
|
883
|
+
| ceiling | set by |
|
|
884
|
+
|---|---|
|
|
885
|
+
| her autonomy | `rose.autonomy = :reply` |
|
|
886
|
+
| the topic | `topic :payments, assistant: :draft` — the minimum over the node and every ancestor, so a child can only tighten |
|
|
887
|
+
| your block | `rose.cap { |ticket| :draft if ticket.requester.try(:vip?) }` |
|
|
888
|
+
| the case | what a reopen writes (below) |
|
|
889
|
+
| a pause | a human switched her off on this case |
|
|
890
|
+
|
|
891
|
+
A **floor** lowers the level because of the case's state, after the ceilings:
|
|
892
|
+
she is deactivated, the case is closed, a person has been asked for, or a
|
|
893
|
+
human holds it.
|
|
894
|
+
|
|
895
|
+
```ruby
|
|
896
|
+
ticket.assistant_policy.ceilings # => { assistant: :reply } only the ones that apply
|
|
897
|
+
ticket.assistant_policy.floors # => [:held_by_human] Lucía took it: :reply becomes :draft
|
|
898
|
+
ticket.assistant_policy.to_h # all five ceiling slots, spelled out, for a log line
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
Policy decides what she may **produce**. It never decides what a person can
|
|
902
|
+
see: the transcript, the case, the queue and the door to a human are the
|
|
903
|
+
same at every level, and a refusal is always a named reason on a record — an
|
|
904
|
+
`assistant_withheld` event, a policy stored in a proposal's metadata — never
|
|
905
|
+
silence.
|
|
906
|
+
|
|
907
|
+
### `respond!` and its three outcomes
|
|
908
|
+
|
|
909
|
+
`respond!` is the verb a harness should call. It hands over an answer and
|
|
910
|
+
lets policy decide what that answer becomes, so the harness never encodes
|
|
911
|
+
rules that change per case:
|
|
912
|
+
|
|
913
|
+
```ruby
|
|
914
|
+
outcome = ticket.respond!(text, by: rose, turn: turn, confidence: 0.82, sources: [{ title: "…", url: "https://…" }])
|
|
915
|
+
|
|
916
|
+
outcome.sent? # it went to the requester (level :reply or above, and it was her turn)
|
|
917
|
+
outcome.drafted? # a person will send it; outcome.draft is the row
|
|
918
|
+
outcome.withheld? # nothing was written; outcome.reason is :policy or :not_your_turn
|
|
919
|
+
outcome.escalated? # the budget ran out: the proposal is waiting AND so is a person
|
|
920
|
+
outcome.turn # the successor turn, for a second action in the same run
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
`draft!` proposes regardless of level, for a host that has already decided it
|
|
924
|
+
wants a proposal. `reply!(by: rose, turn:)` and `note!(by: rose, turn:)` are
|
|
925
|
+
the ordinary verbs with a turn attached.
|
|
926
|
+
|
|
927
|
+
### The turn
|
|
928
|
+
|
|
929
|
+
`ticket.assistant_turn` is an opaque string (`"t7-r12"`) over an integer
|
|
930
|
+
bumped by **every** registered message and **every** transition. Every
|
|
931
|
+
assistant action requires it and consumes it:
|
|
932
|
+
|
|
933
|
+
```ruby
|
|
934
|
+
turn = ticket.assistant_turn # read it before you call a model
|
|
935
|
+
ticket.respond!(answer, by: rose, turn: turn)
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
A model takes seconds and a customer can write again while it thinks. The
|
|
939
|
+
turn is what makes that safe: the action is compared against the case's
|
|
940
|
+
current revision **under its row lock**, and a late, retried or redelivered
|
|
941
|
+
one raises `SupportDesk::StaleTurn` and writes nothing. That one integer is
|
|
942
|
+
also why this gem has no idempotency keys, no claim rows and no leases —
|
|
943
|
+
"is this still the case you read?" is already answered.
|
|
944
|
+
|
|
945
|
+
Two consequences worth knowing:
|
|
946
|
+
|
|
947
|
+
- Check `ticket.assistant_turn == turn` in your job **before you spend
|
|
948
|
+
money**. A mismatch means the newer turn's job already exists.
|
|
949
|
+
- A retry after a committed action is a `StaleTurn`, and that is correct:
|
|
950
|
+
the work was done. Generated jobs `discard_on` it.
|
|
951
|
+
|
|
952
|
+
### Proposals in the console
|
|
953
|
+
|
|
954
|
+
A pending proposal renders above the composer with its confidence, its
|
|
955
|
+
sources and its attachments, and goes stale visibly the moment anything on
|
|
956
|
+
the case moves.
|
|
957
|
+
|
|
958
|
+
```ruby
|
|
959
|
+
draft = ticket.pending_draft
|
|
960
|
+
draft.send!(by: lucia, seen_turn: ticket.assistant_turn) # verbatim
|
|
961
|
+
draft.send!(by: lucia, seen_turn: ticket.assistant_turn, body: "Casi: …") # edited
|
|
962
|
+
draft.reject!(by: lucia, reason: "no es eso")
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
`seen_turn` is the turn the reviewer's **page** was rendered with, and a
|
|
966
|
+
mismatch is a refusal, not a warning: approving a proposal from a page that
|
|
967
|
+
predates the customer's next message would send an answer into a
|
|
968
|
+
conversation that has moved on. There is no "send anyway" flag — the console
|
|
969
|
+
re-renders the case with the current turn and the reviewer's text still in
|
|
970
|
+
the box, and they submit again. One pending proposal per case, always: a
|
|
971
|
+
newer one, a takeover, a human reply or a pause supersedes the last.
|
|
972
|
+
|
|
973
|
+
A sent proposal is the **human's** message. `sent_body` keeps the edit, the
|
|
974
|
+
original body stays on the row, and `draft.edited?` / the `verbatim` and
|
|
975
|
+
`edited` scopes are the acceptance numbers you raise her level on.
|
|
976
|
+
|
|
977
|
+
### The two exits
|
|
978
|
+
|
|
979
|
+
Either side can ask for a person, and both do the same write: her seat is
|
|
980
|
+
released, the reason is recorded, the priority goes up, and a line lands in
|
|
981
|
+
the thread.
|
|
982
|
+
|
|
983
|
+
```ruby
|
|
984
|
+
ticket.escalate!(by: rose, turn: turn, reason: "refund_over_limit", summary: "Pidió el reembolso de …")
|
|
985
|
+
ticket.request_human!(by: alice) # the requester's own door
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
Render the door in your thread — it is a partial in the requester engine,
|
|
989
|
+
and a helper:
|
|
990
|
+
|
|
991
|
+
```erb
|
|
992
|
+
<%= render "support_desk/tickets/human_door", ticket: ticket %>
|
|
993
|
+
<%= support_human_door(ticket) %>
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
It shows the button while an assistant is or has been in play on the case,
|
|
997
|
+
and the status line once a person has been asked for. **History counts**:
|
|
998
|
+
the door does not vanish because somebody edited an initializer after she
|
|
999
|
+
answered. Both exits reach the queue's `needs_human` tab, which appears only
|
|
1000
|
+
on desks that have an assistant or a non-zero count.
|
|
1001
|
+
|
|
1002
|
+
### Disclosure
|
|
1003
|
+
|
|
1004
|
+
Required, with no default. Boot fails until you choose:
|
|
1005
|
+
|
|
1006
|
+
| mode | what the requester gets |
|
|
1007
|
+
|---|---|
|
|
1008
|
+
| `:signature_and_notice` | her messages are signed "Rose · asistente virtual" **and** the thread opens with a notice |
|
|
1009
|
+
| `:signature` | signed; no notice |
|
|
1010
|
+
| `:notice` | a notice; her messages are unsigned, from the desk |
|
|
1011
|
+
| `:none` | nothing is said and nothing is signed |
|
|
1012
|
+
|
|
1013
|
+
**Your legal process decides this, not us.** The gem refuses to pick a
|
|
1014
|
+
default because the right answer depends on a jurisdiction, a sector and a
|
|
1015
|
+
risk appetite it knows nothing about. What it does guarantee is that the
|
|
1016
|
+
choice is never invisible to *you*: whatever the mode, every machine-written
|
|
1017
|
+
message carries `metadata["support_desk"]` naming the assistant, the mode,
|
|
1018
|
+
the turn and the whole policy that allowed it, `ticket.export` labels it
|
|
1019
|
+
`from: "assistant"` in **every** mode including `:none`, and the console
|
|
1020
|
+
marks it for staff. What a customer is told is a product decision; what your
|
|
1021
|
+
records say a machine wrote is not.
|
|
1022
|
+
|
|
1023
|
+
### Humans outrank
|
|
1024
|
+
|
|
1025
|
+
- A person may answer a case she holds under **every** `reply_policy`.
|
|
1026
|
+
`:assignee_only` exists so two people don't answer at once, and she is not
|
|
1027
|
+
one.
|
|
1028
|
+
- A human reply on her case takes it over and supersedes the pending
|
|
1029
|
+
proposal.
|
|
1030
|
+
- `ticket.agents_to_notify` and `desk.humans` return people only. Notifying a
|
|
1031
|
+
machine is notifying nobody. (`desk.agents` includes her, and is an Array.)
|
|
1032
|
+
- She can never send her own proposal, hand a case off, change a topic or
|
|
1033
|
+
attach a subject. She escalates instead.
|
|
1034
|
+
|
|
1035
|
+
### Pause, resume, and what a reopen remembers
|
|
1036
|
+
|
|
1037
|
+
```ruby
|
|
1038
|
+
ticket.pause_assistant!(by: lucia, reason: "cliente enfadado") # floors her at :off on THIS case
|
|
1039
|
+
ticket.resume_assistant!(by: lucia)
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
Pausing releases her seat and throws away her pending proposal. Resuming
|
|
1043
|
+
clears the pause and **nothing else** — a case cap and a request for a
|
|
1044
|
+
person are different decisions made by different people, and only an
|
|
1045
|
+
explicit hand-back (`assign!(to: rose, by: lucia)`) lifts those.
|
|
1046
|
+
|
|
1047
|
+
Reopening a case she closed leaves it unassigned and writes
|
|
1048
|
+
`assistant_cap = "draft"` for the rest of its life. A case that came back is
|
|
1049
|
+
a case where her answer was not the end of it.
|
|
1050
|
+
|
|
1051
|
+
### When the harness is down
|
|
1052
|
+
|
|
1053
|
+
Two scheduled tasks, and they are the difference between a delay and a
|
|
1054
|
+
customer nobody answers:
|
|
1055
|
+
|
|
1056
|
+
```yaml
|
|
1057
|
+
# config/recurring.yml
|
|
1058
|
+
support_desk_release_silent_assistants:
|
|
1059
|
+
command: "SupportDesk.release_silent_assistants!"
|
|
1060
|
+
schedule: every minute
|
|
1061
|
+
support_desk_redispatch_assistant_turns:
|
|
1062
|
+
command: "SupportDesk.redispatch_assistant_turns!"
|
|
1063
|
+
schedule: every 5 minutes
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
`release_silent_assistants` hands over every case she has sat on longer than
|
|
1067
|
+
her `responds_within` without answering — a worker that stopped, a provider
|
|
1068
|
+
that is down, a job that spent its last retry. It asks for a **person** on
|
|
1069
|
+
each one, not just her seat back: a case that waited that long deserves one
|
|
1070
|
+
whatever she would have said. `redispatch_assistant_turns` re-emits the turn
|
|
1071
|
+
for cases nobody acted on, which is safe precisely because the turn is
|
|
1072
|
+
consumed by the first action and every later one is a `StaleTurn`.
|
|
1073
|
+
|
|
1074
|
+
`rake support_desk:assistant_status` reads and writes nothing, and is the
|
|
1075
|
+
line to put in a deploy check. `SupportDesk.doctor` covers the same ground
|
|
1076
|
+
with verdicts.
|
|
1077
|
+
|
|
1078
|
+
### What the model sees
|
|
1079
|
+
|
|
1080
|
+
> [!WARNING]
|
|
1081
|
+
> **`ticket.brief` and `ticket.transcript` are what your harness sends to a
|
|
1082
|
+
> third party.** Two fields in the brief are host data you chose:
|
|
1083
|
+
> `Supportable#support_context` (about the thing the case is about) and
|
|
1084
|
+
> `Requester#support_context` (about the person). `include_internal: true`
|
|
1085
|
+
> adds the desk's private reasoning — notes agents left each other, and
|
|
1086
|
+
> proposals a human rejected with the reason. Decide what belongs in a
|
|
1087
|
+
> prompt before you fill those in, not after.
|
|
1088
|
+
|
|
1089
|
+
```ruby
|
|
1090
|
+
brief = ticket.brief(include_internal: false, transcript_limit: 50)
|
|
1091
|
+
brief.to_h # versioned data — schema_version, desk, assistant, case, requester, transcript
|
|
1092
|
+
brief.to_text # the same facts as sectioned plain text
|
|
1093
|
+
brief.policy # what she may do here, and why
|
|
1094
|
+
|
|
1095
|
+
ticket.transcript.to_text
|
|
1096
|
+
# [2026-09-18 10:02] Alice: No me han pagado
|
|
1097
|
+
# [2026-09-18 10:03] Rose: Lo estoy mirando ahora mismo
|
|
1098
|
+
# [2026-09-18 10:07] Lucía: Ya está resuelto [justificante.pdf]
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
A brief is **facts, never instructions**. There is not one imperative
|
|
1102
|
+
sentence in it and there never will be: what the assistant should *do* with
|
|
1103
|
+
a case is your prompt and your product. The one thing it states about
|
|
1104
|
+
behaviour is `may` / `may_not`, and that is not advice either — it is the
|
|
1105
|
+
authorization, straight off the policy, so a harness never has to re-derive
|
|
1106
|
+
the rules it is working under.
|
|
1107
|
+
|
|
1108
|
+
The transcript speaks four roles (`:requester`, `:human`, `:assistant`,
|
|
1109
|
+
`:system`) where `Ticket#role_of` speaks three: whose turn it is does not
|
|
1110
|
+
change because a machine wrote the desk's last word, but a reader cares.
|
|
1111
|
+
Deleted messages stay in it as tombstones, and an assistant's name follows
|
|
1112
|
+
your configuration — rename her and the whole transcript renames, drop her
|
|
1113
|
+
from the initializer and it keeps saying what the customer was actually
|
|
1114
|
+
shown.
|
|
1115
|
+
|
|
1116
|
+
### Before you launch
|
|
1117
|
+
|
|
1118
|
+
- [ ] `SupportDesk.doctor.print` is green. It checks her bindings, that
|
|
1119
|
+
`Chats.display_name_for` answers for her, that no case she holds needs
|
|
1120
|
+
a person, that a turn subscriber exists, and that nothing has been
|
|
1121
|
+
idle longer than three times her `responds_within`.
|
|
1122
|
+
- [ ] **Moderation covers both shapes.** A signed message has her as its
|
|
1123
|
+
author; a `:notice` or `:none` message has no author at all and is the
|
|
1124
|
+
desk's. Whatever owns your moderation has to catch the nameless one
|
|
1125
|
+
too.
|
|
1126
|
+
- [ ] Notifiers subscribe to `draft_proposed` (a proposal is waiting),
|
|
1127
|
+
`ticket_escalated` and `human_requested` (somebody needs a person).
|
|
1128
|
+
- [ ] The `_human_door` partial renders in your thread.
|
|
1129
|
+
- [ ] Both rake tasks are scheduled, and you have watched them run once.
|
|
1130
|
+
- [ ] `autonomy` is `:draft`, and topic caps are on money and identity.
|
|
1131
|
+
Raise her only when the acceptance rate on reviewed proposals says so.
|
|
1132
|
+
|
|
1133
|
+
### Reference
|
|
1134
|
+
|
|
1135
|
+
**Configuration**
|
|
1136
|
+
|
|
1137
|
+
| setting | type | default |
|
|
1138
|
+
|---|---|---|
|
|
1139
|
+
| `name` | String | the key, humanized |
|
|
1140
|
+
| `avatar` | String, or a callable given the assistant | nil |
|
|
1141
|
+
| `autonomy` | one of `AssistantPolicy::LEVELS` | `:draft` |
|
|
1142
|
+
| `disclosure` | **required**: `:signature_and_notice` · `:signature` · `:notice` · `:none` | — |
|
|
1143
|
+
| `max_turns` | positive Integer, or nil for unlimited (doctor warns) | 6 |
|
|
1144
|
+
| `responds_within` | a Duration, or nil to disable the silent sweep (doctor warns) | 3 minutes |
|
|
1145
|
+
| `may_open_conversations` | true / false | false |
|
|
1146
|
+
| `hand_off_line` · `human_requested_line` · `disclosure_line` | String (`%{name} %{desk} %{reply_within}`), I18n key, block, or nil | the gem's copy |
|
|
1147
|
+
| `hand_off_when` | block `(ticket, message)` → true / false / nil; anything else, or a raise, hands the case over | nil |
|
|
1148
|
+
| `cap` | block `(ticket)` → a level or nil | nil |
|
|
1149
|
+
|
|
1150
|
+
Predicates: `signs?`, `notice?`, `disclosed?`, `may_open_conversations?`,
|
|
1151
|
+
`line_for(setting, ticket)`. The four modes are
|
|
1152
|
+
`AssistantConfiguration::DISCLOSURE_MODES`.
|
|
1153
|
+
|
|
1154
|
+
```ruby
|
|
1155
|
+
config.assistant :rose { |rose| … } config.assistant(:rose) config.assistants config.assistant?(:rose)
|
|
1156
|
+
config.default_assistant = :rose
|
|
1157
|
+
config.desk(:billing) { |desk| desk.assistant = :rose } # an explicit nil DISABLES that desk
|
|
1158
|
+
config.desk(:billing).assistant_key
|
|
1159
|
+
topic :payments, assistant: :draft # Topic#assistant_cap — tightens only
|
|
1160
|
+
acts_as_support_agent kind: :ai # validated; see the upgrade note
|
|
1161
|
+
```
|
|
1162
|
+
|
|
1163
|
+
**Module**
|
|
1164
|
+
|
|
1165
|
+
```ruby
|
|
1166
|
+
SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
|
|
1167
|
+
SupportDesk.reset_assistants!
|
|
1168
|
+
SupportDesk.ai_actor?(record)
|
|
1169
|
+
SupportDesk.release_silent_assistants! # → Integer
|
|
1170
|
+
SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
**`SupportDesk::Assistant`**
|
|
1174
|
+
|
|
1175
|
+
| | |
|
|
1176
|
+
|---|---|
|
|
1177
|
+
| `SupportDesk::Assistant.for(key)` / `.active` | found or created; the on-duty scope |
|
|
1178
|
+
| `config` / `configured?` | her slice of the configuration; whether anything still declares her |
|
|
1179
|
+
| `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration, with safe answers when it is gone |
|
|
1180
|
+
| `disclosed?` `signs?` `notice?` | the mode, as predicates |
|
|
1181
|
+
| `disclosed_name` `display_name` `to_s` `support_agent_name` `support_agent_avatar` | what a requester sees |
|
|
1182
|
+
| `on_duty?` `support_capacity` | the agent contract |
|
|
1183
|
+
| `deactivate!(by:, reason:)` / `activate!(by:)` | the cross-process kill switch; rows are never destroyed |
|
|
1184
|
+
| `desks` / `held_tickets` | where she works, and what she is sitting on |
|
|
1185
|
+
|
|
1186
|
+
**`SupportDesk::AssistantPolicy`**
|
|
1187
|
+
|
|
1188
|
+
`LEVELS` · `RANK` · `VERBS_BY_LEVEL` · `ALL_VERBS` ·
|
|
1189
|
+
`AssistantPolicy.for(ticket, assistant, hand_back: false)` · `level` ·
|
|
1190
|
+
`because` · `ceilings` · `floors` · `assistant` · `ticket` ·
|
|
1191
|
+
`at_least?(level)` · `may?(verb)` · `may_observe?` · `may_draft?` ·
|
|
1192
|
+
`may_reply?` · `may_hold?` · `may_close?` · `allowed_verbs` ·
|
|
1193
|
+
`forbidden_verbs` · `to_h` · `to_s` · `null?` (and `AssistantPolicy::Null`,
|
|
1194
|
+
whose `because` says which of "no assistant here" and "not this desk's
|
|
1195
|
+
assistant" it was).
|
|
1196
|
+
|
|
1197
|
+
**`SupportDesk::Ticket`**
|
|
1198
|
+
|
|
1199
|
+
```ruby
|
|
1200
|
+
# verbs
|
|
1201
|
+
respond!(body, by:, turn:, files:, confidence:, sources:, metadata:, request:) # → Outcome
|
|
1202
|
+
draft!(body, by:, turn:, …) # → Draft
|
|
1203
|
+
escalate!(by:, reason:, summary:, turn:, request:)
|
|
1204
|
+
request_human!(by:, request:) # by: must be the requester
|
|
1205
|
+
pause_assistant!(by:, reason:) / resume_assistant!(by:)
|
|
1206
|
+
|
|
1207
|
+
# readers
|
|
1208
|
+
assistant assistant_policy(assistant = self.assistant, hand_back: false) assistant_turn
|
|
1209
|
+
assistant_in_play? held_by_assistant? human_required? assistant_paused? assistant_turns_left
|
|
1210
|
+
assistant_message?(message) transcript(limit: nil) brief(include_internal:, transcript_limit:)
|
|
1211
|
+
drafts pending_draft notes
|
|
1212
|
+
|
|
1213
|
+
# scopes
|
|
1214
|
+
held_by_assistants held_by_humans needs_human assistant_paused assistant_capped
|
|
1215
|
+
resolved_by_assistant with_pending_draft assistant_idle_since(time)
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
Columns: `assistant_revision`, `last_requester_message_id`,
|
|
1219
|
+
`assistant_turns_count`, `assistant_acted_at`, `assistant_paused_at`,
|
|
1220
|
+
`assistant_paused_reason`, `assistant_cap`, `human_required_at`,
|
|
1221
|
+
`human_required_reason`.
|
|
1222
|
+
|
|
1223
|
+
**`SupportDesk::Draft`**
|
|
1224
|
+
|
|
1225
|
+
`STATUSES` · `MAX_SOURCES` (20) · `MAX_SOURCE_TITLE` (500) ·
|
|
1226
|
+
`MAX_SOURCE_URL` (2048) · `send!(by:, seen_turn:, body:, request:)` ·
|
|
1227
|
+
`reject!(by:, reason:, request:)` · `pending?` `sent?` `rejected?`
|
|
1228
|
+
`superseded?` `expired?` · `stale?` · `edited?` · `final_body` ·
|
|
1229
|
+
`confidence_percent` · `author_key` · scopes `pending` `sent` `rejected`
|
|
1230
|
+
`superseded` `expired` `reviewed` `verbatim` `edited` `by(author)`
|
|
1231
|
+
`chronological` `newest_first`.
|
|
1232
|
+
|
|
1233
|
+
**`SupportDesk::Outcome`** — `action` `message` `draft` `policy` `reason`
|
|
1234
|
+
`turn` · `sent?` `drafted?` `withheld?` `escalated?` · `to_h`.
|
|
1235
|
+
|
|
1236
|
+
**`SupportDesk::Transcript`** — `Turn(role:, name:, body:, at:,
|
|
1237
|
+
attachments:, assisted:, message:)` with `requester?` `human?` `assistant?`
|
|
1238
|
+
`system?` `assisted?` and `to_line`; `to_a` `to_h` `to_text` `last(n)`
|
|
1239
|
+
`since(message)` `size`, Enumerable, and `limit:`. `to_h` carries
|
|
1240
|
+
`truncated`, because a reader seeing the last 50 of 300 turns has to know.
|
|
1241
|
+
|
|
1242
|
+
**`SupportDesk::Brief`** — `SCHEMA_VERSION` · `to_h` · `to_text` · `policy`
|
|
1243
|
+
· `include_internal?` · `transcript`.
|
|
1244
|
+
|
|
1245
|
+
**`Requester#support_context`** — overridable, `{}` by default, the same
|
|
1246
|
+
meaning as `Supportable#support_context`. `ContextCard#requester_pairs`
|
|
1247
|
+
renders it next to the subject's own `pairs`, and `to_h` carries it under
|
|
1248
|
+
`requester.context`.
|
|
1249
|
+
|
|
1250
|
+
**`SupportDesk::Desk`** — `assistant` · `assistant?` · `humans` (people
|
|
1251
|
+
only) · `agents` (people plus her, an Array).
|
|
1252
|
+
|
|
1253
|
+
**Events**
|
|
1254
|
+
|
|
1255
|
+
| event | arguments |
|
|
1256
|
+
|---|---|
|
|
1257
|
+
| `assistant_turn` | `ticket, assistant, message, turn:` — the only one a harness subscribes to |
|
|
1258
|
+
| `draft_proposed` | `ticket, draft` |
|
|
1259
|
+
| `draft_sent` | `ticket, draft, message, by:` |
|
|
1260
|
+
| `draft_rejected` | `ticket, draft, by:, reason:` |
|
|
1261
|
+
| `assistant_withheld` | `ticket, assistant, reason:, policy:` |
|
|
1262
|
+
| `ticket_escalated` | `ticket, from:, reason:, by:` |
|
|
1263
|
+
| `human_requested` | `ticket, by:, reason:` |
|
|
1264
|
+
| `assistant_paused` / `assistant_resumed` | `ticket, by:` |
|
|
1265
|
+
|
|
1266
|
+
New `Event::KINDS`: `human_requested` `assistant_paused` `assistant_resumed`
|
|
1267
|
+
`draft_sent` `draft_rejected` `assistant_withheld` — the last five join
|
|
1268
|
+
`note` and `drop_in` in `Event::INTERNAL_KINDS`, which is what
|
|
1269
|
+
`Event.requester_visible` excludes. An export says a person was asked for;
|
|
1270
|
+
it never says a machine's proposal was discarded. `Event#summary` reads the
|
|
1271
|
+
paragraph an escalation left.
|
|
1272
|
+
|
|
1273
|
+
**Errors** — `SupportDesk::NotAnAssistant` (an AI-kind actor that is not
|
|
1274
|
+
this desk's assistant; a subclass of `NotAnAgent`),
|
|
1275
|
+
`SupportDesk::AssistantNotAllowed` (carries `policy` and `verb`; a subclass
|
|
1276
|
+
of `NotAllowed`), `SupportDesk::StaleTurn` (a subclass of
|
|
1277
|
+
`InvalidTransition`, so `rescue InvalidTransition` still catches it).
|
|
1278
|
+
|
|
1279
|
+
**Console** — `MEMBER_VERBS` gains `send_draft` `reject_draft`
|
|
1280
|
+
`pause_assistant` `resume_assistant`; helpers `support_pending_draft` and
|
|
1281
|
+
`support_assistant`; `unavailable_reason` gains `"no_pending_draft"`. Picker
|
|
1282
|
+
values are `SupportDesk.actor_key(agent)`. `Queue::TABS` gains
|
|
1283
|
+
`:needs_human`, hidden by `visible_tabs` unless the desk has an assistant or
|
|
1284
|
+
the count is non-zero.
|
|
1285
|
+
|
|
1286
|
+
**Requester engine** — `POST /tickets/:id/request_human`, and
|
|
1287
|
+
`support_desk/tickets/_human_door` (local: `ticket`) behind the
|
|
1288
|
+
`support_human_door(ticket)` helper.
|
|
1289
|
+
|
|
1290
|
+
**Rake** — `support_desk:release_silent_assistants` ·
|
|
1291
|
+
`support_desk:redispatch_assistant_turns` (`OLDER_THAN=60`) ·
|
|
1292
|
+
`support_desk:assistant_status`.
|
|
1293
|
+
|
|
1294
|
+
**Doctor** — `assistants (config)` · `assistant turn subscriber` ·
|
|
1295
|
+
`assistant authorship` · `assistant silence` · `assistant seats` ·
|
|
1296
|
+
`assistant idle turns` · `drafts` · `ai agents without policy`.
|
|
1297
|
+
|
|
1298
|
+
**Test helpers**
|
|
1299
|
+
|
|
1300
|
+
| helper | |
|
|
1301
|
+
|---|---|
|
|
1302
|
+
| `support_assistant(key = nil)` | the record |
|
|
1303
|
+
| `respond_as(assistant, ticket, body, turn:, **options)` | → `Outcome` |
|
|
1304
|
+
| `draft_as(assistant, ticket, body, turn:, **options)` | → `Draft` |
|
|
1305
|
+
| `assert_pending_draft(ticket, body:)` / `refute_pending_draft` | `body:` takes a String (substring) or a Regexp |
|
|
1306
|
+
| `assert_needs_human(ticket, reason:)` / `refute_needs_human` | |
|
|
1307
|
+
| `assert_held_by_assistant(ticket, assistant = nil)` | |
|
|
1308
|
+
| `refute_assistant_spoke(ticket)` | no machine has said anything to the requester |
|
|
1309
|
+
| `assert_assistant_policy(ticket, level, because:)` | the level **and** the sentence |
|
|
1310
|
+
| `with_assistant_config(key = nil, **overrides) { … }` | |
|
|
1311
|
+
| `with_topic_assistant_cap(path, level) { … }` | rebuilds the frozen tree with one cap |
|
|
1312
|
+
|
|
1313
|
+
### Upgrading to 0.3
|
|
1314
|
+
|
|
1315
|
+
```bash
|
|
1316
|
+
rails generate support_desk:upgrade # copies the additive assistants migration
|
|
1317
|
+
rails db:migrate
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
Additive and rolling-safe — unlike 0.2, no drain. Deploy **every** process to
|
|
1321
|
+
0.3 before you add `config.assistant`: a 0.2 worker cannot honour a turn it
|
|
1322
|
+
does not know about.
|
|
1323
|
+
|
|
1324
|
+
Four things change whether or not you configure an assistant:
|
|
1325
|
+
|
|
1326
|
+
- **A host model declared `acts_as_support_agent kind: :ai` is now refused
|
|
1327
|
+
for every support write, by it or to it** (`NotAnAssistant`). It used to
|
|
1328
|
+
be treated as a human. `kind:` is validated at declaration, and `doctor`
|
|
1329
|
+
warns about such classes. Only the desk's own `SupportDesk::Assistant` has
|
|
1330
|
+
machine authority.
|
|
1331
|
+
- **Console picker values are actor keys**, not bare ids — an assistant and
|
|
1332
|
+
a user can share an integer id. A bare id is still accepted for one
|
|
1333
|
+
release, and only when exactly one pool member matches it.
|
|
1334
|
+
- `Queue::TABS` gains `:needs_human`, hidden unless it is relevant.
|
|
1335
|
+
- **Every registered message and every transition writes
|
|
1336
|
+
`assistant_revision`**, on every case, with or without an assistant. It is
|
|
1337
|
+
one nullable integer, it is what the turn is made of, and a 0.2 process
|
|
1338
|
+
reading those rows is unaffected — which is why step 3 matters in the
|
|
1339
|
+
other direction: a 0.2 *writer* leaves the counter behind.
|
|
1340
|
+
|
|
1341
|
+
And when you do configure one: `desk.agents` becomes an Array of humans plus
|
|
1342
|
+
her and `desk.humans` is the human-only pool, `announce_assignments` never
|
|
1343
|
+
announces her, a human reply takes her case over under every `reply_policy`
|
|
1344
|
+
and supersedes the pending proposal, `close!` expires proposals, a reopen
|
|
1345
|
+
after her close caps her at `:draft`, and `reply!` / `post_agent_message!`
|
|
1346
|
+
accept `metadata:` and `turn:`.
|
|
1347
|
+
|
|
1348
|
+
`rose.deactivate!(by: owner)` is the kill switch: cross-process, within one
|
|
1349
|
+
transition, no deploy.
|
|
1350
|
+
|
|
494
1351
|
## The wizard
|
|
495
1352
|
|
|
496
1353
|
"What do you need help with?" is a plain object, not a controller, so a host
|
|
@@ -527,12 +1384,97 @@ Pass `key:` from anywhere that runs more than once (a `to_prepare` block, an eng
|
|
|
527
1384
|
Keep push titles and bodies generic — `ticket.notification_title` is safe for previews.
|
|
528
1385
|
`ticket.notification_body` contains case details for an authenticated feed, not a lock screen.
|
|
529
1386
|
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
The whole catalogue, with the arguments each subscriber receives:
|
|
1390
|
+
|
|
1391
|
+
| event | arguments | when |
|
|
1392
|
+
|---|---|---|
|
|
1393
|
+
| `ticket_opened` | `ticket` | a case was **inserted** — by a requester or by the desk; check `ticket.opened_by_support?` before paging your team about their own message |
|
|
1394
|
+
| `requester_replied` | `ticket, message` | a requester message registered, other than the one that opened the case |
|
|
1395
|
+
| `agent_replied` | `ticket, message` | a desk message registered, other than the desk's own opening message |
|
|
1396
|
+
| `ticket_assigned` | `ticket, assignment` | take, assign, or (later) routing — not the silent seat a desk-opened case starts with |
|
|
1397
|
+
| `ticket_handed_off` | `ticket, assignment, from:, note:` | |
|
|
1398
|
+
| `ticket_released` | `ticket, from:, reason:` | |
|
|
1399
|
+
| `ticket_closed` / `ticket_reopened` | `ticket, by:` | `by:` is the requester when their own reply reopened it |
|
|
1400
|
+
| `ticket_topic_changed` | `ticket, from:, to:, by:` | |
|
|
1401
|
+
| `subject_attached` | `ticket, subject, by:` | |
|
|
1402
|
+
| `note_added` | `ticket, event` | internal notes never reach the conversation |
|
|
1403
|
+
| `assistant_turn` | `ticket, assistant, message, turn:` | there is something for an assistant to answer — the only event a harness subscribes to (see [Assistants](#-assistants)) |
|
|
1404
|
+
| `draft_proposed` | `ticket, draft` | a proposal is waiting for a person |
|
|
1405
|
+
| `draft_sent` | `ticket, draft, message, by:` | |
|
|
1406
|
+
| `draft_rejected` | `ticket, draft, by:, reason:` | |
|
|
1407
|
+
| `assistant_withheld` | `ticket, assistant, reason:, policy:` | `respond!` wrote nothing, and the policy that refused is in the payload |
|
|
1408
|
+
| `ticket_escalated` | `ticket, from:, reason:, by:` | a case was handed to a person |
|
|
1409
|
+
| `human_requested` | `ticket, by:, reason:` | the requester pressed the door, or a `hand_off_when` phrase fired |
|
|
1410
|
+
| `assistant_paused` / `assistant_resumed` | `ticket, by:` | |
|
|
1411
|
+
| `ticket_transitioned` | `ticket, kind, by:, request:, payload:` | once per event row — the audit-log hook |
|
|
1412
|
+
|
|
1413
|
+
`ticket.agents_to_notify` is the assignee, or the whole on-duty pool while
|
|
1414
|
+
nobody holds the case — the gem computes it so every host gets "assignee or
|
|
1415
|
+
everyone" right. System messages (opening lines, closure notices) never emit
|
|
1416
|
+
anything and never move the clocks.
|
|
1417
|
+
|
|
1418
|
+
## Errors
|
|
1419
|
+
|
|
1420
|
+
Every error inherits `SupportDesk::Error`, so `rescue SupportDesk::Error`
|
|
1421
|
+
catches anything support-specific; the console turns each into a translated
|
|
1422
|
+
flash. `ConfigurationError` (boot) · `ActorMissing` (a transition with no
|
|
1423
|
+
`by:` and no `Current.actor`) · `NotAnAgent` · `NotARequester` (no
|
|
1424
|
+
`has_support_tickets`, or its `if:` said no) · `NotTheAssignee` (a hand-off
|
|
1425
|
+
by somebody who doesn't hold the case) · `NotAllowed` (policy: a drop-in
|
|
1426
|
+
under `:assignee_only`, somebody else's record, a hidden topic, an agent
|
|
1427
|
+
writing to themselves) · `InvalidTransition` and its subclass `Locked` (a
|
|
1428
|
+
closed case on a desk that locks them, or a requester who can no longer be
|
|
1429
|
+
written to) · `UnknownTopic` · `NotSupportable` · `RateLimited` ·
|
|
1430
|
+
`TooManyOpenTickets`. With an assistant configured, three more:
|
|
1431
|
+
`NotAnAssistant` (an AI-kind actor that isn't this desk's own assistant — a
|
|
1432
|
+
subclass of `NotAnAgent`), `AssistantNotAllowed` (her policy forbids the
|
|
1433
|
+
verb; it carries the `policy` and the `verb`, so you can log the rule
|
|
1434
|
+
instead of parsing the sentence) and `StaleTurn` (the case changed since she
|
|
1435
|
+
read it — a subclass of `InvalidTransition`, so anything rescuing that
|
|
1436
|
+
still catches it).
|
|
1437
|
+
|
|
1438
|
+
## Locales
|
|
1439
|
+
|
|
1440
|
+
`es` and `en` ship with the gem, under `support_desk.*` (requester screens,
|
|
1441
|
+
system lines, notifications, queue tabs, statuses, channels) and
|
|
1442
|
+
`support_desk.console.*` (queue, case page, compose form, flashes, errors).
|
|
1443
|
+
Your own locale files **outrank** the gem's — Rails loads every engine's
|
|
1444
|
+
locales first and the app's last — so override any key in your `es.yml` and
|
|
1445
|
+
the gem's copy loses. The ones hosts usually touch: `support_desk.topics.<path>.label`
|
|
1446
|
+
(and `.ask`, `.hint`), `support_desk.queue.tabs.*`,
|
|
1447
|
+
`support_desk.system.assigned`, `support_desk.thread.*`,
|
|
1448
|
+
`support_desk.console.flashes.*` and `support_desk.console.errors.*`. The
|
|
1449
|
+
suite asserts both languages carry exactly the same keys.
|
|
1450
|
+
|
|
1451
|
+
## Doctor
|
|
1452
|
+
|
|
1453
|
+
```ruby
|
|
1454
|
+
SupportDesk.doctor.print # or .ok? in CI
|
|
1455
|
+
```
|
|
1456
|
+
|
|
1457
|
+
Configuration: `requester_class`, `agents` (the block resolves to records),
|
|
1458
|
+
`topics` (a tree with a way out), `opening lines` (every static line
|
|
1459
|
+
interpolates, every I18n key exists), `supportables` (every `about:` class is
|
|
1460
|
+
supportable), `find_requester` (callable, one argument), `engine mount`,
|
|
1461
|
+
`parent controllers`. Chats seams: `chats subscribers`, `chats authorship`,
|
|
1462
|
+
`desk messager`. Data invariants: `conversations` (every ticket has one),
|
|
1463
|
+
`assignments` (at most one open per case), `assignee pointers`, `provenance`
|
|
1464
|
+
(no half-NULL `opened_by`; warns on legacy NULL rows and names the backfill
|
|
1465
|
+
task), `awaiting` (agrees with the transcript), `references` (unique).
|
|
1466
|
+
Assistants (only where one is configured): `assistants (config)`,
|
|
1467
|
+
`assistant turn subscriber`, `assistant authorship`, `assistant silence`,
|
|
1468
|
+
`assistant seats`, `assistant idle turns`, `drafts`, and `ai agents without
|
|
1469
|
+
policy` — which warns about any host class declared `kind: :ai`, since every
|
|
1470
|
+
support write by it is refused.
|
|
1471
|
+
|
|
530
1472
|
## Compatibility
|
|
531
1473
|
|
|
532
1474
|
Rails 7.2, 8.0 and 8.1; Ruby >= 3.2; PostgreSQL, SQLite and MySQL; bigint or UUID primary keys (the migration follows your app's `primary_key_type`).
|
|
533
1475
|
|
|
534
|
-
PostgreSQL and SQLite enforce unique new-case creation
|
|
535
|
-
with **partial unique indexes**. New submissions reuse an existing open case. Reopening
|
|
1476
|
+
PostgreSQL and SQLite enforce unique new-case creation, one open assignment per ticket and
|
|
1477
|
+
one pending proposal per ticket with **partial unique indexes**. New submissions reuse an existing open case. Reopening
|
|
536
1478
|
historical cases is deliberately exempt from new-case deduplication: if a newer case
|
|
537
1479
|
already exists, both histories remain open and support is notified of the reply. No
|
|
538
1480
|
conversation is silently merged, closed or discarded. MySQL has no partial indexes,
|
|
@@ -558,6 +1500,7 @@ bundle exec appraisal rails-8.1 rake test
|
|
|
558
1500
|
include SupportDesk::TestHelpers
|
|
559
1501
|
|
|
560
1502
|
ticket = open_support_ticket(for: users(:alice), about: orders(:one), message: "…")
|
|
1503
|
+
written = open_support_ticket(for: users(:alice), by: users(:lucia), message: "…") # the desk writes first
|
|
561
1504
|
reply_as users(:lucia), ticket, "…"
|
|
562
1505
|
assert_awaiting_requester ticket
|
|
563
1506
|
assert_ticket_event ticket, :handed_off, from: users(:lucia), to: users(:pedro)
|
|
@@ -565,6 +1508,47 @@ assert_ticket_event ticket, :handed_off, from: users(:lucia), to: users(:pedro)
|
|
|
565
1508
|
with_support_config(reply_policy: :assignee_only) { … }
|
|
566
1509
|
```
|
|
567
1510
|
|
|
1511
|
+
Everything the module gives you:
|
|
1512
|
+
|
|
1513
|
+
| helper | |
|
|
1514
|
+
|---|---|
|
|
1515
|
+
| `open_support_ticket(for:, message:, about:, topic:, by:)` | a case, the way a requester opens one — or the desk, with `by:` |
|
|
1516
|
+
| `reply_as(agent, ticket, body, files:)` | answer; returns the `Chats::Message` |
|
|
1517
|
+
| `ask_again(ticket, body)` | the requester writes again |
|
|
1518
|
+
| `assert_awaiting_reply` / `assert_awaiting_requester` | who owes the next word (these reload the ticket) |
|
|
1519
|
+
| `assert_ticket_open` / `assert_ticket_closed` | status |
|
|
1520
|
+
| `assert_assigned_to(ticket, agent)` / `assert_unassigned` | the seat |
|
|
1521
|
+
| `assert_ticket_event(ticket, kind, from:, to:, by:)` / `refute_ticket_event` | the timeline |
|
|
1522
|
+
| `with_support_config(desk = :default, **overrides) { … }` | different desk settings for one block, put back afterwards |
|
|
1523
|
+
| `capture_support_events(*names) { … }` | `[[name, args, kwargs], …]` of what the block emitted; unsubscribes on the way out |
|
|
1524
|
+
|
|
1525
|
+
And, with an assistant configured, the ones its own suite uses — `support_assistant`, `respond_as`, `draft_as`, `assert_pending_draft` / `refute_pending_draft`, `assert_needs_human` / `refute_needs_human`, `assert_held_by_assistant`, `refute_assistant_spoke`, `assert_assistant_policy`, `with_assistant_config` and `with_topic_assistant_cap`. They are documented in [Assistants](#-assistants).
|
|
1526
|
+
|
|
1527
|
+
Between examples, `SupportDesk.reset!` clears configuration, subscribers,
|
|
1528
|
+
desks and registries; `SupportDesk.reset_desks!` only forgets the memoised
|
|
1529
|
+
desk records.
|
|
1530
|
+
|
|
1531
|
+
## Module-level API
|
|
1532
|
+
|
|
1533
|
+
```ruby
|
|
1534
|
+
SupportDesk.configure { |config| … } SupportDesk.config SupportDesk.configured?
|
|
1535
|
+
SupportDesk.desk(key = :default) # the Desk record, found or created, memoised
|
|
1536
|
+
SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
|
|
1537
|
+
SupportDesk.reset_assistants! SupportDesk.ai_actor?(record)
|
|
1538
|
+
SupportDesk.release_silent_assistants! # the net under a dead harness (schedule it)
|
|
1539
|
+
SupportDesk.redispatch_assistant_turns!(older_than: 1.minute)
|
|
1540
|
+
SupportDesk.find_topic("billing/invoice")
|
|
1541
|
+
SupportDesk.on(event, key: nil) { … } SupportDesk.off(event, key)
|
|
1542
|
+
SupportDesk.doctor
|
|
1543
|
+
SupportDesk.native_path_rules(mount:, title:) # Hotwire Native path rules for the requester screens
|
|
1544
|
+
SupportDesk.humanize_duration(24.hours) # "1 day", in the reader's language
|
|
1545
|
+
SupportDesk.actor_key(record) # a stable key for an actor (GlobalID param); what event payloads store
|
|
1546
|
+
SupportDesk.requester_class?(klass) .supportable_class?(klass) .agent_class?(klass)
|
|
1547
|
+
SupportDesk.eligible?(record, condition) # how both macros read their `if:`
|
|
1548
|
+
SupportDesk.subscribe_to_chats! # the `:message_created` listener; idempotent
|
|
1549
|
+
SupportDesk::VERSION
|
|
1550
|
+
```
|
|
1551
|
+
|
|
568
1552
|
## Development
|
|
569
1553
|
|
|
570
1554
|
After checking out the repo, run `bundle install`, then `bundle exec rake ci`. The dummy app lives in `test/dummy` and mounts all three surfaces the way a real host does: the requester engine at `/messages/support`, `chats` at `/messages`, and the turnkey console at `/admin/support` — plus the same console again inside a host-owned `madmin` namespace, because "the console uses only the public API" is a claim that needs a second implementation to be worth anything.
|