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,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