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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +197 -0
- data/README.md +1131 -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 +21 -3
- 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/create_support_desk_message_registrations.rb.erb +42 -0
- data/lib/generators/support_desk/templates/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +25 -7
- 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 +171 -1
- data/lib/support_desk/engine.rb +16 -1
- 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/message_signatures.rb +20 -0
- data/lib/support_desk/models/assistant.rb +188 -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 +275 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/message_registration.rb +12 -0
- data/lib/support_desk/models/ticket/assistance.rb +899 -0
- data/lib/support_desk/models/ticket.rb +406 -58
- 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 +234 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +151 -0
- data/lib/tasks/support_desk.rake +57 -0
- 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
|
data/lib/support_desk/queue.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
11
|
-
# the same three questions — when, who,
|
|
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
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
data/lib/support_desk/topic.rb
CHANGED
|
@@ -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
|