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
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # What `Ticket#respond!` did — the one verb whose answer depends on policy,
5
+ # so the harness can hand over an answer without first working out what it
6
+ # is allowed to become.
7
+ #
8
+ # outcome = ticket.respond!(text, by: rose, turn: turn)
9
+ # outcome.sent? # it went to the requester
10
+ # outcome.drafted? # a human will send it
11
+ # outcome.withheld? # nothing was written, and #reason says why
12
+ # outcome.turn # the successor turn, for a second action in the same run
13
+ Outcome = Struct.new(:action, :message, :draft, :policy, :reason, :turn, keyword_init: true) do
14
+ def sent? = action == :sent
15
+ def drafted? = action == :drafted
16
+ def withheld? = action == :withheld
17
+
18
+ # Whether the case was ALSO handed to a person: the budget ran out
19
+ # mid-conversation, so the draft is waiting and so is a human.
20
+ def escalated? = reason == :max_turns
21
+
22
+ # Ids, not records: this is what a log line or a job argument wants.
23
+ def to_h
24
+ {
25
+ action: action,
26
+ message: message&.id,
27
+ draft: draft&.id,
28
+ reason: reason,
29
+ turn: turn,
30
+ policy: policy&.to_h
31
+ }
32
+ end
33
+
34
+ def inspect
35
+ "#<SupportDesk::Outcome #{action}#{" #{reason}" if reason} turn=#{turn}>"
36
+ end
37
+ end
38
+ end
@@ -14,7 +14,7 @@ module SupportDesk
14
14
  class Queue
15
15
  # Every tab a queue can answer for, in display order. `scope` and
16
16
  # `counts` both cover all of them.
17
- TABS = %i[awaiting mine unassigned open snoozed closed].freeze
17
+ TABS = %i[awaiting needs_human mine unassigned open snoozed closed].freeze
18
18
 
19
19
  # Tabs whose FEATURE hasn't shipped yet. Snoozing lands in 0.2: until a
20
20
  # ticket can actually be snoozed, the tab is a permanent column of
@@ -24,7 +24,8 @@ module SupportDesk
24
24
  # by name — but nothing offers the tab.
25
25
  UNRELEASED_TABS = %i[snoozed].freeze
26
26
 
27
- # The tabs a console should render, in display order.
27
+ # The tabs a console should render, in display order. `needs_human` is
28
+ # in the list but not always shown — see #visible_tabs.
28
29
  VISIBLE_TABS = (TABS - UNRELEASED_TABS).freeze
29
30
 
30
31
  # How long a nav badge may lie. Long enough that a busy console isn't
@@ -72,6 +73,14 @@ module SupportDesk
72
73
  scoped.open.awaiting_reply.most_urgent_first
73
74
  end
74
75
 
76
+ # Open, and somebody has asked for a person on it: the assistant handed
77
+ # it over, the customer pressed the door, or the silent sweep did. First
78
+ # in the list on purpose — a case a machine could not finish is the one
79
+ # that must never be the one nobody looks at.
80
+ def needs_human
81
+ scoped.open.needs_human.most_urgent_first
82
+ end
83
+
75
84
  # Put aside until a date, the soonest to wake first (0.2).
76
85
  def snoozed = scoped.snoozed.order(:snoozed_until)
77
86
 
@@ -87,10 +96,10 @@ module SupportDesk
87
96
  # (status, awaiting, assignee) and adding up in Ruby costs one round
88
97
  # trip; six `.count` calls cost six.
89
98
  def counts
90
- rows = scoped.group(:status, :awaiting, :assignee_type, :assignee_id).count
99
+ rows = scoped.group(:status, :awaiting, :assignee_type, :assignee_id, needs_human_grouping).count
91
100
 
92
101
  counts = TABS.index_with(0)
93
- rows.each do |(status, awaiting, assignee_type, assignee_id), count|
102
+ rows.each do |(status, awaiting, assignee_type, assignee_id, needs_human), count|
94
103
  assigned_to_me = agent_key == [ assignee_type, assignee_id.to_s ]
95
104
 
96
105
  case status
@@ -99,6 +108,7 @@ module SupportDesk
99
108
  counts[:mine] += count if assigned_to_me
100
109
  counts[:unassigned] += count if assignee_id.nil?
101
110
  counts[:awaiting] += count if awaiting == "agent"
111
+ counts[:needs_human] += count if needs_human.to_i.positive?
102
112
  when "snoozed" then counts[:snoozed] += count
103
113
  when "closed" then counts[:closed] += count
104
114
  end
@@ -106,6 +116,18 @@ module SupportDesk
106
116
  counts
107
117
  end
108
118
 
119
+ # The tabs THIS desk should render. `needs_human` only means something
120
+ # where a machine answers, so a desk without an assistant and without a
121
+ # single flagged case is not given a column of zeros to learn to ignore.
122
+ #
123
+ # Takes the numbers when the caller already has them (`tabs` does), so
124
+ # rendering a tab bar is one query and not two.
125
+ def visible_tabs(numbers = counts)
126
+ return VISIBLE_TABS if desk.assistant? || numbers[:needs_human].to_i.positive?
127
+
128
+ VISIBLE_TABS - [ :needs_human ]
129
+ end
130
+
109
131
  # The nav badge: open tickets this agent should feel responsible for —
110
132
  # theirs, plus everything nobody has picked up. Cached briefly per agent.
111
133
  def badge
@@ -124,7 +146,7 @@ module SupportDesk
124
146
  # ready to render.
125
147
  def tabs
126
148
  numbers = counts
127
- VISIBLE_TABS.map { |tab| [ tab, I18n.t("support_desk.queue.tabs.#{tab}"), numbers[tab] ] }
149
+ visible_tabs(numbers).map { |tab| [ tab, I18n.t("support_desk.queue.tabs.#{tab}"), numbers[tab] ] }
128
150
  end
129
151
 
130
152
  # The relation behind a tab name, so a console can route `params[:tab]`
@@ -149,6 +171,20 @@ module SupportDesk
149
171
  Ticket.where(desk: desk)
150
172
  end
151
173
 
174
+ # "Has somebody asked for a person on this case?", as something every
175
+ # adapter can GROUP BY. A boolean column would group differently on
176
+ # three databases; a CASE expression counts the same everywhere.
177
+ #
178
+ # Built when it is CALLED, and from `table_name` rather than
179
+ # `quoted_table_name`: an identifier assembled at class-definition time
180
+ # is one more thing that has to work during `assets:precompile` in a
181
+ # container with no database, and the rest of this gem keeps every
182
+ # identifier inside a lambda or a method for exactly that reason. The
183
+ # name is the gem's own constant, so there is nothing to quote.
184
+ def needs_human_grouping
185
+ Arel.sql("CASE WHEN #{Ticket.table_name}.human_required_at IS NULL THEN 0 ELSE 1 END")
186
+ end
187
+
152
188
  def mine_or_unassigned
153
189
  scoped.open.where(
154
190
  Ticket.arel_table[:assignee_id].eq(nil).or(
@@ -101,6 +101,158 @@ module SupportDesk
101
101
  "expected no #{kind} event on #{ticket.reference}"
102
102
  end
103
103
 
104
+ # --- Assistants -------------------------------------------------------------
105
+
106
+ # The assistant record for +key+ (the desk's default when omitted).
107
+ def support_assistant(key = nil)
108
+ SupportDesk.assistant(key)
109
+ end
110
+
111
+ # Answer as the assistant and let policy decide what that becomes.
112
+ # Returns the SupportDesk::Outcome.
113
+ def respond_as(assistant, ticket, body = nil, turn: ticket.assistant_turn, **options)
114
+ ticket.respond!(body, by: assistant, turn: turn, **options)
115
+ end
116
+
117
+ # Propose a reply as the assistant. Returns the SupportDesk::Draft.
118
+ def draft_as(assistant, ticket, body = nil, turn: ticket.assistant_turn, **options)
119
+ ticket.draft!(body, by: assistant, turn: turn, **options)
120
+ end
121
+
122
+ # --- Assistant assertions ---------------------------------------------------
123
+
124
+ # There is a proposal waiting, optionally matching its text (a String is
125
+ # a substring, a Regexp is a match). Returns the draft.
126
+ def assert_pending_draft(ticket, body: nil)
127
+ ticket.reload
128
+ draft = ticket.pending_draft
129
+
130
+ refute_nil draft, "expected a pending proposal on #{ticket.reference}, found " \
131
+ "#{ticket.drafts.map(&:status).join(", ").presence || "none"}"
132
+ case body
133
+ when Regexp then assert_match body, draft.body.to_s
134
+ when String then assert_includes draft.body.to_s, body
135
+ end
136
+ draft
137
+ end
138
+
139
+ # Nothing is waiting to be sent.
140
+ def refute_pending_draft(ticket)
141
+ ticket.reload
142
+
143
+ assert_nil ticket.pending_draft,
144
+ "expected no pending proposal on #{ticket.reference}, found #{ticket.pending_draft&.body.inspect}"
145
+ end
146
+
147
+ # Somebody has asked for a person on this case, optionally for this
148
+ # reason ("requester_request", "phrase", "assistant_silent", …).
149
+ def assert_needs_human(ticket, reason: nil)
150
+ ticket.reload
151
+
152
+ assert_predicate ticket, :human_required?,
153
+ "expected #{ticket.reference} to need a person"
154
+ return if reason.nil?
155
+
156
+ assert_equal reason.to_s, ticket.human_required_reason,
157
+ "#{ticket.reference} needs a person for a different reason"
158
+ end
159
+
160
+ # Nobody has.
161
+ def refute_needs_human(ticket)
162
+ ticket.reload
163
+
164
+ refute_predicate ticket, :human_required?,
165
+ "expected #{ticket.reference} not to need a person " \
166
+ "(#{ticket.human_required_reason})"
167
+ end
168
+
169
+ # The assistant is sitting on this case.
170
+ def assert_held_by_assistant(ticket, assistant = nil)
171
+ ticket.reload
172
+
173
+ assert_predicate ticket, :held_by_assistant?,
174
+ "expected #{ticket.reference} to be held by an assistant, was #{ticket.assignee.inspect}"
175
+ return if assistant.nil?
176
+
177
+ assert ticket.assigned_to?(assistant),
178
+ "expected #{ticket.reference} to be held by #{assistant.key}, was #{ticket.assignee.inspect}"
179
+ end
180
+
181
+ # No machine has said anything to the requester in this case.
182
+ def refute_assistant_spoke(ticket)
183
+ ticket.reload
184
+ spoken = ticket.conversation.messages.to_a.select { |message| ticket.assistant_message?(message) }
185
+
186
+ assert_empty spoken.map(&:body),
187
+ "expected the assistant to have said nothing on #{ticket.reference}"
188
+ end
189
+
190
+ # What she may do here, and why. `because:` takes a String (substring) or
191
+ # a Regexp, because the sentence is the point: a level with no reason is
192
+ # a refusal nobody can act on.
193
+ def assert_assistant_policy(ticket, level, because: nil)
194
+ policy = ticket.reload.assistant_policy
195
+
196
+ assert_equal level.to_sym, policy.level,
197
+ "expected #{ticket.reference} to be at #{level} for the assistant (#{policy.because})"
198
+ case because
199
+ when Regexp then assert_match because, policy.because
200
+ when String then assert_includes policy.because, because
201
+ end
202
+ policy
203
+ end
204
+
205
+ # --- Assistant configuration ------------------------------------------------
206
+
207
+ # Run a block with different assistant settings, then put them back:
208
+ #
209
+ # with_assistant_config(autonomy: :reply) { … }
210
+ # with_assistant_config(:rose, max_turns: 1) { … }
211
+ def with_assistant_config(key = nil, **overrides)
212
+ key ||= SupportDesk.config.default_assistant_key
213
+ configuration = SupportDesk.config.assistant(key)
214
+ previous = overrides.keys.index_with { |name| configuration.read(name) }
215
+ had = overrides.keys.index_with { |name| configuration.own?(name) }
216
+
217
+ overrides.each { |name, value| configuration.public_send(:"#{name}=", value) }
218
+ yield
219
+ ensure
220
+ previous.each do |name, value|
221
+ had[name] ? configuration.public_send(:"#{name}=", value) : configuration.send(:reset_setting, name)
222
+ end
223
+ end
224
+
225
+ # Run a block with one topic capped, then put the tree back.
226
+ #
227
+ # The tree is frozen at boot, so this REBUILDS it with the cap applied —
228
+ # the same shape a host would have declared with `topic :payments,
229
+ # assistant: :draft`, without asking a test to restate the whole tree.
230
+ def with_topic_assistant_cap(path, level, desk: :default)
231
+ configuration = SupportDesk.config.desk(desk)
232
+ original = configuration.topics
233
+ configuration.instance_variable_set(:@topics, rebuild_topics_with_cap(original, path.to_s, level))
234
+ yield
235
+ ensure
236
+ configuration.instance_variable_set(:@topics, original)
237
+ end
238
+
239
+ private
240
+
241
+ def rebuild_topics_with_cap(tree, path, level) # :nodoc:
242
+ rebuilt = SupportDesk::TopicTree.new
243
+ copy = lambda do |node, parent|
244
+ options = node.options.dup
245
+ options[:assistant] = level if node.path == path
246
+ fresh = SupportDesk::Topic.new(key: node.key, parent: parent, **options)
247
+ rebuilt.add(fresh, parent: parent)
248
+ node.children.each { |child| copy.call(child, fresh) }
249
+ end
250
+ tree.roots.each { |root| copy.call(root, nil) }
251
+ rebuilt.freeze!
252
+ end
253
+
254
+ public
255
+
104
256
  # --- Configuration ----------------------------------------------------------
105
257
 
106
258
  # Run a block with different desk settings, then put them back:
@@ -7,41 +7,60 @@ module SupportDesk
7
7
  # ticket.timeline.each { |entry| … }
8
8
  # ticket.timeline.print # in `rails c`
9
9
  #
10
- # Entries wrap either a Chats::Message or a SupportDesk::Event, and answer
11
- # the same three questions — when, who, what.
10
+ # Entries wrap a Chats::Message, a SupportDesk::Event or a decided
11
+ # SupportDesk::Draft, and answer the same three questions — when, who,
12
+ # what.
13
+ #
14
+ # A draft is here because it is the only part of the story with no message
15
+ # and no event of its own to carry it: the event says a proposal was sent
16
+ # or discarded, and the row is what the proposal actually SAID. A pending
17
+ # one is not history yet, so it stays out — it is on the screen above,
18
+ # waiting for somebody.
12
19
  class Timeline
13
20
  include Enumerable
14
21
 
15
22
  # One moment in a case.
16
23
  class Entry
17
- attr_reader :at, :message, :event
24
+ attr_reader :at, :message, :event, :draft
18
25
 
19
- # One moment: a message, or an event, and when it happened.
20
- def initialize(at:, message: nil, event: nil)
26
+ # One moment: a message, an event or a decided proposal, and when it
27
+ # happened.
28
+ def initialize(at:, message: nil, event: nil, draft: nil)
21
29
  @at = at
22
30
  @message = message
23
31
  @event = event
32
+ @draft = draft
24
33
  end
25
34
 
26
- # Which of the two this moment is.
35
+ # Which of the three this moment is.
27
36
  def message? = !message.nil?
28
37
  def event? = !event.nil?
38
+ def draft? = !draft.nil?
29
39
 
30
- # :message, or the event's kind (:assigned, :closed, :note…).
40
+ # :message, :draft, or the event's kind (:assigned, :closed, :note…).
31
41
  def kind
32
- message? ? :message : event.kind.to_sym
42
+ return :message if message?
43
+ return :draft if draft?
44
+
45
+ event.kind.to_sym
33
46
  end
34
47
 
35
48
  # Who is responsible for this moment: a message's author (the agent
36
- # who signed it) or sender, or an event's actor.
49
+ # who signed it) or sender, an event's actor, or — for a proposal —
50
+ # the person who decided about it, falling back to the machine that
51
+ # wrote it when nobody did.
37
52
  def actor
53
+ return draft.reviewed_by || draft.author if draft?
38
54
  return event.actor_or_system if event?
39
55
 
40
56
  message.try(:author) || message.sender
41
57
  end
42
58
 
43
- # What was said, or what a note said. Nil for the rest.
59
+ # What was said, what a note said, or what a proposal proposed. Nil
60
+ # for the rest.
44
61
  def body
62
+ return draft.final_body if draft?
63
+
45
64
  message? ? message.try(:visible_body) : event.note
46
65
  end
47
66
 
@@ -71,7 +90,8 @@ module SupportDesk
71
90
 
72
91
  # Every moment, oldest first.
73
92
  def entries
74
- @entries ||= (message_entries + event_entries).sort_by { |entry| [ entry.at || Time.at(0), entry.kind.to_s ] }
93
+ @entries ||= (message_entries + event_entries + draft_entries)
94
+ .sort_by { |entry| [ entry.at || Time.at(0), entry.kind.to_s ] }
75
95
  end
76
96
 
77
97
  # How many moments the case has had, and the latest one.
@@ -100,5 +120,14 @@ module SupportDesk
100
120
  def event_entries
101
121
  ticket.events.chronological.map { |event| Entry.new(at: event.created_at, event: event) }
102
122
  end
123
+
124
+ # Proposals somebody decided about — sent, discarded, or overtaken.
125
+ # One query, and on the overwhelming majority of desks it returns
126
+ # nothing at all, because nothing has ever proposed anything.
127
+ def draft_entries
128
+ ticket.drafts.where.not(status: "pending").chronological.includes(:author, :reviewed_by).map do |draft|
129
+ Entry.new(at: draft.created_at, draft: draft)
130
+ end
131
+ end
103
132
  end
104
133
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_model/type"
4
+ require_relative "assistant_policy"
4
5
 
5
6
  module SupportDesk
6
7
  # What a ticket is about, as a value object.
@@ -190,6 +191,24 @@ module SupportDesk
190
191
  # An agent scope or proc that wins over desk routing for this subtree.
191
192
  def route_to = inherited_or_own(:route_to)
192
193
 
194
+ # The most the assistant may produce on a case filed here: the MINIMUM
195
+ # over this node's own cap and every ancestor's own cap, or nil when
196
+ # nobody capped anything.
197
+ #
198
+ # Deliberately not an INHERITED_OPTION. Inheritance would let a child
199
+ # widen what its parent narrowed — `payments` capped at :draft with a
200
+ # `payments/refund` declared :reply would be a child handing itself
201
+ # authority its parent refused. A minimum can only tighten.
202
+ def assistant_cap
203
+ caps = [ self, *ancestors ].filter_map(&:own_assistant_cap)
204
+ caps.min_by { |level| AssistantPolicy::RANK.fetch(level) }
205
+ end
206
+
207
+ # This node's OWN cap, ignoring the tree (what #assistant_cap minimises).
208
+ def own_assistant_cap # :nodoc:
209
+ options[:assistant]&.to_sym
210
+ end
211
+
193
212
  # The desk key tickets under this topic belong to.
194
213
  def desk_key
195
214
  desk_override || :default
@@ -254,6 +273,9 @@ module SupportDesk
254
273
 
255
274
  def unknown? = true
256
275
  def retired? = true
276
+ # A path no tree knows is a path nobody has reasoned about, so the
277
+ # assistant may look and nothing else.
278
+ def assistant_cap = :observe
257
279
  def label = translate(i18n_key(:label)) || @path.tr("/", " ").humanize
258
280
  def about = []
259
281
  def subject_mode = :none
@@ -163,7 +163,7 @@ module SupportDesk
163
163
 
164
164
  KNOWN_OPTIONS = %i[
165
165
  label about ask candidates subject prefill placeholder only priority route_to desk retired icon
166
- free_form
166
+ free_form assistant
167
167
  ].freeze
168
168
 
169
169
  def validate!(key, options)
@@ -190,6 +190,12 @@ module SupportDesk
190
190
  "got #{options[:priority].inspect}"
191
191
  end
192
192
 
193
+ if options.key?(:assistant) && !AssistantPolicy::LEVELS.include?(options[:assistant]&.to_sym)
194
+ raise ConfigurationError,
195
+ "topic #{key.inspect}: assistant must be one of #{AssistantPolicy::LEVELS.inspect}, " \
196
+ "got #{options[:assistant].inspect}"
197
+ end
198
+
193
199
  %i[candidates only prefill].each do |option|
194
200
  next unless options.key?(option)
195
201
  next if option == :prefill && options[option].is_a?(String)