support_desk 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +126 -0
- data/README.md +1101 -22
- data/Rakefile +16 -0
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +28 -4
- data/app/helpers/support_desk/engine_helper.rb +23 -1
- 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 +33 -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/_new_conversation_form.html.erb +91 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/index.html.erb +14 -2
- data/app/views/support_desk/console/tickets/new.html.erb +25 -0
- 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/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +89 -0
- data/config/locales/support_desk.console.es.yml +91 -0
- data/config/locales/support_desk.en.yml +18 -0
- data/config/locales/support_desk.es.yml +29 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +26 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -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/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +63 -0
- data/lib/generators/support_desk/upgrade_generator.rb +66 -0
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +699 -3
- data/lib/support_desk/console.rb +634 -30
- data/lib/support_desk/console_engine.rb +2 -0
- data/lib/support_desk/console_routes.rb +20 -8
- data/lib/support_desk/context_card.rb +33 -1
- data/lib/support_desk/doctor.rb +195 -2
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +42 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +41 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +45 -12
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +781 -120
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +160 -3
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +4 -12
- data/lib/support_desk.rb +153 -0
- data/lib/tasks/support_desk.rake +73 -0
- metadata +35 -8
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# Everything a machine needs to answer one case, as plain data:
|
|
5
|
+
#
|
|
6
|
+
# brief = ticket.brief
|
|
7
|
+
# brief.to_h # a Hash you can hand to JSON.generate
|
|
8
|
+
# brief.to_text # the same facts as sectioned plain text
|
|
9
|
+
# brief.policy # what she may do here, and why
|
|
10
|
+
#
|
|
11
|
+
# It is the ONE thing a harness reads. Not chats' tables, not the console's
|
|
12
|
+
# partials, not the ticket's columns: a brief is versioned
|
|
13
|
+
# (`schema_version`), complete, and the same shape whatever the host looks
|
|
14
|
+
# like — so a prompt written against it keeps working when the gem grows a
|
|
15
|
+
# column.
|
|
16
|
+
#
|
|
17
|
+
# == Facts, never instructions
|
|
18
|
+
#
|
|
19
|
+
# There is not one imperative sentence in here, and there never will be.
|
|
20
|
+
# What the assistant should DO with a case is the host's prompt, the
|
|
21
|
+
# host's model and the host's problem; what the case IS is ours. A gem
|
|
22
|
+
# that shipped "be polite and concise" inside a brief would be writing
|
|
23
|
+
# somebody else's product copy in a place they cannot edit.
|
|
24
|
+
#
|
|
25
|
+
# The one thing the brief does state about behaviour is `may` / `may_not`,
|
|
26
|
+
# and that is not advice either — it is the authorization, straight from
|
|
27
|
+
# `AssistantPolicy`, so a harness never has to re-derive the rules it is
|
|
28
|
+
# working under (§4.4).
|
|
29
|
+
#
|
|
30
|
+
# == This leaves your building
|
|
31
|
+
#
|
|
32
|
+
# Assume every field here reaches a third party the moment a harness calls
|
|
33
|
+
# a model with it. Two fields are entirely yours to fill and therefore
|
|
34
|
+
# yours to audit:
|
|
35
|
+
#
|
|
36
|
+
# * `case[:subject][:context]` — `Supportable#support_context`
|
|
37
|
+
# * `requester[:context]` — `Requester#support_context`
|
|
38
|
+
#
|
|
39
|
+
# And `include_internal: true` adds the desk's private reasoning — notes
|
|
40
|
+
# agents left for each other, proposals a human rejected and why. It is
|
|
41
|
+
# off by default and it is opt-in per call for that reason.
|
|
42
|
+
class Brief
|
|
43
|
+
# The shape's version. Bumped when a key changes meaning or leaves;
|
|
44
|
+
# a new key is not a bump, because a reader that ignores it is right.
|
|
45
|
+
SCHEMA_VERSION = 1
|
|
46
|
+
|
|
47
|
+
attr_reader :ticket, :transcript
|
|
48
|
+
|
|
49
|
+
# The brief for +ticket+. `transcript_limit:` keeps the last n turns,
|
|
50
|
+
# which is what a context window can actually hold.
|
|
51
|
+
def initialize(ticket, include_internal: false, transcript_limit: 50)
|
|
52
|
+
@ticket = ticket
|
|
53
|
+
@include_internal = include_internal
|
|
54
|
+
@transcript = ticket.transcript(limit: transcript_limit)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether the desk's private reasoning is in here.
|
|
58
|
+
def include_internal? = !!@include_internal
|
|
59
|
+
|
|
60
|
+
# What the assistant may do on this case, and why — the same object
|
|
61
|
+
# every verb asks under the row lock.
|
|
62
|
+
def policy = ticket.assistant_policy
|
|
63
|
+
|
|
64
|
+
# The whole brief, as data.
|
|
65
|
+
def to_h
|
|
66
|
+
{
|
|
67
|
+
schema_version: SCHEMA_VERSION,
|
|
68
|
+
desk: desk_facts,
|
|
69
|
+
assistant: assistant_facts,
|
|
70
|
+
case: case_facts,
|
|
71
|
+
requester: requester_facts,
|
|
72
|
+
internal: (internal_facts if include_internal?),
|
|
73
|
+
transcript: transcript.to_h
|
|
74
|
+
}.compact
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The same facts, as sectioned plain text — for a prompt that is cheaper
|
|
78
|
+
# to read than JSON, and for a log line a person has to skim at 3 a.m.
|
|
79
|
+
def to_text
|
|
80
|
+
sections = [
|
|
81
|
+
section("DESK", desk_lines),
|
|
82
|
+
section("ASSISTANT", assistant_lines),
|
|
83
|
+
section("CASE", case_lines),
|
|
84
|
+
section("REQUESTER", requester_lines),
|
|
85
|
+
(section("INTERNAL", internal_lines) if include_internal?),
|
|
86
|
+
section("TRANSCRIPT", [ transcript.to_text ])
|
|
87
|
+
]
|
|
88
|
+
sections.compact.join("\n\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def inspect
|
|
92
|
+
"#<SupportDesk::Brief #{ticket.reference} #{transcript.size} turn(s)" \
|
|
93
|
+
"#{" +internal" if include_internal?}>"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def desk
|
|
99
|
+
ticket.desk
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def assistant
|
|
103
|
+
ticket.assistant
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def desk_facts
|
|
107
|
+
reply_within = ticket.desk_config.reply_within
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
key: desk&.key,
|
|
111
|
+
name: desk&.name,
|
|
112
|
+
reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within)
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# nil, not an empty Hash, when no assistant works this desk: "there is
|
|
117
|
+
# no assistant here" and "there is one who may do nothing" are different
|
|
118
|
+
# facts, and a harness that confuses them answers from a case it has no
|
|
119
|
+
# seat on.
|
|
120
|
+
def assistant_facts
|
|
121
|
+
return nil if assistant.nil?
|
|
122
|
+
|
|
123
|
+
decision = policy
|
|
124
|
+
{
|
|
125
|
+
key: assistant.key,
|
|
126
|
+
# Her own name, not the disclosed one: what the requester is shown
|
|
127
|
+
# is `disclosure`, one line down, and a model told it is called
|
|
128
|
+
# "Rose · asistente virtual" starts signing itself that way.
|
|
129
|
+
name: assistant.name,
|
|
130
|
+
disclosure: assistant.disclosure,
|
|
131
|
+
level: decision.level,
|
|
132
|
+
because: decision.because,
|
|
133
|
+
may: decision.allowed_verbs,
|
|
134
|
+
may_not: decision.forbidden_verbs,
|
|
135
|
+
turns: { used: ticket.assistant_turns_count.to_i, max: assistant.max_turns }
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def case_facts
|
|
140
|
+
{
|
|
141
|
+
reference: ticket.reference,
|
|
142
|
+
status: ticket.status,
|
|
143
|
+
awaiting: ticket.awaiting,
|
|
144
|
+
opened_at: ticket.opened_at,
|
|
145
|
+
opened_via: ticket.opened_via,
|
|
146
|
+
opened_by: ticket.opened_by_requester? ? "requester" : "support",
|
|
147
|
+
reopened: ticket.reopened?,
|
|
148
|
+
human_required: human_required_facts,
|
|
149
|
+
paused: ticket.assistant_paused?,
|
|
150
|
+
cap: ticket.assistant_cap,
|
|
151
|
+
topic: { path: ticket.topic&.path, label: ticket.topic&.full_label },
|
|
152
|
+
subject: subject_facts
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def human_required_facts
|
|
157
|
+
return nil unless ticket.human_required?
|
|
158
|
+
|
|
159
|
+
{ at: ticket.human_required_at, reason: ticket.human_required_reason }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def subject_facts
|
|
163
|
+
subject = ticket.subject
|
|
164
|
+
return nil if subject.nil?
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
type: subject.class.name,
|
|
168
|
+
label: subject.try(:support_label),
|
|
169
|
+
status: subject.try(:support_status),
|
|
170
|
+
# The host's own key/value pairs. See the class comment: this is
|
|
171
|
+
# what leaves the building.
|
|
172
|
+
context: subject.try(:support_context) || {}
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def requester_facts
|
|
177
|
+
requester = ticket.requester
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
name: Chats.display_name_for(requester),
|
|
181
|
+
since: requester.try(:created_at),
|
|
182
|
+
open_cases: Ticket.not_closed.where(requester: requester).count,
|
|
183
|
+
context: requester.try(:support_context) || {}
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The desk talking to itself. Only ever built when the caller asked.
|
|
188
|
+
def internal_facts
|
|
189
|
+
{
|
|
190
|
+
notes: ticket.notes.map do |event|
|
|
191
|
+
{ at: event.created_at, by: SupportDesk.actor_key(event.actor_or_system), body: event.note }
|
|
192
|
+
end,
|
|
193
|
+
drafts: ticket.drafts.chronological.map do |draft|
|
|
194
|
+
{
|
|
195
|
+
at: draft.created_at,
|
|
196
|
+
status: draft.status,
|
|
197
|
+
body: draft.body,
|
|
198
|
+
confidence: draft.confidence,
|
|
199
|
+
edited: draft.edited?,
|
|
200
|
+
sent_body: draft.sent_body,
|
|
201
|
+
rejection_reason: draft.rejection_reason
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# --- The text rendering -------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def section(title, lines)
|
|
210
|
+
body = Array(lines).compact.reject { |line| line.to_s.strip.empty? }
|
|
211
|
+
return nil if body.empty?
|
|
212
|
+
|
|
213
|
+
"== #{title}\n#{body.join("\n")}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def pair(label, value)
|
|
217
|
+
return nil if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
218
|
+
|
|
219
|
+
"#{label}: #{value}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def desk_lines
|
|
223
|
+
facts = desk_facts
|
|
224
|
+
[ pair("Desk", facts[:name]), pair("Answers within", facts[:reply_within]) ]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def assistant_lines
|
|
228
|
+
facts = assistant_facts
|
|
229
|
+
return [ "No assistant works this desk." ] if facts.nil?
|
|
230
|
+
|
|
231
|
+
[
|
|
232
|
+
pair("Assistant", facts[:name]),
|
|
233
|
+
pair("Disclosure", facts[:disclosure]),
|
|
234
|
+
pair("Level", "#{facts[:level]} (#{facts[:because]})"),
|
|
235
|
+
pair("May", facts[:may].join(", ")),
|
|
236
|
+
pair("May not", facts[:may_not].join(", ")),
|
|
237
|
+
pair("Turns", "#{facts[:turns][:used]}/#{facts[:turns][:max] || "unlimited"}")
|
|
238
|
+
]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def case_lines
|
|
242
|
+
facts = case_facts
|
|
243
|
+
subject = facts[:subject]
|
|
244
|
+
|
|
245
|
+
[
|
|
246
|
+
pair("Reference", facts[:reference]),
|
|
247
|
+
pair("Status", "#{facts[:status]} · awaiting #{facts[:awaiting] || "nobody"}"),
|
|
248
|
+
pair("Opened", "#{facts[:opened_at]&.utc&.strftime("%Y-%m-%d %H:%M")} by #{facts[:opened_by]} " \
|
|
249
|
+
"via #{facts[:opened_via]}"),
|
|
250
|
+
(pair("Reopened", "yes") if facts[:reopened]),
|
|
251
|
+
(pair("A person was asked for", "#{facts[:human_required][:reason]} " \
|
|
252
|
+
"(#{facts[:human_required][:at]&.utc&.strftime("%Y-%m-%d %H:%M")})") if
|
|
253
|
+
facts[:human_required]),
|
|
254
|
+
(pair("Assistant paused", "yes") if facts[:paused]),
|
|
255
|
+
pair("Case cap", facts[:cap]),
|
|
256
|
+
pair("Topic", facts[:topic][:label] || facts[:topic][:path]),
|
|
257
|
+
(pair("About", "#{subject[:label]}#{" · #{subject[:status]}" if subject[:status]}") if subject),
|
|
258
|
+
*(subject ? subject[:context].map { |key, value| pair(" #{key}", value) } : [])
|
|
259
|
+
]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def requester_lines
|
|
263
|
+
facts = requester_facts
|
|
264
|
+
|
|
265
|
+
[
|
|
266
|
+
pair("Name", facts[:name]),
|
|
267
|
+
pair("Customer since", facts[:since]&.utc&.strftime("%Y-%m-%d")),
|
|
268
|
+
pair("Open cases", facts[:open_cases]),
|
|
269
|
+
*facts[:context].map { |key, value| pair(" #{key}", value) }
|
|
270
|
+
]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def internal_lines
|
|
274
|
+
facts = internal_facts
|
|
275
|
+
|
|
276
|
+
facts[:notes].map { |note| "Note (#{note[:at]&.utc&.strftime("%Y-%m-%d %H:%M")}): #{note[:body]}" } +
|
|
277
|
+
facts[:drafts].map do |draft|
|
|
278
|
+
reason = draft[:rejection_reason]
|
|
279
|
+
"Proposal (#{draft[:status]}#{" · #{reason}" if reason.present?}): #{draft[:body]}"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|