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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +1101 -22
  4. data/Rakefile +16 -0
  5. data/app/assets/stylesheets/support_desk.css +10 -0
  6. data/app/controllers/support_desk/tickets_controller.rb +28 -4
  7. data/app/helpers/support_desk/engine_helper.rb +23 -1
  8. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  9. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  10. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  11. data/app/views/support_desk/console/tickets/_context_card.html.erb +33 -0
  12. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  13. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  14. data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
  15. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
  16. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  17. data/app/views/support_desk/console/tickets/index.html.erb +14 -2
  18. data/app/views/support_desk/console/tickets/new.html.erb +25 -0
  19. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  20. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  21. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  22. data/config/console_routes.rb +1 -1
  23. data/config/locales/support_desk.console.en.yml +89 -0
  24. data/config/locales/support_desk.console.es.yml +91 -0
  25. data/config/locales/support_desk.en.yml +18 -0
  26. data/config/locales/support_desk.es.yml +29 -0
  27. data/config/routes.rb +7 -1
  28. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  29. data/lib/generators/support_desk/console_generator.rb +1 -1
  30. data/lib/generators/support_desk/install_generator.rb +26 -0
  31. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  32. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  33. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  34. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  35. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  36. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  37. data/lib/generators/support_desk/templates/initializer.rb +63 -0
  38. data/lib/generators/support_desk/upgrade_generator.rb +66 -0
  39. data/lib/support_desk/assistant_policy.rb +213 -0
  40. data/lib/support_desk/brief.rb +283 -0
  41. data/lib/support_desk/configuration.rb +699 -3
  42. data/lib/support_desk/console.rb +634 -30
  43. data/lib/support_desk/console_engine.rb +2 -0
  44. data/lib/support_desk/console_routes.rb +20 -8
  45. data/lib/support_desk/context_card.rb +33 -1
  46. data/lib/support_desk/doctor.rb +195 -2
  47. data/lib/support_desk/engine.rb +10 -0
  48. data/lib/support_desk/errors.rb +42 -0
  49. data/lib/support_desk/events.rb +12 -5
  50. data/lib/support_desk/macros.rb +41 -12
  51. data/lib/support_desk/models/assignment.rb +3 -1
  52. data/lib/support_desk/models/assistant.rb +153 -0
  53. data/lib/support_desk/models/concerns/agent.rb +39 -4
  54. data/lib/support_desk/models/concerns/requester.rb +45 -12
  55. data/lib/support_desk/models/desk.rb +26 -3
  56. data/lib/support_desk/models/draft.rb +266 -0
  57. data/lib/support_desk/models/event.rb +15 -1
  58. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  59. data/lib/support_desk/models/ticket.rb +781 -120
  60. data/lib/support_desk/outcome.rb +38 -0
  61. data/lib/support_desk/queue.rb +41 -5
  62. data/lib/support_desk/summary.rb +1 -1
  63. data/lib/support_desk/test_helpers.rb +160 -3
  64. data/lib/support_desk/timeline.rb +40 -11
  65. data/lib/support_desk/topic.rb +22 -0
  66. data/lib/support_desk/topic_tree.rb +7 -1
  67. data/lib/support_desk/transcript.rb +237 -0
  68. data/lib/support_desk/version.rb +1 -1
  69. data/lib/support_desk/wizard.rb +4 -12
  70. data/lib/support_desk.rb +153 -0
  71. data/lib/tasks/support_desk.rake +73 -0
  72. 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