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/lib/support_desk/console.rb
CHANGED
|
@@ -52,7 +52,12 @@ module SupportDesk
|
|
|
52
52
|
# routing concern reads it at draw time and `OFFERED_AS` is checked
|
|
53
53
|
# against it, because a verb added in two places is a console that
|
|
54
54
|
# accepts a POST its own Drawer never routed (or the other way round).
|
|
55
|
-
|
|
55
|
+
# The four the assistants added sit at the end, in the order a case
|
|
56
|
+
# meets them: decide her proposal, then switch her off or back on.
|
|
57
|
+
MEMBER_VERBS = %i[
|
|
58
|
+
reply take assign hand_off release close reopen note change_topic
|
|
59
|
+
send_draft reject_draft pause_assistant resume_assistant
|
|
60
|
+
].freeze
|
|
56
61
|
|
|
57
62
|
# The verbs that work on the QUEUE rather than on a case, and the HTTP
|
|
58
63
|
# method each one is drawn with. `open_conversation` is the only write
|
|
@@ -79,9 +84,16 @@ module SupportDesk
|
|
|
79
84
|
#
|
|
80
85
|
# `take` and `assign` are both :assign — taking a case is assigning it
|
|
81
86
|
# to yourself, which is the model's vocabulary, not two permissions.
|
|
87
|
+
#
|
|
88
|
+
# `send_draft` is its OWN entry rather than :reply, because
|
|
89
|
+
# `actions_for` offers it only while there is a proposal to send: mapped
|
|
90
|
+
# to :reply it would have accepted a POST naming a draft somebody had
|
|
91
|
+
# already decided about.
|
|
82
92
|
OFFERED_AS = {
|
|
83
93
|
reply: :reply, take: :assign, assign: :assign, hand_off: :hand_off, release: :release,
|
|
84
|
-
close: :close, reopen: :reopen, note: :note, change_topic: :change_topic
|
|
94
|
+
close: :close, reopen: :reopen, note: :note, change_topic: :change_topic,
|
|
95
|
+
send_draft: :send_draft, reject_draft: :reject_draft,
|
|
96
|
+
pause_assistant: :pause_assistant, resume_assistant: :resume_assistant
|
|
85
97
|
}.freeze
|
|
86
98
|
|
|
87
99
|
# Everything a transition raises because of WHO asked, WHEN, or from WHAT
|
|
@@ -137,7 +149,9 @@ module SupportDesk
|
|
|
137
149
|
helper_method :current_agent, :support_desk_record, :support_queue, :support_transcript,
|
|
138
150
|
:console_ticket_path, :console_tickets_path, :console_file_path,
|
|
139
151
|
:support_conversation_available?, :support_conversation_offered?,
|
|
140
|
-
:support_conversation_topics, :support_conversation_sendable
|
|
152
|
+
:support_conversation_topics, :support_conversation_sendable?,
|
|
153
|
+
:support_pending_draft, :support_assistant, :support_seen_turn,
|
|
154
|
+
:support_editing_draft, :support_console_timeline_sentence
|
|
141
155
|
end
|
|
142
156
|
|
|
143
157
|
# --- The verbs --------------------------------------------------------------
|
|
@@ -210,6 +224,66 @@ module SupportDesk
|
|
|
210
224
|
attempt(:topic_changed) { @ticket.change_topic!(to: topic, by: current_agent, request: request) }
|
|
211
225
|
end
|
|
212
226
|
|
|
227
|
+
# --- Her proposal, and the switch ------------------------------------------
|
|
228
|
+
#
|
|
229
|
+
# Four verbs, and only one of them is interesting. A draft is written by
|
|
230
|
+
# a machine and SENT BY A PERSON: they read it, they may rewrite it, and
|
|
231
|
+
# the requester sees their signature on it. Everything here exists to
|
|
232
|
+
# make that reading real — which is why a send carries the turn the page
|
|
233
|
+
# was rendered with, and why a mismatch comes back as the same screen
|
|
234
|
+
# rather than as a redirect that throws the reviewer's edit away.
|
|
235
|
+
|
|
236
|
+
# Send the proposal, verbatim or edited.
|
|
237
|
+
def send_draft
|
|
238
|
+
draft = support_reviewable_draft
|
|
239
|
+
return if draft.nil?
|
|
240
|
+
|
|
241
|
+
seen = params[:seen_turn]
|
|
242
|
+
return refuse(:invalid_input) unless seen.is_a?(String) && seen.present?
|
|
243
|
+
|
|
244
|
+
body = params[:body].is_a?(String) ? params[:body] : nil
|
|
245
|
+
# The model refuses an edit that is blank with no attachment, and it
|
|
246
|
+
# refuses it with an ArgumentError — a bug in the caller, not a flash.
|
|
247
|
+
# So the console asks the same question first, in the words an agent
|
|
248
|
+
# needs: an empty box is "write something", never "descártala".
|
|
249
|
+
return refuse(:blank_message) if body && body.strip.empty? && !draft.files_attached?
|
|
250
|
+
|
|
251
|
+
draft.send!(by: current_agent, seen_turn: seen, body: body, request: request)
|
|
252
|
+
flash[:notice] = support_console_t("flashes.draft_sent")
|
|
253
|
+
respond_to_transition
|
|
254
|
+
rescue SupportDesk::StaleTurn
|
|
255
|
+
refuse_stale_draft
|
|
256
|
+
rescue StandardError => error
|
|
257
|
+
raise unless support_console_rescuable?(error)
|
|
258
|
+
|
|
259
|
+
flash[:alert] = support_console_error_message(error)
|
|
260
|
+
respond_to_transition
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Throw it away, with a reason worth reading later: the rejections are
|
|
264
|
+
# what tell a host whether the assistant is ready for a higher level.
|
|
265
|
+
def reject_draft
|
|
266
|
+
draft = support_reviewable_draft
|
|
267
|
+
return if draft.nil?
|
|
268
|
+
|
|
269
|
+
attempt(:draft_rejected) do
|
|
270
|
+
draft.reject!(by: current_agent, reason: support_console_text(:reason), request: request)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Switch her off on THIS case: a delicate conversation, a customer who
|
|
275
|
+
# has had enough, a thread somebody wants to handle themselves.
|
|
276
|
+
def pause_assistant
|
|
277
|
+
attempt(:assistant_paused) do
|
|
278
|
+
@ticket.pause_assistant!(by: current_agent, reason: support_console_text(:reason), request: request)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Let her back in on this case.
|
|
283
|
+
def resume_assistant
|
|
284
|
+
attempt(:assistant_resumed) { @ticket.resume_assistant!(by: current_agent, request: request) }
|
|
285
|
+
end
|
|
286
|
+
|
|
213
287
|
# The form for writing to somebody who hasn't written to us — "Escribir
|
|
214
288
|
# a alguien". Renders with whatever the caller supplied: a requester
|
|
215
289
|
# GlobalID from one of your own pages (a user's admin screen, a ride),
|
|
@@ -290,6 +364,70 @@ module SupportDesk
|
|
|
290
364
|
scope
|
|
291
365
|
end
|
|
292
366
|
|
|
367
|
+
# The proposal waiting on this case, or nil. What `show` renders the
|
|
368
|
+
# draft card from, and what the composer's edit mode is about.
|
|
369
|
+
def support_pending_draft(ticket = @ticket)
|
|
370
|
+
ticket&.pending_draft
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# This desk's assistant, or nil. A view asks it to decide whether there
|
|
374
|
+
# is a switch to draw at all — never to decide what may be pressed.
|
|
375
|
+
def support_assistant(ticket = @ticket)
|
|
376
|
+
ticket&.assistant
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# The turn the page is being rendered with, which every send carries
|
|
380
|
+
# back. A case that has never been touched still has one, so this is
|
|
381
|
+
# never nil and the form never has an empty hidden field.
|
|
382
|
+
def support_seen_turn(ticket = @ticket)
|
|
383
|
+
ticket&.assistant_turn
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The draft the composer is EDITING: the reviewer pressed "Editar"
|
|
387
|
+
# (`?compose=reply&draft=ID`), or a stale send came back as this screen
|
|
388
|
+
# with their text still in it. Nil unless it names the pending proposal
|
|
389
|
+
# AND this agent is offered the send — a composer in edit mode with no
|
|
390
|
+
# way to submit is worse than no edit mode at all.
|
|
391
|
+
def support_editing_draft(ticket = @ticket)
|
|
392
|
+
draft = support_pending_draft(ticket)
|
|
393
|
+
return nil if draft.nil?
|
|
394
|
+
return nil unless support_offered_actions(ticket).include?(:send_draft)
|
|
395
|
+
|
|
396
|
+
named = [ params[:draft], params[:draft_id] ].detect { |value| value.is_a?(String) && value.present? }
|
|
397
|
+
named == draft.id.to_s ? draft : nil
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# One line for the timeline kinds the assistants added — "Lucía envió la
|
|
401
|
+
# propuesta de Rose" — from the event's own payload. Nil for everything
|
|
402
|
+
# else, so the partial falls back to the label-and-actor shape 0.2 had.
|
|
403
|
+
#
|
|
404
|
+
# It lives here rather than in the partial because the sentence is COPY:
|
|
405
|
+
# an ejected view must not have to carry a `case` over event kinds to
|
|
406
|
+
# keep saying the right thing in Spanish.
|
|
407
|
+
def support_console_timeline_sentence(entry, actor_name = nil)
|
|
408
|
+
return nil unless entry.event?
|
|
409
|
+
|
|
410
|
+
payload = entry.event.payload.is_a?(Hash) ? entry.event.payload : {}
|
|
411
|
+
assistant = support_console_assistant_name(payload["assistant"])
|
|
412
|
+
|
|
413
|
+
case entry.kind
|
|
414
|
+
when :escalated
|
|
415
|
+
support_console_t("timeline.escalated", actor: actor_name,
|
|
416
|
+
reason: support_console_reason_word(payload["reason"]))
|
|
417
|
+
when :human_requested then support_console_t("timeline.human_requested")
|
|
418
|
+
when :assistant_paused then support_console_t("timeline.assistant_paused", actor: actor_name)
|
|
419
|
+
when :assistant_resumed then support_console_t("timeline.assistant_resumed", actor: actor_name)
|
|
420
|
+
when :draft_sent
|
|
421
|
+
support_console_t("timeline.draft_sent#{"_edited" if payload["edited"]}",
|
|
422
|
+
actor: actor_name, assistant: assistant)
|
|
423
|
+
when :draft_rejected
|
|
424
|
+
support_console_t("timeline.draft_rejected", actor: actor_name, assistant: assistant)
|
|
425
|
+
when :assistant_withheld
|
|
426
|
+
support_console_t("timeline.assistant_withheld", assistant: assistant,
|
|
427
|
+
reason: support_console_withheld_word(payload["reason"]))
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
293
431
|
# The default queue tab and the tickets behind it, for hosts that want
|
|
294
432
|
# the obvious index. Entirely optional — everything it does is three
|
|
295
433
|
# lines of Layer 1.
|
|
@@ -333,7 +471,7 @@ module SupportDesk
|
|
|
333
471
|
# that no test notices until somebody counts.
|
|
334
472
|
def support_queue_tickets
|
|
335
473
|
support_queue.scope(@scope)
|
|
336
|
-
.includes(:requester, :assignee, :desk, :subject, :opened_by,
|
|
474
|
+
.includes(:requester, :assignee, :desk, :subject, :opened_by, :pending_draft,
|
|
337
475
|
conversation: { last_message: %i[sender author] })
|
|
338
476
|
.limit(support_tickets_per_page)
|
|
339
477
|
end
|
|
@@ -638,7 +776,9 @@ module SupportDesk
|
|
|
638
776
|
offered = @ticket.actions_for(current_agent)
|
|
639
777
|
return if offered.include?(OFFERED_AS.fetch(action_name.to_sym))
|
|
640
778
|
|
|
641
|
-
flash[:alert] = support_console_t("errors.#{unavailable_reason}",
|
|
779
|
+
flash[:alert] = support_console_t("errors.#{unavailable_reason}",
|
|
780
|
+
holder: support_console_holder,
|
|
781
|
+
status: support_draft_status_word(support_named_draft))
|
|
642
782
|
respond_to_transition
|
|
643
783
|
end
|
|
644
784
|
|
|
@@ -647,12 +787,25 @@ module SupportDesk
|
|
|
647
787
|
# taken first.
|
|
648
788
|
def unavailable_reason
|
|
649
789
|
return "closed_case" if @ticket.closed?
|
|
790
|
+
return draft_unavailable_reason if %i[send_draft reject_draft].include?(action_name.to_sym)
|
|
650
791
|
return "unavailable_action" unless %i[reply hand_off].include?(action_name.to_sym)
|
|
651
792
|
return "take_it_first" if @ticket.unassigned?
|
|
652
793
|
|
|
653
794
|
"held_by_somebody_else"
|
|
654
795
|
end
|
|
655
796
|
|
|
797
|
+
# Pressing the same button twice is the common way to get here, and
|
|
798
|
+
# "there is no proposal" is the wrong thing to read after sending one.
|
|
799
|
+
# The row the request names is the whole answer: still there and already
|
|
800
|
+
# decided, or gone.
|
|
801
|
+
def draft_unavailable_reason
|
|
802
|
+
named = support_named_draft
|
|
803
|
+
return "draft_already_reviewed" if named && !named.pending?
|
|
804
|
+
return "no_pending_draft" if @ticket.pending_draft.nil?
|
|
805
|
+
|
|
806
|
+
"unavailable_action"
|
|
807
|
+
end
|
|
808
|
+
|
|
656
809
|
# Who has the case, for a refusal that names them.
|
|
657
810
|
def support_console_holder
|
|
658
811
|
@ticket&.assignee&.try(:support_agent_name) || support_console_t("assignment.nobody")
|
|
@@ -730,10 +883,113 @@ module SupportDesk
|
|
|
730
883
|
# desk — never the one `?desk=` names. Those are different desks the
|
|
731
884
|
# moment a host has two, and reading the parameter let a billing agent
|
|
732
885
|
# be assigned to a case on another desk entirely.
|
|
886
|
+
# Values are `SupportDesk.actor_key(agent)` — a GlobalID, which says
|
|
887
|
+
# WHICH CLASS as well as which row. The pool is people and the desk's
|
|
888
|
+
# assistant, and their ids are drawn from different tables: a picker
|
|
889
|
+
# posting a bare "1" could mean either of them, and "assign the case to
|
|
890
|
+
# the machine" is not a mistake to make on a coin flip.
|
|
891
|
+
#
|
|
892
|
+
# A bare id still resolves, because a host may have written one into
|
|
893
|
+
# their own form — but only while exactly one member of the pool answers
|
|
894
|
+
# to it. Two matches is a refusal, not a guess.
|
|
733
895
|
def support_console_agent(id = params[:agent_id])
|
|
734
896
|
return nil if id.blank?
|
|
735
897
|
|
|
736
|
-
|
|
898
|
+
key = id.to_s
|
|
899
|
+
pool = @ticket.desk.agents
|
|
900
|
+
exact = pool.detect { |agent| SupportDesk.actor_key(agent) == key }
|
|
901
|
+
return exact if exact
|
|
902
|
+
|
|
903
|
+
matches = pool.select { |agent| agent.id.to_s == key }
|
|
904
|
+
matches.one? ? matches.first : nil
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# --- The proposal a request names -------------------------------------------
|
|
908
|
+
|
|
909
|
+
# The draft this request names, in ANY status. Deliberately not
|
|
910
|
+
# `drafts.pending`: a second submit of the same button has to be able to
|
|
911
|
+
# read "ya se envió" instead of "no hay ninguna propuesta", and only the
|
|
912
|
+
# row itself knows which.
|
|
913
|
+
def support_console_draft
|
|
914
|
+
raise InvalidInput, :invalid_input unless params[:draft_id].is_a?(String)
|
|
915
|
+
|
|
916
|
+
support_named_draft
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
# The same lookup for the REFUSALS, which run before the action and must
|
|
920
|
+
# never raise: a crafted `draft_id` on a case with nothing pending is a
|
|
921
|
+
# flash, not a 500.
|
|
922
|
+
def support_named_draft
|
|
923
|
+
return @support_named_draft if defined?(@support_named_draft)
|
|
924
|
+
|
|
925
|
+
id = params[:draft_id]
|
|
926
|
+
@support_named_draft = (@ticket.drafts.find_by(id: id) if id.is_a?(String) && id.present?)
|
|
927
|
+
rescue ActiveRecord::StatementInvalid
|
|
928
|
+
# A uuid-keyed host: an id that isn't one is an id nothing has.
|
|
929
|
+
@support_named_draft = nil
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# The draft a decision may be made about, or nil with the refusal
|
|
933
|
+
# already rendered.
|
|
934
|
+
def support_reviewable_draft
|
|
935
|
+
draft = support_console_draft
|
|
936
|
+
if draft.nil?
|
|
937
|
+
refuse(:unknown_draft)
|
|
938
|
+
elsif !draft.pending?
|
|
939
|
+
refuse(:draft_already_reviewed, status: support_draft_status_word(draft))
|
|
940
|
+
else
|
|
941
|
+
return draft
|
|
942
|
+
end
|
|
943
|
+
nil
|
|
944
|
+
rescue InvalidInput => error
|
|
945
|
+
refuse(error.key)
|
|
946
|
+
nil
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
# What happened to a draft, as the word the flash reads: "ya se envió",
|
|
950
|
+
# "ya se descartó". Never the English status — a Spanish desk reading
|
|
951
|
+
# "ya se sent" is the bug this map exists to stop.
|
|
952
|
+
def support_draft_status_word(draft)
|
|
953
|
+
return nil if draft.nil?
|
|
954
|
+
|
|
955
|
+
support_console_t("draft.statuses.#{draft.status}", default: draft.status)
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
# The case moved between the render and the submit. A redirect here
|
|
959
|
+
# would throw away whatever the reviewer typed, so this is the SAME
|
|
960
|
+
# screen again with a 422: their text still in the composer, the CURRENT
|
|
961
|
+
# turn in the form, and the reason on top. There is no "send anyway" —
|
|
962
|
+
# reading the case again is the whole point.
|
|
963
|
+
def refuse_stale_draft
|
|
964
|
+
@ticket.reload
|
|
965
|
+
flash.now[:alert] = support_console_t("errors.stale_turn")
|
|
966
|
+
rerender_support_case
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# The case screen again, as the host renders it: their own `show` runs,
|
|
970
|
+
# so whatever it sets up is set up, and the two things every bundled
|
|
971
|
+
# view needs are filled in when it didn't.
|
|
972
|
+
def rerender_support_case(status: :unprocessable_entity)
|
|
973
|
+
show if respond_to?(:show)
|
|
974
|
+
@actions ||= @ticket.actions_for(current_agent)
|
|
975
|
+
@context_card ||= @ticket.context_card
|
|
976
|
+
render :show, formats: [ :html ], status: status unless performed?
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
# The buttons this agent is offered on +ticket+, asked once per render.
|
|
980
|
+
# `show` usually set them already; a 422 re-render may not have.
|
|
981
|
+
def support_offered_actions(ticket = @ticket)
|
|
982
|
+
return @actions if ticket == @ticket && @actions
|
|
983
|
+
|
|
984
|
+
ticket.actions_for(current_agent)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# A free-text field a verb takes: text, or nothing. A Hash where a
|
|
988
|
+
# reason belongs is a crafted request, and `.to_s` on it would be
|
|
989
|
+
# written into an event payload forever.
|
|
990
|
+
def support_console_text(name)
|
|
991
|
+
value = params[name]
|
|
992
|
+
value.is_a?(String) ? value.presence : nil
|
|
737
993
|
end
|
|
738
994
|
|
|
739
995
|
def support_agent_name(agent)
|
|
@@ -757,8 +1013,8 @@ module SupportDesk
|
|
|
757
1013
|
|
|
758
1014
|
# A refusal the console spotted before the model was asked (an empty
|
|
759
1015
|
# message, an agent who isn't in the pool).
|
|
760
|
-
def refuse(reason)
|
|
761
|
-
flash[:alert] = support_console_t("errors.#{reason}")
|
|
1016
|
+
def refuse(reason, **interpolations)
|
|
1017
|
+
flash[:alert] = support_console_t("errors.#{reason}", **interpolations)
|
|
762
1018
|
respond_to_transition
|
|
763
1019
|
end
|
|
764
1020
|
|
|
@@ -812,6 +1068,32 @@ module SupportDesk
|
|
|
812
1068
|
default: :"support_desk.console.errors.generic")
|
|
813
1069
|
end
|
|
814
1070
|
|
|
1071
|
+
# Who proposed it, in a word. The payloads carry a key on one kind and
|
|
1072
|
+
# an actor key on another, and this desk's own assistant answers to
|
|
1073
|
+
# both; anything else is history (a host swapped assistants), so the
|
|
1074
|
+
# readable half of the token stands in rather than a GlobalID nobody
|
|
1075
|
+
# can read.
|
|
1076
|
+
def support_console_assistant_name(token)
|
|
1077
|
+
token = token.to_s
|
|
1078
|
+
assistant = @ticket&.assistant
|
|
1079
|
+
return assistant.name if assistant && [ assistant.key.to_s, SupportDesk.actor_key(assistant) ].include?(token)
|
|
1080
|
+
return token.humanize if token.match?(/\A[a-z0-9_]+\z/i)
|
|
1081
|
+
|
|
1082
|
+
@ticket&.desk&.name.to_s
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# Why a case was handed to a person, as a sentence rather than as the
|
|
1086
|
+
# token the model writes. A reason a host's own harness invented falls
|
|
1087
|
+
# back to itself — a strange word in the timeline beats no word at all.
|
|
1088
|
+
def support_console_reason_word(reason)
|
|
1089
|
+
support_console_t("escalation_reasons.#{reason}", default: reason.to_s.humanize)
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
# Why she said nothing.
|
|
1093
|
+
def support_console_withheld_word(reason)
|
|
1094
|
+
support_console_t("withheld_reasons.#{reason}", default: reason.to_s.humanize)
|
|
1095
|
+
end
|
|
1096
|
+
|
|
815
1097
|
def support_console_t(key, **interpolations)
|
|
816
1098
|
I18n.t("support_desk.console.#{key}", **interpolations)
|
|
817
1099
|
end
|
|
@@ -59,6 +59,14 @@ 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
|
|
|
@@ -102,7 +110,8 @@ module SupportDesk
|
|
|
102
110
|
requester: {
|
|
103
111
|
name: requester_name,
|
|
104
112
|
since: requester_since,
|
|
105
|
-
open_tickets: requester_open_tickets
|
|
113
|
+
open_tickets: requester_open_tickets,
|
|
114
|
+
context: requester_pairs
|
|
106
115
|
}
|
|
107
116
|
}
|
|
108
117
|
end
|
data/lib/support_desk/doctor.rb
CHANGED
|
@@ -139,6 +139,63 @@ module SupportDesk
|
|
|
139
139
|
ok_with("the console can look people up")
|
|
140
140
|
end
|
|
141
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
|
+
|
|
142
199
|
checks << check("engine mount") do
|
|
143
200
|
path = SupportDesk.root_path
|
|
144
201
|
next warn_with("SupportDesk::Engine isn't mounted — requesters have nowhere to write") if path.nil?
|
|
@@ -187,7 +244,7 @@ module SupportDesk
|
|
|
187
244
|
return [ check("database") { warn_with("support_desk tables are missing — run rails db:migrate") } ] unless
|
|
188
245
|
tables?
|
|
189
246
|
|
|
190
|
-
[
|
|
247
|
+
checks = [
|
|
191
248
|
check("conversations") do
|
|
192
249
|
orphans = Ticket.where(conversation_id: nil).count
|
|
193
250
|
next fail_with("#{orphans} ticket(s) without a conversation") if orphans.positive?
|
|
@@ -248,6 +305,92 @@ module SupportDesk
|
|
|
248
305
|
ok_with("references are unique")
|
|
249
306
|
end
|
|
250
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
|
|
251
394
|
end
|
|
252
395
|
|
|
253
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
|
@@ -51,4 +51,41 @@ module SupportDesk
|
|
|
51
51
|
|
|
52
52
|
# Raised when a requester is already at `config.max_open_tickets`.
|
|
53
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
|
|
54
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
|