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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +998 -14
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +23 -1
- data/app/helpers/support_desk/engine_helper.rb +16 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
- data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/show.html.erb +5 -0
- data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
- data/config/locales/support_desk.console.en.yml +63 -0
- data/config/locales/support_desk.console.es.yml +65 -0
- data/config/locales/support_desk.en.yml +15 -0
- data/config/locales/support_desk.es.yml +23 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
- data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
- data/lib/generators/support_desk/templates/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +12 -2
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +552 -2
- data/lib/support_desk/console.rb +290 -8
- data/lib/support_desk/context_card.rb +10 -1
- data/lib/support_desk/doctor.rb +144 -1
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +37 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +14 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/requester.rb +18 -0
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +334 -37
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/test_helpers.rb +152 -0
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +108 -0
- data/lib/tasks/support_desk.rake +46 -0
- 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
|
data/lib/support_desk/version.rb
CHANGED
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`.
|
data/lib/tasks/support_desk.rake
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|
|
110
|
-
|
|
111
|
-
and
|
|
112
|
-
|
|
113
|
-
|
|
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:
|
|
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: []
|