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,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
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # Everything a machine needs to answer one case, as plain data:
5
+ #
6
+ # brief = ticket.brief
7
+ # brief.to_h # a Hash you can hand to JSON.generate
8
+ # brief.to_text # the same facts as sectioned plain text
9
+ # brief.policy # what she may do here, and why
10
+ #
11
+ # It is the ONE thing a harness reads. Not chats' tables, not the console's
12
+ # partials, not the ticket's columns: a brief is versioned
13
+ # (`schema_version`), complete, and the same shape whatever the host looks
14
+ # like — so a prompt written against it keeps working when the gem grows a
15
+ # column.
16
+ #
17
+ # == Facts, never instructions
18
+ #
19
+ # There is not one imperative sentence in here, and there never will be.
20
+ # What the assistant should DO with a case is the host's prompt, the
21
+ # host's model and the host's problem; what the case IS is ours. A gem
22
+ # that shipped "be polite and concise" inside a brief would be writing
23
+ # somebody else's product copy in a place they cannot edit.
24
+ #
25
+ # The one thing the brief does state about behaviour is `may` / `may_not`,
26
+ # and that is not advice either — it is the authorization, straight from
27
+ # `AssistantPolicy`, so a harness never has to re-derive the rules it is
28
+ # working under (§4.4).
29
+ #
30
+ # == This leaves your building
31
+ #
32
+ # Assume every field here reaches a third party the moment a harness calls
33
+ # a model with it. Two fields are entirely yours to fill and therefore
34
+ # yours to audit:
35
+ #
36
+ # * `case[:subject][:context]` — `Supportable#support_context`
37
+ # * `requester[:context]` — `Requester#support_context`
38
+ #
39
+ # And `include_internal: true` adds the desk's private reasoning — notes
40
+ # agents left for each other, proposals a human rejected and why. It is
41
+ # off by default and it is opt-in per call for that reason.
42
+ class Brief
43
+ # The shape's version. Bumped when a key changes meaning or leaves;
44
+ # a new key is not a bump, because a reader that ignores it is right.
45
+ SCHEMA_VERSION = 1
46
+
47
+ attr_reader :ticket, :transcript
48
+
49
+ # The brief for +ticket+. `transcript_limit:` keeps the last n turns,
50
+ # which is what a context window can actually hold.
51
+ def initialize(ticket, include_internal: false, transcript_limit: 50)
52
+ @ticket = ticket
53
+ @include_internal = include_internal
54
+ @transcript = ticket.transcript(limit: transcript_limit)
55
+ end
56
+
57
+ # Whether the desk's private reasoning is in here.
58
+ def include_internal? = !!@include_internal
59
+
60
+ # What the assistant may do on this case, and why — the same object
61
+ # every verb asks under the row lock.
62
+ def policy = ticket.assistant_policy
63
+
64
+ # The whole brief, as data.
65
+ def to_h
66
+ {
67
+ schema_version: SCHEMA_VERSION,
68
+ desk: desk_facts,
69
+ assistant: assistant_facts,
70
+ case: case_facts,
71
+ requester: requester_facts,
72
+ internal: (internal_facts if include_internal?),
73
+ transcript: transcript.to_h
74
+ }.compact
75
+ end
76
+
77
+ # The same facts, as sectioned plain text — for a prompt that is cheaper
78
+ # to read than JSON, and for a log line a person has to skim at 3 a.m.
79
+ def to_text
80
+ sections = [
81
+ section("DESK", desk_lines),
82
+ section("ASSISTANT", assistant_lines),
83
+ section("CASE", case_lines),
84
+ section("REQUESTER", requester_lines),
85
+ (section("INTERNAL", internal_lines) if include_internal?),
86
+ section("TRANSCRIPT", [ transcript.to_text ])
87
+ ]
88
+ sections.compact.join("\n\n")
89
+ end
90
+
91
+ def inspect
92
+ "#<SupportDesk::Brief #{ticket.reference} #{transcript.size} turn(s)" \
93
+ "#{" +internal" if include_internal?}>"
94
+ end
95
+
96
+ private
97
+
98
+ def desk
99
+ ticket.desk
100
+ end
101
+
102
+ def assistant
103
+ ticket.assistant
104
+ end
105
+
106
+ def desk_facts
107
+ reply_within = ticket.desk_config.reply_within
108
+
109
+ {
110
+ key: desk&.key,
111
+ name: desk&.name,
112
+ reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within)
113
+ }
114
+ end
115
+
116
+ # nil, not an empty Hash, when no assistant works this desk: "there is
117
+ # no assistant here" and "there is one who may do nothing" are different
118
+ # facts, and a harness that confuses them answers from a case it has no
119
+ # seat on.
120
+ def assistant_facts
121
+ return nil if assistant.nil?
122
+
123
+ decision = policy
124
+ {
125
+ key: assistant.key,
126
+ # Her own name, not the disclosed one: what the requester is shown
127
+ # is `disclosure`, one line down, and a model told it is called
128
+ # "Rose · asistente virtual" starts signing itself that way.
129
+ name: assistant.name,
130
+ disclosure: assistant.disclosure,
131
+ level: decision.level,
132
+ because: decision.because,
133
+ may: decision.allowed_verbs,
134
+ may_not: decision.forbidden_verbs,
135
+ turns: { used: ticket.assistant_turns_count.to_i, max: assistant.max_turns }
136
+ }
137
+ end
138
+
139
+ def case_facts
140
+ {
141
+ reference: ticket.reference,
142
+ status: ticket.status,
143
+ awaiting: ticket.awaiting,
144
+ opened_at: ticket.opened_at,
145
+ opened_via: ticket.opened_via,
146
+ opened_by: ticket.opened_by_requester? ? "requester" : "support",
147
+ reopened: ticket.reopened?,
148
+ human_required: human_required_facts,
149
+ paused: ticket.assistant_paused?,
150
+ cap: ticket.assistant_cap,
151
+ topic: { path: ticket.topic&.path, label: ticket.topic&.full_label },
152
+ subject: subject_facts
153
+ }
154
+ end
155
+
156
+ def human_required_facts
157
+ return nil unless ticket.human_required?
158
+
159
+ { at: ticket.human_required_at, reason: ticket.human_required_reason }
160
+ end
161
+
162
+ def subject_facts
163
+ subject = ticket.subject
164
+ return nil if subject.nil?
165
+
166
+ {
167
+ type: subject.class.name,
168
+ label: subject.try(:support_label),
169
+ status: subject.try(:support_status),
170
+ # The host's own key/value pairs. See the class comment: this is
171
+ # what leaves the building.
172
+ context: subject.try(:support_context) || {}
173
+ }
174
+ end
175
+
176
+ def requester_facts
177
+ requester = ticket.requester
178
+
179
+ {
180
+ name: Chats.display_name_for(requester),
181
+ since: requester.try(:created_at),
182
+ open_cases: Ticket.not_closed.where(requester: requester).count,
183
+ context: requester.try(:support_context) || {}
184
+ }
185
+ end
186
+
187
+ # The desk talking to itself. Only ever built when the caller asked.
188
+ def internal_facts
189
+ {
190
+ notes: ticket.notes.map do |event|
191
+ { at: event.created_at, by: SupportDesk.actor_key(event.actor_or_system), body: event.note }
192
+ end,
193
+ drafts: ticket.drafts.chronological.map do |draft|
194
+ {
195
+ at: draft.created_at,
196
+ status: draft.status,
197
+ body: draft.body,
198
+ confidence: draft.confidence,
199
+ edited: draft.edited?,
200
+ sent_body: draft.sent_body,
201
+ rejection_reason: draft.rejection_reason
202
+ }
203
+ end
204
+ }
205
+ end
206
+
207
+ # --- The text rendering -------------------------------------------------------
208
+
209
+ def section(title, lines)
210
+ body = Array(lines).compact.reject { |line| line.to_s.strip.empty? }
211
+ return nil if body.empty?
212
+
213
+ "== #{title}\n#{body.join("\n")}"
214
+ end
215
+
216
+ def pair(label, value)
217
+ return nil if value.nil? || (value.respond_to?(:empty?) && value.empty?)
218
+
219
+ "#{label}: #{value}"
220
+ end
221
+
222
+ def desk_lines
223
+ facts = desk_facts
224
+ [ pair("Desk", facts[:name]), pair("Answers within", facts[:reply_within]) ]
225
+ end
226
+
227
+ def assistant_lines
228
+ facts = assistant_facts
229
+ return [ "No assistant works this desk." ] if facts.nil?
230
+
231
+ [
232
+ pair("Assistant", facts[:name]),
233
+ pair("Disclosure", facts[:disclosure]),
234
+ pair("Level", "#{facts[:level]} (#{facts[:because]})"),
235
+ pair("May", facts[:may].join(", ")),
236
+ pair("May not", facts[:may_not].join(", ")),
237
+ pair("Turns", "#{facts[:turns][:used]}/#{facts[:turns][:max] || "unlimited"}")
238
+ ]
239
+ end
240
+
241
+ def case_lines
242
+ facts = case_facts
243
+ subject = facts[:subject]
244
+
245
+ [
246
+ pair("Reference", facts[:reference]),
247
+ pair("Status", "#{facts[:status]} · awaiting #{facts[:awaiting] || "nobody"}"),
248
+ pair("Opened", "#{facts[:opened_at]&.utc&.strftime("%Y-%m-%d %H:%M")} by #{facts[:opened_by]} " \
249
+ "via #{facts[:opened_via]}"),
250
+ (pair("Reopened", "yes") if facts[:reopened]),
251
+ (pair("A person was asked for", "#{facts[:human_required][:reason]} " \
252
+ "(#{facts[:human_required][:at]&.utc&.strftime("%Y-%m-%d %H:%M")})") if
253
+ facts[:human_required]),
254
+ (pair("Assistant paused", "yes") if facts[:paused]),
255
+ pair("Case cap", facts[:cap]),
256
+ pair("Topic", facts[:topic][:label] || facts[:topic][:path]),
257
+ (pair("About", "#{subject[:label]}#{" · #{subject[:status]}" if subject[:status]}") if subject),
258
+ *(subject ? subject[:context].map { |key, value| pair(" #{key}", value) } : [])
259
+ ]
260
+ end
261
+
262
+ def requester_lines
263
+ facts = requester_facts
264
+
265
+ [
266
+ pair("Name", facts[:name]),
267
+ pair("Customer since", facts[:since]&.utc&.strftime("%Y-%m-%d")),
268
+ pair("Open cases", facts[:open_cases]),
269
+ *facts[:context].map { |key, value| pair(" #{key}", value) }
270
+ ]
271
+ end
272
+
273
+ def internal_lines
274
+ facts = internal_facts
275
+
276
+ facts[:notes].map { |note| "Note (#{note[:at]&.utc&.strftime("%Y-%m-%d %H:%M")}): #{note[:body]}" } +
277
+ facts[:drafts].map do |draft|
278
+ reason = draft[:rejection_reason]
279
+ "Proposal (#{draft[:status]}#{" · #{reason}" if reason.present?}): #{draft[:body]}"
280
+ end
281
+ end
282
+ end
283
+ end