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