support_desk 0.3.0 → 0.4.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 +206 -0
- data/README.md +227 -16
- data/app/controllers/support_desk/application_controller.rb +5 -2
- data/app/controllers/support_desk/tickets_controller.rb +68 -3
- data/app/helpers/support_desk/engine_helper.rb +10 -10
- data/app/helpers/support_desk/thread_helper.rb +29 -0
- data/app/views/support_desk/tickets/show.html.erb +8 -0
- data/config/locales/support_desk.en.yml +28 -0
- data/config/locales/support_desk.es.yml +28 -0
- data/lib/generators/support_desk/install_generator.rb +9 -3
- data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
- data/lib/generators/support_desk/templates/initializer.rb +21 -1
- data/lib/generators/support_desk/upgrade_generator.rb +15 -7
- data/lib/support_desk/brief.rb +1 -1
- data/lib/support_desk/configuration.rb +100 -19
- data/lib/support_desk/conversation_access.rb +42 -0
- data/lib/support_desk/doctor.rb +30 -3
- data/lib/support_desk/engine.rb +13 -1
- data/lib/support_desk/message_signatures.rb +20 -0
- data/lib/support_desk/models/assistant.rb +43 -8
- data/lib/support_desk/models/draft.rb +9 -0
- data/lib/support_desk/models/message_registration.rb +12 -0
- data/lib/support_desk/models/ticket/assistance.rb +254 -30
- data/lib/support_desk/models/ticket.rb +172 -67
- data/lib/support_desk/transcript.rb +9 -12
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +1 -5
- data/lib/support_desk.rb +82 -11
- data/lib/tasks/support_desk.rake +12 -1
- metadata +20 -11
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# chats finds every conversation a request names (the thread, a dock
|
|
5
|
+
# window, the composer, read, typing, refresh, reactions, edits) by asking,
|
|
6
|
+
# in order, the relations `Chats::ApplicationController#conversation_lookup_scopes`
|
|
7
|
+
# returns (chats >= 0.5.1). By default that is the viewer's inbox,
|
|
8
|
+
# `Messager#chats`, with the host's `config.inbox_scope` applied: the right
|
|
9
|
+
# rule for a list and the wrong one for a case. A host that keeps support
|
|
10
|
+
# apart from its inbox (`thread_page = :support`, and an inbox_scope that
|
|
11
|
+
# leaves SupportDesk::Ticket conversations out) would 404 its requesters
|
|
12
|
+
# inside their own cases.
|
|
13
|
+
#
|
|
14
|
+
# So this adds one relation after chats' own: the viewer's own cases
|
|
15
|
+
# (SupportDesk::Ticket.conversations_of), through the same participation
|
|
16
|
+
# and block rules as the inbox, without the host's listing scope. The union
|
|
17
|
+
# of the scopes is the authorization: nobody gains a conversation they are
|
|
18
|
+
# not seated in, a non-support conversation the host hides stays hidden, and
|
|
19
|
+
# a layer of the host's that refuses a conversation (its own
|
|
20
|
+
# `find_conversation`, or a scope it drops) still refuses it.
|
|
21
|
+
#
|
|
22
|
+
# == The contract with the host
|
|
23
|
+
#
|
|
24
|
+
# support_desk only ever adds a scope beside the host's. A host that must
|
|
25
|
+
# refuse a viewer entirely (a suspended account) returns no scopes, and that
|
|
26
|
+
# is final: with nothing to stand beside, support_desk adds nothing either.
|
|
27
|
+
# The ticket page asks the same question (SupportDesk::TicketsController
|
|
28
|
+
# resolves its conversation through chats' own `find_conversation`).
|
|
29
|
+
#
|
|
30
|
+
# Prepended to Chats::ApplicationController by the engine, in `to_prepare`
|
|
31
|
+
# so it survives a code reload.
|
|
32
|
+
module ConversationAccess
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
def conversation_lookup_scopes
|
|
36
|
+
scopes = super
|
|
37
|
+
return scopes if scopes.empty?
|
|
38
|
+
|
|
39
|
+
scopes + [ SupportDesk::Ticket.conversations_of(chats_current_messager) ]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/support_desk/doctor.rb
CHANGED
|
@@ -162,6 +162,14 @@ module SupportDesk
|
|
|
162
162
|
ok_with("#{SupportDesk.config.assistants.size} assistant(s) configured")
|
|
163
163
|
end
|
|
164
164
|
|
|
165
|
+
checks << check("assistant serialization") do
|
|
166
|
+
next ok_with("no assistant on any desk") if assistant_desks.empty?
|
|
167
|
+
next warn_with("the turn cannot exclude a requester message committing behind an answer on " \
|
|
168
|
+
"SQLite — use PostgreSQL or MySQL") if sqlite?
|
|
169
|
+
|
|
170
|
+
ok_with("#{ActiveRecord::Base.connection.adapter_name} takes the row locks the turn rests on")
|
|
171
|
+
end
|
|
172
|
+
|
|
165
173
|
checks << check("assistant turn subscriber") do
|
|
166
174
|
next ok_with("no assistant on any desk") if assistant_desks.empty?
|
|
167
175
|
next warn_with("nothing subscribes to :assistant_turn — no harness will ever answer. See the " \
|
|
@@ -245,6 +253,12 @@ module SupportDesk
|
|
|
245
253
|
tables?
|
|
246
254
|
|
|
247
255
|
checks = [
|
|
256
|
+
check("message registrations") do
|
|
257
|
+
next fail_with("run rails g support_desk:upgrade and rails db:migrate before serving support writes") unless
|
|
258
|
+
MessageRegistration.table_exists?
|
|
259
|
+
|
|
260
|
+
ok_with("message registration receipts available")
|
|
261
|
+
end,
|
|
248
262
|
check("conversations") do
|
|
249
263
|
orphans = Ticket.where(conversation_id: nil).count
|
|
250
264
|
next fail_with("#{orphans} ticket(s) without a conversation") if orphans.positive?
|
|
@@ -317,10 +331,13 @@ module SupportDesk
|
|
|
317
331
|
# and never of the policy's own verdict. A policy cannot page anybody
|
|
318
332
|
# about its own bug.
|
|
319
333
|
def assistant_invariant_checks
|
|
320
|
-
# Nothing about a feature nobody turned on: a host with no assistant
|
|
321
|
-
# configured runs not one extra query (I1).
|
|
322
|
-
return [] if SupportDesk.config.assistants.empty?
|
|
323
334
|
return [] unless assistants_migrated?
|
|
335
|
+
# Nothing about a feature nobody turned on (I1) — but "turned on" is
|
|
336
|
+
# not "configured right now". A host that removed her configuration
|
|
337
|
+
# still has the seats she is sitting on and the proposals she wrote,
|
|
338
|
+
# and the checks about THOSE are the ones that matter most on the way
|
|
339
|
+
# down (R6).
|
|
340
|
+
return [] if SupportDesk.config.assistants.empty? && !SupportDesk::Assistant.exists?
|
|
324
341
|
|
|
325
342
|
checks = []
|
|
326
343
|
|
|
@@ -376,6 +393,16 @@ module SupportDesk
|
|
|
376
393
|
checks
|
|
377
394
|
end
|
|
378
395
|
|
|
396
|
+
# Whether this app is on SQLite, which has no row locks: it serializes
|
|
397
|
+
# writes, and a WAL snapshot reads straight through an open write
|
|
398
|
+
# transaction. Everything the turn rests on — the ticket's row lock and
|
|
399
|
+
# the conversation's — buys nothing there, because nothing waits on it.
|
|
400
|
+
def sqlite?
|
|
401
|
+
ActiveRecord::Base.connection.adapter_name.match?(/sqlite/i)
|
|
402
|
+
rescue StandardError
|
|
403
|
+
false
|
|
404
|
+
end
|
|
405
|
+
|
|
379
406
|
# The desks that actually have an assistant, as Desk records.
|
|
380
407
|
def assistant_desks
|
|
381
408
|
SupportDesk.config.desks.each_key.filter_map do |key|
|
data/lib/support_desk/engine.rb
CHANGED
|
@@ -33,7 +33,7 @@ module SupportDesk
|
|
|
33
33
|
SUPPORT_DESK_LIB = File.expand_path("support_desk", LIB_ROOT)
|
|
34
34
|
|
|
35
35
|
ZEITWERK_IGNORED = %w[
|
|
36
|
-
version.rb errors.rb events.rb topic.rb topic_tree.rb configuration.rb current.rb macros.rb engine.rb
|
|
36
|
+
message_signatures.rb version.rb errors.rb events.rb topic.rb topic_tree.rb configuration.rb current.rb macros.rb engine.rb
|
|
37
37
|
console.rb console_routes.rb console_engine.rb
|
|
38
38
|
].freeze
|
|
39
39
|
|
|
@@ -111,6 +111,11 @@ module SupportDesk
|
|
|
111
111
|
SupportDesk.subscribe_to_chats!
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
initializer "support_desk.message_signatures" do
|
|
115
|
+
require_relative "message_signatures"
|
|
116
|
+
Chats.singleton_class.prepend(SupportDesk::MessageSignatures)
|
|
117
|
+
end
|
|
118
|
+
|
|
114
119
|
# The checks that need the host's own classes loaded — the requester
|
|
115
120
|
# class, and every `about:` class named in a topic tree. In to_prepare
|
|
116
121
|
# (not an initializer) so they re-run after every code reload, which is
|
|
@@ -145,6 +150,13 @@ module SupportDesk
|
|
|
145
150
|
# needed to load the class.
|
|
146
151
|
_assistant = SupportDesk::Assistant
|
|
147
152
|
|
|
153
|
+
# A requester's own case opens through chats whatever the host's
|
|
154
|
+
# `inbox_scope` lists (SupportDesk::ConversationAccess). Here and not in
|
|
155
|
+
# an initializer because both classes reload: a reloaded chats
|
|
156
|
+
# controller is a new class, and it needs the seam again.
|
|
157
|
+
chats_controller = Chats::ApplicationController
|
|
158
|
+
chats_controller.prepend(SupportDesk::ConversationAccess) unless chats_controller < SupportDesk::ConversationAccess
|
|
159
|
+
|
|
148
160
|
SupportDesk.config.validate_classes! if SupportDesk.configured?
|
|
149
161
|
end
|
|
150
162
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The default Chats renderer uses the name/disclosure captured when an
|
|
5
|
+
# assistant spoke. Host signature overrides remain authoritative. Human
|
|
6
|
+
# replies (including approved drafts) still use Chats' normal signature.
|
|
7
|
+
module MessageSignatures
|
|
8
|
+
def message_signature_for(message)
|
|
9
|
+
return super if config.message_signature || !message&.signed?
|
|
10
|
+
|
|
11
|
+
stamp = message.metadata.is_a?(Hash) && message.metadata["support_desk"]
|
|
12
|
+
return super unless stamp.is_a?(Hash) && stamp["kind"] == "ai" && stamp["signed"] == true
|
|
13
|
+
|
|
14
|
+
name = stamp["display_name"]
|
|
15
|
+
return super unless name.is_a?(String) && name.present?
|
|
16
|
+
|
|
17
|
+
I18n.t("chats.message.signature", name: name)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -52,7 +52,9 @@ module SupportDesk
|
|
|
52
52
|
# assistant a host removed from the initializer and kept the history of.
|
|
53
53
|
def configured? = SupportDesk.config.assistant?(key)
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
# What she is called — from configuration while there is any, and from
|
|
56
|
+
# the snapshot below once there is not. See #snapshot_disclosure!.
|
|
57
|
+
def name = settings_config&.name.presence || settings["name"].presence || key.to_s.humanize
|
|
56
58
|
|
|
57
59
|
# Anything `image_tag` accepts, or nil. A callable is passed the record.
|
|
58
60
|
def avatar
|
|
@@ -63,14 +65,41 @@ module SupportDesk
|
|
|
63
65
|
# An unconfigured assistant is capped at :off by construction: there is
|
|
64
66
|
# no rule left saying what she may do, so she may do nothing.
|
|
65
67
|
def autonomy = settings_config&.autonomy || :off
|
|
66
|
-
def disclosure = settings_config&.disclosure
|
|
68
|
+
def disclosure = settings_config&.disclosure || settings["disclosure"]&.to_sym
|
|
67
69
|
def max_turns = settings_config&.max_turns
|
|
68
70
|
def responds_within = settings_config&.responds_within
|
|
69
71
|
def may_open_conversations? = settings_config&.may_open_conversations? || false
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
# Read from configuration while there is any, and from the snapshot
|
|
74
|
+
# otherwise — never simply `false`, because these three decide what a
|
|
75
|
+
# requester is shown next to a message she already sent (R8).
|
|
76
|
+
def disclosed? = disclosure.present? && disclosure != :none
|
|
77
|
+
def signs? = %i[signature_and_notice signature].include?(disclosure)
|
|
78
|
+
def notice? = %i[signature_and_notice notice].include?(disclosure)
|
|
79
|
+
|
|
80
|
+
# Remember what she is called and what is disclosed about her, on her own
|
|
81
|
+
# row. Written whenever configuration resolves her and the two disagree —
|
|
82
|
+
# one UPDATE in the life of a setting, and none on the usual read.
|
|
83
|
+
#
|
|
84
|
+
# Chats asks the AUTHOR for a signature every time a message is rendered,
|
|
85
|
+
# so before this the requester-facing line was whatever configuration said
|
|
86
|
+
# right now: taking her out of the initializer turned "— Rose · virtual
|
|
87
|
+
# assistant" into "— Rose" on messages nobody had touched, and a host
|
|
88
|
+
# reading an old transcript could no longer tell that a machine had
|
|
89
|
+
# written it (R8).
|
|
90
|
+
#
|
|
91
|
+
# Signed messages render their own immutable provenance through the default
|
|
92
|
+
# Chats signature hook. This row snapshot is only a fallback for legacy
|
|
93
|
+
# messages with no provenance and other author displays.
|
|
94
|
+
def snapshot_disclosure! # :nodoc:
|
|
95
|
+
return self unless configured?
|
|
96
|
+
|
|
97
|
+
snapshot = { "name" => name, "disclosure" => disclosure&.to_s }.compact
|
|
98
|
+
return self if snapshot.all? { |field, value| settings[field] == value }
|
|
99
|
+
|
|
100
|
+
update_columns(settings: settings.merge(snapshot))
|
|
101
|
+
self
|
|
102
|
+
end
|
|
74
103
|
|
|
75
104
|
# --- Who she looks like -----------------------------------------------------
|
|
76
105
|
|
|
@@ -99,10 +128,16 @@ module SupportDesk
|
|
|
99
128
|
# policy, not a number of cases.
|
|
100
129
|
def support_capacity = nil
|
|
101
130
|
|
|
102
|
-
# Stop her everywhere, now.
|
|
103
|
-
#
|
|
104
|
-
#
|
|
131
|
+
# Stop her everywhere, now. The cases she is SITTING on are given back by
|
|
132
|
+
# `SupportDesk.reclaim_assistant_seats!` (which
|
|
133
|
+
# `release_silent_assistants!` runs first, and
|
|
134
|
+
# `rake support_desk:reclaim_assistant_seats` runs alone), and it asks
|
|
135
|
+
# for a person on each of them. Run it, or wait for its schedule, before
|
|
105
136
|
# calling the desk quiet.
|
|
137
|
+
#
|
|
138
|
+
# It needs no `responds_within` and no overdue clock: a seat nobody can
|
|
139
|
+
# sit in any more is not a silence problem. In 0.3.0 it was treated as
|
|
140
|
+
# one, so an assistant with no promise kept her seats for ever (R6).
|
|
106
141
|
def deactivate!(by:, reason: nil)
|
|
107
142
|
update!(active: false)
|
|
108
143
|
SupportDesk.logger&.warn(
|
|
@@ -125,9 +125,18 @@ module SupportDesk
|
|
|
125
125
|
raise ArgumentError, "an edited draft can't be blank — reject it instead"
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
+
# The repair commits on its own, so a message whose registration
|
|
129
|
+
# callback was lost is folded in for good even when the approval this
|
|
130
|
+
# call was making is then refused as stale (R3).
|
|
131
|
+
ticket.reconcile_and_commit!
|
|
132
|
+
|
|
128
133
|
message = nil
|
|
129
134
|
ticket.with_lock(requires_new: true) do
|
|
130
135
|
reload
|
|
136
|
+
# Ticket, then conversation: an in-flight customer message holds the
|
|
137
|
+
# conversation row, so `seen_turn` cannot be compared against a case
|
|
138
|
+
# whose next question is one commit away (R1).
|
|
139
|
+
ticket.send(:lock_conversation!)
|
|
131
140
|
ticket.send(:reconcile_unregistered_messages!)
|
|
132
141
|
raise InvalidTransition, "draft #{id} is #{status}, not pending" unless pending?
|
|
133
142
|
unless seen_turn.to_s == ticket.assistant_turn
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# Internal receipt written in the same transaction as the clocks and turn.
|
|
5
|
+
# Message identity establishes idempotency; timestamp/UUID order does not.
|
|
6
|
+
class MessageRegistration < ApplicationRecord
|
|
7
|
+
self.table_name = "support_desk_message_registrations"
|
|
8
|
+
self.primary_key = :message_id
|
|
9
|
+
|
|
10
|
+
belongs_to :ticket, class_name: "SupportDesk::Ticket"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -31,6 +31,49 @@ module SupportDesk
|
|
|
31
31
|
# the same whatever the level, and a refusal is always a named reason on
|
|
32
32
|
# a record (an `assistant_withheld` event, a policy in a draft's
|
|
33
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.
|
|
34
77
|
module Assistance
|
|
35
78
|
extend ActiveSupport::Concern
|
|
36
79
|
|
|
@@ -62,6 +105,30 @@ module SupportDesk
|
|
|
62
105
|
awaiting_reply.where(last_requester_message_at: ..time)
|
|
63
106
|
.where("assistant_acted_at IS NULL OR assistant_acted_at < last_requester_message_at")
|
|
64
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
|
+
}
|
|
65
132
|
end
|
|
66
133
|
|
|
67
134
|
# --- Readers ------------------------------------------------------------------
|
|
@@ -144,10 +211,15 @@ module SupportDesk
|
|
|
144
211
|
# on, Locked when there is nobody to write to, NotAnAssistant when
|
|
145
212
|
# `by:` isn't this desk's assistant.
|
|
146
213
|
def respond!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
|
|
147
|
-
assistant = resolve_assistant!(by)
|
|
148
214
|
raise ArgumentError, "respond! needs something to say" if body.blank? && files.blank?
|
|
149
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
|
+
|
|
150
220
|
with_lock(requires_new: true) do
|
|
221
|
+
assistant = resolve_assistant!(by)
|
|
222
|
+
lock_conversation!
|
|
151
223
|
reconcile_unregistered_messages!
|
|
152
224
|
ensure_current_turn!(turn)
|
|
153
225
|
ensure_writable!
|
|
@@ -168,9 +240,13 @@ module SupportDesk
|
|
|
168
240
|
# the draft stays, and so does a person — a conversation that
|
|
169
241
|
# ran out of turns is one somebody has to finish.
|
|
170
242
|
if policy.may_reply? && left&.zero?
|
|
171
|
-
flag_human_required!(actor: assistant, kind: :escalated, reason: "max_turns",
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
174
250
|
reason = :max_turns
|
|
175
251
|
end
|
|
176
252
|
Outcome.new(action: :drafted, draft: draft, policy: policy, reason: reason, turn: assistant_turn)
|
|
@@ -182,10 +258,13 @@ module SupportDesk
|
|
|
182
258
|
# `respond!` is what a harness should call; this is for a host that has
|
|
183
259
|
# already decided it wants a draft. Returns the SupportDesk::Draft.
|
|
184
260
|
def draft!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
|
|
185
|
-
assistant = resolve_assistant!(by)
|
|
186
261
|
raise ArgumentError, "draft! needs something to say" if body.blank? && files.blank?
|
|
187
262
|
|
|
263
|
+
reconcile_and_commit!
|
|
264
|
+
|
|
188
265
|
with_lock(requires_new: true) do
|
|
266
|
+
assistant = resolve_assistant!(by)
|
|
267
|
+
lock_conversation!
|
|
189
268
|
reconcile_unregistered_messages!
|
|
190
269
|
ensure_current_turn!(turn)
|
|
191
270
|
ensure_writable!
|
|
@@ -208,13 +287,16 @@ module SupportDesk
|
|
|
208
287
|
def escalate!(by: nil, reason:, summary: nil, turn: nil, request: nil)
|
|
209
288
|
actor = resolve_actor(by)
|
|
210
289
|
ensure_agent!(actor)
|
|
211
|
-
|
|
290
|
+
machine = SupportDesk.ai_actor?(actor)
|
|
212
291
|
raise ArgumentError, "escalate! needs a reason" if reason.blank?
|
|
213
292
|
|
|
293
|
+
assistant = nil
|
|
214
294
|
event = nil
|
|
215
295
|
from = nil
|
|
216
296
|
with_lock(requires_new: true) do
|
|
217
|
-
if
|
|
297
|
+
if machine
|
|
298
|
+
# Under the lock, from the row (R9).
|
|
299
|
+
assistant = resolve_assistant!(actor)
|
|
218
300
|
ensure_current_turn!(turn)
|
|
219
301
|
policy = assistant_policy(assistant)
|
|
220
302
|
raise AssistantNotAllowed.new(policy, verb: :escalate) unless policy.may_observe?
|
|
@@ -226,7 +308,7 @@ module SupportDesk
|
|
|
226
308
|
return self unless event
|
|
227
309
|
|
|
228
310
|
stamp_assistant_action! if assistant
|
|
229
|
-
|
|
311
|
+
publish_escalation!(from: from, reason: reason, by: actor)
|
|
230
312
|
self
|
|
231
313
|
end
|
|
232
314
|
|
|
@@ -325,6 +407,91 @@ module SupportDesk
|
|
|
325
407
|
verbs
|
|
326
408
|
end
|
|
327
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
|
+
|
|
328
495
|
# Bump the case's revision — the caller holds the lock. `update_columns`
|
|
329
496
|
# on purpose: inside the transaction, no callbacks, no validations, and
|
|
330
497
|
# the in-memory value moves with the row, so the turn a caller reads
|
|
@@ -333,6 +500,30 @@ module SupportDesk
|
|
|
333
500
|
update_columns(assistant_revision: assistant_revision.to_i + 1)
|
|
334
501
|
end
|
|
335
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
|
+
|
|
336
527
|
# When the assistant last did anything here — what the idle-turn check
|
|
337
528
|
# and the redispatch task read to tell "she decided not to speak" from
|
|
338
529
|
# "nothing is running".
|
|
@@ -358,9 +549,16 @@ module SupportDesk
|
|
|
358
549
|
end
|
|
359
550
|
|
|
360
551
|
# Fresh from the row: `active` is a cross-process kill switch, and a
|
|
361
|
-
# record loaded a minute ago is not evidence about now
|
|
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.
|
|
362
561
|
self.class.ensure_agent_record!(actor)
|
|
363
|
-
actor
|
|
364
562
|
end
|
|
365
563
|
|
|
366
564
|
# The turn check, under the lock, from the revision the row holds.
|
|
@@ -381,27 +579,50 @@ module SupportDesk
|
|
|
381
579
|
# around it. Folding it in here makes the turn stale instead, which is
|
|
382
580
|
# exactly what should happen.
|
|
383
581
|
def reconcile_unregistered_messages!
|
|
384
|
-
return if conversation.nil?
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
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
|
|
403
595
|
|
|
404
|
-
|
|
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
|
|
405
626
|
end
|
|
406
627
|
|
|
407
628
|
# Nothing was written, and the reason is on the record: a policy that
|
|
@@ -446,6 +667,7 @@ module SupportDesk
|
|
|
446
667
|
stamped = { "support_desk" => {
|
|
447
668
|
"assistant" => assistant.key,
|
|
448
669
|
"kind" => "ai",
|
|
670
|
+
"name" => assistant.name,
|
|
449
671
|
"display_name" => assistant.disclosed_name,
|
|
450
672
|
"disclosure" => assistant.disclosure.to_s,
|
|
451
673
|
"signed" => assistant.signs?,
|
|
@@ -620,6 +842,8 @@ module SupportDesk
|
|
|
620
842
|
# turn` before spending money. A hook that raises is reported: a
|
|
621
843
|
# broken subscriber must not roll back the message that triggered it.
|
|
622
844
|
def emit_assistant_turn(message = nil)
|
|
845
|
+
return unless open? && awaiting_reply?
|
|
846
|
+
|
|
623
847
|
assistant = self.assistant
|
|
624
848
|
return if assistant.nil?
|
|
625
849
|
return unless assistant_policy(assistant).may_observe?
|