support_desk 0.1.3 → 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 +126 -0
- data/README.md +1101 -22
- data/Rakefile +16 -0
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +28 -4
- data/app/helpers/support_desk/engine_helper.rb +23 -1
- 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 +33 -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/_new_conversation_form.html.erb +91 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/index.html.erb +14 -2
- data/app/views/support_desk/console/tickets/new.html.erb +25 -0
- 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/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +89 -0
- data/config/locales/support_desk.console.es.yml +91 -0
- data/config/locales/support_desk.en.yml +18 -0
- data/config/locales/support_desk.es.yml +29 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +26 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -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/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +63 -0
- data/lib/generators/support_desk/upgrade_generator.rb +66 -0
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +699 -3
- data/lib/support_desk/console.rb +634 -30
- data/lib/support_desk/console_engine.rb +2 -0
- data/lib/support_desk/console_routes.rb +20 -8
- data/lib/support_desk/context_card.rb +33 -1
- data/lib/support_desk/doctor.rb +195 -2
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +42 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +41 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +45 -12
- 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 +781 -120
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +160 -3
- 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/wizard.rb +4 -12
- data/lib/support_desk.rb +153 -0
- data/lib/tasks/support_desk.rake +73 -0
- metadata +35 -8
|
@@ -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
|
|
@@ -16,7 +16,7 @@ module <%= module_name %>
|
|
|
16
16
|
# The routes come from the `:support_console` routing concern:
|
|
17
17
|
#
|
|
18
18
|
# namespace :<%= target %> do
|
|
19
|
-
# resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
19
|
+
# resources :support_tickets, only: %i[index show new], concerns: :support_console
|
|
20
20
|
# end
|
|
21
21
|
#
|
|
22
22
|
# Both `index` and `show` are yours — the gem deliberately doesn't own the
|
|
@@ -19,6 +19,23 @@ SupportDesk.configure do |config|
|
|
|
19
19
|
#
|
|
20
20
|
# config.agents { User.where(admin: true) }
|
|
21
21
|
|
|
22
|
+
# Who may ask, and who may answer, record by record — the macros' `if:`:
|
|
23
|
+
#
|
|
24
|
+
# class User < ApplicationRecord
|
|
25
|
+
# has_support_tickets if: :kept? # a closed account can neither
|
|
26
|
+
# acts_as_support_agent if: :admin? # ask nor be written to
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# `has_support_tickets if:` is a WRITE rule, not a screen rule: the thread
|
|
30
|
+
# and its history stay readable when it turns false.
|
|
31
|
+
|
|
32
|
+
# How the console finds the person an agent types in "Write to someone":
|
|
33
|
+
# email, phone, handle — your call. Given the typed string, return a
|
|
34
|
+
# requester record or nil. Without it the console accepts only a GlobalID
|
|
35
|
+
# from one of your own pages.
|
|
36
|
+
#
|
|
37
|
+
# config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
|
|
38
|
+
|
|
22
39
|
# ==========================================================================
|
|
23
40
|
# CONTROLLER INTEGRATION
|
|
24
41
|
# ==========================================================================
|
|
@@ -105,6 +122,19 @@ SupportDesk.configure do |config|
|
|
|
105
122
|
#
|
|
106
123
|
# config.announce_assignments = :first_only
|
|
107
124
|
#
|
|
125
|
+
# The system line a thread opens with, posted inside the opening
|
|
126
|
+
# transaction and before the first message. A String with %{label},
|
|
127
|
+
# %{desk} and %{reply_within}, a Symbol naming an I18n key, a block given
|
|
128
|
+
# the ticket, or nil for no line at all.
|
|
129
|
+
#
|
|
130
|
+
# The second one is for a case the DESK opened
|
|
131
|
+
# (`lucia.open_support_conversation_with!(alice, "Vimos que…")`): it has a
|
|
132
|
+
# default, because a message from a desk somebody never wrote to has to
|
|
133
|
+
# explain itself.
|
|
134
|
+
#
|
|
135
|
+
# config.opening_line = "Has abierto una conversación sobre «%{label}». Te contestamos aquí."
|
|
136
|
+
# config.opening_line_from_support = "%{desk} ha abierto esta conversación contigo sobre «%{label}»."
|
|
137
|
+
#
|
|
108
138
|
# What a requester writing into a closed ticket does:
|
|
109
139
|
# :reopen_on_reply the case comes back (no wall, no dead end)
|
|
110
140
|
# :locked the composer is replaced by a notice
|
|
@@ -145,6 +175,39 @@ SupportDesk.configure do |config|
|
|
|
145
175
|
# config.mirror_replies_by_email = :when_away # :always | :when_away | :never
|
|
146
176
|
# config.auto_close_after = nil # e.g. 7.days
|
|
147
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
|
+
|
|
148
211
|
# ==========================================================================
|
|
149
212
|
# MORE THAN ONE DESK
|
|
150
213
|
# ==========================================================================
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module SupportDesk
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails generate support_desk:upgrade` — copy the migrations a version
|
|
9
|
+
# bump needs into an EXISTING install. Nothing else: the initializer, the
|
|
10
|
+
# views, the console and the routes you already own stay untouched.
|
|
11
|
+
#
|
|
12
|
+
# Currently writes the 0.2.0 migration (who opened the case), which is the
|
|
13
|
+
# SAME template a fresh install runs — one file owns those columns, so a
|
|
14
|
+
# fresh install and an upgraded one end up with the same schema and the
|
|
15
|
+
# same rollback. Running this twice writes nothing the second time: the
|
|
16
|
+
# migration already sitting in db/migrate is identical, and Rails skips it.
|
|
17
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
18
|
+
include ActiveRecord::Generators::Migration
|
|
19
|
+
|
|
20
|
+
source_root File.expand_path("templates", __dir__)
|
|
21
|
+
desc "Add the migrations a support_desk version bump needs (0.2.0: opened_by, 0.3.0: assistants)"
|
|
22
|
+
|
|
23
|
+
# Rails' migration numbering, borrowed from ActiveRecord's generators.
|
|
24
|
+
def self.next_migration_number(dir)
|
|
25
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_opened_by_migration
|
|
29
|
+
migration_template "add_opened_by_to_support_desk_tickets.rb.erb",
|
|
30
|
+
File.join(db_migrate_path, "add_opened_by_to_support_desk_tickets.rb")
|
|
31
|
+
end
|
|
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
|
+
|
|
38
|
+
def display_post_upgrade_message
|
|
39
|
+
say "\n🎫 support_desk upgrade migrations copied.", :green
|
|
40
|
+
say "\n 1. Run 'rails db:migrate'. It adds `opened_by` and points every existing"
|
|
41
|
+
say " case at its requester — 0.1 had no other way to open one."
|
|
42
|
+
say " 2. New in 0.2.0:"
|
|
43
|
+
say " lucia.open_support_conversation_with!(alice, \"Vimos que…\") # the desk writes first"
|
|
44
|
+
say " ticket.opened_by / opened_by_support? / opened_by_requester?"
|
|
45
|
+
say " config.opening_line / config.opening_line_from_support / config.find_requester"
|
|
46
|
+
say " has_support_tickets if: :kept? # who may be written to"
|
|
47
|
+
say " 3. Migrate BEFORE 0.2 serves traffic. Pause support writes; stop and drain"
|
|
48
|
+
say " ALL old web requests and workers. This is NOT a rolling deployment."
|
|
49
|
+
say " Keep traffic paused, run 'rake support_desk:backfill_opened_by' under 0.2,"
|
|
50
|
+
say " verify no NULL openers remain, then start only 0.2 and resume traffic."
|
|
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
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def migration_version
|
|
62
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# What the assistant may do on ONE case, right now, and why.
|
|
5
|
+
#
|
|
6
|
+
# policy = ticket.assistant_policy
|
|
7
|
+
# policy.level # => :draft
|
|
8
|
+
# policy.because # => "topic payments caps rose at draft"
|
|
9
|
+
# policy.may_reply? # => false
|
|
10
|
+
#
|
|
11
|
+
# == Ceilings and floors
|
|
12
|
+
#
|
|
13
|
+
# A CEILING lowers what the assistant may ever produce here: her configured
|
|
14
|
+
# autonomy, the topic's cap (the minimum over the topic and every ancestor),
|
|
15
|
+
# the host's `cap` block, the case's persisted cap (what a reopen writes),
|
|
16
|
+
# and a pause. The lowest ceiling wins, and `because` names the rule that
|
|
17
|
+
# decided it — so a refusal can always be traced to one line of
|
|
18
|
+
# configuration.
|
|
19
|
+
#
|
|
20
|
+
# A FLOOR lowers the level because of the case's state: she is inactive,
|
|
21
|
+
# the case is closed, a person was asked for, or a human holds it. Floors
|
|
22
|
+
# run after ceilings and only ever lower.
|
|
23
|
+
#
|
|
24
|
+
# == It is the authorization, not a hint
|
|
25
|
+
#
|
|
26
|
+
# Every assistant verb asks this object, under the ticket's row lock, from
|
|
27
|
+
# freshly read state — `Ticket#actions_for` is a projection of the same
|
|
28
|
+
# decision, never a second opinion. No query: the topic is an in-memory
|
|
29
|
+
# lookup, the assignee is the loaded association, everything else is a
|
|
30
|
+
# column on the row we already hold.
|
|
31
|
+
class AssistantPolicy
|
|
32
|
+
# What the assistant may PRODUCE, from least to most. Ordered, and the
|
|
33
|
+
# order is the meaning: everything compares by RANK, never by name.
|
|
34
|
+
LEVELS = %i[off observe draft reply resolve].freeze
|
|
35
|
+
RANK = LEVELS.each_with_index.to_h.freeze
|
|
36
|
+
|
|
37
|
+
# The verbs each level unlocks. Every level's array is the previous one
|
|
38
|
+
# plus its own, written out rather than computed: a reader has to be able
|
|
39
|
+
# to see what ":reply" means without running anything.
|
|
40
|
+
VERBS_BY_LEVEL = {
|
|
41
|
+
off: [],
|
|
42
|
+
observe: %i[note escalate release],
|
|
43
|
+
draft: %i[note escalate release draft],
|
|
44
|
+
reply: %i[note escalate release draft reply take],
|
|
45
|
+
resolve: %i[note escalate release draft reply take close]
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# Every verb the top level knows — what `forbidden_verbs` subtracts from.
|
|
49
|
+
ALL_VERBS = VERBS_BY_LEVEL.fetch(:resolve)
|
|
50
|
+
|
|
51
|
+
attr_reader :ticket, :assistant, :level, :because, :ceilings, :floors
|
|
52
|
+
|
|
53
|
+
# The policy for +assistant+ on +ticket+.
|
|
54
|
+
#
|
|
55
|
+
# `hand_back: true` asks a different question — "could she hold this case
|
|
56
|
+
# once the human-side flags were cleared?" — and is used by one caller,
|
|
57
|
+
# `Ticket#ensure_assignable!`, so that a human handing a case back is not
|
|
58
|
+
# refused by the very flags that hand-back exists to lift. The hard
|
|
59
|
+
# ceilings (autonomy, topic, the `cap` block) and the hard floors
|
|
60
|
+
# (inactive, closed) still apply.
|
|
61
|
+
def self.for(ticket, assistant = ticket.assistant, hand_back: false)
|
|
62
|
+
return Null.new(ticket, reason: "no assistant on desk #{ticket.desk&.key}") if assistant.nil?
|
|
63
|
+
unless SupportDesk::Ticket.same_actor?(assistant, ticket.assistant)
|
|
64
|
+
return Null.new(ticket, reason: "#{assistant.try(:key) || assistant.class} is not desk " \
|
|
65
|
+
"#{ticket.desk&.key}'s assistant")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
config = assistant.config
|
|
69
|
+
ceilings = {
|
|
70
|
+
assistant: config.autonomy,
|
|
71
|
+
topic: ticket.topic&.assistant_cap,
|
|
72
|
+
cap: evaluate_cap(config, ticket),
|
|
73
|
+
case: (ticket.assistant_cap&.to_sym unless hand_back),
|
|
74
|
+
pause: (:off if ticket.assistant_paused? && !hand_back)
|
|
75
|
+
}.compact
|
|
76
|
+
|
|
77
|
+
# Hash#min_by yields [key, value]; destructuring it the other way round
|
|
78
|
+
# is how a policy starts reporting "the level caps her at :topic".
|
|
79
|
+
rule, level = ceilings.min_by { |_rule, value| RANK.fetch(value) }
|
|
80
|
+
because = ceiling_sentence(rule, level, assistant, ticket)
|
|
81
|
+
floors = []
|
|
82
|
+
|
|
83
|
+
if !assistant.active?
|
|
84
|
+
level = :off
|
|
85
|
+
because = "#{assistant.key} is inactive"
|
|
86
|
+
floors << :inactive
|
|
87
|
+
end
|
|
88
|
+
if ticket.closed? && RANK.fetch(level) > RANK.fetch(:observe)
|
|
89
|
+
level = :observe
|
|
90
|
+
because = "the case is closed"
|
|
91
|
+
floors << :closed
|
|
92
|
+
end
|
|
93
|
+
if !hand_back && ticket.human_required? && RANK.fetch(level) > RANK.fetch(:observe)
|
|
94
|
+
level = :observe
|
|
95
|
+
because = "a person was requested (#{ticket.human_required_reason})"
|
|
96
|
+
floors << :human_required
|
|
97
|
+
end
|
|
98
|
+
if !hand_back && ticket.assigned? && !ticket.assigned_to?(assistant) &&
|
|
99
|
+
RANK.fetch(level) > RANK.fetch(:draft)
|
|
100
|
+
level = :draft
|
|
101
|
+
because = "#{holder_name(ticket)} holds the case"
|
|
102
|
+
floors << :held_by_human
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
new(ticket: ticket, assistant: assistant, level: level, because: because,
|
|
106
|
+
ceilings: ceilings, floors: floors)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Run the host's `cap` block and check what it said. A block that returns
|
|
110
|
+
# something that isn't a level is a configuration mistake, and it is
|
|
111
|
+
# named as one here rather than turning into a NoMethodError inside a
|
|
112
|
+
# transaction. A block that RAISES is the host's exception to see: it
|
|
113
|
+
# propagates (the one caller that can't afford that — the turn emitter —
|
|
114
|
+
# reports it and emits nothing).
|
|
115
|
+
def self.evaluate_cap(config, ticket) # :nodoc:
|
|
116
|
+
block = config.cap
|
|
117
|
+
return nil if block.nil?
|
|
118
|
+
|
|
119
|
+
value = block.call(ticket)
|
|
120
|
+
return nil if value.nil?
|
|
121
|
+
|
|
122
|
+
level = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
123
|
+
unless LEVELS.include?(level)
|
|
124
|
+
raise ConfigurationError,
|
|
125
|
+
"assistant #{config.key}'s cap block must return one of #{LEVELS.map(&:inspect).join(", ")} " \
|
|
126
|
+
"or nil, got #{value.inspect}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
level
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.ceiling_sentence(rule, level, assistant, ticket) # :nodoc:
|
|
133
|
+
case rule
|
|
134
|
+
when :assistant then "#{assistant.key}'s autonomy is #{level}"
|
|
135
|
+
when :topic then "topic #{ticket.topic&.path} caps #{assistant.key} at #{level}"
|
|
136
|
+
when :cap then "this desk's cap block caps #{assistant.key} at #{level}"
|
|
137
|
+
when :case then "this case caps #{assistant.key} at #{level}"
|
|
138
|
+
when :pause then "#{assistant.key} is paused on this case"
|
|
139
|
+
else "#{assistant.key} may work at #{level}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.holder_name(ticket) # :nodoc:
|
|
144
|
+
ticket.assignee.try(:support_agent_name) || "somebody else"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private_class_method :ceiling_sentence, :holder_name
|
|
148
|
+
|
|
149
|
+
# Built by `.for`. The attributes are the whole object: a policy is a
|
|
150
|
+
# value, computed once under the lock and then only read.
|
|
151
|
+
def initialize(ticket:, assistant:, level:, because:, ceilings: {}, floors: [])
|
|
152
|
+
@ticket = ticket
|
|
153
|
+
@assistant = assistant
|
|
154
|
+
@level = level
|
|
155
|
+
@because = because
|
|
156
|
+
@ceilings = ceilings
|
|
157
|
+
@floors = floors
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Whether the effective level is at least +other+.
|
|
161
|
+
def at_least?(other)
|
|
162
|
+
RANK.fetch(level) >= RANK.fetch(other.to_sym)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Whether this verb is unlocked at the effective level.
|
|
166
|
+
def may?(verb) = allowed_verbs.include?(verb.to_sym)
|
|
167
|
+
|
|
168
|
+
def may_observe? = at_least?(:observe)
|
|
169
|
+
def may_draft? = at_least?(:draft)
|
|
170
|
+
def may_reply? = at_least?(:reply)
|
|
171
|
+
# Holding a case means owing the next word, so it takes the level that
|
|
172
|
+
# may say one.
|
|
173
|
+
def may_hold? = may_reply?
|
|
174
|
+
def may_close? = at_least?(:resolve)
|
|
175
|
+
|
|
176
|
+
def allowed_verbs = VERBS_BY_LEVEL.fetch(level)
|
|
177
|
+
def forbidden_verbs = ALL_VERBS - allowed_verbs
|
|
178
|
+
|
|
179
|
+
# Whether this is the null policy — no assistant to ask about.
|
|
180
|
+
def null? = false
|
|
181
|
+
|
|
182
|
+
# The whole decision, as data: what goes into message metadata, a draft
|
|
183
|
+
# row and an event payload, so forensics never has to re-run the policy
|
|
184
|
+
# against a case that has moved on.
|
|
185
|
+
def to_h
|
|
186
|
+
{
|
|
187
|
+
assistant: assistant&.key,
|
|
188
|
+
level: level,
|
|
189
|
+
because: because,
|
|
190
|
+
ceilings: { assistant: ceilings[:assistant], topic: ceilings[:topic], cap: ceilings[:cap],
|
|
191
|
+
case: ceilings[:case], pause: ceilings[:pause] },
|
|
192
|
+
floors: floors
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# "draft — topic payments caps rose at draft"
|
|
197
|
+
def to_s = "#{level} — #{because}"
|
|
198
|
+
|
|
199
|
+
def inspect
|
|
200
|
+
"#<SupportDesk::AssistantPolicy #{assistant&.key || "none"} #{level} #{because.inspect}>"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# No assistant on this desk, or an assistant that is somebody else's.
|
|
204
|
+
# Every predicate says no, and `because` says which of the two it was.
|
|
205
|
+
class Null < AssistantPolicy
|
|
206
|
+
def initialize(ticket, reason:)
|
|
207
|
+
super(ticket: ticket, assistant: nil, level: :off, because: reason, ceilings: {}, floors: [])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def null? = true
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|