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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +998 -14
  4. data/app/assets/stylesheets/support_desk.css +10 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +23 -1
  6. data/app/helpers/support_desk/engine_helper.rb +16 -0
  7. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  8. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  9. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  10. data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
  11. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  12. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  13. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
  14. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  15. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  16. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  17. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  18. data/config/locales/support_desk.console.en.yml +63 -0
  19. data/config/locales/support_desk.console.es.yml +65 -0
  20. data/config/locales/support_desk.en.yml +15 -0
  21. data/config/locales/support_desk.es.yml +23 -0
  22. data/config/routes.rb +7 -1
  23. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  24. data/lib/generators/support_desk/install_generator.rb +12 -0
  25. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  26. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  27. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  28. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  29. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  30. data/lib/generators/support_desk/upgrade_generator.rb +12 -2
  31. data/lib/support_desk/assistant_policy.rb +213 -0
  32. data/lib/support_desk/brief.rb +283 -0
  33. data/lib/support_desk/configuration.rb +552 -2
  34. data/lib/support_desk/console.rb +290 -8
  35. data/lib/support_desk/context_card.rb +10 -1
  36. data/lib/support_desk/doctor.rb +144 -1
  37. data/lib/support_desk/engine.rb +10 -0
  38. data/lib/support_desk/errors.rb +37 -0
  39. data/lib/support_desk/events.rb +12 -5
  40. data/lib/support_desk/macros.rb +14 -1
  41. data/lib/support_desk/models/assistant.rb +153 -0
  42. data/lib/support_desk/models/concerns/requester.rb +18 -0
  43. data/lib/support_desk/models/desk.rb +26 -3
  44. data/lib/support_desk/models/draft.rb +266 -0
  45. data/lib/support_desk/models/event.rb +15 -1
  46. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  47. data/lib/support_desk/models/ticket.rb +334 -37
  48. data/lib/support_desk/outcome.rb +38 -0
  49. data/lib/support_desk/queue.rb +41 -5
  50. data/lib/support_desk/test_helpers.rb +152 -0
  51. data/lib/support_desk/timeline.rb +40 -11
  52. data/lib/support_desk/topic.rb +22 -0
  53. data/lib/support_desk/topic_tree.rb +7 -1
  54. data/lib/support_desk/transcript.rb +237 -0
  55. data/lib/support_desk/version.rb +1 -1
  56. data/lib/support_desk.rb +108 -0
  57. data/lib/tasks/support_desk.rake +46 -0
  58. metadata +30 -8
@@ -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
- MEMBER_VERBS = %i[reply take assign hand_off release close reopen note change_topic].freeze
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}", holder: support_console_holder)
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
- @ticket.desk.agents.detect { |agent| agent.id.to_s == id.to_s }
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
@@ -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?
@@ -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
@@ -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
@@ -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
- if defined?(Rails) && Rails.respond_to?(:error) && Rails.error
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