support_desk 0.2.0 → 0.3.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -0
  3. data/README.md +1131 -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 +21 -3
  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/create_support_desk_message_registrations.rb.erb +42 -0
  30. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  31. data/lib/generators/support_desk/upgrade_generator.rb +25 -7
  32. data/lib/support_desk/assistant_policy.rb +213 -0
  33. data/lib/support_desk/brief.rb +283 -0
  34. data/lib/support_desk/configuration.rb +552 -2
  35. data/lib/support_desk/console.rb +290 -8
  36. data/lib/support_desk/context_card.rb +10 -1
  37. data/lib/support_desk/doctor.rb +171 -1
  38. data/lib/support_desk/engine.rb +16 -1
  39. data/lib/support_desk/errors.rb +37 -0
  40. data/lib/support_desk/events.rb +12 -5
  41. data/lib/support_desk/macros.rb +14 -1
  42. data/lib/support_desk/message_signatures.rb +20 -0
  43. data/lib/support_desk/models/assistant.rb +188 -0
  44. data/lib/support_desk/models/concerns/requester.rb +18 -0
  45. data/lib/support_desk/models/desk.rb +26 -3
  46. data/lib/support_desk/models/draft.rb +275 -0
  47. data/lib/support_desk/models/event.rb +15 -1
  48. data/lib/support_desk/models/message_registration.rb +12 -0
  49. data/lib/support_desk/models/ticket/assistance.rb +899 -0
  50. data/lib/support_desk/models/ticket.rb +406 -58
  51. data/lib/support_desk/outcome.rb +38 -0
  52. data/lib/support_desk/queue.rb +41 -5
  53. data/lib/support_desk/test_helpers.rb +152 -0
  54. data/lib/support_desk/timeline.rb +40 -11
  55. data/lib/support_desk/topic.rb +22 -0
  56. data/lib/support_desk/topic_tree.rb +7 -1
  57. data/lib/support_desk/transcript.rb +234 -0
  58. data/lib/support_desk/version.rb +1 -1
  59. data/lib/support_desk.rb +151 -0
  60. data/lib/tasks/support_desk.rake +57 -0
  61. metadata +33 -8
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.2"
5
5
  end
data/lib/support_desk.rb CHANGED
@@ -11,6 +11,7 @@ require "chats"
11
11
  require_relative "support_desk/version"
12
12
  require_relative "support_desk/errors"
13
13
  require_relative "support_desk/events"
14
+ require_relative "support_desk/assistant_policy"
14
15
  require_relative "support_desk/topic"
15
16
  require_relative "support_desk/topic_tree"
16
17
  require_relative "support_desk/configuration"
@@ -93,6 +94,7 @@ module SupportDesk
93
94
  @configured = false
94
95
  @subscribers = nil
95
96
  @desks = nil
97
+ @assistants = nil
96
98
  self
97
99
  end
98
100
 
@@ -103,6 +105,142 @@ module SupportDesk
103
105
  self
104
106
  end
105
107
 
108
+ # --- Assistants -----------------------------------------------------------
109
+
110
+ # The assistant record for +key+, memoised per process — or the desk's
111
+ # default when called with nothing. nil when this installation has no
112
+ # assistant at all, which is the answer every existing host gets.
113
+ #
114
+ # Found or created exactly like a desk, and never INSERT-first: an
115
+ # assistant is read on every transition and written once in her life.
116
+ def assistant(key = nil)
117
+ key = (key || config.default_assistant_key)
118
+ return nil if key.nil?
119
+
120
+ key = key.to_sym
121
+ unless config.assistant?(key)
122
+ raise ConfigurationError,
123
+ "no assistant #{key.inspect} is configured — declare it with " \
124
+ "`config.assistant #{key.inspect} do |assistant| … end`"
125
+ end
126
+
127
+ # Resolving her is also when what she is CALLED is written down, so a
128
+ # message she signed still says the same thing after somebody takes
129
+ # her out of the initializer (R8). It writes only when configuration
130
+ # and the row disagree.
131
+ (assistants[key] ||= Assistant.for(key)).snapshot_disclosure!
132
+ end
133
+
134
+ # Every assistant record this process has resolved, keyed by key.
135
+ def assistants # :nodoc:
136
+ @assistants ||= {}
137
+ end
138
+
139
+ # Forget the memoised Assistant records without touching configuration —
140
+ # for tests that truncate tables between examples.
141
+ def reset_assistants!
142
+ @assistants = nil
143
+ self
144
+ end
145
+
146
+ # Whether +record+ is a machine rather than a person. Duck-typed, because
147
+ # a host's own `acts_as_support_agent kind: :ai` model answers it too —
148
+ # and is then refused everywhere, which is the point (I2).
149
+ def ai_actor?(record)
150
+ record.respond_to?(:support_agent_kind) && record.support_agent_kind == :ai
151
+ end
152
+
153
+ # Give back every seat an assistant can no longer sit in — she was
154
+ # switched off, her configuration was removed, or her policy no longer
155
+ # lets her hold a case — and ask for a person on each of them.
156
+ #
157
+ # Invalid-assignee recovery, NOT silence detection: it reads the seats
158
+ # that exist rather than the assistants a running process happens to
159
+ # have configured, so a `deactivate!`, a flag flipped off and a topic
160
+ # cap tightened are all covered, with no `responds_within` and no
161
+ # overdue clock anywhere in it (R6). `release_silent_assistants!` runs
162
+ # it first; `rake support_desk:reclaim_assistant_seats` runs it alone.
163
+ #
164
+ # Returns how many seats it reclaimed.
165
+ def reclaim_assistant_seats!
166
+ return 0 unless Assistant.table_exists?
167
+
168
+ reclaimed = 0
169
+ Ticket.open.held_by_assistants.find_each do |ticket|
170
+ reclaimed += 1 if ticket.reclaim_assistant_seat!
171
+ rescue StandardError => e
172
+ report_error(e, context: { hook: :reclaim_assistant_seats, ticket: ticket.id })
173
+ end
174
+ logger&.info("[support_desk] reclaimed #{reclaimed} stranded assistant seat(s)") if reclaimed.positive?
175
+ reclaimed
176
+ end
177
+
178
+ # Release every assistant who has sat on a case longer than her
179
+ # `responds_within` without answering — and ask for a person on it.
180
+ #
181
+ # This is the net under a dead harness: a queue worker that stopped, a
182
+ # model provider that is down, a job that raised its last retry away.
183
+ # Run it every minute (`rake support_desk:release_silent_assistants`).
184
+ # Returns how many cases it moved.
185
+ def release_silent_assistants!
186
+ # A seat nobody can sit in any more is not a silence problem, and it
187
+ # must not need a `responds_within` to be noticed (R6).
188
+ moved = reclaim_assistant_seats!
189
+ config.assistants.each_key do |key|
190
+ agent = assistant(key)
191
+ window = agent&.responds_within
192
+ next if window.nil?
193
+
194
+ Ticket.open.assigned_to(agent).awaiting_reply.waiting_over(window).find_each do |ticket|
195
+ # Conditional, under the case's own lock: every predicate above was
196
+ # true when this row was SELECTED, and a person may have answered
197
+ # it since (R5).
198
+ moved += 1 if ticket.escalate_if_still_silent!(agent, window)
199
+ rescue StandardError => e
200
+ report_error(e, context: { hook: :release_silent_assistants, ticket: ticket.id })
201
+ end
202
+ end
203
+ logger&.info("[support_desk] silent assistants: #{moved} case(s) handed to a person") if moved.positive?
204
+ moved
205
+ end
206
+
207
+ # Re-emit `:assistant_turn` for cases whose turn nobody acted on — a
208
+ # harness that was down when the event fired, a job that was dropped.
209
+ #
210
+ # At-least-once on purpose: a duplicate turn is harmless, because the
211
+ # turn is consumed by the first action and every later one is a
212
+ # StaleTurn. Run it every five minutes. Returns how many it re-emitted.
213
+ def redispatch_assistant_turns!(older_than: 1.minute)
214
+ older_than = older_than.to_i.seconds unless older_than.respond_to?(:ago)
215
+ emitted = 0
216
+
217
+ config.desks.each_key do |key|
218
+ agent = desk(key)&.assistant
219
+ # Repair before deciding. A requester message whose registration was
220
+ # lost after commit leaves the clocks describing a case that no longer
221
+ # exists — and `assistant_idle_since` reads those clocks, so the case
222
+ # it most needs to find is the one it cannot see. Folding the message
223
+ # in COMMITS on its own and emits the turn it produces, so a dead
224
+ # process followed by nothing but this task still ends in an
225
+ # actionable turn (R3).
226
+ Ticket.for_desk(key).with_unregistered_requester_messages.find_each do |ticket|
227
+ emitted += 1 if ticket.reconcile_and_commit!.positive?
228
+ rescue StandardError => e
229
+ report_error(e, context: { hook: :redispatch_assistant_turns, ticket: ticket.id })
230
+ end
231
+
232
+ next if agent.nil?
233
+
234
+ Ticket.open.for_desk(key).assistant_idle_since(older_than.ago).find_each do |ticket|
235
+ next unless ticket.assistant_policy(agent).may_observe?
236
+
237
+ ticket.send(:emit_assistant_turn)
238
+ emitted += 1
239
+ end
240
+ end
241
+ emitted
242
+ end
243
+
106
244
  # --- Desks ------------------------------------------------------------------
107
245
 
108
246
  # The desk record for +key+, memoised per process.
@@ -224,6 +362,19 @@ module SupportDesk
224
362
  defined?(::Rails) ? ::Rails.logger : nil
225
363
  end
226
364
 
365
+ # Report an exception the way the event dispatcher does: `Rails.error`
366
+ # when there is one, the log otherwise. Extracted because every hook the
367
+ # gem runs on a host's behalf — a `hand_off_when` block, a turn
368
+ # emission, a sweep — has to report and carry on rather than take a
369
+ # transition down with it.
370
+ def report_error(error, context: {}) # :nodoc:
371
+ if defined?(Rails) && Rails.respond_to?(:error) && Rails.error
372
+ Rails.error.report(error, handled: true, source: "support_desk", context: context)
373
+ else
374
+ logger&.error("[support_desk] #{error.class}: #{error.message} #{context.inspect}")
375
+ end
376
+ end
377
+
227
378
  # Where the host mounted the requester-facing engine
228
379
  # ("/messages/support"), or nil when they haven't. Used for the grouped
229
380
  # inbox row's link and by `doctor`.
@@ -25,3 +25,60 @@ namespace :support_desk do
25
25
  puts "[support_desk] #{updated} case(s) now point at their requester."
26
26
  end
27
27
  end
28
+
29
+ namespace :support_desk do
30
+ desc "Hand over every case an assistant has sat on without answering (run every minute)"
31
+ task release_silent_assistants: :environment do
32
+ # The net under a dead harness: a queue worker that stopped, a provider
33
+ # that is down, a job that spent its last retry. Each case it finds has
34
+ # its seat released AND a person asked for — a case that waited that
35
+ # long deserves one, whatever the assistant would have said.
36
+ moved = SupportDesk.release_silent_assistants!
37
+
38
+ puts "[support_desk] #{moved} case(s) handed to a person."
39
+ end
40
+
41
+ desc "Give back every seat an assistant can no longer sit in (run every minute)"
42
+ task reclaim_assistant_seats: :environment do
43
+ # Invalid-assignee recovery: she was switched off, her configuration was
44
+ # taken away, or her policy no longer lets her hold a case. It needs no
45
+ # `responds_within` and no overdue clock — `release_silent_assistants`
46
+ # runs it first, and this is the same work on its own.
47
+ reclaimed = SupportDesk.reclaim_assistant_seats!
48
+
49
+ puts "[support_desk] #{reclaimed} stranded seat(s) given back to people."
50
+ end
51
+
52
+ desc "Re-emit the turn for cases nobody answered (OLDER_THAN=60 seconds; run every 5 minutes)"
53
+ task redispatch_assistant_turns: :environment do
54
+ # At-least-once, and that is safe: the turn is consumed by the first
55
+ # action, so a duplicate one is a StaleTurn and writes nothing.
56
+ older_than = (ENV["OLDER_THAN"] || 60).to_i.seconds
57
+ emitted = SupportDesk.redispatch_assistant_turns!(older_than: older_than)
58
+
59
+ puts "[support_desk] #{emitted} turn(s) re-emitted (idle for more than #{older_than.inspect})."
60
+ end
61
+
62
+ desc "Report assistant status without changing tickets"
63
+ task assistant_status: :environment do
64
+ if SupportDesk.config.assistants.empty?
65
+ puts "[support_desk] no assistant is configured."
66
+ next
67
+ end
68
+
69
+ SupportDesk.config.assistants.each_key do |key|
70
+ assistant = SupportDesk.assistant(key)
71
+ held = assistant.held_tickets.count
72
+ idle_window = assistant.responds_within
73
+ idle = if idle_window
74
+ SupportDesk::Ticket.open.assistant_idle_since(idle_window.ago).count
75
+ end
76
+
77
+ puts "[support_desk] #{key} (#{assistant.active? ? "active" : "inactive"}, #{assistant.autonomy}): " \
78
+ "#{held} case(s) held, #{idle || "—"} idle turn(s)"
79
+ end
80
+
81
+ puts "[support_desk] #{SupportDesk::Ticket.open.needs_human.count} case(s) need a person, " \
82
+ "#{SupportDesk::Draft.pending.count} proposal(s) waiting to be reviewed."
83
+ end
84
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: support_desk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-18 00:00:00.000000000 Z
10
+ date: 2026-09-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -106,11 +106,18 @@ dependencies:
106
106
  description: 'support_desk adds a customer support desk to any Rails app: tickets
107
107
  that are conversations. A requester asks for help about something in your app (a
108
108
  ride, an order, a withdrawal) or about nothing in particular, your desk answers,
109
- humans or bots sign the answers, and your team works a queue. It is a product gem
110
- on the `chats` kernel: chats owns the transcript, realtime, attachments, read state
111
- and moderation; support_desk owns cases, topics, assignment, routing and the console
112
- API. One macro makes a model ask for help (`has_support_tickets`), one makes a domain
113
- record something to ask about (`supportable`), one makes a person answer (`acts_as_support_agent`).
109
+ humans sign the answers, and your team works a queue — alongside AI agents that
110
+ are first-class citizens of the desk: an assistant has a seat, a name, a turn budget
111
+ and a level (observe, draft, reply, resolve) enforced inside every transition; she
112
+ proposes replies a person sends, or answers herself where you let her, hands off
113
+ when unsure, and can never touch a human-held case, a capped topic or a customer
114
+ who asked for a person. The gem ships the policy, the drafts and review, the turn
115
+ that makes a late model answer harmless, disclosure and the two exits — and no LLM:
116
+ any provider, any prompt, in a ten-line job. It is a product gem on the `chats`
117
+ kernel: chats owns the transcript, realtime, attachments, read state and moderation;
118
+ support_desk owns cases, topics, assignment, routing and the console API. One macro
119
+ makes a model ask for help (`has_support_tickets`), one makes a domain record something
120
+ to ask about (`supportable`), one makes a person answer (`acts_as_support_agent`).
114
121
  Topics are a tree defined in code that drives the wizard, the picker, routing and
115
122
  metrics; assignment is a history, not a column; every transition writes an append-only
116
123
  event row and emits an error-isolated event your app can subscribe to. Batteries
@@ -142,6 +149,7 @@ files:
142
149
  - app/views/support_desk/console/tickets/_assignment.html.erb
143
150
  - app/views/support_desk/console/tickets/_composer.html.erb
144
151
  - app/views/support_desk/console/tickets/_context_card.html.erb
152
+ - app/views/support_desk/console/tickets/_draft.html.erb
145
153
  - app/views/support_desk/console/tickets/_message.html.erb
146
154
  - app/views/support_desk/console/tickets/_nav_badge.html.erb
147
155
  - app/views/support_desk/console/tickets/_new_conversation_form.html.erb
@@ -154,6 +162,7 @@ files:
154
162
  - app/views/support_desk/console/tickets/show.html.erb
155
163
  - app/views/support_desk/tickets/_context_card.html.erb
156
164
  - app/views/support_desk/tickets/_door.html.erb
165
+ - app/views/support_desk/tickets/_human_door.html.erb
157
166
  - app/views/support_desk/tickets/_pick_thing.html.erb
158
167
  - app/views/support_desk/tickets/_pick_topic.html.erb
159
168
  - app/views/support_desk/tickets/_ticket_row.html.erb
@@ -172,16 +181,24 @@ files:
172
181
  - gemfiles/rails_7.2.gemfile
173
182
  - gemfiles/rails_8.0.gemfile
174
183
  - gemfiles/rails_8.1.gemfile
184
+ - lib/generators/support_desk/assistant_generator.rb
175
185
  - lib/generators/support_desk/console_generator.rb
176
186
  - lib/generators/support_desk/install_generator.rb
187
+ - lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb
177
188
  - lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb
189
+ - lib/generators/support_desk/templates/assistant/service.rb.erb
190
+ - lib/generators/support_desk/templates/assistant/turn_job.rb.erb
191
+ - lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb
178
192
  - lib/generators/support_desk/templates/console/controller.rb.erb
179
193
  - lib/generators/support_desk/templates/console/resource.rb.erb
194
+ - lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb
180
195
  - lib/generators/support_desk/templates/create_support_desk_tables.rb.erb
181
196
  - lib/generators/support_desk/templates/initializer.rb
182
197
  - lib/generators/support_desk/upgrade_generator.rb
183
198
  - lib/generators/support_desk/views_generator.rb
184
199
  - lib/support_desk.rb
200
+ - lib/support_desk/assistant_policy.rb
201
+ - lib/support_desk/brief.rb
185
202
  - lib/support_desk/configuration.rb
186
203
  - lib/support_desk/console.rb
187
204
  - lib/support_desk/console_engine.rb
@@ -193,20 +210,27 @@ files:
193
210
  - lib/support_desk/errors.rb
194
211
  - lib/support_desk/events.rb
195
212
  - lib/support_desk/macros.rb
213
+ - lib/support_desk/message_signatures.rb
196
214
  - lib/support_desk/models/application_record.rb
197
215
  - lib/support_desk/models/assignment.rb
216
+ - lib/support_desk/models/assistant.rb
198
217
  - lib/support_desk/models/concerns/agent.rb
199
218
  - lib/support_desk/models/concerns/requester.rb
200
219
  - lib/support_desk/models/concerns/supportable.rb
201
220
  - lib/support_desk/models/desk.rb
221
+ - lib/support_desk/models/draft.rb
202
222
  - lib/support_desk/models/event.rb
223
+ - lib/support_desk/models/message_registration.rb
203
224
  - lib/support_desk/models/ticket.rb
225
+ - lib/support_desk/models/ticket/assistance.rb
226
+ - lib/support_desk/outcome.rb
204
227
  - lib/support_desk/queue.rb
205
228
  - lib/support_desk/summary.rb
206
229
  - lib/support_desk/test_helpers.rb
207
230
  - lib/support_desk/timeline.rb
208
231
  - lib/support_desk/topic.rb
209
232
  - lib/support_desk/topic_tree.rb
233
+ - lib/support_desk/transcript.rb
210
234
  - lib/support_desk/version.rb
211
235
  - lib/support_desk/wizard.rb
212
236
  - lib/tasks/support_desk.rake
@@ -236,5 +260,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
236
260
  requirements: []
237
261
  rubygems_version: 3.6.2
238
262
  specification_version: 4
239
- summary: Add customer support (tickets that are conversations) to your Rails app
263
+ summary: 'Customer support for your Rails app: tickets that are conversations, worked
264
+ by your team and by AI agents with real guardrails'
240
265
  test_files: []