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,675 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
class Ticket
|
|
5
|
+
# Everything a case knows about its assistant: the turn, the policy
|
|
6
|
+
# gates, the two verbs that are hers alone (`respond!`, `draft!`), the
|
|
7
|
+
# two exits (`escalate!`, `request_human!`) and the human override
|
|
8
|
+
# (`pause_assistant!`).
|
|
9
|
+
#
|
|
10
|
+
# == The turn
|
|
11
|
+
#
|
|
12
|
+
# `assistant_revision` is an integer bumped by every registered message
|
|
13
|
+
# and every transition. `assistant_turn` is that integer, spelled as an
|
|
14
|
+
# opaque string, and EVERY assistant action requires it and consumes it:
|
|
15
|
+
#
|
|
16
|
+
# turn = ticket.assistant_turn # "t7-r12"
|
|
17
|
+
# ticket.respond!(answer, by: rose, turn: turn)
|
|
18
|
+
#
|
|
19
|
+
# A model takes seconds to answer, and a customer can write again while
|
|
20
|
+
# it does. The turn is what makes that safe: the action is compared
|
|
21
|
+
# against the case's current revision under its row lock, and a late,
|
|
22
|
+
# retried or redelivered one raises SupportDesk::StaleTurn and writes
|
|
23
|
+
# nothing. It is also why there are no idempotency keys, claim rows or
|
|
24
|
+
# leases in this gem — one integer under the lock already answers "is
|
|
25
|
+
# this still the case you read?".
|
|
26
|
+
#
|
|
27
|
+
# == Gate the action, never the evidence
|
|
28
|
+
#
|
|
29
|
+
# Policy decides what she may PRODUCE. It never decides what a person can
|
|
30
|
+
# see: the transcript, the case, the queue and the door to a human are
|
|
31
|
+
# the same whatever the level, and a refusal is always a named reason on
|
|
32
|
+
# a record (an `assistant_withheld` event, a policy in a draft's
|
|
33
|
+
# metadata), never silence.
|
|
34
|
+
module Assistance
|
|
35
|
+
extend ActiveSupport::Concern
|
|
36
|
+
|
|
37
|
+
included do
|
|
38
|
+
has_many :drafts, class_name: "SupportDesk::Draft", inverse_of: :ticket, dependent: :destroy
|
|
39
|
+
has_one :pending_draft, -> { pending }, class_name: "SupportDesk::Draft", inverse_of: :ticket
|
|
40
|
+
|
|
41
|
+
# --- Scopes -------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
scope :held_by_assistants, lambda {
|
|
44
|
+
where(assignee_type: SupportDesk::Assistant.polymorphic_name)
|
|
45
|
+
}
|
|
46
|
+
scope :held_by_humans, lambda {
|
|
47
|
+
assigned.where.not(assignee_type: SupportDesk::Assistant.polymorphic_name)
|
|
48
|
+
}
|
|
49
|
+
# The tab that exists so a case a machine could not finish is never
|
|
50
|
+
# the one nobody looks at.
|
|
51
|
+
scope :needs_human, -> { where.not(human_required_at: nil) }
|
|
52
|
+
scope :assistant_paused, -> { where.not(assistant_paused_at: nil) }
|
|
53
|
+
scope :assistant_capped, -> { where.not(assistant_cap: nil) }
|
|
54
|
+
scope :resolved_by_assistant, lambda {
|
|
55
|
+
closed.where(closed_by_type: SupportDesk::Assistant.polymorphic_name)
|
|
56
|
+
}
|
|
57
|
+
scope :with_pending_draft, -> { where(id: SupportDesk::Draft.pending.select(:ticket_id)) }
|
|
58
|
+
# Waiting on the desk since before +time+, with nothing from the
|
|
59
|
+
# assistant since the customer wrote: a harness that is down, or one
|
|
60
|
+
# that keeps deciding to do nothing.
|
|
61
|
+
scope :assistant_idle_since, lambda { |time|
|
|
62
|
+
awaiting_reply.where(last_requester_message_at: ..time)
|
|
63
|
+
.where("assistant_acted_at IS NULL OR assistant_acted_at < last_requester_message_at")
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# --- Readers ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
# The assistant who works this case's desk, or nil.
|
|
70
|
+
def assistant = desk&.assistant
|
|
71
|
+
|
|
72
|
+
# What she may do here, right now, and why. See AssistantPolicy.
|
|
73
|
+
def assistant_policy(assistant = self.assistant, hand_back: false)
|
|
74
|
+
AssistantPolicy.for(self, assistant, hand_back: hand_back)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The token every assistant action has to hold. Never nil: a case that
|
|
78
|
+
# has never been touched still has a turn, which is what makes "the
|
|
79
|
+
# turn you read" a complete answer rather than a special case.
|
|
80
|
+
def assistant_turn = "t#{id}-r#{assistant_revision.to_i}"
|
|
81
|
+
|
|
82
|
+
# Whether this case has anything to do with an assistant — configured
|
|
83
|
+
# on the desk now, or spoken in by one at any point. History counts:
|
|
84
|
+
# the door to a person must not vanish because somebody edited an
|
|
85
|
+
# initializer after she answered.
|
|
86
|
+
def assistant_in_play?
|
|
87
|
+
assistant.present? || assistant_turns_count.to_i.positive?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def held_by_assistant?
|
|
91
|
+
assigned? && assignee_type == SupportDesk::Assistant.polymorphic_name
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Whether a person has been asked for on this case — by the assistant,
|
|
95
|
+
# by the requester, or by the silent sweep.
|
|
96
|
+
def human_required? = human_required_at.present?
|
|
97
|
+
|
|
98
|
+
# Whether a human has switched her off on this case.
|
|
99
|
+
def assistant_paused? = assistant_paused_at.present?
|
|
100
|
+
|
|
101
|
+
# How many times she may still speak here; nil when unlimited.
|
|
102
|
+
def assistant_turns_left
|
|
103
|
+
max = assistant&.max_turns
|
|
104
|
+
return nil if max.nil?
|
|
105
|
+
|
|
106
|
+
[ max - assistant_turns_count.to_i, 0 ].max
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The conversation as ordered turns — the readable half of what a
|
|
110
|
+
# harness works from. `limit:` keeps the LAST n, which is the part
|
|
111
|
+
# still being answered. See SupportDesk::Transcript.
|
|
112
|
+
def transcript(limit: nil)
|
|
113
|
+
Transcript.new(self, limit: limit)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Everything a machine needs to answer this case, as data: the desk,
|
|
117
|
+
# the policy, the case, the requester and the transcript.
|
|
118
|
+
# `include_internal: true` adds the desk's own notes and proposals —
|
|
119
|
+
# off by default, because it leaves the building. See
|
|
120
|
+
# SupportDesk::Brief.
|
|
121
|
+
def brief(include_internal: false, transcript_limit: 50)
|
|
122
|
+
Brief.new(self, include_internal: include_internal, transcript_limit: transcript_limit)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Whether this message came from an assistant — by authorship when she
|
|
126
|
+
# signs, by the provenance stamp when she doesn't. Both shapes, because
|
|
127
|
+
# a host that changes disclosure mode must not rewrite old bubbles.
|
|
128
|
+
def assistant_message?(message)
|
|
129
|
+
return false if message.nil?
|
|
130
|
+
|
|
131
|
+
return true if message.author.is_a?(SupportDesk::Assistant)
|
|
132
|
+
|
|
133
|
+
stamp = message.try(:metadata)
|
|
134
|
+
stamp.is_a?(Hash) && stamp.dig("support_desk", "assistant").present?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# --- Her two verbs ------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
# Answer, and let policy decide what that means: sent to the requester,
|
|
140
|
+
# proposed as a draft for a person to send, or withheld. The ONE verb a
|
|
141
|
+
# harness needs — it never has to encode the rules it is working under.
|
|
142
|
+
#
|
|
143
|
+
# Returns a SupportDesk::Outcome. Raises StaleTurn when the case moved
|
|
144
|
+
# on, Locked when there is nobody to write to, NotAnAssistant when
|
|
145
|
+
# `by:` isn't this desk's assistant.
|
|
146
|
+
def respond!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
|
|
147
|
+
assistant = resolve_assistant!(by)
|
|
148
|
+
raise ArgumentError, "respond! needs something to say" if body.blank? && files.blank?
|
|
149
|
+
|
|
150
|
+
with_lock(requires_new: true) do
|
|
151
|
+
reconcile_unregistered_messages!
|
|
152
|
+
ensure_current_turn!(turn)
|
|
153
|
+
ensure_writable!
|
|
154
|
+
policy = assistant_policy(assistant)
|
|
155
|
+
next withhold!(assistant, policy, :policy, request: request) unless policy.may_draft?
|
|
156
|
+
next withhold!(assistant, policy, :not_your_turn, request: request) unless awaiting_reply?
|
|
157
|
+
|
|
158
|
+
left = assistant_turns_left
|
|
159
|
+
if policy.may_reply? && (left.nil? || left.positive?)
|
|
160
|
+
message = speak!(body, assistant, policy: policy, turn: turn, files: files, confidence: confidence,
|
|
161
|
+
sources: sources, metadata: metadata, request: request)
|
|
162
|
+
Outcome.new(action: :sent, message: message, policy: policy, turn: assistant_turn)
|
|
163
|
+
else
|
|
164
|
+
draft = propose_draft!(body, assistant, policy: policy, files: files, confidence: confidence,
|
|
165
|
+
sources: sources, metadata: metadata)
|
|
166
|
+
reason = nil
|
|
167
|
+
# Out of budget at a level that could otherwise have answered:
|
|
168
|
+
# the draft stays, and so does a person — a conversation that
|
|
169
|
+
# ran out of turns is one somebody has to finish.
|
|
170
|
+
if policy.may_reply? && left&.zero?
|
|
171
|
+
flag_human_required!(actor: assistant, kind: :escalated, reason: "max_turns",
|
|
172
|
+
summary: metadata[:summary] || metadata["summary"],
|
|
173
|
+
line: :hand_off_line, request: request)
|
|
174
|
+
reason = :max_turns
|
|
175
|
+
end
|
|
176
|
+
Outcome.new(action: :drafted, draft: draft, policy: policy, reason: reason, turn: assistant_turn)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Propose a reply for a person to send, whatever the level allows.
|
|
182
|
+
# `respond!` is what a harness should call; this is for a host that has
|
|
183
|
+
# already decided it wants a draft. Returns the SupportDesk::Draft.
|
|
184
|
+
def draft!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
|
|
185
|
+
assistant = resolve_assistant!(by)
|
|
186
|
+
raise ArgumentError, "draft! needs something to say" if body.blank? && files.blank?
|
|
187
|
+
|
|
188
|
+
with_lock(requires_new: true) do
|
|
189
|
+
reconcile_unregistered_messages!
|
|
190
|
+
ensure_current_turn!(turn)
|
|
191
|
+
ensure_writable!
|
|
192
|
+
policy = assistant_policy(assistant)
|
|
193
|
+
raise AssistantNotAllowed.new(policy, verb: :draft) unless policy.may_draft?
|
|
194
|
+
|
|
195
|
+
propose_draft!(body, assistant, policy: policy, files: files, confidence: confidence,
|
|
196
|
+
sources: sources, metadata: metadata)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# --- The two exits ------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
# Hand the case to a person: the assistant's own way out, and what the
|
|
203
|
+
# silent sweep calls as `:system`. Releases her seat, records the
|
|
204
|
+
# reason, raises the priority and tells the requester.
|
|
205
|
+
#
|
|
206
|
+
# `turn:` is required when `by:` is the assistant — escalating is an
|
|
207
|
+
# action like any other, and a stale one must not speak.
|
|
208
|
+
def escalate!(by: nil, reason:, summary: nil, turn: nil, request: nil)
|
|
209
|
+
actor = resolve_actor(by)
|
|
210
|
+
ensure_agent!(actor)
|
|
211
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
212
|
+
raise ArgumentError, "escalate! needs a reason" if reason.blank?
|
|
213
|
+
|
|
214
|
+
event = nil
|
|
215
|
+
from = nil
|
|
216
|
+
with_lock(requires_new: true) do
|
|
217
|
+
if assistant
|
|
218
|
+
ensure_current_turn!(turn)
|
|
219
|
+
policy = assistant_policy(assistant)
|
|
220
|
+
raise AssistantNotAllowed.new(policy, verb: :escalate) unless policy.may_observe?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
event, from = flag_human_required!(actor: actor, kind: :escalated, reason: reason, summary: summary,
|
|
224
|
+
line: :hand_off_line, request: request)
|
|
225
|
+
end
|
|
226
|
+
return self unless event
|
|
227
|
+
|
|
228
|
+
stamp_assistant_action! if assistant
|
|
229
|
+
SupportDesk.emit_after_commit(:ticket_escalated, self, from: from, reason: reason.to_sym, by: actor)
|
|
230
|
+
self
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# "Prefiero hablar con una persona." The requester's own door, and the
|
|
234
|
+
# one thing on this case they can always do: it reopens a closed case
|
|
235
|
+
# where the desk allows it, flags the case, and says so in the thread.
|
|
236
|
+
# Idempotent — pressing it twice writes once.
|
|
237
|
+
def request_human!(by: nil, request: nil)
|
|
238
|
+
actor = resolve_actor(by)
|
|
239
|
+
unless self.class.same_actor?(actor, requester)
|
|
240
|
+
raise NotAllowed,
|
|
241
|
+
"#{describe_actor(actor)} can't ask for a person on #{reference} — it isn't their case"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
event = nil
|
|
245
|
+
with_lock(requires_new: true) do
|
|
246
|
+
reopen!(by: actor, request: request) if closed? && desk_config.closed_tickets == :reopen_on_reply
|
|
247
|
+
ensure_writable!
|
|
248
|
+
event, = flag_human_required!(actor: actor, kind: :human_requested, reason: "requester_request",
|
|
249
|
+
summary: nil, line: :human_requested_line, request: request)
|
|
250
|
+
end
|
|
251
|
+
return self unless event
|
|
252
|
+
|
|
253
|
+
SupportDesk.emit_after_commit(:human_requested, self, by: actor, reason: :requester_request)
|
|
254
|
+
self
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# --- The human override -------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
# Switch the assistant off on THIS case: a delicate conversation, a
|
|
260
|
+
# customer who has had enough, a thread somebody wants to handle
|
|
261
|
+
# themselves. Releases her seat, throws away her pending proposal, and
|
|
262
|
+
# floors her policy at :off until somebody resumes her.
|
|
263
|
+
def pause_assistant!(by: nil, reason: nil, request: nil)
|
|
264
|
+
actor = resolve_actor(by)
|
|
265
|
+
if actor.is_a?(Symbol) || SupportDesk.ai_actor?(actor)
|
|
266
|
+
raise NotAllowed, "only a person can pause the assistant on a case"
|
|
267
|
+
end
|
|
268
|
+
ensure_agent!(actor)
|
|
269
|
+
|
|
270
|
+
event = write_transition!(:assistant_paused, actor: actor, request: request) do
|
|
271
|
+
raise InvalidTransition, "can't pause the assistant on a closed case" if closed?
|
|
272
|
+
next false if assistant_paused?
|
|
273
|
+
|
|
274
|
+
release_assistant_seat!(reason: :released)
|
|
275
|
+
supersede_pending_drafts!
|
|
276
|
+
update!(assistant_paused_at: Time.current, assistant_paused_reason: reason.presence&.to_s)
|
|
277
|
+
{ "reason" => reason.presence&.to_s }
|
|
278
|
+
end
|
|
279
|
+
return self unless event
|
|
280
|
+
|
|
281
|
+
SupportDesk.emit_after_commit(:assistant_paused, self, by: actor)
|
|
282
|
+
self
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Let her back in on this case. Clears the pause and NOTHING ELSE: a
|
|
286
|
+
# case cap and a request for a person are different decisions, made by
|
|
287
|
+
# different people, and only an explicit hand-back lifts those.
|
|
288
|
+
def resume_assistant!(by: nil, request: nil)
|
|
289
|
+
actor = resolve_actor(by)
|
|
290
|
+
if actor.is_a?(Symbol) || SupportDesk.ai_actor?(actor)
|
|
291
|
+
raise NotAllowed, "only a person can resume the assistant on a case"
|
|
292
|
+
end
|
|
293
|
+
ensure_agent!(actor)
|
|
294
|
+
|
|
295
|
+
event = write_transition!(:assistant_resumed, actor: actor, request: request) do
|
|
296
|
+
next false unless assistant_paused?
|
|
297
|
+
|
|
298
|
+
update!(assistant_paused_at: nil, assistant_paused_reason: nil)
|
|
299
|
+
{}
|
|
300
|
+
end
|
|
301
|
+
return self unless event
|
|
302
|
+
|
|
303
|
+
SupportDesk.emit_after_commit(:assistant_resumed, self, by: actor)
|
|
304
|
+
emit_assistant_turn if awaiting_reply?
|
|
305
|
+
self
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# --- Internals ----------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
# The verbs the assistant may press on this case right now — the
|
|
311
|
+
# machine's half of `actions_for`, and the same decision the
|
|
312
|
+
# transitions make, so a harness reading `may` from a brief is reading
|
|
313
|
+
# the authorization and not a hint.
|
|
314
|
+
def assistant_actions_for(assistant) # :nodoc:
|
|
315
|
+
return [] unless assistant.is_a?(SupportDesk::Assistant)
|
|
316
|
+
return [] unless self.class.same_actor?(assistant, self.assistant)
|
|
317
|
+
|
|
318
|
+
verbs = assistant_policy(assistant).allowed_verbs.dup
|
|
319
|
+
verbs -= %i[escalate] if closed?
|
|
320
|
+
verbs -= %i[reply draft take] if requester_unavailable?
|
|
321
|
+
verbs -= %i[take] unless unassigned?
|
|
322
|
+
verbs -= %i[release] unless assigned_to?(assistant)
|
|
323
|
+
verbs -= %i[reply] unless awaiting_reply?
|
|
324
|
+
verbs -= %i[close] unless assigned_to?(assistant) && awaiting_requester? && !human_required?
|
|
325
|
+
verbs
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Bump the case's revision — the caller holds the lock. `update_columns`
|
|
329
|
+
# on purpose: inside the transaction, no callbacks, no validations, and
|
|
330
|
+
# the in-memory value moves with the row, so the turn a caller reads
|
|
331
|
+
# next is the one the database has.
|
|
332
|
+
def bump_assistant_revision! # :nodoc:
|
|
333
|
+
update_columns(assistant_revision: assistant_revision.to_i + 1)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# When the assistant last did anything here — what the idle-turn check
|
|
337
|
+
# and the redispatch task read to tell "she decided not to speak" from
|
|
338
|
+
# "nothing is running".
|
|
339
|
+
def stamp_assistant_action! # :nodoc:
|
|
340
|
+
update_columns(assistant_acted_at: Time.current)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
private
|
|
344
|
+
|
|
345
|
+
# The actor, as this desk's assistant — or a refusal that names which
|
|
346
|
+
# of the three things went wrong. Every AI-kind actor comes through
|
|
347
|
+
# here: a host model declared `kind: :ai` is not an assistant, and
|
|
348
|
+
# another desk's assistant is not this desk's (I2).
|
|
349
|
+
def resolve_assistant!(actor)
|
|
350
|
+
unless actor.is_a?(SupportDesk::Assistant)
|
|
351
|
+
raise NotAnAssistant,
|
|
352
|
+
"#{describe_actor(actor)} is an AI agent but not a SupportDesk::Assistant — only the desk's " \
|
|
353
|
+
"configured assistant may act on a case"
|
|
354
|
+
end
|
|
355
|
+
unless self.class.same_actor?(actor, assistant)
|
|
356
|
+
raise NotAnAssistant,
|
|
357
|
+
"#{actor.key} is not desk #{desk.key}'s assistant"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Fresh from the row: `active` is a cross-process kill switch, and a
|
|
361
|
+
# record loaded a minute ago is not evidence about now.
|
|
362
|
+
self.class.ensure_agent_record!(actor)
|
|
363
|
+
actor
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# The turn check, under the lock, from the revision the row holds.
|
|
367
|
+
def ensure_current_turn!(turn)
|
|
368
|
+
raise ArgumentError, "turn: is required for an assistant — pass the turn you read" if turn.nil?
|
|
369
|
+
return if turn.to_s == assistant_turn
|
|
370
|
+
|
|
371
|
+
raise StaleTurn,
|
|
372
|
+
"#{turn} is stale on #{reference}: the case has changed (now #{assistant_turn})"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Fold in any requester message chats has committed but the gem hasn't
|
|
376
|
+
# folded in yet, under the lock, BEFORE the turn is checked (I5).
|
|
377
|
+
#
|
|
378
|
+
# The subscriber runs after commit and the assistant's job runs on
|
|
379
|
+
# another connection: without this, a message the customer sent a
|
|
380
|
+
# millisecond ago would be invisible to the turn, and she would answer
|
|
381
|
+
# around it. Folding it in here makes the turn stale instead, which is
|
|
382
|
+
# exactly what should happen.
|
|
383
|
+
def reconcile_unregistered_messages!
|
|
384
|
+
return if conversation.nil?
|
|
385
|
+
|
|
386
|
+
scope = conversation.messages.where(kind: "text", sender_type: requester_type, sender_id: requester_id)
|
|
387
|
+
if last_requester_message_at.present?
|
|
388
|
+
scope = if last_requester_message_id.present?
|
|
389
|
+
# Everything after the clock, plus anything sharing its instant
|
|
390
|
+
# that ISN'T the message the clock was set from — two messages
|
|
391
|
+
# can land on one timestamp, and the second one is real.
|
|
392
|
+
scope.where(
|
|
393
|
+
"chats_messages.created_at > :at OR (chats_messages.created_at = :at AND chats_messages.id <> :id)",
|
|
394
|
+
at: last_requester_message_at, id: last_requester_message_id
|
|
395
|
+
)
|
|
396
|
+
else
|
|
397
|
+
# No pointer to compare against (a 0.2 row whose backfill found
|
|
398
|
+
# nothing): the clock alone, rather than a comparison against an
|
|
399
|
+
# empty string that some adapters refuse outright.
|
|
400
|
+
scope.where("chats_messages.created_at > ?", last_requester_message_at)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
scope.oldest_first.each { |message| record_registration!(message) }
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Nothing was written, and the reason is on the record: a policy that
|
|
408
|
+
# refused is evidence, not silence.
|
|
409
|
+
def withhold!(assistant, policy, reason, request: nil)
|
|
410
|
+
event = record_transition!(:assistant_withheld, actor: assistant) do
|
|
411
|
+
{ "assistant" => assistant.key, "reason" => reason.to_s, "policy" => policy.to_h }
|
|
412
|
+
end
|
|
413
|
+
stamp_assistant_action!
|
|
414
|
+
publish_transition(event, :assistant_withheld, assistant, request)
|
|
415
|
+
SupportDesk.emit_after_commit(:assistant_withheld, self, assistant, reason: reason, policy: policy)
|
|
416
|
+
Outcome.new(action: :withheld, reason: reason, policy: policy, turn: assistant_turn)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Say it to the requester. The caller holds the lock and has already
|
|
420
|
+
# decided she may.
|
|
421
|
+
def speak!(body, assistant, policy:, turn:, files:, confidence:, sources:, metadata:, request:)
|
|
422
|
+
notice = (post_disclosure_notice!(assistant) if assistant.notice? && !disclosure_posted?(assistant))
|
|
423
|
+
apply_reply_policy!(assistant, request: request)
|
|
424
|
+
|
|
425
|
+
stamped = assistant_message_metadata(assistant, policy: policy, turn: turn, confidence: confidence,
|
|
426
|
+
sources: sources, host: metadata)
|
|
427
|
+
|
|
428
|
+
# `author: nil` in the nameless modes, so chats prints no signature
|
|
429
|
+
# and the requester sees the desk — while the metadata above still
|
|
430
|
+
# says exactly what wrote it, for staff, for export and for audit.
|
|
431
|
+
posted = post_agent_message!(body, files: files, by: (assistant if assistant.signs?), metadata: stamped)
|
|
432
|
+
pin_before!(notice, posted) if notice
|
|
433
|
+
record_registration!(posted)
|
|
434
|
+
update_columns(assistant_turns_count: assistant_turns_count.to_i + 1)
|
|
435
|
+
stamp_assistant_action!
|
|
436
|
+
posted
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# What every machine-written message carries: who wrote it, under what
|
|
440
|
+
# disclosure, holding which turn, and the whole policy that allowed it.
|
|
441
|
+
#
|
|
442
|
+
# The host's own metadata is NESTED under "host" rather than merged:
|
|
443
|
+
# the "support_desk" key is the gem's evidence, and a host writing
|
|
444
|
+
# `policy` into it would be rewriting it.
|
|
445
|
+
def assistant_message_metadata(assistant, policy:, turn:, confidence: nil, sources: [], host: {})
|
|
446
|
+
stamped = { "support_desk" => {
|
|
447
|
+
"assistant" => assistant.key,
|
|
448
|
+
"kind" => "ai",
|
|
449
|
+
"display_name" => assistant.disclosed_name,
|
|
450
|
+
"disclosure" => assistant.disclosure.to_s,
|
|
451
|
+
"signed" => assistant.signs?,
|
|
452
|
+
"turn" => turn.to_s,
|
|
453
|
+
"confidence" => confidence,
|
|
454
|
+
"sources" => sources.presence,
|
|
455
|
+
"policy" => policy.to_h
|
|
456
|
+
}.compact }
|
|
457
|
+
stamped["host"] = host if host.present?
|
|
458
|
+
stamped
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# The desk's first word, when it is hers. Called from inside the
|
|
462
|
+
# transaction that created the case (see Ticket.post_opening!), where
|
|
463
|
+
# she is already seated and there is no earlier turn for anybody to
|
|
464
|
+
# have held — so the first turn is the one this message consumes.
|
|
465
|
+
def post_assistant_opening!(body, files:, assistant:)
|
|
466
|
+
policy = assistant_policy(assistant)
|
|
467
|
+
stamped = assistant_message_metadata(assistant, policy: policy, turn: assistant_turn)
|
|
468
|
+
posted = post_agent_message!(body, files: files, by: (assistant if assistant.signs?), metadata: stamped)
|
|
469
|
+
update_columns(assistant_turns_count: 1)
|
|
470
|
+
posted
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Write the proposal. Validated BEFORE the pending one is superseded:
|
|
474
|
+
# a draft that can't be saved must not take the previous one with it.
|
|
475
|
+
def propose_draft!(body, assistant, policy:, files: [], confidence: nil, sources: [], metadata: {})
|
|
476
|
+
candidate = drafts.new(author: assistant, proposed_turn: assistant_turn, body: body,
|
|
477
|
+
confidence: confidence, sources: sources || [],
|
|
478
|
+
metadata: { "policy" => policy.to_h, "host" => metadata.presence }.compact)
|
|
479
|
+
candidate.files = files if files.present? && candidate.respond_to?(:files=)
|
|
480
|
+
candidate.validate!
|
|
481
|
+
|
|
482
|
+
# One pending proposal per case. The row lock serialises this, so
|
|
483
|
+
# there is no unique-violation to rescue: supersede, then insert.
|
|
484
|
+
drafts.pending.each(&:supersede!)
|
|
485
|
+
bump_assistant_revision!
|
|
486
|
+
# The turn this proposal LEAVES the case at, not the one it answered
|
|
487
|
+
# — a proposal is itself a change, so stamping the turn it consumed
|
|
488
|
+
# would make every draft stale the moment it was written.
|
|
489
|
+
candidate.proposed_turn = assistant_turn
|
|
490
|
+
candidate.save!
|
|
491
|
+
reset_draft_associations!
|
|
492
|
+
stamp_assistant_action!
|
|
493
|
+
broadcast_change
|
|
494
|
+
SupportDesk.emit_after_commit(:draft_proposed, self, candidate)
|
|
495
|
+
candidate
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# The notice a `:notice` mode opens with, posted once per disclosure
|
|
499
|
+
# MODE: a host that changes mode discloses again, and one that doesn't
|
|
500
|
+
# never repeats itself.
|
|
501
|
+
def post_disclosure_notice!(assistant)
|
|
502
|
+
line = assistant.config.line_for(:disclosure_line, self)
|
|
503
|
+
if line.blank?
|
|
504
|
+
raise ConfigurationError,
|
|
505
|
+
"assistant #{assistant.key} discloses with a notice but has no disclosure_line — " \
|
|
506
|
+
"an undisclosed autonomous message is never posted silently"
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
notice = conversation.post_system_message!(line)
|
|
510
|
+
disclosed = (metadata["assistant_disclosed"] || {}).merge(assistant.key.to_s => assistant.disclosure.to_s)
|
|
511
|
+
update!(metadata: metadata.merge("assistant_disclosed" => disclosed))
|
|
512
|
+
notice
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def disclosure_posted?(assistant)
|
|
516
|
+
metadata.dig("assistant_disclosed", assistant.key.to_s) == assistant.disclosure.to_s
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# chats orders a transcript by (created_at, id), and two inserts in one
|
|
520
|
+
# transaction can share a timestamp — so a notice is pinned one
|
|
521
|
+
# database tick before the message it introduces, exactly as the
|
|
522
|
+
# opening line is.
|
|
523
|
+
def pin_before!(notice, message)
|
|
524
|
+
return if notice.nil? || message.nil?
|
|
525
|
+
|
|
526
|
+
notice.update_columns(created_at: message.created_at - self.class.send(:ordering_tick))
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# The one write behind both hand-offs: release her seat, record who
|
|
530
|
+
# asked and why, raise the priority, and say so in the thread.
|
|
531
|
+
# Returns [event, previous assignee] — nil event when it was a no-op.
|
|
532
|
+
def flag_human_required!(actor:, kind:, reason:, summary:, line:, request:)
|
|
533
|
+
from = nil
|
|
534
|
+
event = write_transition!(kind, actor: actor, request: request) do
|
|
535
|
+
raise InvalidTransition, "can't #{kind} a closed case — reopen it first" if closed?
|
|
536
|
+
next false if human_required?
|
|
537
|
+
|
|
538
|
+
from = assignee
|
|
539
|
+
release_assistant_seat!(reason: :escalated)
|
|
540
|
+
update!(human_required_at: Time.current, human_required_reason: reason.to_s,
|
|
541
|
+
priority: [ priority.to_i, Topic::PRIORITIES[:high] ].max)
|
|
542
|
+
{ "reason" => reason.to_s, "summary" => summary.presence,
|
|
543
|
+
"from" => SupportDesk.actor_key(from) }.compact
|
|
544
|
+
end
|
|
545
|
+
return [ nil, nil ] unless event
|
|
546
|
+
|
|
547
|
+
post_assistant_line!(line) unless requester_unavailable?
|
|
548
|
+
[ event, from ]
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Her seat, and only hers: a human who holds the case keeps it. A case
|
|
552
|
+
# that waited long enough for a person still gets one, seat or no seat.
|
|
553
|
+
def release_assistant_seat!(reason:)
|
|
554
|
+
return unless held_by_assistant?
|
|
555
|
+
|
|
556
|
+
assignments.open.each { |assignment| assignment.release!(reason: reason) }
|
|
557
|
+
update!(assignee: nil)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# One of the assistant's system lines, in the thread. A blank line
|
|
561
|
+
# posts nothing, and a rendering error is reported rather than raised:
|
|
562
|
+
# a missing translation must not roll back a hand-off.
|
|
563
|
+
def post_assistant_line!(setting)
|
|
564
|
+
assistant = self.assistant
|
|
565
|
+
return if assistant.nil? || conversation.nil?
|
|
566
|
+
|
|
567
|
+
line = assistant.config.line_for(setting, self)
|
|
568
|
+
return if line.blank?
|
|
569
|
+
|
|
570
|
+
conversation.post_system_message!(line)
|
|
571
|
+
rescue StandardError => e
|
|
572
|
+
SupportDesk.report_error(e, context: { hook: setting, ticket: id })
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# The host's "somebody typed 'quiero hablar con una persona'" hook, run
|
|
576
|
+
# on every requester message under the registration lock.
|
|
577
|
+
#
|
|
578
|
+
# It FAILS CLOSED (v2 R28): true hands the case over, false and nil do
|
|
579
|
+
# nothing, and anything else — a String, a raise — is reported AND
|
|
580
|
+
# hands the case over. A hook nobody can read the answer of is a hook
|
|
581
|
+
# that has already failed, and the safe direction is a person.
|
|
582
|
+
def evaluate_hand_off_phrase!(message)
|
|
583
|
+
assistant = self.assistant
|
|
584
|
+
return if assistant.nil? || human_required? || !assistant_in_play?
|
|
585
|
+
|
|
586
|
+
block = assistant.config.hand_off_when
|
|
587
|
+
return if block.nil?
|
|
588
|
+
|
|
589
|
+
result = begin
|
|
590
|
+
block.call(self, message)
|
|
591
|
+
rescue StandardError => e
|
|
592
|
+
SupportDesk.report_error(e, context: { hook: :hand_off_when, ticket: id })
|
|
593
|
+
:error
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
case result
|
|
597
|
+
when true
|
|
598
|
+
flag_human_required!(actor: requester, kind: :human_requested, reason: "phrase", summary: nil,
|
|
599
|
+
line: :human_requested_line, request: nil)
|
|
600
|
+
SupportDesk.emit_after_commit(:human_requested, self, by: requester, reason: :phrase)
|
|
601
|
+
when false, nil
|
|
602
|
+
nil
|
|
603
|
+
else
|
|
604
|
+
unless result == :error
|
|
605
|
+
SupportDesk.report_error(
|
|
606
|
+
ArgumentError.new("hand_off_when must return true, false or nil, got #{result.inspect}"),
|
|
607
|
+
context: { hook: :hand_off_when, ticket: id }
|
|
608
|
+
)
|
|
609
|
+
end
|
|
610
|
+
flag_human_required!(actor: requester, kind: :human_requested, reason: "hand_off_when_error",
|
|
611
|
+
summary: nil, line: :human_requested_line, request: nil)
|
|
612
|
+
SupportDesk.emit_after_commit(:human_requested, self, by: requester, reason: :hand_off_when_error)
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# "There is something to answer here." The only event a harness
|
|
617
|
+
# subscribes to, and the only place the gem asks anybody to do work.
|
|
618
|
+
#
|
|
619
|
+
# It carries the turn, so a job can check `ticket.assistant_turn ==
|
|
620
|
+
# turn` before spending money. A hook that raises is reported: a
|
|
621
|
+
# broken subscriber must not roll back the message that triggered it.
|
|
622
|
+
def emit_assistant_turn(message = nil)
|
|
623
|
+
assistant = self.assistant
|
|
624
|
+
return if assistant.nil?
|
|
625
|
+
return unless assistant_policy(assistant).may_observe?
|
|
626
|
+
|
|
627
|
+
SupportDesk.emit_after_commit(:assistant_turn, self, assistant, message, turn: assistant_turn)
|
|
628
|
+
rescue StandardError => e
|
|
629
|
+
SupportDesk.report_error(e, context: { hook: :assistant_turn, ticket: id })
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# A human transition that lowers the effective level below :reply while
|
|
633
|
+
# she holds the case takes her seat — a case she may no longer answer
|
|
634
|
+
# is not a case she can go on holding.
|
|
635
|
+
def release_assistant_if_unfit!
|
|
636
|
+
return false unless held_by_assistant?
|
|
637
|
+
|
|
638
|
+
holder = assignee
|
|
639
|
+
return false if assistant_policy(holder).may_reply?
|
|
640
|
+
|
|
641
|
+
assignments.open.each { |assignment| assignment.release!(reason: :released) }
|
|
642
|
+
update!(assignee: nil)
|
|
643
|
+
true
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Every pending proposal on this case is out of date. `except:` is the
|
|
647
|
+
# draft being sent right now, which is about to become "sent".
|
|
648
|
+
def supersede_pending_drafts!(except: nil)
|
|
649
|
+
scope = drafts.pending
|
|
650
|
+
scope = scope.where.not(id: except) if except.present?
|
|
651
|
+
scope.each(&:supersede!)
|
|
652
|
+
reset_draft_associations!
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# A closed case has nothing pending. Returns how many it expired, for
|
|
656
|
+
# the close event's payload.
|
|
657
|
+
def expire_pending_drafts!
|
|
658
|
+
pending = drafts.pending.to_a
|
|
659
|
+
pending.each(&:expire!)
|
|
660
|
+
reset_draft_associations!
|
|
661
|
+
pending.size
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# The proposals moved, so what this instance remembers about them is
|
|
665
|
+
# a lie. Everything that changes a draft's status calls this, so
|
|
666
|
+
# `ticket.pending_draft` and `actions_for` are right inside the SAME
|
|
667
|
+
# operation — a console that had to `reload` to see its own write is a
|
|
668
|
+
# console that renders a button nobody can press.
|
|
669
|
+
def reset_draft_associations!
|
|
670
|
+
association(:pending_draft).reset
|
|
671
|
+
association(:drafts).reset
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
end
|