support_desk 0.2.0 → 0.3.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -0
  3. data/README.md +1131 -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 +21 -3
  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/create_support_desk_message_registrations.rb.erb +42 -0
  30. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  31. data/lib/generators/support_desk/upgrade_generator.rb +25 -7
  32. data/lib/support_desk/assistant_policy.rb +213 -0
  33. data/lib/support_desk/brief.rb +283 -0
  34. data/lib/support_desk/configuration.rb +552 -2
  35. data/lib/support_desk/console.rb +290 -8
  36. data/lib/support_desk/context_card.rb +10 -1
  37. data/lib/support_desk/doctor.rb +171 -1
  38. data/lib/support_desk/engine.rb +16 -1
  39. data/lib/support_desk/errors.rb +37 -0
  40. data/lib/support_desk/events.rb +12 -5
  41. data/lib/support_desk/macros.rb +14 -1
  42. data/lib/support_desk/message_signatures.rb +20 -0
  43. data/lib/support_desk/models/assistant.rb +188 -0
  44. data/lib/support_desk/models/concerns/requester.rb +18 -0
  45. data/lib/support_desk/models/desk.rb +26 -3
  46. data/lib/support_desk/models/draft.rb +275 -0
  47. data/lib/support_desk/models/event.rb +15 -1
  48. data/lib/support_desk/models/message_registration.rb +12 -0
  49. data/lib/support_desk/models/ticket/assistance.rb +899 -0
  50. data/lib/support_desk/models/ticket.rb +406 -58
  51. data/lib/support_desk/outcome.rb +38 -0
  52. data/lib/support_desk/queue.rb +41 -5
  53. data/lib/support_desk/test_helpers.rb +152 -0
  54. data/lib/support_desk/timeline.rb +40 -11
  55. data/lib/support_desk/topic.rb +22 -0
  56. data/lib/support_desk/topic_tree.rb +7 -1
  57. data/lib/support_desk/transcript.rb +234 -0
  58. data/lib/support_desk/version.rb +1 -1
  59. data/lib/support_desk.rb +151 -0
  60. data/lib/tasks/support_desk.rake +57 -0
  61. metadata +33 -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)
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # The conversation as something other than HTML: one flat, ordered list of
5
+ # turns, each one saying who spoke, what they said and when.
6
+ #
7
+ # ticket.transcript.to_text
8
+ # # [2026-09-18 10:02] Alice: No me han pagado
9
+ # # [2026-09-18 10:03] Rose: Lo estoy mirando ahora mismo
10
+ # # [2026-09-18 10:07] Lucía: Ya está resuelto [justificante.pdf]
11
+ #
12
+ # ticket.transcript(limit: 20).last(5)
13
+ # ticket.transcript.since(message)
14
+ #
15
+ # It exists for the harness that has to hand a conversation to a model
16
+ # without reading chats' tables, and for anything else that wants the
17
+ # transcript as data: an export, a summary job, a console that renders
18
+ # JSON. `Brief` embeds it.
19
+ #
20
+ # == Four roles, not three
21
+ #
22
+ # `Ticket#role_of` answers +:requester+, +:agent+ or +:system+, because
23
+ # that is what the CLOCKS care about — whose turn it is does not change
24
+ # depending on whether a person or a machine wrote the desk's last word.
25
+ # A reader does care, so this is a SEPARATE serializer with its own
26
+ # vocabulary: +:requester+, +:human+, +:assistant+, +:system+. Neither
27
+ # one is derived from the other, and `role_of` is deliberately left
28
+ # alone — the day a third kind of author appears, only this file moves.
29
+ #
30
+ # == Names survive renames
31
+ #
32
+ # The assistant's original name is stored on new messages. Older assistant
33
+ # messages fall back to their stored display name, then to configuration or
34
+ # the author record only when there is no message snapshot. Exported turns
35
+ # retain AI provenance even when their requester-facing mode is nameless.
36
+ #
37
+ # == Deleted messages are turns too
38
+ #
39
+ # A tombstone renders as `support_desk.transcript.deleted` with no
40
+ # attachments, rather than vanishing: a model that cannot see that
41
+ # something was said and withdrawn will answer as if it never was, and a
42
+ # gap in a numbered transcript is worse than a marked one.
43
+ class Transcript
44
+ include Enumerable
45
+
46
+ # One line of the conversation. A value: built once, then only read.
47
+ #
48
+ # * +role+ — :requester | :human | :assistant | :system
49
+ # * +name+ — who to print; the desk's name for system lines
50
+ # * +body+ — what was said, or the tombstone copy
51
+ # * +at+ — when (a Time)
52
+ # * +attachments+ — filenames, as Strings
53
+ # * +assisted+ — a human sent it, a machine proposed it
54
+ # * +message+ — the Chats::Message itself, for anything else
55
+ Turn = Struct.new(:role, :name, :body, :at, :attachments, :assisted, :message, keyword_init: true) do
56
+ def requester? = role == :requester
57
+ def human? = role == :human
58
+ def assistant? = role == :assistant
59
+ def system? = role == :system
60
+
61
+ # Whether a person sent a machine's words. Provenance, not authorship:
62
+ # the message is the human's (see SupportDesk::Draft).
63
+ def assisted? = !!assisted
64
+
65
+ # "[2026-09-18 10:02] Alice: No me han pagado [justificante.pdf]"
66
+ #
67
+ # UTC, to the minute, always: a transcript that a model reads is
68
+ # compared against itself line by line, and a stamp that moves with
69
+ # the reader's zone or carries seconds is noise in a diff.
70
+ def to_line
71
+ said = [ body.to_s.strip.presence, *attachments.map { |file| "[#{file}]" } ].compact
72
+ "[#{at&.utc&.strftime("%Y-%m-%d %H:%M")}] #{name}: #{said.join(" ")}"
73
+ end
74
+
75
+ alias_method :to_s, :to_line
76
+
77
+ def to_h
78
+ { role: role, name: name, body: body, at: at, attachments: attachments, assisted: assisted }
79
+ end
80
+ end
81
+
82
+ attr_reader :ticket, :limit
83
+
84
+ # The transcript of +ticket+. `limit:` keeps the LAST n turns — the end
85
+ # of a conversation is the part that is still being answered.
86
+ def initialize(ticket, limit: nil)
87
+ @ticket = ticket
88
+ @limit = limit&.to_i
89
+ end
90
+
91
+ # Every turn, oldest first.
92
+ def each(&block)
93
+ turns.each(&block)
94
+ end
95
+
96
+ def to_a = turns.dup
97
+
98
+ def size = turns.size
99
+
100
+ alias_method :length, :size
101
+
102
+ # The last +count+ turns, oldest first.
103
+ def last(count = 1)
104
+ turns.last(count)
105
+ end
106
+
107
+ # Everything said after +message+ — "what has happened since the one I
108
+ # answered". A message this transcript doesn't hold (older than `limit:`,
109
+ # from another case, deleted outright) yields the whole window rather
110
+ # than nothing: the caller has seen none of it, and an empty answer would
111
+ # read as "nothing happened".
112
+ def since(message)
113
+ return to_a if message.nil?
114
+
115
+ index = turns.index { |turn| turn.message&.id.to_s == message.id.to_s }
116
+ return to_a if index.nil?
117
+
118
+ turns[(index + 1)..] || []
119
+ end
120
+
121
+ # The whole thing as one block of text, for a prompt or a log line.
122
+ def to_text
123
+ turns.map(&:to_line).join("\n")
124
+ end
125
+
126
+ alias_method :to_s, :to_text
127
+
128
+ # The transcript as data. `truncated` is the honest half of `limit:`:
129
+ # a reader that is seeing the last 50 of 300 turns has to know it.
130
+ def to_h
131
+ {
132
+ size: total,
133
+ truncated: total > turns.size,
134
+ turns: turns.map(&:to_h)
135
+ }
136
+ end
137
+
138
+ def inspect
139
+ "#<SupportDesk::Transcript #{ticket.reference} #{turns.size} turn(s)>"
140
+ end
141
+
142
+ private
143
+
144
+ # Built once per instance: a transcript is a snapshot of a conversation
145
+ # at the moment somebody asked for it, and a second read that disagreed
146
+ # with the first would make `since` and `last` answer about two
147
+ # different conversations.
148
+ def turns
149
+ @turns ||= messages.map { |message| turn_for(message) }
150
+ end
151
+
152
+ def messages
153
+ return [] if ticket.conversation.nil?
154
+
155
+ scope = ticket.conversation.messages.oldest_first
156
+ all = scope.to_a
157
+ @total = all.size
158
+ limit ? all.last(limit) : all
159
+ end
160
+
161
+ def total
162
+ turns # force the read, which is what counts
163
+ @total.to_i
164
+ end
165
+
166
+ def turn_for(message)
167
+ role = role_for(message)
168
+ tombstone = message.respond_to?(:deleted?) && message.deleted?
169
+
170
+ Turn.new(
171
+ role: role,
172
+ name: name_for(role, message),
173
+ body: tombstone ? I18n.t("support_desk.transcript.deleted") : message.body,
174
+ at: message.created_at,
175
+ attachments: tombstone ? [] : attachment_names(message),
176
+ assisted: assisted?(message),
177
+ message: message
178
+ )
179
+ end
180
+
181
+ # System first (a disclosure notice is a system line, whatever wrote
182
+ # it), then the machine, then the two kinds of person.
183
+ def role_for(message)
184
+ return :system if message.respond_to?(:system?) && message.system?
185
+ return :assistant if ticket.assistant_message?(message)
186
+
187
+ case ticket.send(:role_of, message)
188
+ when :requester then :requester
189
+ when :agent then :human
190
+ else :system
191
+ end
192
+ end
193
+
194
+ def name_for(role, message)
195
+ case role
196
+ when :requester then Chats.display_name_for(ticket.requester)
197
+ when :assistant then assistant_name(message)
198
+ when :human then message.author.try(:support_agent_name) || ticket.desk&.name
199
+ else ticket.desk&.name
200
+ end
201
+ end
202
+
203
+ # Message snapshots survive configuration changes. Imports without a
204
+ # snapshot use the configured key or author as a legacy fallback.
205
+ def assistant_name(message)
206
+ stamp = provenance(message)
207
+ return stamp["name"] if stamp["name"].is_a?(String) && stamp["name"].present?
208
+ return stamp["display_name"] if stamp["display_name"].is_a?(String) && stamp["display_name"].present?
209
+
210
+ key = stamp["assistant"]
211
+ if key.present? && SupportDesk.config.assistant?(key)
212
+ return SupportDesk.config.assistant(key).name
213
+ end
214
+
215
+ stamp["display_name"].presence || message.author.try(:name) || ticket.desk&.name
216
+ end
217
+
218
+ def assisted?(message)
219
+ provenance(message)["drafted_by"].present?
220
+ end
221
+
222
+ def provenance(message)
223
+ stamp = message.try(:metadata)
224
+ return {} unless stamp.is_a?(Hash)
225
+
226
+ nested = stamp["support_desk"]
227
+ nested.is_a?(Hash) ? nested : {}
228
+ end
229
+
230
+ def attachment_names(message)
231
+ message.try(:files)&.map { |file| file.try(:filename).to_s } || []
232
+ end
233
+ end
234
+ end