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,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # The conversation as something other than HTML: one flat, ordered list of
5
+ # turns, each one saying who spoke, what they said and when.
6
+ #
7
+ # ticket.transcript.to_text
8
+ # # [2026-09-18 10:02] Alice: No me han pagado
9
+ # # [2026-09-18 10:03] Rose: Lo estoy mirando ahora mismo
10
+ # # [2026-09-18 10:07] Lucía: Ya está resuelto [justificante.pdf]
11
+ #
12
+ # ticket.transcript(limit: 20).last(5)
13
+ # ticket.transcript.since(message)
14
+ #
15
+ # It exists for the harness that has to hand a conversation to a model
16
+ # without reading chats' tables, and for anything else that wants the
17
+ # transcript as data: an export, a summary job, a console that renders
18
+ # JSON. `Brief` embeds it.
19
+ #
20
+ # == Four roles, not three
21
+ #
22
+ # `Ticket#role_of` answers +:requester+, +:agent+ or +:system+, because
23
+ # that is what the CLOCKS care about — whose turn it is does not change
24
+ # depending on whether a person or a machine wrote the desk's last word.
25
+ # A reader does care, so this is a SEPARATE serializer with its own
26
+ # vocabulary: +:requester+, +:human+, +:assistant+, +:system+. Neither
27
+ # one is derived from the other, and `role_of` is deliberately left
28
+ # alone — the day a third kind of author appears, only this file moves.
29
+ #
30
+ # == Names survive renames
31
+ #
32
+ # An assistant's name is read from the message's own provenance stamp
33
+ # first (`metadata["support_desk"]["assistant"]`, the KEY), resolved
34
+ # through the configuration when she is still declared, and only then
35
+ # falls back to the `display_name` the message was written with. So a
36
+ # transcript reads "Rose" while Rose is configured, keeps saying what the
37
+ # customer was actually shown for an assistant nobody declares any more,
38
+ # and never invents a name for a message written under a nameless
39
+ # disclosure mode.
40
+ #
41
+ # == Deleted messages are turns too
42
+ #
43
+ # A tombstone renders as `support_desk.transcript.deleted` with no
44
+ # attachments, rather than vanishing: a model that cannot see that
45
+ # something was said and withdrawn will answer as if it never was, and a
46
+ # gap in a numbered transcript is worse than a marked one.
47
+ class Transcript
48
+ include Enumerable
49
+
50
+ # One line of the conversation. A value: built once, then only read.
51
+ #
52
+ # * +role+ — :requester | :human | :assistant | :system
53
+ # * +name+ — who to print; the desk's name for system lines
54
+ # * +body+ — what was said, or the tombstone copy
55
+ # * +at+ — when (a Time)
56
+ # * +attachments+ — filenames, as Strings
57
+ # * +assisted+ — a human sent it, a machine proposed it
58
+ # * +message+ — the Chats::Message itself, for anything else
59
+ Turn = Struct.new(:role, :name, :body, :at, :attachments, :assisted, :message, keyword_init: true) do
60
+ def requester? = role == :requester
61
+ def human? = role == :human
62
+ def assistant? = role == :assistant
63
+ def system? = role == :system
64
+
65
+ # Whether a person sent a machine's words. Provenance, not authorship:
66
+ # the message is the human's (see SupportDesk::Draft).
67
+ def assisted? = !!assisted
68
+
69
+ # "[2026-09-18 10:02] Alice: No me han pagado [justificante.pdf]"
70
+ #
71
+ # UTC, to the minute, always: a transcript that a model reads is
72
+ # compared against itself line by line, and a stamp that moves with
73
+ # the reader's zone or carries seconds is noise in a diff.
74
+ def to_line
75
+ said = [ body.to_s.strip.presence, *attachments.map { |file| "[#{file}]" } ].compact
76
+ "[#{at&.utc&.strftime("%Y-%m-%d %H:%M")}] #{name}: #{said.join(" ")}"
77
+ end
78
+
79
+ alias_method :to_s, :to_line
80
+
81
+ def to_h
82
+ { role: role, name: name, body: body, at: at, attachments: attachments, assisted: assisted }
83
+ end
84
+ end
85
+
86
+ attr_reader :ticket, :limit
87
+
88
+ # The transcript of +ticket+. `limit:` keeps the LAST n turns — the end
89
+ # of a conversation is the part that is still being answered.
90
+ def initialize(ticket, limit: nil)
91
+ @ticket = ticket
92
+ @limit = limit&.to_i
93
+ end
94
+
95
+ # Every turn, oldest first.
96
+ def each(&block)
97
+ turns.each(&block)
98
+ end
99
+
100
+ def to_a = turns.dup
101
+
102
+ def size = turns.size
103
+
104
+ alias_method :length, :size
105
+
106
+ # The last +count+ turns, oldest first.
107
+ def last(count = 1)
108
+ turns.last(count)
109
+ end
110
+
111
+ # Everything said after +message+ — "what has happened since the one I
112
+ # answered". A message this transcript doesn't hold (older than `limit:`,
113
+ # from another case, deleted outright) yields the whole window rather
114
+ # than nothing: the caller has seen none of it, and an empty answer would
115
+ # read as "nothing happened".
116
+ def since(message)
117
+ return to_a if message.nil?
118
+
119
+ index = turns.index { |turn| turn.message&.id.to_s == message.id.to_s }
120
+ return to_a if index.nil?
121
+
122
+ turns[(index + 1)..] || []
123
+ end
124
+
125
+ # The whole thing as one block of text, for a prompt or a log line.
126
+ def to_text
127
+ turns.map(&:to_line).join("\n")
128
+ end
129
+
130
+ alias_method :to_s, :to_text
131
+
132
+ # The transcript as data. `truncated` is the honest half of `limit:`:
133
+ # a reader that is seeing the last 50 of 300 turns has to know it.
134
+ def to_h
135
+ {
136
+ size: total,
137
+ truncated: total > turns.size,
138
+ turns: turns.map(&:to_h)
139
+ }
140
+ end
141
+
142
+ def inspect
143
+ "#<SupportDesk::Transcript #{ticket.reference} #{turns.size} turn(s)>"
144
+ end
145
+
146
+ private
147
+
148
+ # Built once per instance: a transcript is a snapshot of a conversation
149
+ # at the moment somebody asked for it, and a second read that disagreed
150
+ # with the first would make `since` and `last` answer about two
151
+ # different conversations.
152
+ def turns
153
+ @turns ||= messages.map { |message| turn_for(message) }
154
+ end
155
+
156
+ def messages
157
+ return [] if ticket.conversation.nil?
158
+
159
+ scope = ticket.conversation.messages.oldest_first
160
+ all = scope.to_a
161
+ @total = all.size
162
+ limit ? all.last(limit) : all
163
+ end
164
+
165
+ def total
166
+ turns # force the read, which is what counts
167
+ @total.to_i
168
+ end
169
+
170
+ def turn_for(message)
171
+ role = role_for(message)
172
+ tombstone = message.respond_to?(:deleted?) && message.deleted?
173
+
174
+ Turn.new(
175
+ role: role,
176
+ name: name_for(role, message),
177
+ body: tombstone ? I18n.t("support_desk.transcript.deleted") : message.body,
178
+ at: message.created_at,
179
+ attachments: tombstone ? [] : attachment_names(message),
180
+ assisted: assisted?(message),
181
+ message: message
182
+ )
183
+ end
184
+
185
+ # System first (a disclosure notice is a system line, whatever wrote
186
+ # it), then the machine, then the two kinds of person.
187
+ def role_for(message)
188
+ return :system if message.respond_to?(:system?) && message.system?
189
+ return :assistant if ticket.assistant_message?(message)
190
+
191
+ case ticket.send(:role_of, message)
192
+ when :requester then :requester
193
+ when :agent then :human
194
+ else :system
195
+ end
196
+ end
197
+
198
+ def name_for(role, message)
199
+ case role
200
+ when :requester then Chats.display_name_for(ticket.requester)
201
+ when :assistant then assistant_name(message)
202
+ when :human then message.author.try(:support_agent_name) || ticket.desk&.name
203
+ else ticket.desk&.name
204
+ end
205
+ end
206
+
207
+ # The key the message was stamped with, resolved to the name that key
208
+ # has NOW — and the stored display name when nothing declares her any
209
+ # more. A message written by the record rather than by `speak!` (an
210
+ # import, a host that posts its own) has neither, so the record answers.
211
+ def assistant_name(message)
212
+ stamp = provenance(message)
213
+ key = stamp["assistant"]
214
+ if key.present? && SupportDesk.config.assistant?(key)
215
+ return SupportDesk.config.assistant(key).name
216
+ end
217
+
218
+ stamp["display_name"].presence || message.author.try(:name) || ticket.desk&.name
219
+ end
220
+
221
+ def assisted?(message)
222
+ provenance(message)["drafted_by"].present?
223
+ end
224
+
225
+ def provenance(message)
226
+ stamp = message.try(:metadata)
227
+ return {} unless stamp.is_a?(Hash)
228
+
229
+ nested = stamp["support_desk"]
230
+ nested.is_a?(Hash) ? nested : {}
231
+ end
232
+
233
+ def attachment_names(message)
234
+ message.try(:files)&.map { |file| file.try(:filename).to_s } || []
235
+ end
236
+ end
237
+ end
@@ -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.0"
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,99 @@ 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
+ assistants[key] ||= Assistant.for(key)
128
+ end
129
+
130
+ # Every assistant record this process has resolved, keyed by key.
131
+ def assistants # :nodoc:
132
+ @assistants ||= {}
133
+ end
134
+
135
+ # Forget the memoised Assistant records without touching configuration —
136
+ # for tests that truncate tables between examples.
137
+ def reset_assistants!
138
+ @assistants = nil
139
+ self
140
+ end
141
+
142
+ # Whether +record+ is a machine rather than a person. Duck-typed, because
143
+ # a host's own `acts_as_support_agent kind: :ai` model answers it too —
144
+ # and is then refused everywhere, which is the point (I2).
145
+ def ai_actor?(record)
146
+ record.respond_to?(:support_agent_kind) && record.support_agent_kind == :ai
147
+ end
148
+
149
+ # Release every assistant who has sat on a case longer than her
150
+ # `responds_within` without answering — and ask for a person on it.
151
+ #
152
+ # This is the net under a dead harness: a queue worker that stopped, a
153
+ # model provider that is down, a job that raised its last retry away.
154
+ # Run it every minute (`rake support_desk:release_silent_assistants`).
155
+ # Returns how many cases it moved.
156
+ def release_silent_assistants!
157
+ moved = 0
158
+ config.assistants.each_key do |key|
159
+ agent = assistant(key)
160
+ window = agent&.responds_within
161
+ next if window.nil?
162
+
163
+ Ticket.open.assigned_to(agent).awaiting_reply.waiting_over(window).find_each do |ticket|
164
+ next if ticket.human_required?
165
+
166
+ ticket.escalate!(by: :system, reason: "assistant_silent",
167
+ summary: "no answer in #{humanize_duration(window)}")
168
+ moved += 1 if ticket.human_required?
169
+ rescue StandardError => e
170
+ report_error(e, context: { hook: :release_silent_assistants, ticket: ticket.id })
171
+ end
172
+ end
173
+ logger&.info("[support_desk] silent assistants: #{moved} case(s) handed to a person") if moved.positive?
174
+ moved
175
+ end
176
+
177
+ # Re-emit `:assistant_turn` for cases whose turn nobody acted on — a
178
+ # harness that was down when the event fired, a job that was dropped.
179
+ #
180
+ # At-least-once on purpose: a duplicate turn is harmless, because the
181
+ # turn is consumed by the first action and every later one is a
182
+ # StaleTurn. Run it every five minutes. Returns how many it re-emitted.
183
+ def redispatch_assistant_turns!(older_than: 1.minute)
184
+ older_than = older_than.to_i.seconds unless older_than.respond_to?(:ago)
185
+ emitted = 0
186
+
187
+ config.desks.each_key do |key|
188
+ agent = desk(key)&.assistant
189
+ next if agent.nil?
190
+
191
+ Ticket.open.for_desk(key).assistant_idle_since(older_than.ago).find_each do |ticket|
192
+ next unless ticket.assistant_policy(agent).may_observe?
193
+
194
+ ticket.send(:emit_assistant_turn)
195
+ emitted += 1
196
+ end
197
+ end
198
+ emitted
199
+ end
200
+
106
201
  # --- Desks ------------------------------------------------------------------
107
202
 
108
203
  # The desk record for +key+, memoised per process.
@@ -224,6 +319,19 @@ module SupportDesk
224
319
  defined?(::Rails) ? ::Rails.logger : nil
225
320
  end
226
321
 
322
+ # Report an exception the way the event dispatcher does: `Rails.error`
323
+ # when there is one, the log otherwise. Extracted because every hook the
324
+ # gem runs on a host's behalf — a `hand_off_when` block, a turn
325
+ # emission, a sweep — has to report and carry on rather than take a
326
+ # transition down with it.
327
+ def report_error(error, context: {}) # :nodoc:
328
+ if defined?(Rails) && Rails.respond_to?(:error) && Rails.error
329
+ Rails.error.report(error, handled: true, source: "support_desk", context: context)
330
+ else
331
+ logger&.error("[support_desk] #{error.class}: #{error.message} #{context.inspect}")
332
+ end
333
+ end
334
+
227
335
  # Where the host mounted the requester-facing engine
228
336
  # ("/messages/support"), or nil when they haven't. Used for the grouped
229
337
  # inbox row's link and by `doctor`.
@@ -25,3 +25,49 @@ 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 "Re-emit the turn for cases nobody answered (OLDER_THAN=60 seconds; run every 5 minutes)"
42
+ task redispatch_assistant_turns: :environment do
43
+ # At-least-once, and that is safe: the turn is consumed by the first
44
+ # action, so a duplicate one is a StaleTurn and writes nothing.
45
+ older_than = (ENV["OLDER_THAN"] || 60).to_i.seconds
46
+ emitted = SupportDesk.redispatch_assistant_turns!(older_than: older_than)
47
+
48
+ puts "[support_desk] #{emitted} turn(s) re-emitted (idle for more than #{older_than.inspect})."
49
+ end
50
+
51
+ desc "What the assistants are doing right now (read-only)"
52
+ task assistant_status: :environment do
53
+ if SupportDesk.config.assistants.empty?
54
+ puts "[support_desk] no assistant is configured."
55
+ next
56
+ end
57
+
58
+ SupportDesk.config.assistants.each_key do |key|
59
+ assistant = SupportDesk.assistant(key)
60
+ held = assistant.held_tickets.count
61
+ idle_window = assistant.responds_within
62
+ idle = if idle_window
63
+ SupportDesk::Ticket.open.assistant_idle_since(idle_window.ago).count
64
+ end
65
+
66
+ puts "[support_desk] #{key} (#{assistant.active? ? "active" : "inactive"}, #{assistant.autonomy}): " \
67
+ "#{held} case(s) held, #{idle || "—"} idle turn(s)"
68
+ end
69
+
70
+ puts "[support_desk] #{SupportDesk::Ticket.open.needs_human.count} case(s) need a person, " \
71
+ "#{SupportDesk::Draft.pending.count} proposal(s) waiting to be reviewed."
72
+ end
73
+ 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.0
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-19 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,9 +181,14 @@ 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
180
194
  - lib/generators/support_desk/templates/create_support_desk_tables.rb.erb
@@ -182,6 +196,8 @@ files:
182
196
  - lib/generators/support_desk/upgrade_generator.rb
183
197
  - lib/generators/support_desk/views_generator.rb
184
198
  - lib/support_desk.rb
199
+ - lib/support_desk/assistant_policy.rb
200
+ - lib/support_desk/brief.rb
185
201
  - lib/support_desk/configuration.rb
186
202
  - lib/support_desk/console.rb
187
203
  - lib/support_desk/console_engine.rb
@@ -195,18 +211,23 @@ files:
195
211
  - lib/support_desk/macros.rb
196
212
  - lib/support_desk/models/application_record.rb
197
213
  - lib/support_desk/models/assignment.rb
214
+ - lib/support_desk/models/assistant.rb
198
215
  - lib/support_desk/models/concerns/agent.rb
199
216
  - lib/support_desk/models/concerns/requester.rb
200
217
  - lib/support_desk/models/concerns/supportable.rb
201
218
  - lib/support_desk/models/desk.rb
219
+ - lib/support_desk/models/draft.rb
202
220
  - lib/support_desk/models/event.rb
203
221
  - lib/support_desk/models/ticket.rb
222
+ - lib/support_desk/models/ticket/assistance.rb
223
+ - lib/support_desk/outcome.rb
204
224
  - lib/support_desk/queue.rb
205
225
  - lib/support_desk/summary.rb
206
226
  - lib/support_desk/test_helpers.rb
207
227
  - lib/support_desk/timeline.rb
208
228
  - lib/support_desk/topic.rb
209
229
  - lib/support_desk/topic_tree.rb
230
+ - lib/support_desk/transcript.rb
210
231
  - lib/support_desk/version.rb
211
232
  - lib/support_desk/wizard.rb
212
233
  - lib/tasks/support_desk.rake
@@ -236,5 +257,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
236
257
  requirements: []
237
258
  rubygems_version: 3.6.2
238
259
  specification_version: 4
239
- summary: Add customer support (tickets that are conversations) to your Rails app
260
+ summary: 'Customer support for your Rails app: tickets that are conversations, worked
261
+ by your team and by AI agents with real guardrails'
240
262
  test_files: []