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,899 @@
|
|
|
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
|
+
#
|
|
35
|
+
# == The locking protocol: TICKET, then CONVERSATION
|
|
36
|
+
#
|
|
37
|
+
# The turn is only as good as the reconciliation behind it, and
|
|
38
|
+
# reconciliation is a SELECT: on its own it cannot exclude a requester
|
|
39
|
+
# message that commits one millisecond later. The customer does not take
|
|
40
|
+
# the ticket lock — they press send, and chats writes a message.
|
|
41
|
+
#
|
|
42
|
+
# What chats DOES take is the conversation row. Every message insert
|
|
43
|
+
# updates `chats_conversations` inside its own transaction
|
|
44
|
+
# (`Chats::Message#register_on_conversation`, plus the `messages_count`
|
|
45
|
+
# counter cache), so that row is the serialization point for writes this
|
|
46
|
+
# gem does not own. Every speaking path therefore takes the ticket's row
|
|
47
|
+
# lock and then, before reconciling, the conversation's
|
|
48
|
+
# (`#lock_conversation!`):
|
|
49
|
+
#
|
|
50
|
+
# * a requester message already in flight holds that row, so we block
|
|
51
|
+
# until it commits — and reconciliation then sees it, which makes the
|
|
52
|
+
# turn stale, which is exactly what should happen;
|
|
53
|
+
# * a message that starts after we hold the row blocks until we commit,
|
|
54
|
+
# so our answer is ordered before it and its own turn follows.
|
|
55
|
+
#
|
|
56
|
+
# THE ORDER IS ALWAYS TICKET → CONVERSATION. A requester write locks only
|
|
57
|
+
# the conversation; the after-commit subscriber's `register!` locks the
|
|
58
|
+
# ticket and then, through the system lines a hand-off posts, the
|
|
59
|
+
# conversation. Nothing takes them the other way round, and nothing new
|
|
60
|
+
# may.
|
|
61
|
+
#
|
|
62
|
+
# What it does NOT cover:
|
|
63
|
+
#
|
|
64
|
+
# * A message whose INSERT was already stamped when we win the row is
|
|
65
|
+
# still stamped earlier than our answer, so a transcript can show a
|
|
66
|
+
# question above an answer that did not address it. That is a genuinely
|
|
67
|
+
# simultaneous send, and its registration raises a turn of its own.
|
|
68
|
+
#
|
|
69
|
+
# * SQLITE. All of the above is a `SELECT … FOR UPDATE` blocking a
|
|
70
|
+
# concurrent writer, which is PostgreSQL and MySQL. SQLite has no row
|
|
71
|
+
# locks: it serializes WRITES, and in WAL mode our reconciliation reads
|
|
72
|
+
# the last committed snapshot straight through a requester's open write
|
|
73
|
+
# transaction. So a message committing behind the SELECT is still
|
|
74
|
+
# missed there, exactly as it was in 0.3.0 — the conversation row buys
|
|
75
|
+
# nothing, because nothing waits on it. Run PostgreSQL or MySQL for a
|
|
76
|
+
# production desk with an assistant; `doctor` warns about this.
|
|
77
|
+
module Assistance
|
|
78
|
+
extend ActiveSupport::Concern
|
|
79
|
+
|
|
80
|
+
included do
|
|
81
|
+
has_many :drafts, class_name: "SupportDesk::Draft", inverse_of: :ticket, dependent: :destroy
|
|
82
|
+
has_one :pending_draft, -> { pending }, class_name: "SupportDesk::Draft", inverse_of: :ticket
|
|
83
|
+
|
|
84
|
+
# --- Scopes -------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
scope :held_by_assistants, lambda {
|
|
87
|
+
where(assignee_type: SupportDesk::Assistant.polymorphic_name)
|
|
88
|
+
}
|
|
89
|
+
scope :held_by_humans, lambda {
|
|
90
|
+
assigned.where.not(assignee_type: SupportDesk::Assistant.polymorphic_name)
|
|
91
|
+
}
|
|
92
|
+
# The tab that exists so a case a machine could not finish is never
|
|
93
|
+
# the one nobody looks at.
|
|
94
|
+
scope :needs_human, -> { where.not(human_required_at: nil) }
|
|
95
|
+
scope :assistant_paused, -> { where.not(assistant_paused_at: nil) }
|
|
96
|
+
scope :assistant_capped, -> { where.not(assistant_cap: nil) }
|
|
97
|
+
scope :resolved_by_assistant, lambda {
|
|
98
|
+
closed.where(closed_by_type: SupportDesk::Assistant.polymorphic_name)
|
|
99
|
+
}
|
|
100
|
+
scope :with_pending_draft, -> { where(id: SupportDesk::Draft.pending.select(:ticket_id)) }
|
|
101
|
+
# Waiting on the desk since before +time+, with nothing from the
|
|
102
|
+
# assistant since the customer wrote: a harness that is down, or one
|
|
103
|
+
# that keeps deciding to do nothing.
|
|
104
|
+
scope :assistant_idle_since, lambda { |time|
|
|
105
|
+
awaiting_reply.where(last_requester_message_at: ..time)
|
|
106
|
+
.where("assistant_acted_at IS NULL OR assistant_acted_at < last_requester_message_at")
|
|
107
|
+
}
|
|
108
|
+
# Cases whose conversation holds a requester message the gem never
|
|
109
|
+
# folded in. The registration runs after the message's commit, so it can
|
|
110
|
+
# be LOST — a killed worker, a dropped subscriber — and the clocks then
|
|
111
|
+
# describe a case that no longer exists: `awaiting_requester` on a case
|
|
112
|
+
# that is waiting for an answer, which no idle query can see. This one
|
|
113
|
+
# asks the messages instead of the clocks (R3).
|
|
114
|
+
#
|
|
115
|
+
# Built in Arel rather than as a string, because it compares two tables'
|
|
116
|
+
# columns to each other and there is no value to bind: it has to be
|
|
117
|
+
# readable as "no user input reaches this SQL" at a glance.
|
|
118
|
+
scope :with_unregistered_requester_messages, lambda {
|
|
119
|
+
tickets = arel_table
|
|
120
|
+
messages = Chats::Message.arel_table
|
|
121
|
+
receipts = MessageRegistration.arel_table
|
|
122
|
+
registered = MessageRegistration.select(Arel.sql("1"))
|
|
123
|
+
.where(receipts[:message_id].eq(messages[:id]))
|
|
124
|
+
unregistered = Chats::Message.select(Arel.sql("1"))
|
|
125
|
+
.where(kind: "text")
|
|
126
|
+
.where(messages[:conversation_id].eq(tickets[:conversation_id]))
|
|
127
|
+
.where(messages[:sender_type].eq(tickets[:requester_type]))
|
|
128
|
+
.where(messages[:sender_id].eq(tickets[:requester_id]))
|
|
129
|
+
.where(registered.arel.exists.not)
|
|
130
|
+
where(unregistered.arel.exists)
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# --- Readers ------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
# The assistant who works this case's desk, or nil.
|
|
137
|
+
def assistant = desk&.assistant
|
|
138
|
+
|
|
139
|
+
# What she may do here, right now, and why. See AssistantPolicy.
|
|
140
|
+
def assistant_policy(assistant = self.assistant, hand_back: false)
|
|
141
|
+
AssistantPolicy.for(self, assistant, hand_back: hand_back)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The token every assistant action has to hold. Never nil: a case that
|
|
145
|
+
# has never been touched still has a turn, which is what makes "the
|
|
146
|
+
# turn you read" a complete answer rather than a special case.
|
|
147
|
+
def assistant_turn = "t#{id}-r#{assistant_revision.to_i}"
|
|
148
|
+
|
|
149
|
+
# Whether this case has anything to do with an assistant — configured
|
|
150
|
+
# on the desk now, or spoken in by one at any point. History counts:
|
|
151
|
+
# the door to a person must not vanish because somebody edited an
|
|
152
|
+
# initializer after she answered.
|
|
153
|
+
def assistant_in_play?
|
|
154
|
+
assistant.present? || assistant_turns_count.to_i.positive?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def held_by_assistant?
|
|
158
|
+
assigned? && assignee_type == SupportDesk::Assistant.polymorphic_name
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Whether a person has been asked for on this case — by the assistant,
|
|
162
|
+
# by the requester, or by the silent sweep.
|
|
163
|
+
def human_required? = human_required_at.present?
|
|
164
|
+
|
|
165
|
+
# Whether a human has switched her off on this case.
|
|
166
|
+
def assistant_paused? = assistant_paused_at.present?
|
|
167
|
+
|
|
168
|
+
# How many times she may still speak here; nil when unlimited.
|
|
169
|
+
def assistant_turns_left
|
|
170
|
+
max = assistant&.max_turns
|
|
171
|
+
return nil if max.nil?
|
|
172
|
+
|
|
173
|
+
[ max - assistant_turns_count.to_i, 0 ].max
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The conversation as ordered turns — the readable half of what a
|
|
177
|
+
# harness works from. `limit:` keeps the LAST n, which is the part
|
|
178
|
+
# still being answered. See SupportDesk::Transcript.
|
|
179
|
+
def transcript(limit: nil)
|
|
180
|
+
Transcript.new(self, limit: limit)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Everything a machine needs to answer this case, as data: the desk,
|
|
184
|
+
# the policy, the case, the requester and the transcript.
|
|
185
|
+
# `include_internal: true` adds the desk's own notes and proposals —
|
|
186
|
+
# off by default, because it leaves the building. See
|
|
187
|
+
# SupportDesk::Brief.
|
|
188
|
+
def brief(include_internal: false, transcript_limit: 50)
|
|
189
|
+
Brief.new(self, include_internal: include_internal, transcript_limit: transcript_limit)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Whether this message came from an assistant — by authorship when she
|
|
193
|
+
# signs, by the provenance stamp when she doesn't. Both shapes, because
|
|
194
|
+
# a host that changes disclosure mode must not rewrite old bubbles.
|
|
195
|
+
def assistant_message?(message)
|
|
196
|
+
return false if message.nil?
|
|
197
|
+
|
|
198
|
+
return true if message.author.is_a?(SupportDesk::Assistant)
|
|
199
|
+
|
|
200
|
+
stamp = message.try(:metadata)
|
|
201
|
+
stamp.is_a?(Hash) && stamp.dig("support_desk", "assistant").present?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# --- Her two verbs ------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
# Answer, and let policy decide what that means: sent to the requester,
|
|
207
|
+
# proposed as a draft for a person to send, or withheld. The ONE verb a
|
|
208
|
+
# harness needs — it never has to encode the rules it is working under.
|
|
209
|
+
#
|
|
210
|
+
# Returns a SupportDesk::Outcome. Raises StaleTurn when the case moved
|
|
211
|
+
# on, Locked when there is nobody to write to, NotAnAssistant when
|
|
212
|
+
# `by:` isn't this desk's assistant.
|
|
213
|
+
def respond!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
|
|
214
|
+
raise ArgumentError, "respond! needs something to say" if body.blank? && files.blank?
|
|
215
|
+
|
|
216
|
+
# Durable repair first, in a transaction of its own: what it folds in
|
|
217
|
+
# survives the StaleTurn that folding it in may cause (R3).
|
|
218
|
+
reconcile_and_commit!
|
|
219
|
+
|
|
220
|
+
with_lock(requires_new: true) do
|
|
221
|
+
assistant = resolve_assistant!(by)
|
|
222
|
+
lock_conversation!
|
|
223
|
+
reconcile_unregistered_messages!
|
|
224
|
+
ensure_current_turn!(turn)
|
|
225
|
+
ensure_writable!
|
|
226
|
+
policy = assistant_policy(assistant)
|
|
227
|
+
next withhold!(assistant, policy, :policy, request: request) unless policy.may_draft?
|
|
228
|
+
next withhold!(assistant, policy, :not_your_turn, request: request) unless awaiting_reply?
|
|
229
|
+
|
|
230
|
+
left = assistant_turns_left
|
|
231
|
+
if policy.may_reply? && (left.nil? || left.positive?)
|
|
232
|
+
message = speak!(body, assistant, policy: policy, turn: turn, files: files, confidence: confidence,
|
|
233
|
+
sources: sources, metadata: metadata, request: request)
|
|
234
|
+
Outcome.new(action: :sent, message: message, policy: policy, turn: assistant_turn)
|
|
235
|
+
else
|
|
236
|
+
draft = propose_draft!(body, assistant, policy: policy, files: files, confidence: confidence,
|
|
237
|
+
sources: sources, metadata: metadata)
|
|
238
|
+
reason = nil
|
|
239
|
+
# Out of budget at a level that could otherwise have answered:
|
|
240
|
+
# the draft stays, and so does a person — a conversation that
|
|
241
|
+
# ran out of turns is one somebody has to finish.
|
|
242
|
+
if policy.may_reply? && left&.zero?
|
|
243
|
+
handed_off, from = flag_human_required!(actor: assistant, kind: :escalated, reason: "max_turns",
|
|
244
|
+
summary: metadata[:summary] || metadata["summary"],
|
|
245
|
+
line: :hand_off_line, request: request)
|
|
246
|
+
# A conversation that ran out of turns is a hand-off like any
|
|
247
|
+
# other, and it says so out loud: the host's "a person is
|
|
248
|
+
# needed here" notifier listens for this and nothing else (R7).
|
|
249
|
+
publish_escalation!(from: from, reason: :max_turns, by: assistant) if handed_off
|
|
250
|
+
reason = :max_turns
|
|
251
|
+
end
|
|
252
|
+
Outcome.new(action: :drafted, draft: draft, policy: policy, reason: reason, turn: assistant_turn)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Propose a reply for a person to send, whatever the level allows.
|
|
258
|
+
# `respond!` is what a harness should call; this is for a host that has
|
|
259
|
+
# already decided it wants a draft. Returns the SupportDesk::Draft.
|
|
260
|
+
def draft!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
|
|
261
|
+
raise ArgumentError, "draft! needs something to say" if body.blank? && files.blank?
|
|
262
|
+
|
|
263
|
+
reconcile_and_commit!
|
|
264
|
+
|
|
265
|
+
with_lock(requires_new: true) do
|
|
266
|
+
assistant = resolve_assistant!(by)
|
|
267
|
+
lock_conversation!
|
|
268
|
+
reconcile_unregistered_messages!
|
|
269
|
+
ensure_current_turn!(turn)
|
|
270
|
+
ensure_writable!
|
|
271
|
+
policy = assistant_policy(assistant)
|
|
272
|
+
raise AssistantNotAllowed.new(policy, verb: :draft) unless policy.may_draft?
|
|
273
|
+
|
|
274
|
+
propose_draft!(body, assistant, policy: policy, files: files, confidence: confidence,
|
|
275
|
+
sources: sources, metadata: metadata)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# --- The two exits ------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
# Hand the case to a person: the assistant's own way out, and what the
|
|
282
|
+
# silent sweep calls as `:system`. Releases her seat, records the
|
|
283
|
+
# reason, raises the priority and tells the requester.
|
|
284
|
+
#
|
|
285
|
+
# `turn:` is required when `by:` is the assistant — escalating is an
|
|
286
|
+
# action like any other, and a stale one must not speak.
|
|
287
|
+
def escalate!(by: nil, reason:, summary: nil, turn: nil, request: nil)
|
|
288
|
+
actor = resolve_actor(by)
|
|
289
|
+
ensure_agent!(actor)
|
|
290
|
+
machine = SupportDesk.ai_actor?(actor)
|
|
291
|
+
raise ArgumentError, "escalate! needs a reason" if reason.blank?
|
|
292
|
+
|
|
293
|
+
assistant = nil
|
|
294
|
+
event = nil
|
|
295
|
+
from = nil
|
|
296
|
+
with_lock(requires_new: true) do
|
|
297
|
+
if machine
|
|
298
|
+
# Under the lock, from the row (R9).
|
|
299
|
+
assistant = resolve_assistant!(actor)
|
|
300
|
+
ensure_current_turn!(turn)
|
|
301
|
+
policy = assistant_policy(assistant)
|
|
302
|
+
raise AssistantNotAllowed.new(policy, verb: :escalate) unless policy.may_observe?
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
event, from = flag_human_required!(actor: actor, kind: :escalated, reason: reason, summary: summary,
|
|
306
|
+
line: :hand_off_line, request: request)
|
|
307
|
+
end
|
|
308
|
+
return self unless event
|
|
309
|
+
|
|
310
|
+
stamp_assistant_action! if assistant
|
|
311
|
+
publish_escalation!(from: from, reason: reason, by: actor)
|
|
312
|
+
self
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# "Prefiero hablar con una persona." The requester's own door, and the
|
|
316
|
+
# one thing on this case they can always do: it reopens a closed case
|
|
317
|
+
# where the desk allows it, flags the case, and says so in the thread.
|
|
318
|
+
# Idempotent — pressing it twice writes once.
|
|
319
|
+
def request_human!(by: nil, request: nil)
|
|
320
|
+
actor = resolve_actor(by)
|
|
321
|
+
unless self.class.same_actor?(actor, requester)
|
|
322
|
+
raise NotAllowed,
|
|
323
|
+
"#{describe_actor(actor)} can't ask for a person on #{reference} — it isn't their case"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
event = nil
|
|
327
|
+
with_lock(requires_new: true) do
|
|
328
|
+
reopen!(by: actor, request: request) if closed? && desk_config.closed_tickets == :reopen_on_reply
|
|
329
|
+
ensure_writable!
|
|
330
|
+
event, = flag_human_required!(actor: actor, kind: :human_requested, reason: "requester_request",
|
|
331
|
+
summary: nil, line: :human_requested_line, request: request)
|
|
332
|
+
end
|
|
333
|
+
return self unless event
|
|
334
|
+
|
|
335
|
+
SupportDesk.emit_after_commit(:human_requested, self, by: actor, reason: :requester_request)
|
|
336
|
+
self
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# --- The human override -------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
# Switch the assistant off on THIS case: a delicate conversation, a
|
|
342
|
+
# customer who has had enough, a thread somebody wants to handle
|
|
343
|
+
# themselves. Releases her seat, throws away her pending proposal, and
|
|
344
|
+
# floors her policy at :off until somebody resumes her.
|
|
345
|
+
def pause_assistant!(by: nil, reason: nil, request: nil)
|
|
346
|
+
actor = resolve_actor(by)
|
|
347
|
+
if actor.is_a?(Symbol) || SupportDesk.ai_actor?(actor)
|
|
348
|
+
raise NotAllowed, "only a person can pause the assistant on a case"
|
|
349
|
+
end
|
|
350
|
+
ensure_agent!(actor)
|
|
351
|
+
|
|
352
|
+
event = write_transition!(:assistant_paused, actor: actor, request: request) do
|
|
353
|
+
raise InvalidTransition, "can't pause the assistant on a closed case" if closed?
|
|
354
|
+
next false if assistant_paused?
|
|
355
|
+
|
|
356
|
+
release_assistant_seat!(reason: :released)
|
|
357
|
+
supersede_pending_drafts!
|
|
358
|
+
update!(assistant_paused_at: Time.current, assistant_paused_reason: reason.presence&.to_s)
|
|
359
|
+
{ "reason" => reason.presence&.to_s }
|
|
360
|
+
end
|
|
361
|
+
return self unless event
|
|
362
|
+
|
|
363
|
+
SupportDesk.emit_after_commit(:assistant_paused, self, by: actor)
|
|
364
|
+
self
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Let her back in on this case. Clears the pause and NOTHING ELSE: a
|
|
368
|
+
# case cap and a request for a person are different decisions, made by
|
|
369
|
+
# different people, and only an explicit hand-back lifts those.
|
|
370
|
+
def resume_assistant!(by: nil, request: nil)
|
|
371
|
+
actor = resolve_actor(by)
|
|
372
|
+
if actor.is_a?(Symbol) || SupportDesk.ai_actor?(actor)
|
|
373
|
+
raise NotAllowed, "only a person can resume the assistant on a case"
|
|
374
|
+
end
|
|
375
|
+
ensure_agent!(actor)
|
|
376
|
+
|
|
377
|
+
event = write_transition!(:assistant_resumed, actor: actor, request: request) do
|
|
378
|
+
next false unless assistant_paused?
|
|
379
|
+
|
|
380
|
+
update!(assistant_paused_at: nil, assistant_paused_reason: nil)
|
|
381
|
+
{}
|
|
382
|
+
end
|
|
383
|
+
return self unless event
|
|
384
|
+
|
|
385
|
+
SupportDesk.emit_after_commit(:assistant_resumed, self, by: actor)
|
|
386
|
+
emit_assistant_turn if awaiting_reply?
|
|
387
|
+
self
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# --- Internals ----------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
# The verbs the assistant may press on this case right now — the
|
|
393
|
+
# machine's half of `actions_for`, and the same decision the
|
|
394
|
+
# transitions make, so a harness reading `may` from a brief is reading
|
|
395
|
+
# the authorization and not a hint.
|
|
396
|
+
def assistant_actions_for(assistant) # :nodoc:
|
|
397
|
+
return [] unless assistant.is_a?(SupportDesk::Assistant)
|
|
398
|
+
return [] unless self.class.same_actor?(assistant, self.assistant)
|
|
399
|
+
|
|
400
|
+
verbs = assistant_policy(assistant).allowed_verbs.dup
|
|
401
|
+
verbs -= %i[escalate] if closed?
|
|
402
|
+
verbs -= %i[reply draft take] if requester_unavailable?
|
|
403
|
+
verbs -= %i[take] unless unassigned?
|
|
404
|
+
verbs -= %i[release] unless assigned_to?(assistant)
|
|
405
|
+
verbs -= %i[reply] unless awaiting_reply?
|
|
406
|
+
verbs -= %i[close] unless assigned_to?(assistant) && awaiting_requester? && !human_required?
|
|
407
|
+
verbs
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# The silent sweep's own transition: hand this case to a person ONLY if
|
|
411
|
+
# it is still the case the sweep selected — open, still hers, still owing
|
|
412
|
+
# the next word, still past her promise, and with nobody asked for yet.
|
|
413
|
+
# Returns true when it moved. See SupportDesk.release_silent_assistants!.
|
|
414
|
+
#
|
|
415
|
+
# Every one of those predicates was true when the sweep SELECTED its
|
|
416
|
+
# candidates, and a person can answer, take the case or reset the clock
|
|
417
|
+
# between that query and this write. 0.3.0 rechecked only "closed" and
|
|
418
|
+
# "already asked for", so a case somebody had just answered was marked
|
|
419
|
+
# human-required anyway: its priority went up and the customer was told a
|
|
420
|
+
# person was coming, on a case that already had one (R5).
|
|
421
|
+
def escalate_if_still_silent!(assistant, window) # :nodoc:
|
|
422
|
+
event = nil
|
|
423
|
+
from = nil
|
|
424
|
+
with_lock(requires_new: true) do
|
|
425
|
+
next unless open?
|
|
426
|
+
next unless assigned_to?(assistant)
|
|
427
|
+
next unless awaiting_reply?
|
|
428
|
+
next if human_required?
|
|
429
|
+
next unless waiting_since.present? && waiting_since <= window.ago
|
|
430
|
+
|
|
431
|
+
event, from = flag_human_required!(
|
|
432
|
+
actor: :system, kind: :escalated, reason: "assistant_silent",
|
|
433
|
+
summary: "no answer in #{SupportDesk.humanize_duration(window)}",
|
|
434
|
+
line: :hand_off_line, request: nil
|
|
435
|
+
)
|
|
436
|
+
end
|
|
437
|
+
return false unless event
|
|
438
|
+
|
|
439
|
+
publish_escalation!(from: from, reason: "assistant_silent", by: :system)
|
|
440
|
+
true
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Give a stranded seat back to people: the assistant holding this case is
|
|
444
|
+
# switched off, no longer declared, or no longer allowed to hold it.
|
|
445
|
+
# Returns true when it moved. See SupportDesk.reclaim_assistant_seats!.
|
|
446
|
+
#
|
|
447
|
+
# This is INVALID-ASSIGNEE recovery, and it is deliberately not silence
|
|
448
|
+
# detection: it needs no `responds_within`, no overdue clock and no
|
|
449
|
+
# waiting side. `deactivate!` promises the seats come back, and in 0.3.0
|
|
450
|
+
# that promise was kept only for an assistant who had a promise of her
|
|
451
|
+
# own and a case that was already late (R6).
|
|
452
|
+
def reclaim_assistant_seat! # :nodoc:
|
|
453
|
+
event = nil
|
|
454
|
+
from = nil
|
|
455
|
+
escalated = false
|
|
456
|
+
with_lock(requires_new: true) do
|
|
457
|
+
next unless open?
|
|
458
|
+
next unless held_by_assistant?
|
|
459
|
+
|
|
460
|
+
from = assignee
|
|
461
|
+
next if assistant_may_keep_seat?(from)
|
|
462
|
+
|
|
463
|
+
if human_required?
|
|
464
|
+
# Somebody has already been asked for, and why is their business —
|
|
465
|
+
# the only thing left to give back is the seat.
|
|
466
|
+
event = write_transition!(:released, actor: :system) do
|
|
467
|
+
release_assistant_seat!(reason: :released)
|
|
468
|
+
{ "from" => SupportDesk.actor_key(from), "reason" => "assistant_unavailable" }
|
|
469
|
+
end
|
|
470
|
+
else
|
|
471
|
+
escalated = true
|
|
472
|
+
event, = flag_human_required!(actor: :system, kind: :escalated, reason: "assistant_unavailable",
|
|
473
|
+
summary: nil, line: :hand_off_line, request: nil)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
return false unless event
|
|
477
|
+
|
|
478
|
+
if escalated
|
|
479
|
+
publish_escalation!(from: from, reason: "assistant_unavailable", by: :system)
|
|
480
|
+
else
|
|
481
|
+
SupportDesk.emit_after_commit(:ticket_released, self, from: from, reason: :assistant_unavailable)
|
|
482
|
+
end
|
|
483
|
+
true
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# The ONE place `:ticket_escalated` is published. Every hand-off that
|
|
487
|
+
# takes her off a case goes through it — her own `escalate!`, the budget
|
|
488
|
+
# branch of `respond!`, the silent sweep — so no path can write the
|
|
489
|
+
# transition and forget the signal (R7). A no-op writes no event and
|
|
490
|
+
# publishes nothing.
|
|
491
|
+
def publish_escalation!(from:, reason:, by:) # :nodoc:
|
|
492
|
+
SupportDesk.emit_after_commit(:ticket_escalated, self, from: from, reason: reason.to_sym, by: by)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Bump the case's revision — the caller holds the lock. `update_columns`
|
|
496
|
+
# on purpose: inside the transaction, no callbacks, no validations, and
|
|
497
|
+
# the in-memory value moves with the row, so the turn a caller reads
|
|
498
|
+
# next is the one the database has.
|
|
499
|
+
def bump_assistant_revision! # :nodoc:
|
|
500
|
+
update_columns(assistant_revision: assistant_revision.to_i + 1)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Fold in every requester message chats has committed but nobody has
|
|
504
|
+
# registered, in a transaction of ITS OWN, and commit it — whatever the
|
|
505
|
+
# answer that discovered it then decides. Returns how many it folded in.
|
|
506
|
+
#
|
|
507
|
+
# A registration lost after commit (a worker killed between the message's
|
|
508
|
+
# COMMIT and the subscriber that registers it) used to be unrepairable:
|
|
509
|
+
# reconciliation ran inside the candidate answer's savepoint, the bumped
|
|
510
|
+
# revision made that very answer stale, and the StaleTurn rolled the
|
|
511
|
+
# repair back with it. The next run read the same revision and did the
|
|
512
|
+
# same thing, for ever (R3).
|
|
513
|
+
#
|
|
514
|
+
# Durability is the caller's: at the top level (a job, the recovery task)
|
|
515
|
+
# this really commits, and the `:assistant_turn` it emits for what it
|
|
516
|
+
# registered is an ACTIONABLE turn. Inside a host's own transaction it is
|
|
517
|
+
# a savepoint like any other, and it commits when that does.
|
|
518
|
+
def reconcile_and_commit! # :nodoc:
|
|
519
|
+
folded = 0
|
|
520
|
+
with_lock(requires_new: true) do
|
|
521
|
+
lock_conversation!
|
|
522
|
+
folded = reconcile_unregistered_messages!
|
|
523
|
+
end
|
|
524
|
+
folded
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# When the assistant last did anything here — what the idle-turn check
|
|
528
|
+
# and the redispatch task read to tell "she decided not to speak" from
|
|
529
|
+
# "nothing is running".
|
|
530
|
+
def stamp_assistant_action! # :nodoc:
|
|
531
|
+
update_columns(assistant_acted_at: Time.current)
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
private
|
|
535
|
+
|
|
536
|
+
# The actor, as this desk's assistant — or a refusal that names which
|
|
537
|
+
# of the three things went wrong. Every AI-kind actor comes through
|
|
538
|
+
# here: a host model declared `kind: :ai` is not an assistant, and
|
|
539
|
+
# another desk's assistant is not this desk's (I2).
|
|
540
|
+
def resolve_assistant!(actor)
|
|
541
|
+
unless actor.is_a?(SupportDesk::Assistant)
|
|
542
|
+
raise NotAnAssistant,
|
|
543
|
+
"#{describe_actor(actor)} is an AI agent but not a SupportDesk::Assistant — only the desk's " \
|
|
544
|
+
"configured assistant may act on a case"
|
|
545
|
+
end
|
|
546
|
+
unless self.class.same_actor?(actor, assistant)
|
|
547
|
+
raise NotAnAssistant,
|
|
548
|
+
"#{actor.key} is not desk #{desk.key}'s assistant"
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Fresh from the row: `active` is a cross-process kill switch, and a
|
|
552
|
+
# record loaded a minute ago is not evidence about now — nor is one
|
|
553
|
+
# loaded before this call waited for the case's lock. The FRESH
|
|
554
|
+
# record is what is handed back, because the policy reads `active?`
|
|
555
|
+
# off the record it is given (R9).
|
|
556
|
+
#
|
|
557
|
+
# Every caller resolves inside the lock. That still leaves the
|
|
558
|
+
# ordinary in-flight window — a switch that commits after this read
|
|
559
|
+
# and before our own commit wins nothing, and its next check stops
|
|
560
|
+
# the next call — which is documented rather than claimed away.
|
|
561
|
+
self.class.ensure_agent_record!(actor)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# The turn check, under the lock, from the revision the row holds.
|
|
565
|
+
def ensure_current_turn!(turn)
|
|
566
|
+
raise ArgumentError, "turn: is required for an assistant — pass the turn you read" if turn.nil?
|
|
567
|
+
return if turn.to_s == assistant_turn
|
|
568
|
+
|
|
569
|
+
raise StaleTurn,
|
|
570
|
+
"#{turn} is stale on #{reference}: the case has changed (now #{assistant_turn})"
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Fold in any requester message chats has committed but the gem hasn't
|
|
574
|
+
# folded in yet, under the lock, BEFORE the turn is checked (I5).
|
|
575
|
+
#
|
|
576
|
+
# The subscriber runs after commit and the assistant's job runs on
|
|
577
|
+
# another connection: without this, a message the customer sent a
|
|
578
|
+
# millisecond ago would be invisible to the turn, and she would answer
|
|
579
|
+
# around it. Folding it in here makes the turn stale instead, which is
|
|
580
|
+
# exactly what should happen.
|
|
581
|
+
def reconcile_unregistered_messages!
|
|
582
|
+
return 0 if conversation.nil?
|
|
583
|
+
|
|
584
|
+
folded = unregistered_requester_messages.oldest_first.to_a
|
|
585
|
+
folded.each { |message| record_registration!(message) }
|
|
586
|
+
folded.size
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Every committed requester message without a receipt, regardless of
|
|
590
|
+
# timestamp or UUID order. The clocks are not evidence of registration.
|
|
591
|
+
def unregistered_requester_messages
|
|
592
|
+
conversation.messages.where(kind: "text", sender_type: requester_type, sender_id: requester_id)
|
|
593
|
+
.where.not(id: message_registrations.select(:message_id))
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# A SELECT … FOR UPDATE on the conversation row, taken AFTER the ticket's
|
|
597
|
+
# and before any reconciliation. See the module comment: this row is what
|
|
598
|
+
# chats updates inside every message's own transaction, so it is the only
|
|
599
|
+
# seam that serializes a customer's write against ours without a write
|
|
600
|
+
# hook in chats (R1).
|
|
601
|
+
#
|
|
602
|
+
# Read through the id rather than the association: `lock!` refuses a
|
|
603
|
+
# record with unsaved changes, and nothing here wants the in-memory
|
|
604
|
+
# conversation reloaded.
|
|
605
|
+
def lock_conversation!
|
|
606
|
+
return if conversation_id.blank?
|
|
607
|
+
|
|
608
|
+
Chats::Conversation.lock.find_by(id: conversation_id)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Whether the assistant sitting on this case may go on sitting on it: she
|
|
612
|
+
# is on duty, she is still declared, and her policy still lets her hold
|
|
613
|
+
# it. Read under the lock, from the reloaded assignee.
|
|
614
|
+
#
|
|
615
|
+
# A policy that RAISES — a desk pointing at an assistant nobody declares
|
|
616
|
+
# any more — is an answer too, and it is "no": a seat nothing can reason
|
|
617
|
+
# about belongs to a person.
|
|
618
|
+
def assistant_may_keep_seat?(holder)
|
|
619
|
+
return false unless holder.is_a?(SupportDesk::Assistant)
|
|
620
|
+
return false unless holder.active?
|
|
621
|
+
return false unless holder.configured?
|
|
622
|
+
|
|
623
|
+
assistant_policy(holder).may_hold?
|
|
624
|
+
rescue StandardError
|
|
625
|
+
false
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# Nothing was written, and the reason is on the record: a policy that
|
|
629
|
+
# refused is evidence, not silence.
|
|
630
|
+
def withhold!(assistant, policy, reason, request: nil)
|
|
631
|
+
event = record_transition!(:assistant_withheld, actor: assistant) do
|
|
632
|
+
{ "assistant" => assistant.key, "reason" => reason.to_s, "policy" => policy.to_h }
|
|
633
|
+
end
|
|
634
|
+
stamp_assistant_action!
|
|
635
|
+
publish_transition(event, :assistant_withheld, assistant, request)
|
|
636
|
+
SupportDesk.emit_after_commit(:assistant_withheld, self, assistant, reason: reason, policy: policy)
|
|
637
|
+
Outcome.new(action: :withheld, reason: reason, policy: policy, turn: assistant_turn)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Say it to the requester. The caller holds the lock and has already
|
|
641
|
+
# decided she may.
|
|
642
|
+
def speak!(body, assistant, policy:, turn:, files:, confidence:, sources:, metadata:, request:)
|
|
643
|
+
notice = (post_disclosure_notice!(assistant) if assistant.notice? && !disclosure_posted?(assistant))
|
|
644
|
+
apply_reply_policy!(assistant, request: request)
|
|
645
|
+
|
|
646
|
+
stamped = assistant_message_metadata(assistant, policy: policy, turn: turn, confidence: confidence,
|
|
647
|
+
sources: sources, host: metadata)
|
|
648
|
+
|
|
649
|
+
# `author: nil` in the nameless modes, so chats prints no signature
|
|
650
|
+
# and the requester sees the desk — while the metadata above still
|
|
651
|
+
# says exactly what wrote it, for staff, for export and for audit.
|
|
652
|
+
posted = post_agent_message!(body, files: files, by: (assistant if assistant.signs?), metadata: stamped)
|
|
653
|
+
pin_before!(notice, posted) if notice
|
|
654
|
+
record_registration!(posted)
|
|
655
|
+
update_columns(assistant_turns_count: assistant_turns_count.to_i + 1)
|
|
656
|
+
stamp_assistant_action!
|
|
657
|
+
posted
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# What every machine-written message carries: who wrote it, under what
|
|
661
|
+
# disclosure, holding which turn, and the whole policy that allowed it.
|
|
662
|
+
#
|
|
663
|
+
# The host's own metadata is NESTED under "host" rather than merged:
|
|
664
|
+
# the "support_desk" key is the gem's evidence, and a host writing
|
|
665
|
+
# `policy` into it would be rewriting it.
|
|
666
|
+
def assistant_message_metadata(assistant, policy:, turn:, confidence: nil, sources: [], host: {})
|
|
667
|
+
stamped = { "support_desk" => {
|
|
668
|
+
"assistant" => assistant.key,
|
|
669
|
+
"kind" => "ai",
|
|
670
|
+
"name" => assistant.name,
|
|
671
|
+
"display_name" => assistant.disclosed_name,
|
|
672
|
+
"disclosure" => assistant.disclosure.to_s,
|
|
673
|
+
"signed" => assistant.signs?,
|
|
674
|
+
"turn" => turn.to_s,
|
|
675
|
+
"confidence" => confidence,
|
|
676
|
+
"sources" => sources.presence,
|
|
677
|
+
"policy" => policy.to_h
|
|
678
|
+
}.compact }
|
|
679
|
+
stamped["host"] = host if host.present?
|
|
680
|
+
stamped
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# The desk's first word, when it is hers. Called from inside the
|
|
684
|
+
# transaction that created the case (see Ticket.post_opening!), where
|
|
685
|
+
# she is already seated and there is no earlier turn for anybody to
|
|
686
|
+
# have held — so the first turn is the one this message consumes.
|
|
687
|
+
def post_assistant_opening!(body, files:, assistant:)
|
|
688
|
+
policy = assistant_policy(assistant)
|
|
689
|
+
stamped = assistant_message_metadata(assistant, policy: policy, turn: assistant_turn)
|
|
690
|
+
posted = post_agent_message!(body, files: files, by: (assistant if assistant.signs?), metadata: stamped)
|
|
691
|
+
update_columns(assistant_turns_count: 1)
|
|
692
|
+
posted
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Write the proposal. Validated BEFORE the pending one is superseded:
|
|
696
|
+
# a draft that can't be saved must not take the previous one with it.
|
|
697
|
+
def propose_draft!(body, assistant, policy:, files: [], confidence: nil, sources: [], metadata: {})
|
|
698
|
+
candidate = drafts.new(author: assistant, proposed_turn: assistant_turn, body: body,
|
|
699
|
+
confidence: confidence, sources: sources || [],
|
|
700
|
+
metadata: { "policy" => policy.to_h, "host" => metadata.presence }.compact)
|
|
701
|
+
candidate.files = files if files.present? && candidate.respond_to?(:files=)
|
|
702
|
+
candidate.validate!
|
|
703
|
+
|
|
704
|
+
# One pending proposal per case. The row lock serialises this, so
|
|
705
|
+
# there is no unique-violation to rescue: supersede, then insert.
|
|
706
|
+
drafts.pending.each(&:supersede!)
|
|
707
|
+
bump_assistant_revision!
|
|
708
|
+
# The turn this proposal LEAVES the case at, not the one it answered
|
|
709
|
+
# — a proposal is itself a change, so stamping the turn it consumed
|
|
710
|
+
# would make every draft stale the moment it was written.
|
|
711
|
+
candidate.proposed_turn = assistant_turn
|
|
712
|
+
candidate.save!
|
|
713
|
+
reset_draft_associations!
|
|
714
|
+
stamp_assistant_action!
|
|
715
|
+
broadcast_change
|
|
716
|
+
SupportDesk.emit_after_commit(:draft_proposed, self, candidate)
|
|
717
|
+
candidate
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# The notice a `:notice` mode opens with, posted once per disclosure
|
|
721
|
+
# MODE: a host that changes mode discloses again, and one that doesn't
|
|
722
|
+
# never repeats itself.
|
|
723
|
+
def post_disclosure_notice!(assistant)
|
|
724
|
+
line = assistant.config.line_for(:disclosure_line, self)
|
|
725
|
+
if line.blank?
|
|
726
|
+
raise ConfigurationError,
|
|
727
|
+
"assistant #{assistant.key} discloses with a notice but has no disclosure_line — " \
|
|
728
|
+
"an undisclosed autonomous message is never posted silently"
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
notice = conversation.post_system_message!(line)
|
|
732
|
+
disclosed = (metadata["assistant_disclosed"] || {}).merge(assistant.key.to_s => assistant.disclosure.to_s)
|
|
733
|
+
update!(metadata: metadata.merge("assistant_disclosed" => disclosed))
|
|
734
|
+
notice
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def disclosure_posted?(assistant)
|
|
738
|
+
metadata.dig("assistant_disclosed", assistant.key.to_s) == assistant.disclosure.to_s
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# chats orders a transcript by (created_at, id), and two inserts in one
|
|
742
|
+
# transaction can share a timestamp — so a notice is pinned one
|
|
743
|
+
# database tick before the message it introduces, exactly as the
|
|
744
|
+
# opening line is.
|
|
745
|
+
def pin_before!(notice, message)
|
|
746
|
+
return if notice.nil? || message.nil?
|
|
747
|
+
|
|
748
|
+
notice.update_columns(created_at: message.created_at - self.class.send(:ordering_tick))
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# The one write behind both hand-offs: release her seat, record who
|
|
752
|
+
# asked and why, raise the priority, and say so in the thread.
|
|
753
|
+
# Returns [event, previous assignee] — nil event when it was a no-op.
|
|
754
|
+
def flag_human_required!(actor:, kind:, reason:, summary:, line:, request:)
|
|
755
|
+
from = nil
|
|
756
|
+
event = write_transition!(kind, actor: actor, request: request) do
|
|
757
|
+
raise InvalidTransition, "can't #{kind} a closed case — reopen it first" if closed?
|
|
758
|
+
next false if human_required?
|
|
759
|
+
|
|
760
|
+
from = assignee
|
|
761
|
+
release_assistant_seat!(reason: :escalated)
|
|
762
|
+
update!(human_required_at: Time.current, human_required_reason: reason.to_s,
|
|
763
|
+
priority: [ priority.to_i, Topic::PRIORITIES[:high] ].max)
|
|
764
|
+
{ "reason" => reason.to_s, "summary" => summary.presence,
|
|
765
|
+
"from" => SupportDesk.actor_key(from) }.compact
|
|
766
|
+
end
|
|
767
|
+
return [ nil, nil ] unless event
|
|
768
|
+
|
|
769
|
+
post_assistant_line!(line) unless requester_unavailable?
|
|
770
|
+
[ event, from ]
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# Her seat, and only hers: a human who holds the case keeps it. A case
|
|
774
|
+
# that waited long enough for a person still gets one, seat or no seat.
|
|
775
|
+
def release_assistant_seat!(reason:)
|
|
776
|
+
return unless held_by_assistant?
|
|
777
|
+
|
|
778
|
+
assignments.open.each { |assignment| assignment.release!(reason: reason) }
|
|
779
|
+
update!(assignee: nil)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# One of the assistant's system lines, in the thread. A blank line
|
|
783
|
+
# posts nothing, and a rendering error is reported rather than raised:
|
|
784
|
+
# a missing translation must not roll back a hand-off.
|
|
785
|
+
def post_assistant_line!(setting)
|
|
786
|
+
assistant = self.assistant
|
|
787
|
+
return if assistant.nil? || conversation.nil?
|
|
788
|
+
|
|
789
|
+
line = assistant.config.line_for(setting, self)
|
|
790
|
+
return if line.blank?
|
|
791
|
+
|
|
792
|
+
conversation.post_system_message!(line)
|
|
793
|
+
rescue StandardError => e
|
|
794
|
+
SupportDesk.report_error(e, context: { hook: setting, ticket: id })
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# The host's "somebody typed 'quiero hablar con una persona'" hook, run
|
|
798
|
+
# on every requester message under the registration lock.
|
|
799
|
+
#
|
|
800
|
+
# It FAILS CLOSED (v2 R28): true hands the case over, false and nil do
|
|
801
|
+
# nothing, and anything else — a String, a raise — is reported AND
|
|
802
|
+
# hands the case over. A hook nobody can read the answer of is a hook
|
|
803
|
+
# that has already failed, and the safe direction is a person.
|
|
804
|
+
def evaluate_hand_off_phrase!(message)
|
|
805
|
+
assistant = self.assistant
|
|
806
|
+
return if assistant.nil? || human_required? || !assistant_in_play?
|
|
807
|
+
|
|
808
|
+
block = assistant.config.hand_off_when
|
|
809
|
+
return if block.nil?
|
|
810
|
+
|
|
811
|
+
result = begin
|
|
812
|
+
block.call(self, message)
|
|
813
|
+
rescue StandardError => e
|
|
814
|
+
SupportDesk.report_error(e, context: { hook: :hand_off_when, ticket: id })
|
|
815
|
+
:error
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
case result
|
|
819
|
+
when true
|
|
820
|
+
flag_human_required!(actor: requester, kind: :human_requested, reason: "phrase", summary: nil,
|
|
821
|
+
line: :human_requested_line, request: nil)
|
|
822
|
+
SupportDesk.emit_after_commit(:human_requested, self, by: requester, reason: :phrase)
|
|
823
|
+
when false, nil
|
|
824
|
+
nil
|
|
825
|
+
else
|
|
826
|
+
unless result == :error
|
|
827
|
+
SupportDesk.report_error(
|
|
828
|
+
ArgumentError.new("hand_off_when must return true, false or nil, got #{result.inspect}"),
|
|
829
|
+
context: { hook: :hand_off_when, ticket: id }
|
|
830
|
+
)
|
|
831
|
+
end
|
|
832
|
+
flag_human_required!(actor: requester, kind: :human_requested, reason: "hand_off_when_error",
|
|
833
|
+
summary: nil, line: :human_requested_line, request: nil)
|
|
834
|
+
SupportDesk.emit_after_commit(:human_requested, self, by: requester, reason: :hand_off_when_error)
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# "There is something to answer here." The only event a harness
|
|
839
|
+
# subscribes to, and the only place the gem asks anybody to do work.
|
|
840
|
+
#
|
|
841
|
+
# It carries the turn, so a job can check `ticket.assistant_turn ==
|
|
842
|
+
# turn` before spending money. A hook that raises is reported: a
|
|
843
|
+
# broken subscriber must not roll back the message that triggered it.
|
|
844
|
+
def emit_assistant_turn(message = nil)
|
|
845
|
+
return unless open? && awaiting_reply?
|
|
846
|
+
|
|
847
|
+
assistant = self.assistant
|
|
848
|
+
return if assistant.nil?
|
|
849
|
+
return unless assistant_policy(assistant).may_observe?
|
|
850
|
+
|
|
851
|
+
SupportDesk.emit_after_commit(:assistant_turn, self, assistant, message, turn: assistant_turn)
|
|
852
|
+
rescue StandardError => e
|
|
853
|
+
SupportDesk.report_error(e, context: { hook: :assistant_turn, ticket: id })
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# A human transition that lowers the effective level below :reply while
|
|
857
|
+
# she holds the case takes her seat — a case she may no longer answer
|
|
858
|
+
# is not a case she can go on holding.
|
|
859
|
+
def release_assistant_if_unfit!
|
|
860
|
+
return false unless held_by_assistant?
|
|
861
|
+
|
|
862
|
+
holder = assignee
|
|
863
|
+
return false if assistant_policy(holder).may_reply?
|
|
864
|
+
|
|
865
|
+
assignments.open.each { |assignment| assignment.release!(reason: :released) }
|
|
866
|
+
update!(assignee: nil)
|
|
867
|
+
true
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# Every pending proposal on this case is out of date. `except:` is the
|
|
871
|
+
# draft being sent right now, which is about to become "sent".
|
|
872
|
+
def supersede_pending_drafts!(except: nil)
|
|
873
|
+
scope = drafts.pending
|
|
874
|
+
scope = scope.where.not(id: except) if except.present?
|
|
875
|
+
scope.each(&:supersede!)
|
|
876
|
+
reset_draft_associations!
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# A closed case has nothing pending. Returns how many it expired, for
|
|
880
|
+
# the close event's payload.
|
|
881
|
+
def expire_pending_drafts!
|
|
882
|
+
pending = drafts.pending.to_a
|
|
883
|
+
pending.each(&:expire!)
|
|
884
|
+
reset_draft_associations!
|
|
885
|
+
pending.size
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# The proposals moved, so what this instance remembers about them is
|
|
889
|
+
# a lie. Everything that changes a draft's status calls this, so
|
|
890
|
+
# `ticket.pending_draft` and `actions_for` are right inside the SAME
|
|
891
|
+
# operation — a console that had to `reload` to see its own write is a
|
|
892
|
+
# console that renders a button nobody can press.
|
|
893
|
+
def reset_draft_associations!
|
|
894
|
+
association(:pending_draft).reset
|
|
895
|
+
association(:drafts).reset
|
|
896
|
+
end
|
|
897
|
+
end
|
|
898
|
+
end
|
|
899
|
+
end
|