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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Support
4
+ # <%= assistant_name %>'s turn.
5
+ #
6
+ # The gem emits `:assistant_turn` when there is something to answer; this
7
+ # job is the only thing that runs between that event and a word reaching a
8
+ # customer.
9
+ #
10
+ # Four rules, and they are the whole contract (README → Assistants):
11
+ #
12
+ # 1. **Arguments are ids, never records and never a prompt.** A serialized
13
+ # ticket is a ticket as it was when the job was enqueued, and by the
14
+ # time a worker picks it up that is a guess.
15
+ # 2. **Check the turn before spending money.** `assistant_turn` moves on
16
+ # every message and every transition, so a mismatch means the case
17
+ # changed while this job waited — and the newer turn's job already
18
+ # exists. Returning here costs nothing; calling a model costs money to
19
+ # produce an answer that would be refused anyway.
20
+ # 3. **Hand the answer to exactly one verb**, all of them `by:` her and
21
+ # `turn:` this turn. Each consumes the turn and returns the successor
22
+ # (`Outcome#turn` / `ticket.assistant_turn`); a second action in the
23
+ # same run uses that one.
24
+ # 4. **`SupportDesk::StaleTurn` and `Locked` are normal outcomes**, not
25
+ # failures — hence `discard_on` below. `SupportDesk::AssistantNotAllowed`
26
+ # is NOT: the brief said `may_not`, so reaching it is a bug in here.
27
+ class <%= key.camelize %>TurnJob < ApplicationJob
28
+ queue_as :support
29
+
30
+ # A late job, a redelivered one and a case that moved on all land as
31
+ # StaleTurn, and all three mean the same thing: somebody else has this.
32
+ # Locked means there is nobody to write to any more. RecordNotFound
33
+ # means the case is gone. None of them is worth a retry.
34
+ #
35
+ # Provider timeouts ARE worth one — put `retry_on` for those here. Note
36
+ # that a retry AFTER a committed action raises StaleTurn and is
37
+ # discarded, which is exactly right: the work was done.
38
+ discard_on SupportDesk::StaleTurn, SupportDesk::Locked, ActiveRecord::RecordNotFound
39
+
40
+ def perform(ticket_id, assistant_key, turn)
41
+ ticket = SupportDesk::Ticket.find(ticket_id)
42
+ return unless ticket.assistant_turn == turn # the case moved on while we waited
43
+
44
+ <%= key %> = SupportDesk.assistant(assistant_key)
45
+ return unless ticket.assistant_policy(<%= key %>).may_observe?
46
+
47
+ answer = <%= service_class %>.answer(ticket.brief, ticket.transcript)
48
+
49
+ case answer.kind
50
+ when :answer
51
+ # `respond!` lets POLICY decide what this becomes: sent, proposed as
52
+ # a draft for a person to send, or withheld with a reason on the
53
+ # record. Never encode the level in here — it changes per case.
54
+ ticket.respond!(answer.text, by: <%= key %>, turn: turn, confidence: answer.confidence,
55
+ sources: answer.sources, metadata: answer.metadata)
56
+ when :hand_off
57
+ ticket.escalate!(by: <%= key %>, turn: turn, reason: answer.reason, summary: answer.summary)
58
+ when :note
59
+ # Staff only. Never reaches the conversation.
60
+ ticket.note!(answer.text, by: <%= key %>, turn: turn)
61
+ when :nothing
62
+ # She decided not to act. The turn is NOT consumed and the case
63
+ # stays in the humans' queue — which is why `SupportDesk.doctor`
64
+ # counts idle turns: a harness that always answers :nothing looks
65
+ # exactly like one that is down.
66
+ nil
67
+ else
68
+ raise ArgumentError, "unsupported answer kind #{answer.kind.inspect}"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The three things about a harness that are worth a test, and the only three
6
+ # that are the same in every app: the level decides what her answer BECOMES,
7
+ # raising the level changes that and nothing else, and a turn that is no
8
+ # longer the case's writes nothing at all.
9
+ #
10
+ # <%= service_class %>.answer is stubbed throughout — this is a test of the
11
+ # wiring, not of a model. Point `requester` and `agent` at your own fixtures
12
+ # and it runs.
13
+ class <%= key.camelize %>TurnJobTest < ActiveSupport::TestCase
14
+ include SupportDesk::TestHelpers
15
+
16
+ setup do
17
+ @<%= key %> = SupportDesk.assistant(:<%= key %>)
18
+ @ticket = open_support_ticket(for: requester, message: "No me han pagado")
19
+ end
20
+
21
+ test "at :draft her answer waits for a person" do
22
+ answering("Lo estamos revisando") do
23
+ <%= job_class %>.perform_now(@ticket.id, "<%= key %>", @ticket.assistant_turn)
24
+ end
25
+
26
+ draft = assert_pending_draft @ticket, body: "revisando"
27
+ assert_equal @<%= key %>, draft.author
28
+ # Nothing reached the customer: a proposal is not a message.
29
+ refute_assistant_spoke @ticket
30
+ end
31
+
32
+ test "at :reply she answers the customer herself" do
33
+ with_assistant_config(:<%= key %>, autonomy: :reply) do
34
+ answering("Lo estamos revisando") do
35
+ <%= job_class %>.perform_now(@ticket.id, "<%= key %>", @ticket.assistant_turn)
36
+ end
37
+ end
38
+
39
+ assert_awaiting_requester @ticket
40
+ refute_pending_draft @ticket
41
+ end
42
+
43
+ test "a stale turn writes nothing and costs nothing" do
44
+ stale = @ticket.assistant_turn
45
+ ask_again(@ticket, "¿hola?") # the customer wrote again: the turn moved
46
+
47
+ called = false
48
+ <%= service_class %>.stub(:answer, ->(*) { called = true; answer_for("…") }) do
49
+ <%= job_class %>.perform_now(@ticket.id, "<%= key %>", stale)
50
+ end
51
+
52
+ assert_not called, "the job called the model on a turn that was no longer the case's"
53
+ refute_pending_draft @ticket
54
+ refute_assistant_spoke @ticket
55
+ end
56
+
57
+ private
58
+
59
+ # The person asking. Point this at your own fixture or factory.
60
+ def requester = users(:one)
61
+
62
+ def answer_for(text)
63
+ <%= service_class %>::Answer.new(kind: :answer, text: text, confidence: 0.9)
64
+ end
65
+
66
+ def answering(text, &block)
67
+ <%= service_class %>.stub(:answer, ->(*) { answer_for(text) }, &block)
68
+ end
69
+ end
@@ -16,7 +16,7 @@ module <%= module_name %>
16
16
  # The routes come from the `:support_console` routing concern:
17
17
  #
18
18
  # namespace :<%= target %> do
19
- # resources :support_tickets, only: %i[index show], concerns: :support_console
19
+ # resources :support_tickets, only: %i[index show new], concerns: :support_console
20
20
  # end
21
21
  #
22
22
  # Both `index` and `show` are yours — the gem deliberately doesn't own the
@@ -19,6 +19,23 @@ SupportDesk.configure do |config|
19
19
  #
20
20
  # config.agents { User.where(admin: true) }
21
21
 
22
+ # Who may ask, and who may answer, record by record — the macros' `if:`:
23
+ #
24
+ # class User < ApplicationRecord
25
+ # has_support_tickets if: :kept? # a closed account can neither
26
+ # acts_as_support_agent if: :admin? # ask nor be written to
27
+ # end
28
+ #
29
+ # `has_support_tickets if:` is a WRITE rule, not a screen rule: the thread
30
+ # and its history stay readable when it turns false.
31
+
32
+ # How the console finds the person an agent types in "Write to someone":
33
+ # email, phone, handle — your call. Given the typed string, return a
34
+ # requester record or nil. Without it the console accepts only a GlobalID
35
+ # from one of your own pages.
36
+ #
37
+ # config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
38
+
22
39
  # ==========================================================================
23
40
  # CONTROLLER INTEGRATION
24
41
  # ==========================================================================
@@ -105,6 +122,19 @@ SupportDesk.configure do |config|
105
122
  #
106
123
  # config.announce_assignments = :first_only
107
124
  #
125
+ # The system line a thread opens with, posted inside the opening
126
+ # transaction and before the first message. A String with %{label},
127
+ # %{desk} and %{reply_within}, a Symbol naming an I18n key, a block given
128
+ # the ticket, or nil for no line at all.
129
+ #
130
+ # The second one is for a case the DESK opened
131
+ # (`lucia.open_support_conversation_with!(alice, "Vimos que…")`): it has a
132
+ # default, because a message from a desk somebody never wrote to has to
133
+ # explain itself.
134
+ #
135
+ # config.opening_line = "Has abierto una conversación sobre «%{label}». Te contestamos aquí."
136
+ # config.opening_line_from_support = "%{desk} ha abierto esta conversación contigo sobre «%{label}»."
137
+ #
108
138
  # What a requester writing into a closed ticket does:
109
139
  # :reopen_on_reply the case comes back (no wall, no dead end)
110
140
  # :locked the composer is replaced by a notice
@@ -145,6 +175,39 @@ SupportDesk.configure do |config|
145
175
  # config.mirror_replies_by_email = :when_away # :always | :when_away | :never
146
176
  # config.auto_close_after = nil # e.g. 7.days
147
177
 
178
+ # ==========================================================================
179
+ # AN ASSISTANT (0.3)
180
+ # ==========================================================================
181
+ #
182
+ # An AI agent, with the guardrails in code. `autonomy` is the most she may
183
+ # ever produce; topics only cap it DOWN. `disclosure` is REQUIRED — whether
184
+ # a customer is told they are talking to a machine is your decision, and
185
+ # `:none` is how you say "nothing", on purpose.
186
+ #
187
+ # config.assistant :rose do |rose|
188
+ # rose.name = "Rose"
189
+ # rose.autonomy = :draft # :off :observe :draft :reply :resolve
190
+ # rose.disclosure = :signature_and_notice # :signature | :notice | :none
191
+ # rose.max_turns = 6
192
+ # rose.responds_within = 3.minutes
193
+ # rose.hand_off_when { |_ticket, message| message.body.to_s.match?(/\bpersona\b/i) }
194
+ # rose.cap { |ticket| :draft if ticket.requester.try(:vip?) }
195
+ # end
196
+ # config.default_assistant = :rose
197
+ #
198
+ # The desk-level binding, for installations with more than one desk. An
199
+ # explicit nil means "no assistant HERE", which is not the same as
200
+ # inheriting the default one.
201
+ #
202
+ # config.desk(:billing) { |desk| desk.assistant = nil }
203
+ #
204
+ # Then subscribe your harness to the one event it needs — see the README's
205
+ # assistants section for the whole contract.
206
+ #
207
+ # SupportDesk.on(:assistant_turn, key: "support.rose.turn") do |ticket, assistant, _message, turn:|
208
+ # Support::RoseTurnJob.set(wait: 20.seconds).perform_later(ticket.id, assistant.key, turn)
209
+ # end
210
+
148
211
  # ==========================================================================
149
212
  # MORE THAN ONE DESK
150
213
  # ==========================================================================
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module SupportDesk
7
+ module Generators
8
+ # `rails generate support_desk:upgrade` — copy the migrations a version
9
+ # bump needs into an EXISTING install. Nothing else: the initializer, the
10
+ # views, the console and the routes you already own stay untouched.
11
+ #
12
+ # Currently writes the 0.2.0 migration (who opened the case), which is the
13
+ # SAME template a fresh install runs — one file owns those columns, so a
14
+ # fresh install and an upgraded one end up with the same schema and the
15
+ # same rollback. Running this twice writes nothing the second time: the
16
+ # migration already sitting in db/migrate is identical, and Rails skips it.
17
+ class UpgradeGenerator < Rails::Generators::Base
18
+ include ActiveRecord::Generators::Migration
19
+
20
+ source_root File.expand_path("templates", __dir__)
21
+ desc "Add the migrations a support_desk version bump needs (0.2.0: opened_by, 0.3.0: assistants)"
22
+
23
+ # Rails' migration numbering, borrowed from ActiveRecord's generators.
24
+ def self.next_migration_number(dir)
25
+ ActiveRecord::Generators::Base.next_migration_number(dir)
26
+ end
27
+
28
+ def create_opened_by_migration
29
+ migration_template "add_opened_by_to_support_desk_tickets.rb.erb",
30
+ File.join(db_migrate_path, "add_opened_by_to_support_desk_tickets.rb")
31
+ end
32
+
33
+ def create_assistants_migration
34
+ migration_template "add_assistants_to_support_desk.rb.erb",
35
+ File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
36
+ end
37
+
38
+ def display_post_upgrade_message
39
+ say "\n🎫 support_desk upgrade migrations copied.", :green
40
+ say "\n 1. Run 'rails db:migrate'. It adds `opened_by` and points every existing"
41
+ say " case at its requester — 0.1 had no other way to open one."
42
+ say " 2. New in 0.2.0:"
43
+ say " lucia.open_support_conversation_with!(alice, \"Vimos que…\") # the desk writes first"
44
+ say " ticket.opened_by / opened_by_support? / opened_by_requester?"
45
+ say " config.opening_line / config.opening_line_from_support / config.find_requester"
46
+ say " has_support_tickets if: :kept? # who may be written to"
47
+ say " 3. Migrate BEFORE 0.2 serves traffic. Pause support writes; stop and drain"
48
+ say " ALL old web requests and workers. This is NOT a rolling deployment."
49
+ say " Keep traffic paused, run 'rake support_desk:backfill_opened_by' under 0.2,"
50
+ say " verify no NULL openers remain, then start only 0.2 and resume traffic."
51
+ say " 4. New in 0.3.0 — assistants. The migration is ADDITIVE and rolling-safe:"
52
+ say " add `config.assistant` only once every process is on 0.3.0. Then"
53
+ say " rails g support_desk:assistant Rose --disclosure signature"
54
+ say " writes the harness and prints the stanza, the subscription and the two"
55
+ say " scheduled tasks. It never edits your initializer."
56
+ say " 5. See the CHANGELOG for the full list.\n", :green
57
+ end
58
+
59
+ private
60
+
61
+ def migration_version
62
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # What the assistant may do on ONE case, right now, and why.
5
+ #
6
+ # policy = ticket.assistant_policy
7
+ # policy.level # => :draft
8
+ # policy.because # => "topic payments caps rose at draft"
9
+ # policy.may_reply? # => false
10
+ #
11
+ # == Ceilings and floors
12
+ #
13
+ # A CEILING lowers what the assistant may ever produce here: her configured
14
+ # autonomy, the topic's cap (the minimum over the topic and every ancestor),
15
+ # the host's `cap` block, the case's persisted cap (what a reopen writes),
16
+ # and a pause. The lowest ceiling wins, and `because` names the rule that
17
+ # decided it — so a refusal can always be traced to one line of
18
+ # configuration.
19
+ #
20
+ # A FLOOR lowers the level because of the case's state: she is inactive,
21
+ # the case is closed, a person was asked for, or a human holds it. Floors
22
+ # run after ceilings and only ever lower.
23
+ #
24
+ # == It is the authorization, not a hint
25
+ #
26
+ # Every assistant verb asks this object, under the ticket's row lock, from
27
+ # freshly read state — `Ticket#actions_for` is a projection of the same
28
+ # decision, never a second opinion. No query: the topic is an in-memory
29
+ # lookup, the assignee is the loaded association, everything else is a
30
+ # column on the row we already hold.
31
+ class AssistantPolicy
32
+ # What the assistant may PRODUCE, from least to most. Ordered, and the
33
+ # order is the meaning: everything compares by RANK, never by name.
34
+ LEVELS = %i[off observe draft reply resolve].freeze
35
+ RANK = LEVELS.each_with_index.to_h.freeze
36
+
37
+ # The verbs each level unlocks. Every level's array is the previous one
38
+ # plus its own, written out rather than computed: a reader has to be able
39
+ # to see what ":reply" means without running anything.
40
+ VERBS_BY_LEVEL = {
41
+ off: [],
42
+ observe: %i[note escalate release],
43
+ draft: %i[note escalate release draft],
44
+ reply: %i[note escalate release draft reply take],
45
+ resolve: %i[note escalate release draft reply take close]
46
+ }.freeze
47
+
48
+ # Every verb the top level knows — what `forbidden_verbs` subtracts from.
49
+ ALL_VERBS = VERBS_BY_LEVEL.fetch(:resolve)
50
+
51
+ attr_reader :ticket, :assistant, :level, :because, :ceilings, :floors
52
+
53
+ # The policy for +assistant+ on +ticket+.
54
+ #
55
+ # `hand_back: true` asks a different question — "could she hold this case
56
+ # once the human-side flags were cleared?" — and is used by one caller,
57
+ # `Ticket#ensure_assignable!`, so that a human handing a case back is not
58
+ # refused by the very flags that hand-back exists to lift. The hard
59
+ # ceilings (autonomy, topic, the `cap` block) and the hard floors
60
+ # (inactive, closed) still apply.
61
+ def self.for(ticket, assistant = ticket.assistant, hand_back: false)
62
+ return Null.new(ticket, reason: "no assistant on desk #{ticket.desk&.key}") if assistant.nil?
63
+ unless SupportDesk::Ticket.same_actor?(assistant, ticket.assistant)
64
+ return Null.new(ticket, reason: "#{assistant.try(:key) || assistant.class} is not desk " \
65
+ "#{ticket.desk&.key}'s assistant")
66
+ end
67
+
68
+ config = assistant.config
69
+ ceilings = {
70
+ assistant: config.autonomy,
71
+ topic: ticket.topic&.assistant_cap,
72
+ cap: evaluate_cap(config, ticket),
73
+ case: (ticket.assistant_cap&.to_sym unless hand_back),
74
+ pause: (:off if ticket.assistant_paused? && !hand_back)
75
+ }.compact
76
+
77
+ # Hash#min_by yields [key, value]; destructuring it the other way round
78
+ # is how a policy starts reporting "the level caps her at :topic".
79
+ rule, level = ceilings.min_by { |_rule, value| RANK.fetch(value) }
80
+ because = ceiling_sentence(rule, level, assistant, ticket)
81
+ floors = []
82
+
83
+ if !assistant.active?
84
+ level = :off
85
+ because = "#{assistant.key} is inactive"
86
+ floors << :inactive
87
+ end
88
+ if ticket.closed? && RANK.fetch(level) > RANK.fetch(:observe)
89
+ level = :observe
90
+ because = "the case is closed"
91
+ floors << :closed
92
+ end
93
+ if !hand_back && ticket.human_required? && RANK.fetch(level) > RANK.fetch(:observe)
94
+ level = :observe
95
+ because = "a person was requested (#{ticket.human_required_reason})"
96
+ floors << :human_required
97
+ end
98
+ if !hand_back && ticket.assigned? && !ticket.assigned_to?(assistant) &&
99
+ RANK.fetch(level) > RANK.fetch(:draft)
100
+ level = :draft
101
+ because = "#{holder_name(ticket)} holds the case"
102
+ floors << :held_by_human
103
+ end
104
+
105
+ new(ticket: ticket, assistant: assistant, level: level, because: because,
106
+ ceilings: ceilings, floors: floors)
107
+ end
108
+
109
+ # Run the host's `cap` block and check what it said. A block that returns
110
+ # something that isn't a level is a configuration mistake, and it is
111
+ # named as one here rather than turning into a NoMethodError inside a
112
+ # transaction. A block that RAISES is the host's exception to see: it
113
+ # propagates (the one caller that can't afford that — the turn emitter —
114
+ # reports it and emits nothing).
115
+ def self.evaluate_cap(config, ticket) # :nodoc:
116
+ block = config.cap
117
+ return nil if block.nil?
118
+
119
+ value = block.call(ticket)
120
+ return nil if value.nil?
121
+
122
+ level = value.respond_to?(:to_sym) ? value.to_sym : value
123
+ unless LEVELS.include?(level)
124
+ raise ConfigurationError,
125
+ "assistant #{config.key}'s cap block must return one of #{LEVELS.map(&:inspect).join(", ")} " \
126
+ "or nil, got #{value.inspect}"
127
+ end
128
+
129
+ level
130
+ end
131
+
132
+ def self.ceiling_sentence(rule, level, assistant, ticket) # :nodoc:
133
+ case rule
134
+ when :assistant then "#{assistant.key}'s autonomy is #{level}"
135
+ when :topic then "topic #{ticket.topic&.path} caps #{assistant.key} at #{level}"
136
+ when :cap then "this desk's cap block caps #{assistant.key} at #{level}"
137
+ when :case then "this case caps #{assistant.key} at #{level}"
138
+ when :pause then "#{assistant.key} is paused on this case"
139
+ else "#{assistant.key} may work at #{level}"
140
+ end
141
+ end
142
+
143
+ def self.holder_name(ticket) # :nodoc:
144
+ ticket.assignee.try(:support_agent_name) || "somebody else"
145
+ end
146
+
147
+ private_class_method :ceiling_sentence, :holder_name
148
+
149
+ # Built by `.for`. The attributes are the whole object: a policy is a
150
+ # value, computed once under the lock and then only read.
151
+ def initialize(ticket:, assistant:, level:, because:, ceilings: {}, floors: [])
152
+ @ticket = ticket
153
+ @assistant = assistant
154
+ @level = level
155
+ @because = because
156
+ @ceilings = ceilings
157
+ @floors = floors
158
+ end
159
+
160
+ # Whether the effective level is at least +other+.
161
+ def at_least?(other)
162
+ RANK.fetch(level) >= RANK.fetch(other.to_sym)
163
+ end
164
+
165
+ # Whether this verb is unlocked at the effective level.
166
+ def may?(verb) = allowed_verbs.include?(verb.to_sym)
167
+
168
+ def may_observe? = at_least?(:observe)
169
+ def may_draft? = at_least?(:draft)
170
+ def may_reply? = at_least?(:reply)
171
+ # Holding a case means owing the next word, so it takes the level that
172
+ # may say one.
173
+ def may_hold? = may_reply?
174
+ def may_close? = at_least?(:resolve)
175
+
176
+ def allowed_verbs = VERBS_BY_LEVEL.fetch(level)
177
+ def forbidden_verbs = ALL_VERBS - allowed_verbs
178
+
179
+ # Whether this is the null policy — no assistant to ask about.
180
+ def null? = false
181
+
182
+ # The whole decision, as data: what goes into message metadata, a draft
183
+ # row and an event payload, so forensics never has to re-run the policy
184
+ # against a case that has moved on.
185
+ def to_h
186
+ {
187
+ assistant: assistant&.key,
188
+ level: level,
189
+ because: because,
190
+ ceilings: { assistant: ceilings[:assistant], topic: ceilings[:topic], cap: ceilings[:cap],
191
+ case: ceilings[:case], pause: ceilings[:pause] },
192
+ floors: floors
193
+ }
194
+ end
195
+
196
+ # "draft — topic payments caps rose at draft"
197
+ def to_s = "#{level} — #{because}"
198
+
199
+ def inspect
200
+ "#<SupportDesk::AssistantPolicy #{assistant&.key || "none"} #{level} #{because.inspect}>"
201
+ end
202
+
203
+ # No assistant on this desk, or an assistant that is somebody else's.
204
+ # Every predicate says no, and `because` says which of the two it was.
205
+ class Null < AssistantPolicy
206
+ def initialize(ticket, reason:)
207
+ super(ticket: ticket, assistant: nil, level: :off, because: reason, ceilings: {}, floors: [])
208
+ end
209
+
210
+ def null? = true
211
+ end
212
+ end
213
+ end