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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +998 -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 +12 -0
- 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/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +12 -2
- 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 +144 -1
- data/lib/support_desk/engine.rb +10 -0
- 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/models/assistant.rb +153 -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 +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 +334 -37
- 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 +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +108 -0
- data/lib/tasks/support_desk.rake +46 -0
- metadata +30 -8
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# What the assistant may do on ONE case, right now, and why.
|
|
5
|
+
#
|
|
6
|
+
# policy = ticket.assistant_policy
|
|
7
|
+
# policy.level # => :draft
|
|
8
|
+
# policy.because # => "topic payments caps rose at draft"
|
|
9
|
+
# policy.may_reply? # => false
|
|
10
|
+
#
|
|
11
|
+
# == Ceilings and floors
|
|
12
|
+
#
|
|
13
|
+
# A CEILING lowers what the assistant may ever produce here: her configured
|
|
14
|
+
# autonomy, the topic's cap (the minimum over the topic and every ancestor),
|
|
15
|
+
# the host's `cap` block, the case's persisted cap (what a reopen writes),
|
|
16
|
+
# and a pause. The lowest ceiling wins, and `because` names the rule that
|
|
17
|
+
# decided it — so a refusal can always be traced to one line of
|
|
18
|
+
# configuration.
|
|
19
|
+
#
|
|
20
|
+
# A FLOOR lowers the level because of the case's state: she is inactive,
|
|
21
|
+
# the case is closed, a person was asked for, or a human holds it. Floors
|
|
22
|
+
# run after ceilings and only ever lower.
|
|
23
|
+
#
|
|
24
|
+
# == It is the authorization, not a hint
|
|
25
|
+
#
|
|
26
|
+
# Every assistant verb asks this object, under the ticket's row lock, from
|
|
27
|
+
# freshly read state — `Ticket#actions_for` is a projection of the same
|
|
28
|
+
# decision, never a second opinion. No query: the topic is an in-memory
|
|
29
|
+
# lookup, the assignee is the loaded association, everything else is a
|
|
30
|
+
# column on the row we already hold.
|
|
31
|
+
class AssistantPolicy
|
|
32
|
+
# What the assistant may PRODUCE, from least to most. Ordered, and the
|
|
33
|
+
# order is the meaning: everything compares by RANK, never by name.
|
|
34
|
+
LEVELS = %i[off observe draft reply resolve].freeze
|
|
35
|
+
RANK = LEVELS.each_with_index.to_h.freeze
|
|
36
|
+
|
|
37
|
+
# The verbs each level unlocks. Every level's array is the previous one
|
|
38
|
+
# plus its own, written out rather than computed: a reader has to be able
|
|
39
|
+
# to see what ":reply" means without running anything.
|
|
40
|
+
VERBS_BY_LEVEL = {
|
|
41
|
+
off: [],
|
|
42
|
+
observe: %i[note escalate release],
|
|
43
|
+
draft: %i[note escalate release draft],
|
|
44
|
+
reply: %i[note escalate release draft reply take],
|
|
45
|
+
resolve: %i[note escalate release draft reply take close]
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# Every verb the top level knows — what `forbidden_verbs` subtracts from.
|
|
49
|
+
ALL_VERBS = VERBS_BY_LEVEL.fetch(:resolve)
|
|
50
|
+
|
|
51
|
+
attr_reader :ticket, :assistant, :level, :because, :ceilings, :floors
|
|
52
|
+
|
|
53
|
+
# The policy for +assistant+ on +ticket+.
|
|
54
|
+
#
|
|
55
|
+
# `hand_back: true` asks a different question — "could she hold this case
|
|
56
|
+
# once the human-side flags were cleared?" — and is used by one caller,
|
|
57
|
+
# `Ticket#ensure_assignable!`, so that a human handing a case back is not
|
|
58
|
+
# refused by the very flags that hand-back exists to lift. The hard
|
|
59
|
+
# ceilings (autonomy, topic, the `cap` block) and the hard floors
|
|
60
|
+
# (inactive, closed) still apply.
|
|
61
|
+
def self.for(ticket, assistant = ticket.assistant, hand_back: false)
|
|
62
|
+
return Null.new(ticket, reason: "no assistant on desk #{ticket.desk&.key}") if assistant.nil?
|
|
63
|
+
unless SupportDesk::Ticket.same_actor?(assistant, ticket.assistant)
|
|
64
|
+
return Null.new(ticket, reason: "#{assistant.try(:key) || assistant.class} is not desk " \
|
|
65
|
+
"#{ticket.desk&.key}'s assistant")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
config = assistant.config
|
|
69
|
+
ceilings = {
|
|
70
|
+
assistant: config.autonomy,
|
|
71
|
+
topic: ticket.topic&.assistant_cap,
|
|
72
|
+
cap: evaluate_cap(config, ticket),
|
|
73
|
+
case: (ticket.assistant_cap&.to_sym unless hand_back),
|
|
74
|
+
pause: (:off if ticket.assistant_paused? && !hand_back)
|
|
75
|
+
}.compact
|
|
76
|
+
|
|
77
|
+
# Hash#min_by yields [key, value]; destructuring it the other way round
|
|
78
|
+
# is how a policy starts reporting "the level caps her at :topic".
|
|
79
|
+
rule, level = ceilings.min_by { |_rule, value| RANK.fetch(value) }
|
|
80
|
+
because = ceiling_sentence(rule, level, assistant, ticket)
|
|
81
|
+
floors = []
|
|
82
|
+
|
|
83
|
+
if !assistant.active?
|
|
84
|
+
level = :off
|
|
85
|
+
because = "#{assistant.key} is inactive"
|
|
86
|
+
floors << :inactive
|
|
87
|
+
end
|
|
88
|
+
if ticket.closed? && RANK.fetch(level) > RANK.fetch(:observe)
|
|
89
|
+
level = :observe
|
|
90
|
+
because = "the case is closed"
|
|
91
|
+
floors << :closed
|
|
92
|
+
end
|
|
93
|
+
if !hand_back && ticket.human_required? && RANK.fetch(level) > RANK.fetch(:observe)
|
|
94
|
+
level = :observe
|
|
95
|
+
because = "a person was requested (#{ticket.human_required_reason})"
|
|
96
|
+
floors << :human_required
|
|
97
|
+
end
|
|
98
|
+
if !hand_back && ticket.assigned? && !ticket.assigned_to?(assistant) &&
|
|
99
|
+
RANK.fetch(level) > RANK.fetch(:draft)
|
|
100
|
+
level = :draft
|
|
101
|
+
because = "#{holder_name(ticket)} holds the case"
|
|
102
|
+
floors << :held_by_human
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
new(ticket: ticket, assistant: assistant, level: level, because: because,
|
|
106
|
+
ceilings: ceilings, floors: floors)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Run the host's `cap` block and check what it said. A block that returns
|
|
110
|
+
# something that isn't a level is a configuration mistake, and it is
|
|
111
|
+
# named as one here rather than turning into a NoMethodError inside a
|
|
112
|
+
# transaction. A block that RAISES is the host's exception to see: it
|
|
113
|
+
# propagates (the one caller that can't afford that — the turn emitter —
|
|
114
|
+
# reports it and emits nothing).
|
|
115
|
+
def self.evaluate_cap(config, ticket) # :nodoc:
|
|
116
|
+
block = config.cap
|
|
117
|
+
return nil if block.nil?
|
|
118
|
+
|
|
119
|
+
value = block.call(ticket)
|
|
120
|
+
return nil if value.nil?
|
|
121
|
+
|
|
122
|
+
level = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
123
|
+
unless LEVELS.include?(level)
|
|
124
|
+
raise ConfigurationError,
|
|
125
|
+
"assistant #{config.key}'s cap block must return one of #{LEVELS.map(&:inspect).join(", ")} " \
|
|
126
|
+
"or nil, got #{value.inspect}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
level
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.ceiling_sentence(rule, level, assistant, ticket) # :nodoc:
|
|
133
|
+
case rule
|
|
134
|
+
when :assistant then "#{assistant.key}'s autonomy is #{level}"
|
|
135
|
+
when :topic then "topic #{ticket.topic&.path} caps #{assistant.key} at #{level}"
|
|
136
|
+
when :cap then "this desk's cap block caps #{assistant.key} at #{level}"
|
|
137
|
+
when :case then "this case caps #{assistant.key} at #{level}"
|
|
138
|
+
when :pause then "#{assistant.key} is paused on this case"
|
|
139
|
+
else "#{assistant.key} may work at #{level}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.holder_name(ticket) # :nodoc:
|
|
144
|
+
ticket.assignee.try(:support_agent_name) || "somebody else"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private_class_method :ceiling_sentence, :holder_name
|
|
148
|
+
|
|
149
|
+
# Built by `.for`. The attributes are the whole object: a policy is a
|
|
150
|
+
# value, computed once under the lock and then only read.
|
|
151
|
+
def initialize(ticket:, assistant:, level:, because:, ceilings: {}, floors: [])
|
|
152
|
+
@ticket = ticket
|
|
153
|
+
@assistant = assistant
|
|
154
|
+
@level = level
|
|
155
|
+
@because = because
|
|
156
|
+
@ceilings = ceilings
|
|
157
|
+
@floors = floors
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Whether the effective level is at least +other+.
|
|
161
|
+
def at_least?(other)
|
|
162
|
+
RANK.fetch(level) >= RANK.fetch(other.to_sym)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Whether this verb is unlocked at the effective level.
|
|
166
|
+
def may?(verb) = allowed_verbs.include?(verb.to_sym)
|
|
167
|
+
|
|
168
|
+
def may_observe? = at_least?(:observe)
|
|
169
|
+
def may_draft? = at_least?(:draft)
|
|
170
|
+
def may_reply? = at_least?(:reply)
|
|
171
|
+
# Holding a case means owing the next word, so it takes the level that
|
|
172
|
+
# may say one.
|
|
173
|
+
def may_hold? = may_reply?
|
|
174
|
+
def may_close? = at_least?(:resolve)
|
|
175
|
+
|
|
176
|
+
def allowed_verbs = VERBS_BY_LEVEL.fetch(level)
|
|
177
|
+
def forbidden_verbs = ALL_VERBS - allowed_verbs
|
|
178
|
+
|
|
179
|
+
# Whether this is the null policy — no assistant to ask about.
|
|
180
|
+
def null? = false
|
|
181
|
+
|
|
182
|
+
# The whole decision, as data: what goes into message metadata, a draft
|
|
183
|
+
# row and an event payload, so forensics never has to re-run the policy
|
|
184
|
+
# against a case that has moved on.
|
|
185
|
+
def to_h
|
|
186
|
+
{
|
|
187
|
+
assistant: assistant&.key,
|
|
188
|
+
level: level,
|
|
189
|
+
because: because,
|
|
190
|
+
ceilings: { assistant: ceilings[:assistant], topic: ceilings[:topic], cap: ceilings[:cap],
|
|
191
|
+
case: ceilings[:case], pause: ceilings[:pause] },
|
|
192
|
+
floors: floors
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# "draft — topic payments caps rose at draft"
|
|
197
|
+
def to_s = "#{level} — #{because}"
|
|
198
|
+
|
|
199
|
+
def inspect
|
|
200
|
+
"#<SupportDesk::AssistantPolicy #{assistant&.key || "none"} #{level} #{because.inspect}>"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# No assistant on this desk, or an assistant that is somebody else's.
|
|
204
|
+
# Every predicate says no, and `because` says which of the two it was.
|
|
205
|
+
class Null < AssistantPolicy
|
|
206
|
+
def initialize(ticket, reason:)
|
|
207
|
+
super(ticket: ticket, assistant: nil, level: :off, because: reason, ceilings: {}, floors: [])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def null? = true
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -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
|