support_desk 0.1.3 → 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 +126 -0
- data/README.md +1101 -22
- data/Rakefile +16 -0
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +28 -4
- data/app/helpers/support_desk/engine_helper.rb +23 -1
- 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 +33 -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/_new_conversation_form.html.erb +91 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/index.html.erb +14 -2
- data/app/views/support_desk/console/tickets/new.html.erb +25 -0
- 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/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +89 -0
- data/config/locales/support_desk.console.es.yml +91 -0
- data/config/locales/support_desk.en.yml +18 -0
- data/config/locales/support_desk.es.yml +29 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +26 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -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/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +63 -0
- data/lib/generators/support_desk/upgrade_generator.rb +66 -0
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +699 -3
- data/lib/support_desk/console.rb +634 -30
- data/lib/support_desk/console_engine.rb +2 -0
- data/lib/support_desk/console_routes.rb +20 -8
- data/lib/support_desk/context_card.rb +33 -1
- data/lib/support_desk/doctor.rb +195 -2
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +42 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +41 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +45 -12
- 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 +781 -120
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +160 -3
- 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/wizard.rb +4 -12
- data/lib/support_desk.rb +153 -0
- data/lib/tasks/support_desk.rake +73 -0
- metadata +35 -8
|
@@ -48,6 +48,8 @@ module SupportDesk
|
|
|
48
48
|
# that makes a later precedence bug much harder to read. SupportDesk
|
|
49
49
|
# ::Engine already ships them for both namespaces.
|
|
50
50
|
paths["config/locales"] = []
|
|
51
|
+
# The requester engine owns task discovery too; both share this root.
|
|
52
|
+
paths["lib/tasks"] = []
|
|
51
53
|
|
|
52
54
|
# `concerns: :support_console` in the HOST's routes file. Registered from
|
|
53
55
|
# an initializer, which is early enough: the app's routes are not drawn
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SupportDesk
|
|
4
|
-
# The `:support_console` routing concern — the one line that turns
|
|
4
|
+
# The `:support_console` routing concern — the one line that turns a few
|
|
5
5
|
# RESTful actions into a working console:
|
|
6
6
|
#
|
|
7
7
|
# namespace :madmin do
|
|
8
|
-
# resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
8
|
+
# resources :support_tickets, only: %i[index show new], concerns: :support_console
|
|
9
9
|
# end
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# `new` stays yours — add it to `only:` when you render the form the
|
|
12
|
+
# console's `open_conversation` posts to ("Write to someone"). The concern
|
|
13
|
+
# draws the collection routes (`next`, `open_conversation`) and every
|
|
14
|
+
# member verb.
|
|
15
|
+
#
|
|
16
|
+
# It draws the collection routes first and the member routes after, which
|
|
17
|
+
# is what keeps `/madmin/support_tickets/next` from being swallowed by
|
|
13
18
|
# `/madmin/support_tickets/:id` (a `resources` block draws its concerns
|
|
14
19
|
# before its own member mappings, so "next" wins).
|
|
15
20
|
#
|
|
@@ -26,8 +31,11 @@ module SupportDesk
|
|
|
26
31
|
# The name a host writes in their routes file.
|
|
27
32
|
CONCERN = :support_console
|
|
28
33
|
|
|
29
|
-
# The
|
|
30
|
-
|
|
34
|
+
# The verbs, from the ONE table that has them: the concern that answers
|
|
35
|
+
# them owns it, so a verb can never be routed without an action or
|
|
36
|
+
# answered without a route. Kept here as an alias for a release, because
|
|
37
|
+
# a host may have read it.
|
|
38
|
+
MEMBER_VERBS = SupportDesk::Console::MEMBER_VERBS
|
|
31
39
|
|
|
32
40
|
# Rails' own message for this ("can't use collection outside resource(s)
|
|
33
41
|
# scope") is true and says nothing about which concern caused it, which
|
|
@@ -82,12 +90,16 @@ module SupportDesk
|
|
|
82
90
|
# to every route, which is how `concerns :support_console, path: "t"`
|
|
83
91
|
# keeps working.
|
|
84
92
|
def call(mapper, options = {})
|
|
93
|
+
# Read at DRAW time, from SupportDesk::Console — which is loaded by
|
|
94
|
+
# the spine long before any routes file runs, so there is no
|
|
95
|
+
# load-order risk in taking the verbs from the concern that answers
|
|
96
|
+
# them rather than keeping a second list here.
|
|
85
97
|
mapper.collection do
|
|
86
|
-
mapper.
|
|
98
|
+
SupportDesk::Console::COLLECTION_VERBS.each { |verb, method| mapper.public_send(method, verb, **options) }
|
|
87
99
|
end
|
|
88
100
|
|
|
89
101
|
mapper.member do
|
|
90
|
-
MEMBER_VERBS.each { |verb| mapper.post verb, **options }
|
|
102
|
+
SupportDesk::Console::MEMBER_VERBS.each { |verb| mapper.post verb, **options }
|
|
91
103
|
end
|
|
92
104
|
rescue ArgumentError => e
|
|
93
105
|
raise unless e.message.include?("outside resource")
|
|
@@ -59,9 +59,40 @@ module SupportDesk
|
|
|
59
59
|
Chats.avatar_for(requester)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# Key/value pairs about the PERSON, from `Requester#support_context` —
|
|
63
|
+
# the same idea as #pairs, one row up: `pairs` describes the thing the
|
|
64
|
+
# case is about, these describe whoever is asking. Empty unless the host
|
|
65
|
+
# filled it in.
|
|
66
|
+
def requester_pairs
|
|
67
|
+
requester.try(:support_context) || {}
|
|
68
|
+
end
|
|
69
|
+
|
|
62
70
|
# When this requester joined — context for "is this a new user?".
|
|
63
71
|
def requester_since = requester.try(:created_at)
|
|
64
72
|
|
|
73
|
+
# Who opened the case, as the console should print it: their name when
|
|
74
|
+
# the record still resolves, the desk's name in front of it when WE
|
|
75
|
+
# wrote first, and an honest label when there is no record to name.
|
|
76
|
+
#
|
|
77
|
+
# The three unnameable cases are not the same thing and must not read the
|
|
78
|
+
# same way: no provenance at all, an actor whose record is gone, and a
|
|
79
|
+
# requester-opened case, which is just their name.
|
|
80
|
+
#
|
|
81
|
+
# "Not recorded" rather than "automation": 0.2 opens every case as a
|
|
82
|
+
# record (automation openers are refused — docs/12-open-questions.md
|
|
83
|
+
# Q17), so a NULL pair is a row 0.1 wrote and nobody has backfilled yet,
|
|
84
|
+
# which is exactly what `SupportDesk.doctor` asks somebody to do.
|
|
85
|
+
def opened_by_label
|
|
86
|
+
opener = ticket.opened_by
|
|
87
|
+
return I18n.t("support_desk.console.context.not_recorded") if ticket.opened_by_id.blank?
|
|
88
|
+
return I18n.t("support_desk.console.context.unavailable") if opener.nil?
|
|
89
|
+
|
|
90
|
+
name = opener.try(:support_agent_name) || Chats.display_name_for(opener)
|
|
91
|
+
return name if ticket.opened_by_requester?
|
|
92
|
+
|
|
93
|
+
"#{ticket.desk.name} · #{name}"
|
|
94
|
+
end
|
|
95
|
+
|
|
65
96
|
# How many open cases this requester has right now, this one included.
|
|
66
97
|
def requester_open_tickets
|
|
67
98
|
Ticket.not_closed.where(requester: requester).count
|
|
@@ -79,7 +110,8 @@ module SupportDesk
|
|
|
79
110
|
requester: {
|
|
80
111
|
name: requester_name,
|
|
81
112
|
since: requester_since,
|
|
82
|
-
open_tickets: requester_open_tickets
|
|
113
|
+
open_tickets: requester_open_tickets,
|
|
114
|
+
context: requester_pairs
|
|
83
115
|
}
|
|
84
116
|
}
|
|
85
117
|
end
|
data/lib/support_desk/doctor.rb
CHANGED
|
@@ -103,6 +103,13 @@ module SupportDesk
|
|
|
103
103
|
ok_with("#{tree.count} topic(s), #{tree.leaves.size} leaf/leaves")
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
checks << check("opening lines (#{desk.key})") do
|
|
107
|
+
problems = desk.opening_line_problems
|
|
108
|
+
next fail_with(problems.join("; ")) if problems.any?
|
|
109
|
+
|
|
110
|
+
ok_with("resolve")
|
|
111
|
+
end
|
|
112
|
+
|
|
106
113
|
checks << check("supportables (#{desk.key})") do
|
|
107
114
|
missing = desk.topics.about_class_names.reject do |name|
|
|
108
115
|
klass = name.safe_constantize
|
|
@@ -114,6 +121,81 @@ module SupportDesk
|
|
|
114
121
|
end
|
|
115
122
|
end
|
|
116
123
|
|
|
124
|
+
checks << check("find_requester") do
|
|
125
|
+
callable = SupportDesk.config.find_requester
|
|
126
|
+
next ok_with("not set — the console takes a GlobalID from your own pages") if callable.nil?
|
|
127
|
+
next fail_with("find_requester must respond to #call") unless callable.respond_to?(:call)
|
|
128
|
+
|
|
129
|
+
# Asked of the SHAPE, never by calling it: a diagnostic that runs a
|
|
130
|
+
# host's lookup is a diagnostic that queries production. A callable
|
|
131
|
+
# object is as valid as a lambda, so `arity` (Proc-only) is out.
|
|
132
|
+
parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
133
|
+
required = parameters.count { |type, _| type == :req }
|
|
134
|
+
open_ended = parameters.any? { |type, _| %i[opt rest].include?(type) }
|
|
135
|
+
unless required <= 1 && (required == 1 || open_ended)
|
|
136
|
+
next fail_with("find_requester takes #{required} required argument(s); the console calls it with one")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
ok_with("the console can look people up")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if SupportDesk.config.assistants.any?
|
|
143
|
+
checks << check("assistants (config)") do
|
|
144
|
+
problems = []
|
|
145
|
+
warnings = []
|
|
146
|
+
SupportDesk.config.assistants.each_value do |assistant|
|
|
147
|
+
problems.concat(assistant.line_problems)
|
|
148
|
+
warnings << "#{assistant.key} has no max_turns — a loop with no bound is a loop" if
|
|
149
|
+
assistant.max_turns.nil?
|
|
150
|
+
warnings << "#{assistant.key} has no responds_within — nothing will notice if she goes quiet" if
|
|
151
|
+
assistant.responds_within.nil?
|
|
152
|
+
end
|
|
153
|
+
SupportDesk.config.desks.each_value do |desk|
|
|
154
|
+
key = desk.assistant_key
|
|
155
|
+
next if key.nil? || SupportDesk.config.assistant?(key)
|
|
156
|
+
|
|
157
|
+
problems << "desk #{desk.key} points at assistant #{key.inspect}, which isn't configured"
|
|
158
|
+
end
|
|
159
|
+
next fail_with(problems.join("; ")) if problems.any?
|
|
160
|
+
next warn_with(warnings.join("; ")) if warnings.any?
|
|
161
|
+
|
|
162
|
+
ok_with("#{SupportDesk.config.assistants.size} assistant(s) configured")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
checks << check("assistant turn subscriber") do
|
|
166
|
+
next ok_with("no assistant on any desk") if assistant_desks.empty?
|
|
167
|
+
next warn_with("nothing subscribes to :assistant_turn — no harness will ever answer. See the " \
|
|
168
|
+
"README's assistants section") if SupportDesk.subscribers[:assistant_turn].empty?
|
|
169
|
+
|
|
170
|
+
ok_with("#{SupportDesk.subscribers[:assistant_turn].size} subscriber(s)")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
checks << check("assistant authorship") do
|
|
174
|
+
blank = assistant_desks.filter_map do |desk|
|
|
175
|
+
assistant = desk.assistant
|
|
176
|
+
assistant.key if Chats.display_name_for(assistant).blank?
|
|
177
|
+
end
|
|
178
|
+
next fail_with("chats has no display name for #{blank.join(", ")} — a signed message would go out " \
|
|
179
|
+
"unsigned; check `Chats.config.messager_display_name`") if blank.any?
|
|
180
|
+
|
|
181
|
+
ok_with("every assistant has a name chats can print")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
checks << check("ai agents without policy") do
|
|
185
|
+
strays = SupportDesk.agent_class_names.select do |name|
|
|
186
|
+
klass = name.safe_constantize
|
|
187
|
+
next false if klass.nil? || klass == SupportDesk::Assistant
|
|
188
|
+
next false unless klass.respond_to?(:support_desk_agent_options)
|
|
189
|
+
|
|
190
|
+
klass.support_desk_agent_options[:kind] == :ai
|
|
191
|
+
end
|
|
192
|
+
next warn_with("#{strays.join(", ")} declares `kind: :ai` but isn't this gem's assistant — every " \
|
|
193
|
+
"support write by it, or to it, is refused") if strays.any?
|
|
194
|
+
|
|
195
|
+
ok_with("no unmanaged AI agents")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
117
199
|
checks << check("engine mount") do
|
|
118
200
|
path = SupportDesk.root_path
|
|
119
201
|
next warn_with("SupportDesk::Engine isn't mounted — requesters have nowhere to write") if path.nil?
|
|
@@ -162,7 +244,7 @@ module SupportDesk
|
|
|
162
244
|
return [ check("database") { warn_with("support_desk tables are missing — run rails db:migrate") } ] unless
|
|
163
245
|
tables?
|
|
164
246
|
|
|
165
|
-
[
|
|
247
|
+
checks = [
|
|
166
248
|
check("conversations") do
|
|
167
249
|
orphans = Ticket.where(conversation_id: nil).count
|
|
168
250
|
next fail_with("#{orphans} ticket(s) without a conversation") if orphans.positive?
|
|
@@ -185,8 +267,33 @@ module SupportDesk
|
|
|
185
267
|
|
|
186
268
|
ok_with("assignee matches the open assignment")
|
|
187
269
|
end,
|
|
270
|
+
check("provenance") do
|
|
271
|
+
next warn_with("no opened_by column — run `rails g support_desk:upgrade` and migrate") unless
|
|
272
|
+
Ticket.column_names.include?("opened_by_id")
|
|
273
|
+
|
|
274
|
+
half = Ticket.where(opened_by_type: nil).where.not(opened_by_id: nil)
|
|
275
|
+
.or(Ticket.where.not(opened_by_type: nil).where(opened_by_id: nil)).count
|
|
276
|
+
next fail_with("#{half} ticket(s) with half an opened_by (a type and no id, or the reverse)") if
|
|
277
|
+
half.positive?
|
|
278
|
+
|
|
279
|
+
# NULL is not automation: nothing in this version writes it, so
|
|
280
|
+
# every one of these is a row 0.1 opened, or one an old process
|
|
281
|
+
# wrote during the upgrade window.
|
|
282
|
+
legacy = Ticket.where(opened_by_id: nil).count
|
|
283
|
+
next warn_with("#{legacy} case(s) don't say who opened them — run " \
|
|
284
|
+
"`rake support_desk:backfill_opened_by`") if legacy.positive?
|
|
285
|
+
|
|
286
|
+
ok_with("every case says who opened it")
|
|
287
|
+
end,
|
|
188
288
|
check("awaiting") do
|
|
189
|
-
|
|
289
|
+
# The NULL leg is not decoration: `last_agent_message_at >
|
|
290
|
+
# last_requester_message_at` is NULL when the requester has never
|
|
291
|
+
# written, so a case waiting on the desk that only the desk has
|
|
292
|
+
# spoken in slips past a plain comparison.
|
|
293
|
+
stale = Ticket.awaiting_reply.where(
|
|
294
|
+
"last_agent_message_at > last_requester_message_at OR " \
|
|
295
|
+
"(last_agent_message_at IS NOT NULL AND last_requester_message_at IS NULL)"
|
|
296
|
+
).count
|
|
190
297
|
next fail_with("#{stale} ticket(s) waiting on the desk after the desk already answered") if stale.positive?
|
|
191
298
|
|
|
192
299
|
ok_with("awaiting agrees with the transcript")
|
|
@@ -198,6 +305,92 @@ module SupportDesk
|
|
|
198
305
|
ok_with("references are unique")
|
|
199
306
|
end
|
|
200
307
|
]
|
|
308
|
+
|
|
309
|
+
checks.concat(assistant_invariant_checks)
|
|
310
|
+
checks
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The assistants' own invariants: nobody sitting on a case they may not
|
|
314
|
+
# work, nobody silently not answering, one pending proposal per case.
|
|
315
|
+
#
|
|
316
|
+
# Every one of them is asked of EVIDENCE — a seat, a timestamp, a row —
|
|
317
|
+
# and never of the policy's own verdict. A policy cannot page anybody
|
|
318
|
+
# about its own bug.
|
|
319
|
+
def assistant_invariant_checks
|
|
320
|
+
# Nothing about a feature nobody turned on: a host with no assistant
|
|
321
|
+
# configured runs not one extra query (I1).
|
|
322
|
+
return [] if SupportDesk.config.assistants.empty?
|
|
323
|
+
return [] unless assistants_migrated?
|
|
324
|
+
|
|
325
|
+
checks = []
|
|
326
|
+
|
|
327
|
+
SupportDesk.config.assistants.each_key do |key|
|
|
328
|
+
assistant = SupportDesk.assistant(key)
|
|
329
|
+
window = assistant&.responds_within
|
|
330
|
+
next if window.nil?
|
|
331
|
+
|
|
332
|
+
checks << check("assistant silence (#{key})") do
|
|
333
|
+
quiet = Ticket.open.assigned_to(assistant).awaiting_reply.waiting_over(window).count
|
|
334
|
+
next fail_with("#{quiet} case(s) have waited longer than #{window.inspect} for #{key} — is the " \
|
|
335
|
+
"harness running? `rake support_desk:release_silent_assistants` hands them over") if
|
|
336
|
+
quiet.positive?
|
|
337
|
+
|
|
338
|
+
ok_with("nobody is waiting on #{key}")
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
checks << check("assistant seats") do
|
|
343
|
+
seated = Ticket.open.held_by_assistants.to_a
|
|
344
|
+
wrong = seated.reject { |ticket| ticket.assistant_policy.may_hold? }
|
|
345
|
+
next fail_with("#{wrong.size} case(s) held by an assistant who may not hold them " \
|
|
346
|
+
"(#{wrong.first(3).map(&:reference).join(", ")}) — release them") if wrong.any?
|
|
347
|
+
|
|
348
|
+
ok_with("#{seated.size} seat(s), all of them allowed")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
assistant_desks.each do |desk|
|
|
352
|
+
window = desk.assistant.responds_within
|
|
353
|
+
next if window.nil?
|
|
354
|
+
|
|
355
|
+
checks << check("assistant idle turns (#{desk.key})") do
|
|
356
|
+
idle = Ticket.open.for_desk(desk.key).assistant_idle_since((window * 3).ago).count
|
|
357
|
+
next warn_with("#{idle} case(s) have had no answer and no assistant action — the harness may be " \
|
|
358
|
+
"down; `rake support_desk:redispatch_assistant_turns` re-emits their turns") if
|
|
359
|
+
idle.positive?
|
|
360
|
+
|
|
361
|
+
ok_with("every turn has been picked up")
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
checks << check("drafts") do
|
|
366
|
+
duplicated = Draft.pending.group(:ticket_id).having("COUNT(*) > 1").count.size
|
|
367
|
+
next fail_with("#{duplicated} case(s) with more than one pending proposal") if duplicated.positive?
|
|
368
|
+
|
|
369
|
+
orphaned = Draft.pending.where(ticket_id: Ticket.closed.select(:id)).count
|
|
370
|
+
next warn_with("#{orphaned} pending proposal(s) on closed cases — a close expires them, so these " \
|
|
371
|
+
"predate 0.3 or were written by hand") if orphaned.positive?
|
|
372
|
+
|
|
373
|
+
ok_with("at most one pending proposal per case")
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
checks
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# The desks that actually have an assistant, as Desk records.
|
|
380
|
+
def assistant_desks
|
|
381
|
+
SupportDesk.config.desks.each_key.filter_map do |key|
|
|
382
|
+
desk = SupportDesk.desk(key)
|
|
383
|
+
desk if desk&.assistant?
|
|
384
|
+
end
|
|
385
|
+
rescue StandardError
|
|
386
|
+
[]
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def assistants_migrated?
|
|
390
|
+
Ticket.column_names.include?("human_required_at") &&
|
|
391
|
+
ActiveRecord::Base.connection.table_exists?(Draft.table_name)
|
|
392
|
+
rescue StandardError
|
|
393
|
+
false
|
|
201
394
|
end
|
|
202
395
|
|
|
203
396
|
def tables?
|
data/lib/support_desk/engine.rb
CHANGED
|
@@ -135,6 +135,16 @@ module SupportDesk
|
|
|
135
135
|
# the worst shape for a bug. Found by the CarHey integration (#2).
|
|
136
136
|
_desk = SupportDesk::Desk
|
|
137
137
|
|
|
138
|
+
# And the assistant, for the THIRD registry with the same shape:
|
|
139
|
+
# `acts_as_support_agent` registers a class when that class loads, and
|
|
140
|
+
# both `SupportDesk.agent_class?` and the doctor's "ai agents without
|
|
141
|
+
# policy" check read that registry. Under lazy autoloading nothing has
|
|
142
|
+
# referenced her before the first case is answered, so the doctor would
|
|
143
|
+
# report a healthy desk as having no assistant at all. A constant
|
|
144
|
+
# reference and nothing else: no row is created and no connection is
|
|
145
|
+
# needed to load the class.
|
|
146
|
+
_assistant = SupportDesk::Assistant
|
|
147
|
+
|
|
138
148
|
SupportDesk.config.validate_classes! if SupportDesk.configured?
|
|
139
149
|
end
|
|
140
150
|
end
|
data/lib/support_desk/errors.rb
CHANGED
|
@@ -17,6 +17,11 @@ module SupportDesk
|
|
|
17
17
|
# agent (no `acts_as_support_agent`, or its `if:` said no).
|
|
18
18
|
class NotAnAgent < Error; end
|
|
19
19
|
|
|
20
|
+
# Raised when the record handed to a requester-side operation isn't an
|
|
21
|
+
# eligible requester (no `has_support_tickets`, or its `if:` said no) — a
|
|
22
|
+
# closed account, for instance, can neither ask nor be written to.
|
|
23
|
+
class NotARequester < Error; end
|
|
24
|
+
|
|
20
25
|
# Raised by `hand_off!` when the actor doesn't currently hold the ticket.
|
|
21
26
|
class NotTheAssignee < Error; end
|
|
22
27
|
|
|
@@ -46,4 +51,41 @@ module SupportDesk
|
|
|
46
51
|
|
|
47
52
|
# Raised when a requester is already at `config.max_open_tickets`.
|
|
48
53
|
class TooManyOpenTickets < Error; end
|
|
54
|
+
|
|
55
|
+
# Raised when an AI-kind actor is not the desk's own SupportDesk::Assistant.
|
|
56
|
+
# A host model declared `acts_as_support_agent kind: :ai` never inherits
|
|
57
|
+
# human authority: every support write by it, or to it, is refused rather
|
|
58
|
+
# than quietly treated as a human's (see the README's assistants section).
|
|
59
|
+
class NotAnAssistant < NotAnAgent; end
|
|
60
|
+
|
|
61
|
+
# Raised when the assistant's policy forbids the verb on this case. It
|
|
62
|
+
# carries the policy that refused and the verb that was asked for, so a
|
|
63
|
+
# host can log the RULE rather than parse the sentence.
|
|
64
|
+
class AssistantNotAllowed < NotAllowed
|
|
65
|
+
attr_reader :policy, :verb
|
|
66
|
+
|
|
67
|
+
def initialize(policy, verb:, message: nil)
|
|
68
|
+
@policy = policy
|
|
69
|
+
@verb = verb.to_sym
|
|
70
|
+
super(message || default_message)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# "rose may not reply on T-AB12CD: topic payments caps rose at draft" —
|
|
76
|
+
# who, what, where, and the rule that decided it.
|
|
77
|
+
def default_message
|
|
78
|
+
return "an assistant may not #{@verb} here" if @policy.nil?
|
|
79
|
+
|
|
80
|
+
"#{@policy.assistant&.key || "the assistant"} may not #{@verb} on " \
|
|
81
|
+
"#{@policy.ticket&.reference || "this case"}: #{@policy.because}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Raised when an assistant acts on a turn that is no longer the case's:
|
|
86
|
+
# somebody (the requester, a human, the assistant itself) changed the case
|
|
87
|
+
# since it was read. A subclass of InvalidTransition, so a host that
|
|
88
|
+
# rescues that still catches it — and a late, retried or redelivered job
|
|
89
|
+
# writes nothing.
|
|
90
|
+
class StaleTurn < InvalidTransition; end
|
|
49
91
|
end
|
data/lib/support_desk/events.rb
CHANGED
|
@@ -34,6 +34,17 @@ module SupportDesk
|
|
|
34
34
|
ticket_topic_changed: "ticket, from:, to:, by:",
|
|
35
35
|
subject_attached: "ticket, subject, by:",
|
|
36
36
|
note_added: "ticket, event",
|
|
37
|
+
# The assistants (0.3). `assistant_turn` is the one a harness
|
|
38
|
+
# subscribes to: everything else is something to notify a human about.
|
|
39
|
+
assistant_turn: "ticket, assistant, message, turn:",
|
|
40
|
+
draft_proposed: "ticket, draft",
|
|
41
|
+
draft_sent: "ticket, draft, message, by:",
|
|
42
|
+
draft_rejected: "ticket, draft, by:, reason:",
|
|
43
|
+
assistant_withheld: "ticket, assistant, reason:, policy:",
|
|
44
|
+
ticket_escalated: "ticket, from:, reason:, by:",
|
|
45
|
+
human_requested: "ticket, by:, reason:",
|
|
46
|
+
assistant_paused: "ticket, by:",
|
|
47
|
+
assistant_resumed: "ticket, by:",
|
|
37
48
|
ticket_transitioned: "ticket, kind, by:, request:, payload:"
|
|
38
49
|
}.freeze
|
|
39
50
|
|
|
@@ -112,11 +123,7 @@ module SupportDesk
|
|
|
112
123
|
end
|
|
113
124
|
|
|
114
125
|
def report_subscriber_error(error, event)
|
|
115
|
-
|
|
116
|
-
Rails.error.report(error, handled: true, source: "support_desk", context: { event: event })
|
|
117
|
-
else
|
|
118
|
-
logger&.error("[support_desk] subscriber raised on #{event}: #{error.class}: #{error.message}")
|
|
119
|
-
end
|
|
126
|
+
report_error(error, context: { event: event, hook: :subscriber })
|
|
120
127
|
end
|
|
121
128
|
end
|
|
122
129
|
end
|
data/lib/support_desk/macros.rb
CHANGED
|
@@ -20,11 +20,15 @@ module SupportDesk
|
|
|
20
20
|
module Macros
|
|
21
21
|
# The requester side. `desk:` names which desk this model writes to;
|
|
22
22
|
# `as:` records a role on every ticket ("driver", "passenger") so the
|
|
23
|
-
# console can tell two kinds of requester apart
|
|
24
|
-
|
|
23
|
+
# console can tell two kinds of requester apart; `if:` is the per-record
|
|
24
|
+
# check for "may this person ask for help — and be written to — right
|
|
25
|
+
# now" (`if: :kept?` for a host with soft-deleted accounts).
|
|
26
|
+
def has_support_tickets(desk: :default, as: nil, **options)
|
|
27
|
+
condition = Macros.condition!(options, macro: "has_support_tickets", known: ":if, :desk, :as")
|
|
28
|
+
|
|
25
29
|
include SupportDesk::Requester
|
|
26
30
|
|
|
27
|
-
self.support_desk_requester_options = { desk: desk.to_sym, as: as&.to_s }.freeze
|
|
31
|
+
self.support_desk_requester_options = { desk: desk.to_sym, as: as&.to_s, if: condition }.freeze
|
|
28
32
|
SupportDesk.register_requester(self)
|
|
29
33
|
end
|
|
30
34
|
|
|
@@ -48,26 +52,51 @@ module SupportDesk
|
|
|
48
52
|
|
|
49
53
|
# The agent side: who may answer. `if:` is the per-record eligibility
|
|
50
54
|
# check (a Symbol method name or a callable); `kind:` is :human or :ai.
|
|
51
|
-
#
|
|
52
|
-
#
|
|
55
|
+
# One verb is added — `open_support_conversation_with!`, the only agent
|
|
56
|
+
# action that has no ticket yet. Everything else keeps the ticket as the
|
|
57
|
+
# subject of the sentence (`ticket.assign!(to: lucia)`).
|
|
53
58
|
def acts_as_support_agent(kind: :human, **options)
|
|
59
|
+
condition = Macros.condition!(options, macro: "acts_as_support_agent", known: ":if, :kind")
|
|
60
|
+
kind = Macros.kind!(kind)
|
|
61
|
+
|
|
62
|
+
include SupportDesk::Agent
|
|
63
|
+
|
|
64
|
+
self.support_desk_agent_options = { if: condition, kind: kind }.freeze
|
|
65
|
+
SupportDesk.register_agent(self)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Who the agent IS: a person, or a machine. There are two kinds because
|
|
69
|
+
# they are answerable to different rules, and an unknown third would be
|
|
70
|
+
# treated as a human by every check that isn't looking for :ai — so it
|
|
71
|
+
# fails at class definition instead.
|
|
72
|
+
def self.kind!(kind)
|
|
73
|
+
kind = kind.to_sym if kind.respond_to?(:to_sym)
|
|
74
|
+
return kind if %i[human ai].include?(kind)
|
|
75
|
+
|
|
76
|
+
raise ConfigurationError,
|
|
77
|
+
"acts_as_support_agent kind: must be :human or :ai, got #{kind.inspect}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The `if:` both macros take: a method name or a callable, and the only
|
|
81
|
+
# option either of them accepts beyond its own keywords. Written once
|
|
82
|
+
# because "may this person answer" and "may this person ask" are the same
|
|
83
|
+
# question asked of a different side, and two copies of it would be two
|
|
84
|
+
# places to fix the day one grows a third shape.
|
|
85
|
+
def self.condition!(options, macro:, known:)
|
|
54
86
|
unknown = options.keys - [ :if ]
|
|
55
87
|
if unknown.any?
|
|
56
88
|
raise ConfigurationError,
|
|
57
|
-
"unknown
|
|
58
|
-
"#{unknown.map(&:inspect).join(", ")} — known options are
|
|
89
|
+
"unknown #{macro} option#{"s" if unknown.size > 1} " \
|
|
90
|
+
"#{unknown.map(&:inspect).join(", ")} — known options are #{known}"
|
|
59
91
|
end
|
|
60
92
|
|
|
61
93
|
condition = options[:if]
|
|
62
94
|
unless condition.nil? || condition.is_a?(Symbol) || condition.respond_to?(:call)
|
|
63
95
|
raise ConfigurationError,
|
|
64
|
-
"
|
|
96
|
+
"#{macro} if: must be a method name or a callable, got #{condition.inspect}"
|
|
65
97
|
end
|
|
66
98
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
self.support_desk_agent_options = { if: condition, kind: kind.to_sym }.freeze
|
|
70
|
-
SupportDesk.register_agent(self)
|
|
99
|
+
condition
|
|
71
100
|
end
|
|
72
101
|
end
|
|
73
102
|
end
|
|
@@ -11,7 +11,9 @@ module SupportDesk
|
|
|
11
11
|
class Assignment < ApplicationRecord
|
|
12
12
|
self.table_name = "support_desk_assignments"
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# `opened`: the agent who wrote first holds the case. It is a seat nobody
|
|
15
|
+
# was told about — see Ticket.open!(by:) — not an assignment somebody made.
|
|
16
|
+
REASONS = %w[taken assigned handed_off routed drop_in_takeover escalated reopened opened].freeze
|
|
15
17
|
RELEASE_REASONS = %w[handed_off released shift_end closed escalated].freeze
|
|
16
18
|
|
|
17
19
|
belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :assignments
|