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,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# support_desk 0.3.0 — ASSISTANTS.
|
|
4
|
+
#
|
|
5
|
+
# Two new tables (the assistant herself, and the replies she proposes) and
|
|
6
|
+
# nine columns on the tickets table: the turn, the pointer the turn reads,
|
|
7
|
+
# her budget, and the three independent human-side flags (a person was
|
|
8
|
+
# asked for, she was paused here, this case caps her).
|
|
9
|
+
#
|
|
10
|
+
# Every statement is guarded, so this is safe to run against a schema that
|
|
11
|
+
# already has some of it — a half-finished migration, an upgrade run twice,
|
|
12
|
+
# a host who added a column by hand.
|
|
13
|
+
#
|
|
14
|
+
# Column types are read from the columns they POINT AT, never from today's
|
|
15
|
+
# generator setting: `ticket_id` follows support_desk_tickets.id, message
|
|
16
|
+
# ids follow chats_messages.id, and the polymorphic ids follow the ones
|
|
17
|
+
# support_desk_assignments already uses for an agent. An app that changed
|
|
18
|
+
# `primary_key_type` after installing would otherwise be handed bigint
|
|
19
|
+
# columns for uuid ids.
|
|
20
|
+
class AddAssistantsToSupportDesk < ActiveRecord::Migration<%= migration_version %>
|
|
21
|
+
TICKET_INDEX = "index_support_desk_tickets_on_needs_human"
|
|
22
|
+
|
|
23
|
+
def up
|
|
24
|
+
create_assistants_table
|
|
25
|
+
create_drafts_table
|
|
26
|
+
add_ticket_columns
|
|
27
|
+
backfill_requester_pointer
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Exactly what `up` added. Only meaningful BEFORE an assistant has
|
|
31
|
+
# answered anything: dropping these tables throws away every proposal a
|
|
32
|
+
# human reviewed and every reason a case was handed over. A code rollback
|
|
33
|
+
# to 0.2 does NOT need this — the columns are nullable and the tables are
|
|
34
|
+
# simply ignored.
|
|
35
|
+
def down
|
|
36
|
+
remove_index :support_desk_tickets, name: TICKET_INDEX if
|
|
37
|
+
index_exists?(:support_desk_tickets, [ :desk_id, :human_required_at ], name: TICKET_INDEX)
|
|
38
|
+
|
|
39
|
+
ticket_columns.each_key do |name|
|
|
40
|
+
remove_column :support_desk_tickets, name if column_exists?(:support_desk_tickets, name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
drop_table :support_desk_drafts if table_exists?(:support_desk_drafts)
|
|
44
|
+
drop_table :support_desk_assistants if table_exists?(:support_desk_assistants)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# support_desk_assistants
|
|
51
|
+
#
|
|
52
|
+
# Who she is, and whether she is on. Everything she MAY DO lives in the
|
|
53
|
+
# initializer, in code — so a policy change is a deploy and a diff, not a
|
|
54
|
+
# row somebody edited. `active` is the exception on purpose: it is the
|
|
55
|
+
# cross-process kill switch, read fresh before every write she makes.
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
def create_assistants_table
|
|
58
|
+
unless table_exists?(:support_desk_assistants)
|
|
59
|
+
create_table :support_desk_assistants, id: primary_key_type do |t|
|
|
60
|
+
t.string :key, null: false
|
|
61
|
+
t.boolean :active, null: false, default: true
|
|
62
|
+
t.send(json_column_type, :settings, default: json_column_default)
|
|
63
|
+
|
|
64
|
+
t.timestamps
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return if index_exists?(:support_desk_assistants, :key, name: "index_support_desk_assistants_on_key")
|
|
69
|
+
|
|
70
|
+
add_index :support_desk_assistants, :key, unique: true, name: "index_support_desk_assistants_on_key"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# support_desk_drafts
|
|
75
|
+
#
|
|
76
|
+
# A reply she proposed and a human sent, edited or threw away. `body` is
|
|
77
|
+
# what she wrote and `sent_body` what went out when they differ, so the
|
|
78
|
+
# verbatim/edited/rejected split a host reads before raising her level is
|
|
79
|
+
# a query and not a guess. `proposed_turn` is the case's turn when she
|
|
80
|
+
# wrote it — what makes `stale?` mean "the conversation moved on".
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
def create_drafts_table
|
|
83
|
+
unless table_exists?(:support_desk_drafts)
|
|
84
|
+
create_table :support_desk_drafts, id: primary_key_type do |t|
|
|
85
|
+
t.column :ticket_id, sql_type_of(:support_desk_tickets, "id"), null: false
|
|
86
|
+
t.string :author_type, null: false
|
|
87
|
+
t.column :author_id, sql_type_of(:support_desk_assignments, "agent_id"), null: false
|
|
88
|
+
t.string :reviewed_by_type
|
|
89
|
+
t.column :reviewed_by_id, sql_type_of(:support_desk_assignments, "agent_id")
|
|
90
|
+
t.column :sent_message_id, sql_type_of(:chats_messages, "id")
|
|
91
|
+
|
|
92
|
+
t.string :proposed_turn, null: false
|
|
93
|
+
# Null for an attachment-only proposal — a screenshot with nothing
|
|
94
|
+
# to add is a whole answer.
|
|
95
|
+
t.text :body
|
|
96
|
+
t.text :sent_body
|
|
97
|
+
t.string :status, null: false, default: "pending"
|
|
98
|
+
# 0.000 to 1.000: what the model declared, never what it was worth.
|
|
99
|
+
t.decimal :confidence, precision: 4, scale: 3
|
|
100
|
+
t.send(json_column_type, :sources, default: json_column_default)
|
|
101
|
+
t.send(json_column_type, :metadata, default: json_column_default)
|
|
102
|
+
t.string :rejection_reason
|
|
103
|
+
t.datetime :reviewed_at
|
|
104
|
+
|
|
105
|
+
t.timestamps
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
add_foreign_key :support_desk_drafts, :support_desk_tickets, column: :ticket_id
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
unless index_exists?(:support_desk_drafts, [ :ticket_id, :created_at ],
|
|
112
|
+
name: "index_support_desk_drafts_on_ticket")
|
|
113
|
+
add_index :support_desk_drafts, [ :ticket_id, :created_at ], name: "index_support_desk_drafts_on_ticket"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
unless index_exists?(:support_desk_drafts, [ :author_type, :author_id, :status ],
|
|
117
|
+
name: "index_support_desk_drafts_on_author")
|
|
118
|
+
add_index :support_desk_drafts, [ :author_type, :author_id, :status ],
|
|
119
|
+
name: "index_support_desk_drafts_on_author"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ONE pending proposal per case — the belt under the row lock that
|
|
123
|
+
# serialises them. Enforced on every adapter that has partial indexes
|
|
124
|
+
# (all but MySQL, Trilogy included); elsewhere the model's
|
|
125
|
+
# supersede-then-create is the whole story and `doctor` checks it.
|
|
126
|
+
return unless partial_indexes?
|
|
127
|
+
return if index_exists?(:support_desk_drafts, :ticket_id, name: "index_support_desk_drafts_on_pending")
|
|
128
|
+
|
|
129
|
+
add_index :support_desk_drafts, :ticket_id, unique: true, where: "status = 'pending'",
|
|
130
|
+
name: "index_support_desk_drafts_on_pending"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# support_desk_tickets (+)
|
|
135
|
+
#
|
|
136
|
+
# `assistant_revision` is the turn: one integer, bumped by every
|
|
137
|
+
# registered message and every transition, which is what makes a late or
|
|
138
|
+
# redelivered answer a no-op instead of a second reply.
|
|
139
|
+
#
|
|
140
|
+
# The three human-side flags are THREE columns and not one status, because
|
|
141
|
+
# they are three different decisions made by three different people:
|
|
142
|
+
# somebody asked for a person, a human paused her here, and this case caps
|
|
143
|
+
# her (what a reopen after she closed it writes). Resuming her lifts one
|
|
144
|
+
# of them, and only an explicit hand-back lifts all three.
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
def add_ticket_columns
|
|
147
|
+
ticket_columns.each do |name, options|
|
|
148
|
+
next if column_exists?(:support_desk_tickets, name)
|
|
149
|
+
|
|
150
|
+
add_column :support_desk_tickets, name, options[:type], **options.except(:type)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
return if index_exists?(:support_desk_tickets, [ :desk_id, :human_required_at ], name: TICKET_INDEX)
|
|
154
|
+
|
|
155
|
+
add_index :support_desk_tickets, [ :desk_id, :human_required_at ], name: TICKET_INDEX
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def ticket_columns
|
|
159
|
+
{
|
|
160
|
+
assistant_revision: { type: :bigint, null: false, default: 0 },
|
|
161
|
+
last_requester_message_id: { type: sql_type_of(:chats_messages, "id") },
|
|
162
|
+
assistant_turns_count: { type: :integer, null: false, default: 0 },
|
|
163
|
+
assistant_acted_at: { type: :datetime },
|
|
164
|
+
assistant_paused_at: { type: :datetime },
|
|
165
|
+
assistant_paused_reason: { type: :string },
|
|
166
|
+
assistant_cap: { type: :string },
|
|
167
|
+
human_required_at: { type: :datetime },
|
|
168
|
+
human_required_reason: { type: :string }
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The pointer the turn's reconciliation reads: which requester message the
|
|
173
|
+
# clocks are standing on, by id and not only by time.
|
|
174
|
+
#
|
|
175
|
+
# SQL, and deliberately not the model: a backfill that loaded today's
|
|
176
|
+
# Ticket would run this release's validations, callbacks and events over
|
|
177
|
+
# last release's rows. ORDER BY rather than MAX(id), because ids can be
|
|
178
|
+
# uuids and "the biggest uuid" is not "the newest message".
|
|
179
|
+
def backfill_requester_pointer
|
|
180
|
+
execute(<<~SQL.squish)
|
|
181
|
+
UPDATE support_desk_tickets SET last_requester_message_id = (
|
|
182
|
+
SELECT m.id FROM chats_messages m
|
|
183
|
+
WHERE m.conversation_id = support_desk_tickets.conversation_id
|
|
184
|
+
AND m.sender_type = support_desk_tickets.requester_type
|
|
185
|
+
AND m.sender_id = support_desk_tickets.requester_id
|
|
186
|
+
AND m.kind = 'text'
|
|
187
|
+
ORDER BY m.created_at DESC, m.id DESC LIMIT 1)
|
|
188
|
+
WHERE last_requester_message_id IS NULL AND conversation_id IS NOT NULL
|
|
189
|
+
SQL
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The storage this schema already uses for that column — uuid, bigint,
|
|
193
|
+
# integer. A new column that points at those records has to be stored the
|
|
194
|
+
# same way, whatever the generator is configured with today.
|
|
195
|
+
def sql_type_of(table, column)
|
|
196
|
+
found = connection.columns(table).find { |candidate| candidate.name == column }
|
|
197
|
+
unless found
|
|
198
|
+
raise ActiveRecord::MigrationError,
|
|
199
|
+
"#{table} has no #{column} column — run support_desk's and chats' install migrations first."
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
found.sql_type
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Honor the host's configured primary key type (uuid vs bigint) for the
|
|
206
|
+
# tables this migration CREATES. Everything it points at is derived above.
|
|
207
|
+
def primary_key_type
|
|
208
|
+
config = Rails.configuration.generators
|
|
209
|
+
config.options[config.orm][:primary_key_type] || :primary_key
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Whether this adapter can enforce a rule over SOME rows ("one pending
|
|
213
|
+
# draft per case"). Everything but MySQL can — and Trilogy is MySQL under
|
|
214
|
+
# a different ADAPTER_NAME, so a /mysql/ pattern alone would hand such a
|
|
215
|
+
# host an index it cannot create.
|
|
216
|
+
def partial_indexes?
|
|
217
|
+
!connection.adapter_name.match?(/mysql|trilogy/i)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# jsonb on every PostgreSQL adapter — matched by prefix because PostGIS
|
|
221
|
+
# answers "PostGIS", not "PostgreSQL".
|
|
222
|
+
def json_column_type
|
|
223
|
+
return :jsonb if connection.adapter_name.match?(/\Apostg/i)
|
|
224
|
+
|
|
225
|
+
:json
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# MySQL 8+ doesn't allow default values on JSON columns. `sources`
|
|
229
|
+
# defaults to [] in Ruby (see SupportDesk::Draft) for the same reason it
|
|
230
|
+
# can't default here.
|
|
231
|
+
def json_column_default
|
|
232
|
+
return nil if connection.adapter_name.match?(/mysql|trilogy/i)
|
|
233
|
+
|
|
234
|
+
{}
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Support
|
|
4
|
+
# <%= assistant_name %>'s mind. Everything the gem knows how to do stops at the
|
|
5
|
+
# door of this file: which provider you call, what you put in the prompt,
|
|
6
|
+
# how you cache, what you spend, and what you refuse to answer are yours.
|
|
7
|
+
#
|
|
8
|
+
# What comes in:
|
|
9
|
+
#
|
|
10
|
+
# * `brief` — a SupportDesk::Brief. `brief.to_h` is versioned data;
|
|
11
|
+
# `brief.to_text` is the same facts as plain text. It carries the desk,
|
|
12
|
+
# the case, the requester, and `may` / `may_not` — the authorization,
|
|
13
|
+
# so you never have to re-derive the rules this case is under.
|
|
14
|
+
# * `transcript` — a SupportDesk::Transcript. `to_text` is the readable
|
|
15
|
+
# rendering; `to_a` is Turns (`role`, `name`, `body`, `at`,
|
|
16
|
+
# `attachments`, `assisted`).
|
|
17
|
+
#
|
|
18
|
+
# ⚠️ Both of these go to whatever you call. `support_context` (the
|
|
19
|
+
# subject's and the requester's) is host data, chosen by you, and it
|
|
20
|
+
# leaves your building the moment this method does. Decide what belongs in
|
|
21
|
+
# a prompt before you fill those in, not after.
|
|
22
|
+
#
|
|
23
|
+
# What goes out: an Answer. Nothing else — this method never touches the
|
|
24
|
+
# ticket. The job owns the writes, so the turn, the policy and the lock
|
|
25
|
+
# are checked in exactly one place.
|
|
26
|
+
class <%= key.camelize %>
|
|
27
|
+
# `kind` is the whole decision:
|
|
28
|
+
#
|
|
29
|
+
# :answer — say this. `text` required; `confidence` (0..1) and
|
|
30
|
+
# `sources` ([{ title:, url: }]) are shown to the agent
|
|
31
|
+
# reviewing the proposal, so fill them in honestly.
|
|
32
|
+
# :hand_off — a person should take this. `reason` required, `summary`
|
|
33
|
+
# is the paragraph the next human reads first.
|
|
34
|
+
# :note — leave a staff note and stop. Never reaches the customer.
|
|
35
|
+
# :nothing — do nothing. The case stays in the humans' queue.
|
|
36
|
+
Answer = Struct.new(:kind, :text, :confidence, :sources, :reason, :summary, :metadata,
|
|
37
|
+
keyword_init: true) do
|
|
38
|
+
def initialize(**attributes)
|
|
39
|
+
super
|
|
40
|
+
self.kind ||= :nothing
|
|
41
|
+
self.sources ||= []
|
|
42
|
+
self.metadata ||= {}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Answer one case.
|
|
47
|
+
#
|
|
48
|
+
# def self.answer(brief, transcript)
|
|
49
|
+
# reply = MyModel.complete(system: PROMPT, user: brief.to_text)
|
|
50
|
+
# return Answer.new(kind: :hand_off, reason: "unsure") if reply.confidence < 0.6
|
|
51
|
+
#
|
|
52
|
+
# Answer.new(kind: :answer, text: reply.text, confidence: reply.confidence)
|
|
53
|
+
# end
|
|
54
|
+
def self.answer(brief, transcript)
|
|
55
|
+
raise NotImplementedError,
|
|
56
|
+
"#{self} has no answer yet. Write one — the contract is the Assistants " \
|
|
57
|
+
"section of the support_desk README (brief in, Answer out, no ticket writes)."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Support
|
|
4
|
+
# <%= assistant_name %>'s turn.
|
|
5
|
+
#
|
|
6
|
+
# The gem emits `:assistant_turn` when there is something to answer; this
|
|
7
|
+
# job is the only thing that runs between that event and a word reaching a
|
|
8
|
+
# customer.
|
|
9
|
+
#
|
|
10
|
+
# Four rules, and they are the whole contract (README → Assistants):
|
|
11
|
+
#
|
|
12
|
+
# 1. **Arguments are ids, never records and never a prompt.** A serialized
|
|
13
|
+
# ticket is a ticket as it was when the job was enqueued, and by the
|
|
14
|
+
# time a worker picks it up that is a guess.
|
|
15
|
+
# 2. **Check the turn before spending money.** `assistant_turn` moves on
|
|
16
|
+
# every message and every transition, so a mismatch means the case
|
|
17
|
+
# changed while this job waited — and the newer turn's job already
|
|
18
|
+
# exists. Returning here costs nothing; calling a model costs money to
|
|
19
|
+
# produce an answer that would be refused anyway.
|
|
20
|
+
# 3. **Hand the answer to exactly one verb**, all of them `by:` her and
|
|
21
|
+
# `turn:` this turn. Each consumes the turn and returns the successor
|
|
22
|
+
# (`Outcome#turn` / `ticket.assistant_turn`); a second action in the
|
|
23
|
+
# same run uses that one.
|
|
24
|
+
# 4. **`SupportDesk::StaleTurn` and `Locked` are normal outcomes**, not
|
|
25
|
+
# failures — hence `discard_on` below. `SupportDesk::AssistantNotAllowed`
|
|
26
|
+
# is NOT: the brief said `may_not`, so reaching it is a bug in here.
|
|
27
|
+
class <%= key.camelize %>TurnJob < ApplicationJob
|
|
28
|
+
queue_as :support
|
|
29
|
+
|
|
30
|
+
# A late job, a redelivered one and a case that moved on all land as
|
|
31
|
+
# StaleTurn, and all three mean the same thing: somebody else has this.
|
|
32
|
+
# Locked means there is nobody to write to any more. RecordNotFound
|
|
33
|
+
# means the case is gone. None of them is worth a retry.
|
|
34
|
+
#
|
|
35
|
+
# Provider timeouts ARE worth one — put `retry_on` for those here. Note
|
|
36
|
+
# that a retry AFTER a committed action raises StaleTurn and is
|
|
37
|
+
# discarded, which is exactly right: the work was done.
|
|
38
|
+
discard_on SupportDesk::StaleTurn, SupportDesk::Locked, ActiveRecord::RecordNotFound
|
|
39
|
+
|
|
40
|
+
def perform(ticket_id, assistant_key, turn)
|
|
41
|
+
ticket = SupportDesk::Ticket.find(ticket_id)
|
|
42
|
+
return unless ticket.assistant_turn == turn # the case moved on while we waited
|
|
43
|
+
|
|
44
|
+
<%= key %> = SupportDesk.assistant(assistant_key)
|
|
45
|
+
return unless ticket.assistant_policy(<%= key %>).may_observe?
|
|
46
|
+
|
|
47
|
+
answer = <%= service_class %>.answer(ticket.brief, ticket.transcript)
|
|
48
|
+
|
|
49
|
+
case answer.kind
|
|
50
|
+
when :answer
|
|
51
|
+
# `respond!` lets POLICY decide what this becomes: sent, proposed as
|
|
52
|
+
# a draft for a person to send, or withheld with a reason on the
|
|
53
|
+
# record. Never encode the level in here — it changes per case.
|
|
54
|
+
ticket.respond!(answer.text, by: <%= key %>, turn: turn, confidence: answer.confidence,
|
|
55
|
+
sources: answer.sources, metadata: answer.metadata)
|
|
56
|
+
when :hand_off
|
|
57
|
+
ticket.escalate!(by: <%= key %>, turn: turn, reason: answer.reason, summary: answer.summary)
|
|
58
|
+
when :note
|
|
59
|
+
# Staff only. Never reaches the conversation.
|
|
60
|
+
ticket.note!(answer.text, by: <%= key %>, turn: turn)
|
|
61
|
+
when :nothing
|
|
62
|
+
# She decided not to act. The turn is NOT consumed and the case
|
|
63
|
+
# stays in the humans' queue — which is why `SupportDesk.doctor`
|
|
64
|
+
# counts idle turns: a harness that always answers :nothing looks
|
|
65
|
+
# exactly like one that is down.
|
|
66
|
+
nil
|
|
67
|
+
else
|
|
68
|
+
raise ArgumentError, "unsupported answer kind #{answer.kind.inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# The three things about a harness that are worth a test, and the only three
|
|
6
|
+
# that are the same in every app: the level decides what her answer BECOMES,
|
|
7
|
+
# raising the level changes that and nothing else, and a turn that is no
|
|
8
|
+
# longer the case's writes nothing at all.
|
|
9
|
+
#
|
|
10
|
+
# <%= service_class %>.answer is stubbed throughout — this is a test of the
|
|
11
|
+
# wiring, not of a model. Point `requester` and `agent` at your own fixtures
|
|
12
|
+
# and it runs.
|
|
13
|
+
class <%= key.camelize %>TurnJobTest < ActiveSupport::TestCase
|
|
14
|
+
include SupportDesk::TestHelpers
|
|
15
|
+
|
|
16
|
+
setup do
|
|
17
|
+
@<%= key %> = SupportDesk.assistant(:<%= key %>)
|
|
18
|
+
@ticket = open_support_ticket(for: requester, message: "No me han pagado")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
test "at :draft her answer waits for a person" do
|
|
22
|
+
answering("Lo estamos revisando") do
|
|
23
|
+
<%= job_class %>.perform_now(@ticket.id, "<%= key %>", @ticket.assistant_turn)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
draft = assert_pending_draft @ticket, body: "revisando"
|
|
27
|
+
assert_equal @<%= key %>, draft.author
|
|
28
|
+
# Nothing reached the customer: a proposal is not a message.
|
|
29
|
+
refute_assistant_spoke @ticket
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test "at :reply she answers the customer herself" do
|
|
33
|
+
with_assistant_config(:<%= key %>, autonomy: :reply) do
|
|
34
|
+
answering("Lo estamos revisando") do
|
|
35
|
+
<%= job_class %>.perform_now(@ticket.id, "<%= key %>", @ticket.assistant_turn)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
assert_awaiting_requester @ticket
|
|
40
|
+
refute_pending_draft @ticket
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
test "a stale turn writes nothing and costs nothing" do
|
|
44
|
+
stale = @ticket.assistant_turn
|
|
45
|
+
ask_again(@ticket, "¿hola?") # the customer wrote again: the turn moved
|
|
46
|
+
|
|
47
|
+
called = false
|
|
48
|
+
<%= service_class %>.stub(:answer, ->(*) { called = true; answer_for("…") }) do
|
|
49
|
+
<%= job_class %>.perform_now(@ticket.id, "<%= key %>", stale)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
assert_not called, "the job called the model on a turn that was no longer the case's"
|
|
53
|
+
refute_pending_draft @ticket
|
|
54
|
+
refute_assistant_spoke @ticket
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# The person asking. Point this at your own fixture or factory.
|
|
60
|
+
def requester = users(:one)
|
|
61
|
+
|
|
62
|
+
def answer_for(text)
|
|
63
|
+
<%= service_class %>::Answer.new(kind: :answer, text: text, confidence: 0.9)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def answering(text, &block)
|
|
67
|
+
<%= service_class %>.stub(:answer, ->(*) { answer_for(text) }, &block)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -175,6 +175,39 @@ SupportDesk.configure do |config|
|
|
|
175
175
|
# config.mirror_replies_by_email = :when_away # :always | :when_away | :never
|
|
176
176
|
# config.auto_close_after = nil # e.g. 7.days
|
|
177
177
|
|
|
178
|
+
# ==========================================================================
|
|
179
|
+
# AN ASSISTANT (0.3)
|
|
180
|
+
# ==========================================================================
|
|
181
|
+
#
|
|
182
|
+
# An AI agent, with the guardrails in code. `autonomy` is the most she may
|
|
183
|
+
# ever produce; topics only cap it DOWN. `disclosure` is REQUIRED — whether
|
|
184
|
+
# a customer is told they are talking to a machine is your decision, and
|
|
185
|
+
# `:none` is how you say "nothing", on purpose.
|
|
186
|
+
#
|
|
187
|
+
# config.assistant :rose do |rose|
|
|
188
|
+
# rose.name = "Rose"
|
|
189
|
+
# rose.autonomy = :draft # :off :observe :draft :reply :resolve
|
|
190
|
+
# rose.disclosure = :signature_and_notice # :signature | :notice | :none
|
|
191
|
+
# rose.max_turns = 6
|
|
192
|
+
# rose.responds_within = 3.minutes
|
|
193
|
+
# rose.hand_off_when { |_ticket, message| message.body.to_s.match?(/\bpersona\b/i) }
|
|
194
|
+
# rose.cap { |ticket| :draft if ticket.requester.try(:vip?) }
|
|
195
|
+
# end
|
|
196
|
+
# config.default_assistant = :rose
|
|
197
|
+
#
|
|
198
|
+
# The desk-level binding, for installations with more than one desk. An
|
|
199
|
+
# explicit nil means "no assistant HERE", which is not the same as
|
|
200
|
+
# inheriting the default one.
|
|
201
|
+
#
|
|
202
|
+
# config.desk(:billing) { |desk| desk.assistant = nil }
|
|
203
|
+
#
|
|
204
|
+
# Then subscribe your harness to the one event it needs — see the README's
|
|
205
|
+
# assistants section for the whole contract.
|
|
206
|
+
#
|
|
207
|
+
# SupportDesk.on(:assistant_turn, key: "support.rose.turn") do |ticket, assistant, _message, turn:|
|
|
208
|
+
# Support::RoseTurnJob.set(wait: 20.seconds).perform_later(ticket.id, assistant.key, turn)
|
|
209
|
+
# end
|
|
210
|
+
|
|
178
211
|
# ==========================================================================
|
|
179
212
|
# MORE THAN ONE DESK
|
|
180
213
|
# ==========================================================================
|
|
@@ -18,7 +18,7 @@ module SupportDesk
|
|
|
18
18
|
include ActiveRecord::Generators::Migration
|
|
19
19
|
|
|
20
20
|
source_root File.expand_path("templates", __dir__)
|
|
21
|
-
desc "Add the migrations a support_desk version bump needs (0.2.0: opened_by)"
|
|
21
|
+
desc "Add the migrations a support_desk version bump needs (0.2.0: opened_by, 0.3.0: assistants)"
|
|
22
22
|
|
|
23
23
|
# Rails' migration numbering, borrowed from ActiveRecord's generators.
|
|
24
24
|
def self.next_migration_number(dir)
|
|
@@ -30,6 +30,11 @@ module SupportDesk
|
|
|
30
30
|
File.join(db_migrate_path, "add_opened_by_to_support_desk_tickets.rb")
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
def create_assistants_migration
|
|
34
|
+
migration_template "add_assistants_to_support_desk.rb.erb",
|
|
35
|
+
File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
|
|
36
|
+
end
|
|
37
|
+
|
|
33
38
|
def display_post_upgrade_message
|
|
34
39
|
say "\n🎫 support_desk upgrade migrations copied.", :green
|
|
35
40
|
say "\n 1. Run 'rails db:migrate'. It adds `opened_by` and points every existing"
|
|
@@ -43,7 +48,12 @@ module SupportDesk
|
|
|
43
48
|
say " ALL old web requests and workers. This is NOT a rolling deployment."
|
|
44
49
|
say " Keep traffic paused, run 'rake support_desk:backfill_opened_by' under 0.2,"
|
|
45
50
|
say " verify no NULL openers remain, then start only 0.2 and resume traffic."
|
|
46
|
-
say " 4.
|
|
51
|
+
say " 4. New in 0.3.0 — assistants. The migration is ADDITIVE and rolling-safe:"
|
|
52
|
+
say " add `config.assistant` only once every process is on 0.3.0. Then"
|
|
53
|
+
say " rails g support_desk:assistant Rose --disclosure signature"
|
|
54
|
+
say " writes the harness and prints the stanza, the subscription and the two"
|
|
55
|
+
say " scheduled tasks. It never edits your initializer."
|
|
56
|
+
say " 5. See the CHANGELOG for the full list.\n", :green
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
private
|