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,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module SupportDesk
|
|
6
|
+
module Generators
|
|
7
|
+
# `rails generate support_desk:assistant Rose --disclosure signature` —
|
|
8
|
+
# the harness, as files in YOUR app.
|
|
9
|
+
#
|
|
10
|
+
# The gem never calls a model. It emits `:assistant_turn` and it accepts
|
|
11
|
+
# `respond!` / `draft!` / `escalate!` / `note!`; everything between those
|
|
12
|
+
# two points — which provider, which prompt, which retries, what it
|
|
13
|
+
# costs — is yours. This generator writes that middle: a job wired to
|
|
14
|
+
# the event and a service with one method to fill in.
|
|
15
|
+
#
|
|
16
|
+
# Three things it deliberately does NOT do:
|
|
17
|
+
#
|
|
18
|
+
# * It never touches your initializer. `config.assistant` is a policy
|
|
19
|
+
# decision, and a generator that appends one turns "let me look at
|
|
20
|
+
# this" into a live assistant. The stanza is PRINTED; you paste it.
|
|
21
|
+
# * It never overwrites. Run it again after an upgrade and it tells you
|
|
22
|
+
# which files it left alone.
|
|
23
|
+
# * It copies no locales. The copy an assistant speaks in is the gem's
|
|
24
|
+
# (`support_desk.system.*`) until you override those keys in your own
|
|
25
|
+
# locale file, where they belong.
|
|
26
|
+
#
|
|
27
|
+
# `--disclosure` is required and has no default. Whether a customer is
|
|
28
|
+
# told they are talking to a machine is not a thing a gem gets to decide
|
|
29
|
+
# for a host in a jurisdiction it knows nothing about, so the generator
|
|
30
|
+
# refuses to write anything until somebody has chosen.
|
|
31
|
+
class AssistantGenerator < Rails::Generators::Base
|
|
32
|
+
# The four modes, and what each one does to what the requester sees.
|
|
33
|
+
DISCLOSURE_MODES = {
|
|
34
|
+
"signature_and_notice" => "she signs every message AND the thread opens with a notice",
|
|
35
|
+
"signature" => "she signs every message; no notice",
|
|
36
|
+
"notice" => "the thread opens with a notice; her messages are unsigned, from the desk",
|
|
37
|
+
"none" => "nothing is said and nothing is signed — your legal process has to be the one that chose this"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
source_root File.expand_path("templates/assistant", __dir__)
|
|
41
|
+
|
|
42
|
+
desc "Generate the harness for a support assistant: a turn job and a service"
|
|
43
|
+
|
|
44
|
+
argument :name, type: :string, banner: "Rose",
|
|
45
|
+
desc: "What she is called. Becomes her key (rose), her service and her job"
|
|
46
|
+
|
|
47
|
+
class_option :disclosure, type: :string, required: false, banner: "MODE",
|
|
48
|
+
desc: "REQUIRED: #{DISCLOSURE_MODES.keys.join(" | ")}"
|
|
49
|
+
class_option :desk, type: :string, default: "default",
|
|
50
|
+
desc: "The desk she works"
|
|
51
|
+
class_option :autonomy, type: :string, default: "draft",
|
|
52
|
+
desc: AssistantPolicy::LEVELS.join(" | ")
|
|
53
|
+
|
|
54
|
+
# Everything is checked BEFORE the first file is written. A generator
|
|
55
|
+
# that writes two of its three files and then refuses the third leaves
|
|
56
|
+
# an app that neither boots nor regenerates.
|
|
57
|
+
def validate_options!
|
|
58
|
+
@refused = true
|
|
59
|
+
return say_disclosure_modes if options[:disclosure].blank?
|
|
60
|
+
|
|
61
|
+
unless DISCLOSURE_MODES.key?(disclosure)
|
|
62
|
+
say_status :error, "unknown disclosure #{options[:disclosure].inspect}", :red
|
|
63
|
+
return say_disclosure_modes
|
|
64
|
+
end
|
|
65
|
+
unless AssistantPolicy::LEVELS.map(&:to_s).include?(autonomy)
|
|
66
|
+
say_status :error, "unknown autonomy #{options[:autonomy].inspect} — one of " \
|
|
67
|
+
"#{AssistantPolicy::LEVELS.join(", ")}", :red
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@refused = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create_turn_job
|
|
75
|
+
return if refused?
|
|
76
|
+
|
|
77
|
+
template "turn_job.rb.erb", "app/jobs/support/#{key}_turn_job.rb", skip: true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def create_service
|
|
81
|
+
return if refused?
|
|
82
|
+
|
|
83
|
+
template "service.rb.erb", "app/services/support/#{key}.rb", skip: true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def create_job_test
|
|
87
|
+
return if refused?
|
|
88
|
+
|
|
89
|
+
template "turn_job_test.rb.erb", "test/jobs/support/#{key}_turn_job_test.rb", skip: true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The four things a host still has to do by hand, in the order they
|
|
93
|
+
# have to happen. Printed, never written: each one is a decision.
|
|
94
|
+
def display_post_install_message
|
|
95
|
+
return if refused?
|
|
96
|
+
|
|
97
|
+
say "\n🤖 #{assistant_name}'s harness is in app/jobs/support/ and app/services/support/.", :green
|
|
98
|
+
say "\n Existing files were left alone — nothing here is ever overwritten."
|
|
99
|
+
|
|
100
|
+
say "\n 1. Paste this into config/initializers/support_desk.rb, inside the configure block:"
|
|
101
|
+
say "\n#{initializer_stanza}"
|
|
102
|
+
|
|
103
|
+
say " 2. Subscribe the job to the one event the gem emits:"
|
|
104
|
+
say "\n#{subscription_stanza}"
|
|
105
|
+
|
|
106
|
+
say " 3. Schedule the two maintenance tasks. They are what makes a dead harness"
|
|
107
|
+
say " a delay instead of a customer nobody answers:"
|
|
108
|
+
say "\n#{scheduler_stanza}"
|
|
109
|
+
|
|
110
|
+
say " 4. Write #{service_class}.answer — it raises NotImplementedError until you do."
|
|
111
|
+
say " The README's Assistants section is the whole contract."
|
|
112
|
+
|
|
113
|
+
say "\n Then: SupportDesk.doctor.print, and start at --autonomy draft. Every word"
|
|
114
|
+
say " goes through a person until the numbers say otherwise.\n", :green
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def refused? = @refused
|
|
120
|
+
|
|
121
|
+
# "Rose" → "rose"; "Customer Care" → "customer_care".
|
|
122
|
+
def key = name.to_s.strip.underscore.parameterize(separator: "_")
|
|
123
|
+
|
|
124
|
+
# What she is called, as typed — the `name` setting, not the key.
|
|
125
|
+
def assistant_name = name.to_s.strip
|
|
126
|
+
|
|
127
|
+
def service_class = "Support::#{key.camelize}"
|
|
128
|
+
def job_class = "Support::#{key.camelize}TurnJob"
|
|
129
|
+
def disclosure = options[:disclosure].to_s.strip
|
|
130
|
+
def autonomy = options[:autonomy].to_s.strip
|
|
131
|
+
def desk_key = options[:desk].to_s.strip.presence || "default"
|
|
132
|
+
def default_desk? = desk_key == "default"
|
|
133
|
+
|
|
134
|
+
def say_disclosure_modes
|
|
135
|
+
say "\nsupport_desk:assistant needs --disclosure. Nothing was written.", :yellow
|
|
136
|
+
say "\n Whether a customer is told they are talking to a machine is a legal and"
|
|
137
|
+
say " product decision, and it is yours. The four modes:\n\n"
|
|
138
|
+
DISCLOSURE_MODES.each { |mode, meaning| say " --disclosure #{mode.ljust(21)} #{meaning}" }
|
|
139
|
+
say "\n rails g support_desk:assistant #{name} --disclosure signature\n\n"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def initializer_stanza
|
|
143
|
+
lines = [
|
|
144
|
+
" config.assistant :#{key} do |#{key}|",
|
|
145
|
+
" #{key}.name = #{assistant_name.inspect}",
|
|
146
|
+
" #{key}.autonomy = :#{autonomy}",
|
|
147
|
+
" #{key}.disclosure = :#{disclosure}",
|
|
148
|
+
" #{key}.max_turns = 6",
|
|
149
|
+
" #{key}.responds_within = 3.minutes",
|
|
150
|
+
" # #{key}.hand_off_when { |_ticket, message| " \
|
|
151
|
+
"message.body.to_s.match?(/\\b(persona|humano|agente)\\b/i) }",
|
|
152
|
+
" # #{key}.cap { |ticket| :draft if ticket.requester.try(:vip?) }",
|
|
153
|
+
" end"
|
|
154
|
+
]
|
|
155
|
+
lines << if default_desk?
|
|
156
|
+
" config.default_assistant = :#{key}"
|
|
157
|
+
else
|
|
158
|
+
" config.desk(:#{desk_key}) { |desk| desk.assistant = :#{key} }"
|
|
159
|
+
end
|
|
160
|
+
"#{lines.join("\n")}\n\n"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def subscription_stanza
|
|
164
|
+
<<~RUBY
|
|
165
|
+
|
|
166
|
+
SupportDesk.on(:assistant_turn, key: "support.#{key}.turn") do |ticket, assistant, _message, turn:|
|
|
167
|
+
#{job_class}.set(wait: 20.seconds).perform_later(ticket.id, assistant.key, turn)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
RUBY
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def scheduler_stanza
|
|
174
|
+
<<~YAML
|
|
175
|
+
|
|
176
|
+
# config/recurring.yml (solid_queue)
|
|
177
|
+
production:
|
|
178
|
+
support_desk_release_silent_assistants:
|
|
179
|
+
command: "SupportDesk.release_silent_assistants!"
|
|
180
|
+
schedule: every minute
|
|
181
|
+
support_desk_redispatch_assistant_turns:
|
|
182
|
+
command: "SupportDesk.redispatch_assistant_turns!"
|
|
183
|
+
schedule: every 5 minutes
|
|
184
|
+
|
|
185
|
+
# or cron
|
|
186
|
+
* * * * * cd /app && bin/rails support_desk:release_silent_assistants
|
|
187
|
+
*/5 * * * * cd /app && bin/rails support_desk:redispatch_assistant_turns
|
|
188
|
+
|
|
189
|
+
YAML
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -60,7 +60,7 @@ module SupportDesk
|
|
|
60
60
|
say " 1. One route line:"
|
|
61
61
|
say " # config/routes.rb"
|
|
62
62
|
say " namespace :#{target} do"
|
|
63
|
-
say " resources :support_tickets, only: %i[index show], concerns: :support_console"
|
|
63
|
+
say " resources :support_tickets, only: %i[index show new], concerns: :support_console"
|
|
64
64
|
say " end"
|
|
65
65
|
|
|
66
66
|
say " 2. A badge in your admin nav:"
|
|
@@ -27,6 +27,23 @@ module SupportDesk
|
|
|
27
27
|
File.join(db_migrate_path, "create_support_desk_tables.rb")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Who opened each case. A SEPARATE migration, copied from the same
|
|
31
|
+
# template `support_desk:upgrade` hands an existing install, so one
|
|
32
|
+
# file owns those two columns and their index wherever they came from
|
|
33
|
+
# — and a fresh install's `down` removes exactly what its `up` added.
|
|
34
|
+
def create_opened_by_migration
|
|
35
|
+
migration_template "add_opened_by_to_support_desk_tickets.rb.erb",
|
|
36
|
+
File.join(db_migrate_path, "add_opened_by_to_support_desk_tickets.rb")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The assistants (0.3). The SAME file `support_desk:upgrade` copies,
|
|
40
|
+
# for the same reason: one migration owns those tables and columns
|
|
41
|
+
# wherever they came from.
|
|
42
|
+
def create_assistants_migration
|
|
43
|
+
migration_template "add_assistants_to_support_desk.rb.erb",
|
|
44
|
+
File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
|
|
45
|
+
end
|
|
46
|
+
|
|
30
47
|
# The annotated initializer — every setting the gem has, with what it
|
|
31
48
|
# means and what it defaults to.
|
|
32
49
|
def create_initializer
|
|
@@ -72,6 +89,15 @@ module SupportDesk
|
|
|
72
89
|
say " # app/mailboxes/application_mailbox.rb"
|
|
73
90
|
say " # routing(/^support@/i => :support_desk)"
|
|
74
91
|
|
|
92
|
+
say "\n Already installed and bumping the version? 'rails g support_desk:upgrade' copies"
|
|
93
|
+
say " only the migrations the new version needs (0.2.0: who opened the case), and"
|
|
94
|
+
say " nothing you own. Follow the CHANGELOG's drained cutover: migrate, pause"
|
|
95
|
+
say " support traffic, drain old web/workers, backfill, then serve only 0.2."
|
|
96
|
+
|
|
97
|
+
say "\n Assistants (0.3): when a machine should answer first, 'rails g support_desk:assistant"
|
|
98
|
+
say " Rose --disclosure signature' writes the harness and prints the stanza. Read the"
|
|
99
|
+
say " README's Assistants section before you flip it on — disclosure is a required choice."
|
|
100
|
+
|
|
75
101
|
say "\nCheck your work any time with SupportDesk.doctor.print"
|
|
76
102
|
say "You now have support tickets that are real conversations. 🚀\n", :green
|
|
77
103
|
end
|
|
@@ -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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# support_desk 0.2.0 — WHO OPENED THE CASE.
|
|
4
|
+
#
|
|
5
|
+
# `opened_by` is a record, exactly like `closed_by`: the requester when they
|
|
6
|
+
# asked, the agent when the desk wrote first. It is nullable for one reason
|
|
7
|
+
# only — rows written by 0.1.x have no provenance to state — and this
|
|
8
|
+
# migration backfills every one of them to its requester, because 0.1 had no
|
|
9
|
+
# way to open a case as anybody else.
|
|
10
|
+
#
|
|
11
|
+
# The new column's type is read from the tickets table's OWN `requester_id`
|
|
12
|
+
# rather than from today's generator setting: both columns point at the same
|
|
13
|
+
# host records, so an app that changed `primary_key_type` after installing
|
|
14
|
+
# would otherwise be handed a bigint column for uuid ids.
|
|
15
|
+
class AddOpenedByToSupportDeskTickets < ActiveRecord::Migration<%= migration_version %>
|
|
16
|
+
INDEX_NAME = "index_support_desk_tickets_on_opened_by"
|
|
17
|
+
|
|
18
|
+
def up
|
|
19
|
+
ensure_columns_are_ours!
|
|
20
|
+
|
|
21
|
+
add_reference :support_desk_tickets, :opened_by, polymorphic: true, null: true,
|
|
22
|
+
type: opened_by_id_type, index: false
|
|
23
|
+
add_index :support_desk_tickets, [ :opened_by_type, :opened_by_id ], name: INDEX_NAME
|
|
24
|
+
|
|
25
|
+
# SQL, and deliberately not the model: a backfill that loaded today's
|
|
26
|
+
# Ticket would run this release's validations, callbacks and events over
|
|
27
|
+
# last release's rows, in a migration that must only move two columns.
|
|
28
|
+
execute(<<~SQL.squish)
|
|
29
|
+
UPDATE support_desk_tickets
|
|
30
|
+
SET opened_by_type = requester_type,
|
|
31
|
+
opened_by_id = requester_id
|
|
32
|
+
WHERE opened_by_id IS NULL
|
|
33
|
+
SQL
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Exactly what `up` added, and nothing else. Note that this DESTROYS
|
|
37
|
+
# provenance: every case the desk opened stops being distinguishable from
|
|
38
|
+
# one the requester opened. It is here for a failed deploy of the schema
|
|
39
|
+
# alone, never as the way back from a code rollback.
|
|
40
|
+
def down
|
|
41
|
+
remove_index :support_desk_tickets, name: INDEX_NAME if
|
|
42
|
+
index_exists?(:support_desk_tickets, [ :opened_by_type, :opened_by_id ], name: INDEX_NAME)
|
|
43
|
+
|
|
44
|
+
remove_column :support_desk_tickets, :opened_by_type if
|
|
45
|
+
column_exists?(:support_desk_tickets, :opened_by_type)
|
|
46
|
+
remove_column :support_desk_tickets, :opened_by_id if
|
|
47
|
+
column_exists?(:support_desk_tickets, :opened_by_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# A column we did not add is a schema we cannot reason about: its type may
|
|
53
|
+
# not match, it may hold something else's data, and `down` would drop it.
|
|
54
|
+
# Say so with the way out rather than skipping the backfill and the index
|
|
55
|
+
# in silence.
|
|
56
|
+
def ensure_columns_are_ours!
|
|
57
|
+
existing = %i[opened_by_type opened_by_id].select { |name| column_exists?(:support_desk_tickets, name) }
|
|
58
|
+
return if existing.empty?
|
|
59
|
+
|
|
60
|
+
raise ActiveRecord::MigrationError,
|
|
61
|
+
"support_desk_tickets already has #{existing.join(" and ")}. This migration owns those two columns " \
|
|
62
|
+
"and #{INDEX_NAME}, so it won't overwrite them. Verify both column types, the index and existing " \
|
|
63
|
+
"provenance against this migration before reconciling its version in schema_migrations. " \
|
|
64
|
+
"db:migrate:up runs this method again; it does not mark it as already run. Preserve existing data."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The storage this table already uses for a host record's id — uuid,
|
|
68
|
+
# bigint, integer. `opened_by_id` points at the same records as
|
|
69
|
+
# `requester_id`, so it has to be stored the same way.
|
|
70
|
+
def opened_by_id_type
|
|
71
|
+
column = connection.columns(:support_desk_tickets).find { |candidate| candidate.name == "requester_id" }
|
|
72
|
+
unless column
|
|
73
|
+
raise ActiveRecord::MigrationError,
|
|
74
|
+
"support_desk_tickets has no requester_id column — run support_desk's install migration first."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
column.sql_type
|
|
78
|
+
end
|
|
79
|
+
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
|