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
data/lib/support_desk/macros.rb
CHANGED
|
@@ -57,13 +57,26 @@ module SupportDesk
|
|
|
57
57
|
# subject of the sentence (`ticket.assign!(to: lucia)`).
|
|
58
58
|
def acts_as_support_agent(kind: :human, **options)
|
|
59
59
|
condition = Macros.condition!(options, macro: "acts_as_support_agent", known: ":if, :kind")
|
|
60
|
+
kind = Macros.kind!(kind)
|
|
60
61
|
|
|
61
62
|
include SupportDesk::Agent
|
|
62
63
|
|
|
63
|
-
self.support_desk_agent_options = { if: condition, kind: kind
|
|
64
|
+
self.support_desk_agent_options = { if: condition, kind: kind }.freeze
|
|
64
65
|
SupportDesk.register_agent(self)
|
|
65
66
|
end
|
|
66
67
|
|
|
68
|
+
# Who the agent IS: a person, or a machine. There are two kinds because
|
|
69
|
+
# they are answerable to different rules, and an unknown third would be
|
|
70
|
+
# treated as a human by every check that isn't looking for :ai — so it
|
|
71
|
+
# fails at class definition instead.
|
|
72
|
+
def self.kind!(kind)
|
|
73
|
+
kind = kind.to_sym if kind.respond_to?(:to_sym)
|
|
74
|
+
return kind if %i[human ai].include?(kind)
|
|
75
|
+
|
|
76
|
+
raise ConfigurationError,
|
|
77
|
+
"acts_as_support_agent kind: must be :human or :ai, got #{kind.inspect}"
|
|
78
|
+
end
|
|
79
|
+
|
|
67
80
|
# The `if:` both macros take: a method name or a callable, and the only
|
|
68
81
|
# option either of them accepts beyond its own keywords. Written once
|
|
69
82
|
# because "may this person answer" and "may this person ask" are the same
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The assistant: an AI agent, as a record.
|
|
5
|
+
#
|
|
6
|
+
# rose = SupportDesk.assistant(:rose)
|
|
7
|
+
# rose.autonomy # => :draft (from configuration)
|
|
8
|
+
# rose.deactivate!(by: lucia) # the cross-process kill switch
|
|
9
|
+
#
|
|
10
|
+
# Two things live here and nothing else: WHO she is (a row a message can
|
|
11
|
+
# be authored by, an assignment can point at, and an audit log can name)
|
|
12
|
+
# and WHETHER she is on. Everything she may do lives in configuration, in
|
|
13
|
+
# code, so a policy change is a deploy and a diff — see
|
|
14
|
+
# SupportDesk::Configuration::AssistantConfiguration and
|
|
15
|
+
# SupportDesk::AssistantPolicy.
|
|
16
|
+
#
|
|
17
|
+
# `active` is the one runtime switch, and it is a DATABASE column on
|
|
18
|
+
# purpose: `rose.deactivate!(by: owner)` stops her everywhere within one
|
|
19
|
+
# transition, with no deploy, because `ensure_agent_record!` re-reads this
|
|
20
|
+
# row before every write. Rows are never destroyed — a case she answered in
|
|
21
|
+
# March must still be able to say who wrote it.
|
|
22
|
+
class Assistant < ApplicationRecord
|
|
23
|
+
self.table_name = "support_desk_assistants"
|
|
24
|
+
|
|
25
|
+
acts_as_support_agent kind: :ai, if: :active?
|
|
26
|
+
|
|
27
|
+
# Ruby-side default so settings is always a Hash even on MySQL, where a
|
|
28
|
+
# JSON column can't carry a DB default.
|
|
29
|
+
attribute :settings, default: -> { {} }
|
|
30
|
+
|
|
31
|
+
validates :key, presence: true
|
|
32
|
+
|
|
33
|
+
scope :active, -> { where(active: true) }
|
|
34
|
+
|
|
35
|
+
# The assistant for +key+, found or created. Never INSERT-first, exactly
|
|
36
|
+
# like Desk.for: read on every transition, written once.
|
|
37
|
+
def self.for(key)
|
|
38
|
+
key = key.to_s
|
|
39
|
+
find_by(key: key) || create_or_find_by!(key: key)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- Configuration ----------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
# Her slice of the configuration. Raises ConfigurationError when nothing
|
|
45
|
+
# declares her any more — which is why everything below reads through
|
|
46
|
+
# #settings_config and falls back to the safest answer instead.
|
|
47
|
+
def config
|
|
48
|
+
SupportDesk.config.assistant(key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether this row still has configuration behind it. False for an
|
|
52
|
+
# assistant a host removed from the initializer and kept the history of.
|
|
53
|
+
def configured? = SupportDesk.config.assistant?(key)
|
|
54
|
+
|
|
55
|
+
def name = settings_config&.name || key.to_s.humanize
|
|
56
|
+
|
|
57
|
+
# Anything `image_tag` accepts, or nil. A callable is passed the record.
|
|
58
|
+
def avatar
|
|
59
|
+
value = settings_config&.avatar
|
|
60
|
+
value.respond_to?(:call) ? value.call(self) : value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# An unconfigured assistant is capped at :off by construction: there is
|
|
64
|
+
# no rule left saying what she may do, so she may do nothing.
|
|
65
|
+
def autonomy = settings_config&.autonomy || :off
|
|
66
|
+
def disclosure = settings_config&.disclosure
|
|
67
|
+
def max_turns = settings_config&.max_turns
|
|
68
|
+
def responds_within = settings_config&.responds_within
|
|
69
|
+
def may_open_conversations? = settings_config&.may_open_conversations? || false
|
|
70
|
+
|
|
71
|
+
def disclosed? = settings_config ? settings_config.disclosed? : false
|
|
72
|
+
def signs? = settings_config ? settings_config.signs? : false
|
|
73
|
+
def notice? = settings_config ? settings_config.notice? : false
|
|
74
|
+
|
|
75
|
+
# --- Who she looks like -----------------------------------------------------
|
|
76
|
+
|
|
77
|
+
# The name a REQUESTER sees. Disclosure is not a badge somebody might
|
|
78
|
+
# miss: when she discloses, the name itself says what she is.
|
|
79
|
+
def disclosed_name
|
|
80
|
+
return name unless disclosed?
|
|
81
|
+
|
|
82
|
+
I18n.t("support_desk.assistant.disclosed_name", name: name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# chats reads a display name through the host's own lambda, which
|
|
86
|
+
# usually tries these in order — so all three answer the disclosed name
|
|
87
|
+
# and no host has to special-case a support assistant.
|
|
88
|
+
def display_name = disclosed_name
|
|
89
|
+
def to_s = disclosed_name
|
|
90
|
+
def support_agent_name = disclosed_name
|
|
91
|
+
def support_agent_avatar = avatar
|
|
92
|
+
|
|
93
|
+
# --- Duty -------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
# The kill switch, read fresh from the row on every transition.
|
|
96
|
+
def on_duty? = active?
|
|
97
|
+
|
|
98
|
+
# Unlimited by construction: her ceiling is `max_turns` per case and the
|
|
99
|
+
# policy, not a number of cases.
|
|
100
|
+
def support_capacity = nil
|
|
101
|
+
|
|
102
|
+
# Stop her everywhere, now. Cases she holds are released by the silent
|
|
103
|
+
# sweep (`SupportDesk.release_silent_assistants!`), which also asks for a
|
|
104
|
+
# person on each of them — run it, or wait for its schedule, before
|
|
105
|
+
# calling the desk quiet.
|
|
106
|
+
def deactivate!(by:, reason: nil)
|
|
107
|
+
update!(active: false)
|
|
108
|
+
SupportDesk.logger&.warn(
|
|
109
|
+
"[support_desk] assistant #{key} deactivated by #{SupportDesk.actor_key(by) || "somebody"}" \
|
|
110
|
+
"#{": #{reason}" if reason.present?}"
|
|
111
|
+
)
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Put her back on duty.
|
|
116
|
+
def activate!(by:)
|
|
117
|
+
update!(active: true)
|
|
118
|
+
SupportDesk.logger&.info(
|
|
119
|
+
"[support_desk] assistant #{key} activated by #{SupportDesk.actor_key(by) || "somebody"}"
|
|
120
|
+
)
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# --- Where she works --------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
# The desks whose configuration points at her.
|
|
127
|
+
def desks
|
|
128
|
+
SupportDesk.config.desks.each_key.filter_map do |desk_key|
|
|
129
|
+
SupportDesk.desk(desk_key) if SupportDesk.config.desk(desk_key).assistant_key == key.to_sym
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The open cases she is sitting on.
|
|
134
|
+
def held_tickets
|
|
135
|
+
Ticket.open.assigned_to(self)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The assistant, in one line.
|
|
139
|
+
def inspect
|
|
140
|
+
"#<SupportDesk::Assistant #{key} #{name.inspect} #{autonomy} #{active? ? "active" : "inactive"}>"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Her configuration, or nil when nothing declares her any more. Every
|
|
146
|
+
# reader goes through this so a historical row still renders.
|
|
147
|
+
def settings_config
|
|
148
|
+
return nil unless configured?
|
|
149
|
+
|
|
150
|
+
config
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -9,6 +9,12 @@ module SupportDesk
|
|
|
9
9
|
# alice.support_desk
|
|
10
10
|
# alice.awaiting_support_reply?
|
|
11
11
|
# alice.unread_support_count
|
|
12
|
+
#
|
|
13
|
+
# class User < ApplicationRecord
|
|
14
|
+
# has_support_tickets
|
|
15
|
+
#
|
|
16
|
+
# def support_context = { "Plan" => plan.name, "Viajes" => rides.count }
|
|
17
|
+
# end
|
|
12
18
|
module Requester
|
|
13
19
|
extend ActiveSupport::Concern
|
|
14
20
|
|
|
@@ -69,6 +75,18 @@ module SupportDesk
|
|
|
69
75
|
)
|
|
70
76
|
end
|
|
71
77
|
|
|
78
|
+
# Key/value pairs about the PERSON, the way `Supportable#support_context`
|
|
79
|
+
# is key/value pairs about the thing they are asking about: their plan,
|
|
80
|
+
# how long they have been here, whatever your agents always look up
|
|
81
|
+
# anyway. Rendered in the console's context card, and included in
|
|
82
|
+
# `Ticket#brief`.
|
|
83
|
+
#
|
|
84
|
+
# Empty by default, and it stays empty until you fill it — everything
|
|
85
|
+
# in here is read by whoever answers the case, and by whatever model a
|
|
86
|
+
# harness hands the brief to. Put what an agent needs to help; leave out
|
|
87
|
+
# what they don't.
|
|
88
|
+
def support_context = {}
|
|
89
|
+
|
|
72
90
|
# True when any of this requester's open tickets is waiting on the desk.
|
|
73
91
|
def awaiting_support_reply?
|
|
74
92
|
support_tickets.not_closed.awaiting_reply.exists?
|
|
@@ -79,12 +79,35 @@ module SupportDesk
|
|
|
79
79
|
config.read(:email) || settings["email"].presence
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
# The
|
|
82
|
+
# The assistant who works this desk, or nil. Resolved through
|
|
83
|
+
# `SupportDesk.assistant`, so it is the same memoised record everywhere.
|
|
84
|
+
def assistant
|
|
85
|
+
key = config.assistant_key
|
|
86
|
+
return nil if key.nil?
|
|
87
|
+
|
|
88
|
+
SupportDesk.assistant(key)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Whether this desk has one at all.
|
|
92
|
+
def assistant? = !assistant.nil?
|
|
93
|
+
|
|
94
|
+
# The PEOPLE in the pool. What `agents_to_notify` pages, what a human
|
|
95
|
+
# picker offers, and the reason a host never has to reject machines in
|
|
96
|
+
# its own notifier.
|
|
97
|
+
def humans
|
|
98
|
+
config.agent_pool.to_a.reject { |agent| SupportDesk.ai_actor?(agent) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Everybody who can answer here, the assistant included — pickers and
|
|
102
|
+
# routing. An Array, not a relation: the assistant isn't in the host's
|
|
103
|
+
# scope, and a pool that is half a query and half a record is a pool
|
|
104
|
+
# that can't be either.
|
|
83
105
|
def agents
|
|
84
|
-
|
|
106
|
+
humans + [ assistant ].compact
|
|
85
107
|
end
|
|
86
108
|
|
|
87
|
-
# The pool, minus anyone who says they're off duty
|
|
109
|
+
# The pool, minus anyone who says they're off duty (a deactivated
|
|
110
|
+
# assistant says so).
|
|
88
111
|
def on_duty_agents
|
|
89
112
|
agents.select { |agent| !agent.respond_to?(:on_duty?) || agent.on_duty? }
|
|
90
113
|
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# A reply the assistant proposes and a human sends — or doesn't.
|
|
5
|
+
#
|
|
6
|
+
# draft = ticket.pending_draft
|
|
7
|
+
# draft.send!(by: lucia, seen_turn: ticket.assistant_turn) # verbatim
|
|
8
|
+
# draft.send!(by: lucia, seen_turn: …, body: "Casi: …") # edited
|
|
9
|
+
# draft.reject!(by: lucia, reason: "no es eso")
|
|
10
|
+
#
|
|
11
|
+
# == Who wrote it
|
|
12
|
+
#
|
|
13
|
+
# A sent draft is a message from the HUMAN who sent it (12 #20): they read
|
|
14
|
+
# it, they own it, and the requester sees their signature. What the machine
|
|
15
|
+
# contributed is provenance — `support_desk.drafted_by`, `draft_id` and
|
|
16
|
+
# `edited` in the message's metadata, and this row, which keeps the
|
|
17
|
+
# original body even when the sent one was edited.
|
|
18
|
+
#
|
|
19
|
+
# == Staleness is not a warning, it is the contract
|
|
20
|
+
#
|
|
21
|
+
# `proposed_turn` is the ticket's turn when the draft was written.
|
|
22
|
+
# `seen_turn` is the turn the reviewer was LOOKING at. Sending compares
|
|
23
|
+
# `seen_turn` against the case's turn right now, under its row lock — so a
|
|
24
|
+
# draft approved from a page that predates the customer's next message is
|
|
25
|
+
# refused (SupportDesk::StaleTurn) rather than sent into a conversation
|
|
26
|
+
# that has moved on. There is no "send anyway" flag: re-read the case and
|
|
27
|
+
# submit again with the current turn.
|
|
28
|
+
class Draft < ApplicationRecord
|
|
29
|
+
self.table_name = "support_desk_drafts"
|
|
30
|
+
|
|
31
|
+
# pending → sent | rejected (a human decided), or superseded (a newer
|
|
32
|
+
# proposal, a takeover, a pause) | expired (the case closed).
|
|
33
|
+
STATUSES = %w[pending sent rejected superseded expired].freeze
|
|
34
|
+
|
|
35
|
+
# Enough for any honest answer, and a ceiling on what a model can stuff
|
|
36
|
+
# into a JSON column.
|
|
37
|
+
MAX_SOURCES = 20
|
|
38
|
+
MAX_SOURCE_TITLE = 500
|
|
39
|
+
MAX_SOURCE_URL = 2048
|
|
40
|
+
|
|
41
|
+
belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :drafts
|
|
42
|
+
belongs_to :author, polymorphic: true
|
|
43
|
+
belongs_to :reviewed_by, polymorphic: true, optional: true
|
|
44
|
+
belongs_to :sent_message, class_name: "Chats::Message", optional: true
|
|
45
|
+
|
|
46
|
+
# Attachments ride on ActiveStorage when the host has it installed —
|
|
47
|
+
# the same condition chats' own messages use.
|
|
48
|
+
has_many_attached :files if defined?(ActiveStorage)
|
|
49
|
+
|
|
50
|
+
# Ruby-side defaults so both are always usable even on MySQL, where a
|
|
51
|
+
# JSON column can't carry a DB default.
|
|
52
|
+
attribute :sources, default: -> { [] }
|
|
53
|
+
attribute :metadata, default: -> { {} }
|
|
54
|
+
|
|
55
|
+
validates :status, inclusion: { in: STATUSES }
|
|
56
|
+
validates :proposed_turn, presence: true
|
|
57
|
+
validates :confidence, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 },
|
|
58
|
+
allow_nil: true
|
|
59
|
+
validate :body_or_files_present
|
|
60
|
+
validate :sources_well_formed
|
|
61
|
+
|
|
62
|
+
scope :pending, -> { where(status: "pending") }
|
|
63
|
+
scope :sent, -> { where(status: "sent") }
|
|
64
|
+
scope :rejected, -> { where(status: "rejected") }
|
|
65
|
+
scope :superseded, -> { where(status: "superseded") }
|
|
66
|
+
scope :expired, -> { where(status: "expired") }
|
|
67
|
+
# What a human actually decided about — the denominator every acceptance
|
|
68
|
+
# number in the README is measured against.
|
|
69
|
+
scope :reviewed, -> { where(status: %w[sent rejected]) }
|
|
70
|
+
scope :verbatim, -> { sent.where(sent_body: nil) }
|
|
71
|
+
scope :edited, -> { sent.where.not(sent_body: nil) }
|
|
72
|
+
scope :by, ->(author) { where(author_type: author.class.polymorphic_name, author_id: author.id) }
|
|
73
|
+
scope :chronological, -> { order(:created_at, :id) }
|
|
74
|
+
scope :newest_first, -> { order(created_at: :desc, id: :desc) }
|
|
75
|
+
|
|
76
|
+
def pending? = status == "pending"
|
|
77
|
+
def sent? = status == "sent"
|
|
78
|
+
def rejected? = status == "rejected"
|
|
79
|
+
def superseded? = status == "superseded"
|
|
80
|
+
def expired? = status == "expired"
|
|
81
|
+
|
|
82
|
+
# Whether the case has changed since this was proposed — a customer
|
|
83
|
+
# message, a human note, a takeover, anything. The turn is the whole
|
|
84
|
+
# answer: it moves for every one of them.
|
|
85
|
+
def stale?
|
|
86
|
+
pending? && proposed_turn.to_s != ticket.assistant_turn
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Whether the human rewrote it before sending.
|
|
90
|
+
def edited? = sent? && sent_body.present?
|
|
91
|
+
|
|
92
|
+
# What the requester got (or would get): the edit when there was one.
|
|
93
|
+
def final_body = sent_body.presence || body
|
|
94
|
+
|
|
95
|
+
# The declared confidence as a whole percentage, or nil.
|
|
96
|
+
def confidence_percent
|
|
97
|
+
return nil if confidence.nil?
|
|
98
|
+
|
|
99
|
+
(confidence.to_f * 100).round
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Who proposed it, as the stable key event payloads and metadata use.
|
|
103
|
+
def author_key = SupportDesk.actor_key(author)
|
|
104
|
+
|
|
105
|
+
# Whether this draft has attachments to carry to the message.
|
|
106
|
+
def files_attached? # :nodoc:
|
|
107
|
+
respond_to?(:files) && files.attached?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- The two decisions --------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
# Send it: the message is the HUMAN's, the proposal is provenance.
|
|
113
|
+
# `seen_turn` is the case's turn as the reviewer's page rendered it, and
|
|
114
|
+
# a mismatch is a refusal — see the class comment. `body:` replaces the
|
|
115
|
+
# text (an edit); nil sends it verbatim. Returns the Chats::Message.
|
|
116
|
+
def send!(by:, seen_turn:, body: nil, request: nil)
|
|
117
|
+
human = ticket.send(:resolve_actor, by)
|
|
118
|
+
if human.is_a?(Symbol) || SupportDesk.ai_actor?(human)
|
|
119
|
+
raise NotAllowed,
|
|
120
|
+
"an assistant can't approve her own draft — a person sends it, and it is signed by them"
|
|
121
|
+
end
|
|
122
|
+
ticket.send(:ensure_agent!, human)
|
|
123
|
+
raise ArgumentError, "seen_turn: is required — send the turn the page was rendered with" if seen_turn.blank?
|
|
124
|
+
if !body.nil? && body.to_s.strip.empty? && !files_attached?
|
|
125
|
+
raise ArgumentError, "an edited draft can't be blank — reject it instead"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
message = nil
|
|
129
|
+
ticket.with_lock(requires_new: true) do
|
|
130
|
+
reload
|
|
131
|
+
ticket.send(:reconcile_unregistered_messages!)
|
|
132
|
+
raise InvalidTransition, "draft #{id} is #{status}, not pending" unless pending?
|
|
133
|
+
unless seen_turn.to_s == ticket.assistant_turn
|
|
134
|
+
raise StaleTurn,
|
|
135
|
+
"the case changed since you read it (#{seen_turn} → #{ticket.assistant_turn}); read it again"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
final = body.nil? ? self.body : body
|
|
139
|
+
edited = final != self.body
|
|
140
|
+
# BEFORE anything moves: `stale?` reads `pending?`, and the update
|
|
141
|
+
# below is what stops it being true.
|
|
142
|
+
was_stale = stale?
|
|
143
|
+
|
|
144
|
+
message = ticket.reply!(
|
|
145
|
+
final, by: human, files: (files_attached? ? files.blobs : []), request: request,
|
|
146
|
+
metadata: { "support_desk" => { "drafted_by" => author_key, "draft_id" => id.to_s, "edited" => edited } }
|
|
147
|
+
)
|
|
148
|
+
update!(status: "sent", reviewed_by: human, reviewed_at: Time.current, sent_message: message,
|
|
149
|
+
sent_body: (final if edited))
|
|
150
|
+
ticket.send(:reset_draft_associations!)
|
|
151
|
+
ticket.send(:write_transition!, :draft_sent, actor: human, request: request) do
|
|
152
|
+
{ "draft" => id.to_s, "assistant" => author_key, "edited" => edited,
|
|
153
|
+
"was_stale" => was_stale, "message" => message.id.to_s }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
SupportDesk.emit_after_commit(:draft_sent, ticket, self, message, by: human)
|
|
158
|
+
message
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Throw it away, with a reason worth reading later: the rejection reasons
|
|
162
|
+
# are what tell a host whether an assistant is ready for a higher level.
|
|
163
|
+
def reject!(by:, reason: nil, request: nil)
|
|
164
|
+
human = ticket.send(:resolve_actor, by)
|
|
165
|
+
if human.is_a?(Symbol) || SupportDesk.ai_actor?(human)
|
|
166
|
+
raise NotAllowed, "an assistant can't review her own draft"
|
|
167
|
+
end
|
|
168
|
+
ticket.send(:ensure_agent!, human)
|
|
169
|
+
|
|
170
|
+
ticket.with_lock(requires_new: true) do
|
|
171
|
+
reload
|
|
172
|
+
raise InvalidTransition, "draft #{id} is #{status}, not pending" unless pending?
|
|
173
|
+
|
|
174
|
+
update!(status: "rejected", reviewed_by: human, reviewed_at: Time.current,
|
|
175
|
+
rejection_reason: reason.presence&.to_s)
|
|
176
|
+
ticket.send(:reset_draft_associations!)
|
|
177
|
+
ticket.send(:write_transition!, :draft_rejected, actor: human, request: request) do
|
|
178
|
+
{ "draft" => id.to_s, "assistant" => author_key, "reason" => reason.presence&.to_s }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
SupportDesk.emit_after_commit(:draft_rejected, ticket, self, by: human, reason: reason)
|
|
183
|
+
self
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A newer proposal, a human reply, a takeover or a pause replaced it.
|
|
187
|
+
# Idempotent, and writes no event: nobody decided anything.
|
|
188
|
+
def supersede! # :nodoc:
|
|
189
|
+
return self unless pending?
|
|
190
|
+
|
|
191
|
+
update!(status: "superseded")
|
|
192
|
+
self
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The case closed under it.
|
|
196
|
+
def expire! # :nodoc:
|
|
197
|
+
return self unless pending?
|
|
198
|
+
|
|
199
|
+
update!(status: "expired")
|
|
200
|
+
self
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The draft, in one line.
|
|
204
|
+
def inspect
|
|
205
|
+
"#<SupportDesk::Draft #{id} #{status} ticket=#{ticket_id} #{author_key}>"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# A model wrote these, so the shape is whatever came back. `to_h` is the
|
|
211
|
+
# generous reading (a list of pairs, a hash-like object) and it RAISES on
|
|
212
|
+
# anything else — `["title", "x"]` is a TypeError, not a hash — so the
|
|
213
|
+
# coercion answers nil and the validation below says what it wanted,
|
|
214
|
+
# instead of taking the save down with an exception nobody can act on.
|
|
215
|
+
def coerce_source(entry)
|
|
216
|
+
return entry.stringify_keys if entry.is_a?(Hash)
|
|
217
|
+
return nil if entry.is_a?(String) || !entry.respond_to?(:to_h)
|
|
218
|
+
|
|
219
|
+
entry.to_h.stringify_keys
|
|
220
|
+
rescue TypeError, ArgumentError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def body_or_files_present
|
|
225
|
+
return if body.present? || files_attached?
|
|
226
|
+
|
|
227
|
+
errors.add(:body, "can't be blank without an attachment")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A model wrote these. They render as links in a console, so they are
|
|
231
|
+
# checked like anything a stranger typed: a list, of pairs, with http(s)
|
|
232
|
+
# URLs and nothing longer than a page can show.
|
|
233
|
+
def sources_well_formed
|
|
234
|
+
value = sources
|
|
235
|
+
return if value.blank?
|
|
236
|
+
|
|
237
|
+
unless value.is_a?(Array)
|
|
238
|
+
errors.add(:sources, "must be a list of { title:, url: } entries")
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
if value.size > MAX_SOURCES
|
|
242
|
+
errors.add(:sources, "can't have more than #{MAX_SOURCES} entries (got #{value.size})")
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
value.each do |original|
|
|
247
|
+
entry = coerce_source(original)
|
|
248
|
+
unless entry.is_a?(Hash)
|
|
249
|
+
errors.add(:sources, "entries must be { title:, url: } hashes, got #{original.class}")
|
|
250
|
+
next
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
title = entry["title"]
|
|
254
|
+
url = entry["url"]
|
|
255
|
+
errors.add(:sources, "every entry needs a title") if title.blank?
|
|
256
|
+
errors.add(:sources, "a title can't be longer than #{MAX_SOURCE_TITLE} characters") if
|
|
257
|
+
title.to_s.length > MAX_SOURCE_TITLE
|
|
258
|
+
next if url.blank?
|
|
259
|
+
|
|
260
|
+
errors.add(:sources, "a url can't be longer than #{MAX_SOURCE_URL} characters") if
|
|
261
|
+
url.to_s.length > MAX_SOURCE_URL
|
|
262
|
+
errors.add(:sources, "url #{url.inspect} must be http(s)") unless url.to_s.match?(%r{\Ahttps?://}i)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -14,6 +14,16 @@ module SupportDesk
|
|
|
14
14
|
KINDS = %w[
|
|
15
15
|
opened assigned handed_off released drop_in closed reopened topic_changed subject_attached note
|
|
16
16
|
snoozed woken escalated channel_added email_bounced email_unverified rated tagged
|
|
17
|
+
human_requested assistant_paused assistant_resumed draft_sent draft_rejected assistant_withheld
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# The five kinds that describe how the desk works rather than what
|
|
21
|
+
# happened to the case. They join `note` and `drop_in` outside
|
|
22
|
+
# #requester_visible: a customer's export says a person was asked for
|
|
23
|
+
# (`escalated`, `human_requested`), never that a machine's proposal was
|
|
24
|
+
# discarded.
|
|
25
|
+
INTERNAL_KINDS = %w[
|
|
26
|
+
note drop_in assistant_paused assistant_resumed draft_sent draft_rejected assistant_withheld
|
|
17
27
|
].freeze
|
|
18
28
|
|
|
19
29
|
belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :events
|
|
@@ -28,7 +38,7 @@ module SupportDesk
|
|
|
28
38
|
scope :notes, -> { where(kind: "note") }
|
|
29
39
|
# Everything a requester may see in an export: their own case's story,
|
|
30
40
|
# never the desk's internal reasoning.
|
|
31
|
-
scope :requester_visible, -> { where.not(kind:
|
|
41
|
+
scope :requester_visible, -> { where.not(kind: INTERNAL_KINDS) }
|
|
32
42
|
|
|
33
43
|
# Write one event. `actor` may be a record or a Symbol (`:system`,
|
|
34
44
|
# `:routing`) — symbols are kept in the payload, since there is no row
|
|
@@ -64,6 +74,10 @@ module SupportDesk
|
|
|
64
74
|
# The text of an internal note, for kind "note".
|
|
65
75
|
def note = payload["note"]
|
|
66
76
|
|
|
77
|
+
# The one-paragraph "here is where I got to" an assistant leaves when she
|
|
78
|
+
# hands a case over, for kinds "escalated" and "human_requested".
|
|
79
|
+
def summary = payload["summary"]
|
|
80
|
+
|
|
67
81
|
# The event, in one line.
|
|
68
82
|
def inspect
|
|
69
83
|
"#<SupportDesk::Event #{kind} ticket=#{ticket_id} #{created_at&.iso8601}>"
|