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
@@ -0,0 +1,899 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ class Ticket
5
+ # Everything a case knows about its assistant: the turn, the policy
6
+ # gates, the two verbs that are hers alone (`respond!`, `draft!`), the
7
+ # two exits (`escalate!`, `request_human!`) and the human override
8
+ # (`pause_assistant!`).
9
+ #
10
+ # == The turn
11
+ #
12
+ # `assistant_revision` is an integer bumped by every registered message
13
+ # and every transition. `assistant_turn` is that integer, spelled as an
14
+ # opaque string, and EVERY assistant action requires it and consumes it:
15
+ #
16
+ # turn = ticket.assistant_turn # "t7-r12"
17
+ # ticket.respond!(answer, by: rose, turn: turn)
18
+ #
19
+ # A model takes seconds to answer, and a customer can write again while
20
+ # it does. The turn is what makes that safe: the action is compared
21
+ # against the case's current revision under its row lock, and a late,
22
+ # retried or redelivered one raises SupportDesk::StaleTurn and writes
23
+ # nothing. It is also why there are no idempotency keys, claim rows or
24
+ # leases in this gem — one integer under the lock already answers "is
25
+ # this still the case you read?".
26
+ #
27
+ # == Gate the action, never the evidence
28
+ #
29
+ # Policy decides what she may PRODUCE. It never decides what a person can
30
+ # see: the transcript, the case, the queue and the door to a human are
31
+ # the same whatever the level, and a refusal is always a named reason on
32
+ # a record (an `assistant_withheld` event, a policy in a draft's
33
+ # metadata), never silence.
34
+ #
35
+ # == The locking protocol: TICKET, then CONVERSATION
36
+ #
37
+ # The turn is only as good as the reconciliation behind it, and
38
+ # reconciliation is a SELECT: on its own it cannot exclude a requester
39
+ # message that commits one millisecond later. The customer does not take
40
+ # the ticket lock — they press send, and chats writes a message.
41
+ #
42
+ # What chats DOES take is the conversation row. Every message insert
43
+ # updates `chats_conversations` inside its own transaction
44
+ # (`Chats::Message#register_on_conversation`, plus the `messages_count`
45
+ # counter cache), so that row is the serialization point for writes this
46
+ # gem does not own. Every speaking path therefore takes the ticket's row
47
+ # lock and then, before reconciling, the conversation's
48
+ # (`#lock_conversation!`):
49
+ #
50
+ # * a requester message already in flight holds that row, so we block
51
+ # until it commits — and reconciliation then sees it, which makes the
52
+ # turn stale, which is exactly what should happen;
53
+ # * a message that starts after we hold the row blocks until we commit,
54
+ # so our answer is ordered before it and its own turn follows.
55
+ #
56
+ # THE ORDER IS ALWAYS TICKET → CONVERSATION. A requester write locks only
57
+ # the conversation; the after-commit subscriber's `register!` locks the
58
+ # ticket and then, through the system lines a hand-off posts, the
59
+ # conversation. Nothing takes them the other way round, and nothing new
60
+ # may.
61
+ #
62
+ # What it does NOT cover:
63
+ #
64
+ # * A message whose INSERT was already stamped when we win the row is
65
+ # still stamped earlier than our answer, so a transcript can show a
66
+ # question above an answer that did not address it. That is a genuinely
67
+ # simultaneous send, and its registration raises a turn of its own.
68
+ #
69
+ # * SQLITE. All of the above is a `SELECT … FOR UPDATE` blocking a
70
+ # concurrent writer, which is PostgreSQL and MySQL. SQLite has no row
71
+ # locks: it serializes WRITES, and in WAL mode our reconciliation reads
72
+ # the last committed snapshot straight through a requester's open write
73
+ # transaction. So a message committing behind the SELECT is still
74
+ # missed there, exactly as it was in 0.3.0 — the conversation row buys
75
+ # nothing, because nothing waits on it. Run PostgreSQL or MySQL for a
76
+ # production desk with an assistant; `doctor` warns about this.
77
+ module Assistance
78
+ extend ActiveSupport::Concern
79
+
80
+ included do
81
+ has_many :drafts, class_name: "SupportDesk::Draft", inverse_of: :ticket, dependent: :destroy
82
+ has_one :pending_draft, -> { pending }, class_name: "SupportDesk::Draft", inverse_of: :ticket
83
+
84
+ # --- Scopes -------------------------------------------------------------
85
+
86
+ scope :held_by_assistants, lambda {
87
+ where(assignee_type: SupportDesk::Assistant.polymorphic_name)
88
+ }
89
+ scope :held_by_humans, lambda {
90
+ assigned.where.not(assignee_type: SupportDesk::Assistant.polymorphic_name)
91
+ }
92
+ # The tab that exists so a case a machine could not finish is never
93
+ # the one nobody looks at.
94
+ scope :needs_human, -> { where.not(human_required_at: nil) }
95
+ scope :assistant_paused, -> { where.not(assistant_paused_at: nil) }
96
+ scope :assistant_capped, -> { where.not(assistant_cap: nil) }
97
+ scope :resolved_by_assistant, lambda {
98
+ closed.where(closed_by_type: SupportDesk::Assistant.polymorphic_name)
99
+ }
100
+ scope :with_pending_draft, -> { where(id: SupportDesk::Draft.pending.select(:ticket_id)) }
101
+ # Waiting on the desk since before +time+, with nothing from the
102
+ # assistant since the customer wrote: a harness that is down, or one
103
+ # that keeps deciding to do nothing.
104
+ scope :assistant_idle_since, lambda { |time|
105
+ awaiting_reply.where(last_requester_message_at: ..time)
106
+ .where("assistant_acted_at IS NULL OR assistant_acted_at < last_requester_message_at")
107
+ }
108
+ # Cases whose conversation holds a requester message the gem never
109
+ # folded in. The registration runs after the message's commit, so it can
110
+ # be LOST — a killed worker, a dropped subscriber — and the clocks then
111
+ # describe a case that no longer exists: `awaiting_requester` on a case
112
+ # that is waiting for an answer, which no idle query can see. This one
113
+ # asks the messages instead of the clocks (R3).
114
+ #
115
+ # Built in Arel rather than as a string, because it compares two tables'
116
+ # columns to each other and there is no value to bind: it has to be
117
+ # readable as "no user input reaches this SQL" at a glance.
118
+ scope :with_unregistered_requester_messages, lambda {
119
+ tickets = arel_table
120
+ messages = Chats::Message.arel_table
121
+ receipts = MessageRegistration.arel_table
122
+ registered = MessageRegistration.select(Arel.sql("1"))
123
+ .where(receipts[:message_id].eq(messages[:id]))
124
+ unregistered = Chats::Message.select(Arel.sql("1"))
125
+ .where(kind: "text")
126
+ .where(messages[:conversation_id].eq(tickets[:conversation_id]))
127
+ .where(messages[:sender_type].eq(tickets[:requester_type]))
128
+ .where(messages[:sender_id].eq(tickets[:requester_id]))
129
+ .where(registered.arel.exists.not)
130
+ where(unregistered.arel.exists)
131
+ }
132
+ end
133
+
134
+ # --- Readers ------------------------------------------------------------------
135
+
136
+ # The assistant who works this case's desk, or nil.
137
+ def assistant = desk&.assistant
138
+
139
+ # What she may do here, right now, and why. See AssistantPolicy.
140
+ def assistant_policy(assistant = self.assistant, hand_back: false)
141
+ AssistantPolicy.for(self, assistant, hand_back: hand_back)
142
+ end
143
+
144
+ # The token every assistant action has to hold. Never nil: a case that
145
+ # has never been touched still has a turn, which is what makes "the
146
+ # turn you read" a complete answer rather than a special case.
147
+ def assistant_turn = "t#{id}-r#{assistant_revision.to_i}"
148
+
149
+ # Whether this case has anything to do with an assistant — configured
150
+ # on the desk now, or spoken in by one at any point. History counts:
151
+ # the door to a person must not vanish because somebody edited an
152
+ # initializer after she answered.
153
+ def assistant_in_play?
154
+ assistant.present? || assistant_turns_count.to_i.positive?
155
+ end
156
+
157
+ def held_by_assistant?
158
+ assigned? && assignee_type == SupportDesk::Assistant.polymorphic_name
159
+ end
160
+
161
+ # Whether a person has been asked for on this case — by the assistant,
162
+ # by the requester, or by the silent sweep.
163
+ def human_required? = human_required_at.present?
164
+
165
+ # Whether a human has switched her off on this case.
166
+ def assistant_paused? = assistant_paused_at.present?
167
+
168
+ # How many times she may still speak here; nil when unlimited.
169
+ def assistant_turns_left
170
+ max = assistant&.max_turns
171
+ return nil if max.nil?
172
+
173
+ [ max - assistant_turns_count.to_i, 0 ].max
174
+ end
175
+
176
+ # The conversation as ordered turns — the readable half of what a
177
+ # harness works from. `limit:` keeps the LAST n, which is the part
178
+ # still being answered. See SupportDesk::Transcript.
179
+ def transcript(limit: nil)
180
+ Transcript.new(self, limit: limit)
181
+ end
182
+
183
+ # Everything a machine needs to answer this case, as data: the desk,
184
+ # the policy, the case, the requester and the transcript.
185
+ # `include_internal: true` adds the desk's own notes and proposals —
186
+ # off by default, because it leaves the building. See
187
+ # SupportDesk::Brief.
188
+ def brief(include_internal: false, transcript_limit: 50)
189
+ Brief.new(self, include_internal: include_internal, transcript_limit: transcript_limit)
190
+ end
191
+
192
+ # Whether this message came from an assistant — by authorship when she
193
+ # signs, by the provenance stamp when she doesn't. Both shapes, because
194
+ # a host that changes disclosure mode must not rewrite old bubbles.
195
+ def assistant_message?(message)
196
+ return false if message.nil?
197
+
198
+ return true if message.author.is_a?(SupportDesk::Assistant)
199
+
200
+ stamp = message.try(:metadata)
201
+ stamp.is_a?(Hash) && stamp.dig("support_desk", "assistant").present?
202
+ end
203
+
204
+ # --- Her two verbs ------------------------------------------------------------
205
+
206
+ # Answer, and let policy decide what that means: sent to the requester,
207
+ # proposed as a draft for a person to send, or withheld. The ONE verb a
208
+ # harness needs — it never has to encode the rules it is working under.
209
+ #
210
+ # Returns a SupportDesk::Outcome. Raises StaleTurn when the case moved
211
+ # on, Locked when there is nobody to write to, NotAnAssistant when
212
+ # `by:` isn't this desk's assistant.
213
+ def respond!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
214
+ raise ArgumentError, "respond! needs something to say" if body.blank? && files.blank?
215
+
216
+ # Durable repair first, in a transaction of its own: what it folds in
217
+ # survives the StaleTurn that folding it in may cause (R3).
218
+ reconcile_and_commit!
219
+
220
+ with_lock(requires_new: true) do
221
+ assistant = resolve_assistant!(by)
222
+ lock_conversation!
223
+ reconcile_unregistered_messages!
224
+ ensure_current_turn!(turn)
225
+ ensure_writable!
226
+ policy = assistant_policy(assistant)
227
+ next withhold!(assistant, policy, :policy, request: request) unless policy.may_draft?
228
+ next withhold!(assistant, policy, :not_your_turn, request: request) unless awaiting_reply?
229
+
230
+ left = assistant_turns_left
231
+ if policy.may_reply? && (left.nil? || left.positive?)
232
+ message = speak!(body, assistant, policy: policy, turn: turn, files: files, confidence: confidence,
233
+ sources: sources, metadata: metadata, request: request)
234
+ Outcome.new(action: :sent, message: message, policy: policy, turn: assistant_turn)
235
+ else
236
+ draft = propose_draft!(body, assistant, policy: policy, files: files, confidence: confidence,
237
+ sources: sources, metadata: metadata)
238
+ reason = nil
239
+ # Out of budget at a level that could otherwise have answered:
240
+ # the draft stays, and so does a person — a conversation that
241
+ # ran out of turns is one somebody has to finish.
242
+ if policy.may_reply? && left&.zero?
243
+ handed_off, from = flag_human_required!(actor: assistant, kind: :escalated, reason: "max_turns",
244
+ summary: metadata[:summary] || metadata["summary"],
245
+ line: :hand_off_line, request: request)
246
+ # A conversation that ran out of turns is a hand-off like any
247
+ # other, and it says so out loud: the host's "a person is
248
+ # needed here" notifier listens for this and nothing else (R7).
249
+ publish_escalation!(from: from, reason: :max_turns, by: assistant) if handed_off
250
+ reason = :max_turns
251
+ end
252
+ Outcome.new(action: :drafted, draft: draft, policy: policy, reason: reason, turn: assistant_turn)
253
+ end
254
+ end
255
+ end
256
+
257
+ # Propose a reply for a person to send, whatever the level allows.
258
+ # `respond!` is what a harness should call; this is for a host that has
259
+ # already decided it wants a draft. Returns the SupportDesk::Draft.
260
+ def draft!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
261
+ raise ArgumentError, "draft! needs something to say" if body.blank? && files.blank?
262
+
263
+ reconcile_and_commit!
264
+
265
+ with_lock(requires_new: true) do
266
+ assistant = resolve_assistant!(by)
267
+ lock_conversation!
268
+ reconcile_unregistered_messages!
269
+ ensure_current_turn!(turn)
270
+ ensure_writable!
271
+ policy = assistant_policy(assistant)
272
+ raise AssistantNotAllowed.new(policy, verb: :draft) unless policy.may_draft?
273
+
274
+ propose_draft!(body, assistant, policy: policy, files: files, confidence: confidence,
275
+ sources: sources, metadata: metadata)
276
+ end
277
+ end
278
+
279
+ # --- The two exits ------------------------------------------------------------
280
+
281
+ # Hand the case to a person: the assistant's own way out, and what the
282
+ # silent sweep calls as `:system`. Releases her seat, records the
283
+ # reason, raises the priority and tells the requester.
284
+ #
285
+ # `turn:` is required when `by:` is the assistant — escalating is an
286
+ # action like any other, and a stale one must not speak.
287
+ def escalate!(by: nil, reason:, summary: nil, turn: nil, request: nil)
288
+ actor = resolve_actor(by)
289
+ ensure_agent!(actor)
290
+ machine = SupportDesk.ai_actor?(actor)
291
+ raise ArgumentError, "escalate! needs a reason" if reason.blank?
292
+
293
+ assistant = nil
294
+ event = nil
295
+ from = nil
296
+ with_lock(requires_new: true) do
297
+ if machine
298
+ # Under the lock, from the row (R9).
299
+ assistant = resolve_assistant!(actor)
300
+ ensure_current_turn!(turn)
301
+ policy = assistant_policy(assistant)
302
+ raise AssistantNotAllowed.new(policy, verb: :escalate) unless policy.may_observe?
303
+ end
304
+
305
+ event, from = flag_human_required!(actor: actor, kind: :escalated, reason: reason, summary: summary,
306
+ line: :hand_off_line, request: request)
307
+ end
308
+ return self unless event
309
+
310
+ stamp_assistant_action! if assistant
311
+ publish_escalation!(from: from, reason: reason, by: actor)
312
+ self
313
+ end
314
+
315
+ # "Prefiero hablar con una persona." The requester's own door, and the
316
+ # one thing on this case they can always do: it reopens a closed case
317
+ # where the desk allows it, flags the case, and says so in the thread.
318
+ # Idempotent — pressing it twice writes once.
319
+ def request_human!(by: nil, request: nil)
320
+ actor = resolve_actor(by)
321
+ unless self.class.same_actor?(actor, requester)
322
+ raise NotAllowed,
323
+ "#{describe_actor(actor)} can't ask for a person on #{reference} — it isn't their case"
324
+ end
325
+
326
+ event = nil
327
+ with_lock(requires_new: true) do
328
+ reopen!(by: actor, request: request) if closed? && desk_config.closed_tickets == :reopen_on_reply
329
+ ensure_writable!
330
+ event, = flag_human_required!(actor: actor, kind: :human_requested, reason: "requester_request",
331
+ summary: nil, line: :human_requested_line, request: request)
332
+ end
333
+ return self unless event
334
+
335
+ SupportDesk.emit_after_commit(:human_requested, self, by: actor, reason: :requester_request)
336
+ self
337
+ end
338
+
339
+ # --- The human override -------------------------------------------------------
340
+
341
+ # Switch the assistant off on THIS case: a delicate conversation, a
342
+ # customer who has had enough, a thread somebody wants to handle
343
+ # themselves. Releases her seat, throws away her pending proposal, and
344
+ # floors her policy at :off until somebody resumes her.
345
+ def pause_assistant!(by: nil, reason: nil, request: nil)
346
+ actor = resolve_actor(by)
347
+ if actor.is_a?(Symbol) || SupportDesk.ai_actor?(actor)
348
+ raise NotAllowed, "only a person can pause the assistant on a case"
349
+ end
350
+ ensure_agent!(actor)
351
+
352
+ event = write_transition!(:assistant_paused, actor: actor, request: request) do
353
+ raise InvalidTransition, "can't pause the assistant on a closed case" if closed?
354
+ next false if assistant_paused?
355
+
356
+ release_assistant_seat!(reason: :released)
357
+ supersede_pending_drafts!
358
+ update!(assistant_paused_at: Time.current, assistant_paused_reason: reason.presence&.to_s)
359
+ { "reason" => reason.presence&.to_s }
360
+ end
361
+ return self unless event
362
+
363
+ SupportDesk.emit_after_commit(:assistant_paused, self, by: actor)
364
+ self
365
+ end
366
+
367
+ # Let her back in on this case. Clears the pause and NOTHING ELSE: a
368
+ # case cap and a request for a person are different decisions, made by
369
+ # different people, and only an explicit hand-back lifts those.
370
+ def resume_assistant!(by: nil, request: nil)
371
+ actor = resolve_actor(by)
372
+ if actor.is_a?(Symbol) || SupportDesk.ai_actor?(actor)
373
+ raise NotAllowed, "only a person can resume the assistant on a case"
374
+ end
375
+ ensure_agent!(actor)
376
+
377
+ event = write_transition!(:assistant_resumed, actor: actor, request: request) do
378
+ next false unless assistant_paused?
379
+
380
+ update!(assistant_paused_at: nil, assistant_paused_reason: nil)
381
+ {}
382
+ end
383
+ return self unless event
384
+
385
+ SupportDesk.emit_after_commit(:assistant_resumed, self, by: actor)
386
+ emit_assistant_turn if awaiting_reply?
387
+ self
388
+ end
389
+
390
+ # --- Internals ----------------------------------------------------------------
391
+
392
+ # The verbs the assistant may press on this case right now — the
393
+ # machine's half of `actions_for`, and the same decision the
394
+ # transitions make, so a harness reading `may` from a brief is reading
395
+ # the authorization and not a hint.
396
+ def assistant_actions_for(assistant) # :nodoc:
397
+ return [] unless assistant.is_a?(SupportDesk::Assistant)
398
+ return [] unless self.class.same_actor?(assistant, self.assistant)
399
+
400
+ verbs = assistant_policy(assistant).allowed_verbs.dup
401
+ verbs -= %i[escalate] if closed?
402
+ verbs -= %i[reply draft take] if requester_unavailable?
403
+ verbs -= %i[take] unless unassigned?
404
+ verbs -= %i[release] unless assigned_to?(assistant)
405
+ verbs -= %i[reply] unless awaiting_reply?
406
+ verbs -= %i[close] unless assigned_to?(assistant) && awaiting_requester? && !human_required?
407
+ verbs
408
+ end
409
+
410
+ # The silent sweep's own transition: hand this case to a person ONLY if
411
+ # it is still the case the sweep selected — open, still hers, still owing
412
+ # the next word, still past her promise, and with nobody asked for yet.
413
+ # Returns true when it moved. See SupportDesk.release_silent_assistants!.
414
+ #
415
+ # Every one of those predicates was true when the sweep SELECTED its
416
+ # candidates, and a person can answer, take the case or reset the clock
417
+ # between that query and this write. 0.3.0 rechecked only "closed" and
418
+ # "already asked for", so a case somebody had just answered was marked
419
+ # human-required anyway: its priority went up and the customer was told a
420
+ # person was coming, on a case that already had one (R5).
421
+ def escalate_if_still_silent!(assistant, window) # :nodoc:
422
+ event = nil
423
+ from = nil
424
+ with_lock(requires_new: true) do
425
+ next unless open?
426
+ next unless assigned_to?(assistant)
427
+ next unless awaiting_reply?
428
+ next if human_required?
429
+ next unless waiting_since.present? && waiting_since <= window.ago
430
+
431
+ event, from = flag_human_required!(
432
+ actor: :system, kind: :escalated, reason: "assistant_silent",
433
+ summary: "no answer in #{SupportDesk.humanize_duration(window)}",
434
+ line: :hand_off_line, request: nil
435
+ )
436
+ end
437
+ return false unless event
438
+
439
+ publish_escalation!(from: from, reason: "assistant_silent", by: :system)
440
+ true
441
+ end
442
+
443
+ # Give a stranded seat back to people: the assistant holding this case is
444
+ # switched off, no longer declared, or no longer allowed to hold it.
445
+ # Returns true when it moved. See SupportDesk.reclaim_assistant_seats!.
446
+ #
447
+ # This is INVALID-ASSIGNEE recovery, and it is deliberately not silence
448
+ # detection: it needs no `responds_within`, no overdue clock and no
449
+ # waiting side. `deactivate!` promises the seats come back, and in 0.3.0
450
+ # that promise was kept only for an assistant who had a promise of her
451
+ # own and a case that was already late (R6).
452
+ def reclaim_assistant_seat! # :nodoc:
453
+ event = nil
454
+ from = nil
455
+ escalated = false
456
+ with_lock(requires_new: true) do
457
+ next unless open?
458
+ next unless held_by_assistant?
459
+
460
+ from = assignee
461
+ next if assistant_may_keep_seat?(from)
462
+
463
+ if human_required?
464
+ # Somebody has already been asked for, and why is their business —
465
+ # the only thing left to give back is the seat.
466
+ event = write_transition!(:released, actor: :system) do
467
+ release_assistant_seat!(reason: :released)
468
+ { "from" => SupportDesk.actor_key(from), "reason" => "assistant_unavailable" }
469
+ end
470
+ else
471
+ escalated = true
472
+ event, = flag_human_required!(actor: :system, kind: :escalated, reason: "assistant_unavailable",
473
+ summary: nil, line: :hand_off_line, request: nil)
474
+ end
475
+ end
476
+ return false unless event
477
+
478
+ if escalated
479
+ publish_escalation!(from: from, reason: "assistant_unavailable", by: :system)
480
+ else
481
+ SupportDesk.emit_after_commit(:ticket_released, self, from: from, reason: :assistant_unavailable)
482
+ end
483
+ true
484
+ end
485
+
486
+ # The ONE place `:ticket_escalated` is published. Every hand-off that
487
+ # takes her off a case goes through it — her own `escalate!`, the budget
488
+ # branch of `respond!`, the silent sweep — so no path can write the
489
+ # transition and forget the signal (R7). A no-op writes no event and
490
+ # publishes nothing.
491
+ def publish_escalation!(from:, reason:, by:) # :nodoc:
492
+ SupportDesk.emit_after_commit(:ticket_escalated, self, from: from, reason: reason.to_sym, by: by)
493
+ end
494
+
495
+ # Bump the case's revision — the caller holds the lock. `update_columns`
496
+ # on purpose: inside the transaction, no callbacks, no validations, and
497
+ # the in-memory value moves with the row, so the turn a caller reads
498
+ # next is the one the database has.
499
+ def bump_assistant_revision! # :nodoc:
500
+ update_columns(assistant_revision: assistant_revision.to_i + 1)
501
+ end
502
+
503
+ # Fold in every requester message chats has committed but nobody has
504
+ # registered, in a transaction of ITS OWN, and commit it — whatever the
505
+ # answer that discovered it then decides. Returns how many it folded in.
506
+ #
507
+ # A registration lost after commit (a worker killed between the message's
508
+ # COMMIT and the subscriber that registers it) used to be unrepairable:
509
+ # reconciliation ran inside the candidate answer's savepoint, the bumped
510
+ # revision made that very answer stale, and the StaleTurn rolled the
511
+ # repair back with it. The next run read the same revision and did the
512
+ # same thing, for ever (R3).
513
+ #
514
+ # Durability is the caller's: at the top level (a job, the recovery task)
515
+ # this really commits, and the `:assistant_turn` it emits for what it
516
+ # registered is an ACTIONABLE turn. Inside a host's own transaction it is
517
+ # a savepoint like any other, and it commits when that does.
518
+ def reconcile_and_commit! # :nodoc:
519
+ folded = 0
520
+ with_lock(requires_new: true) do
521
+ lock_conversation!
522
+ folded = reconcile_unregistered_messages!
523
+ end
524
+ folded
525
+ end
526
+
527
+ # When the assistant last did anything here — what the idle-turn check
528
+ # and the redispatch task read to tell "she decided not to speak" from
529
+ # "nothing is running".
530
+ def stamp_assistant_action! # :nodoc:
531
+ update_columns(assistant_acted_at: Time.current)
532
+ end
533
+
534
+ private
535
+
536
+ # The actor, as this desk's assistant — or a refusal that names which
537
+ # of the three things went wrong. Every AI-kind actor comes through
538
+ # here: a host model declared `kind: :ai` is not an assistant, and
539
+ # another desk's assistant is not this desk's (I2).
540
+ def resolve_assistant!(actor)
541
+ unless actor.is_a?(SupportDesk::Assistant)
542
+ raise NotAnAssistant,
543
+ "#{describe_actor(actor)} is an AI agent but not a SupportDesk::Assistant — only the desk's " \
544
+ "configured assistant may act on a case"
545
+ end
546
+ unless self.class.same_actor?(actor, assistant)
547
+ raise NotAnAssistant,
548
+ "#{actor.key} is not desk #{desk.key}'s assistant"
549
+ end
550
+
551
+ # Fresh from the row: `active` is a cross-process kill switch, and a
552
+ # record loaded a minute ago is not evidence about now — nor is one
553
+ # loaded before this call waited for the case's lock. The FRESH
554
+ # record is what is handed back, because the policy reads `active?`
555
+ # off the record it is given (R9).
556
+ #
557
+ # Every caller resolves inside the lock. That still leaves the
558
+ # ordinary in-flight window — a switch that commits after this read
559
+ # and before our own commit wins nothing, and its next check stops
560
+ # the next call — which is documented rather than claimed away.
561
+ self.class.ensure_agent_record!(actor)
562
+ end
563
+
564
+ # The turn check, under the lock, from the revision the row holds.
565
+ def ensure_current_turn!(turn)
566
+ raise ArgumentError, "turn: is required for an assistant — pass the turn you read" if turn.nil?
567
+ return if turn.to_s == assistant_turn
568
+
569
+ raise StaleTurn,
570
+ "#{turn} is stale on #{reference}: the case has changed (now #{assistant_turn})"
571
+ end
572
+
573
+ # Fold in any requester message chats has committed but the gem hasn't
574
+ # folded in yet, under the lock, BEFORE the turn is checked (I5).
575
+ #
576
+ # The subscriber runs after commit and the assistant's job runs on
577
+ # another connection: without this, a message the customer sent a
578
+ # millisecond ago would be invisible to the turn, and she would answer
579
+ # around it. Folding it in here makes the turn stale instead, which is
580
+ # exactly what should happen.
581
+ def reconcile_unregistered_messages!
582
+ return 0 if conversation.nil?
583
+
584
+ folded = unregistered_requester_messages.oldest_first.to_a
585
+ folded.each { |message| record_registration!(message) }
586
+ folded.size
587
+ end
588
+
589
+ # Every committed requester message without a receipt, regardless of
590
+ # timestamp or UUID order. The clocks are not evidence of registration.
591
+ def unregistered_requester_messages
592
+ conversation.messages.where(kind: "text", sender_type: requester_type, sender_id: requester_id)
593
+ .where.not(id: message_registrations.select(:message_id))
594
+ end
595
+
596
+ # A SELECT … FOR UPDATE on the conversation row, taken AFTER the ticket's
597
+ # and before any reconciliation. See the module comment: this row is what
598
+ # chats updates inside every message's own transaction, so it is the only
599
+ # seam that serializes a customer's write against ours without a write
600
+ # hook in chats (R1).
601
+ #
602
+ # Read through the id rather than the association: `lock!` refuses a
603
+ # record with unsaved changes, and nothing here wants the in-memory
604
+ # conversation reloaded.
605
+ def lock_conversation!
606
+ return if conversation_id.blank?
607
+
608
+ Chats::Conversation.lock.find_by(id: conversation_id)
609
+ end
610
+
611
+ # Whether the assistant sitting on this case may go on sitting on it: she
612
+ # is on duty, she is still declared, and her policy still lets her hold
613
+ # it. Read under the lock, from the reloaded assignee.
614
+ #
615
+ # A policy that RAISES — a desk pointing at an assistant nobody declares
616
+ # any more — is an answer too, and it is "no": a seat nothing can reason
617
+ # about belongs to a person.
618
+ def assistant_may_keep_seat?(holder)
619
+ return false unless holder.is_a?(SupportDesk::Assistant)
620
+ return false unless holder.active?
621
+ return false unless holder.configured?
622
+
623
+ assistant_policy(holder).may_hold?
624
+ rescue StandardError
625
+ false
626
+ end
627
+
628
+ # Nothing was written, and the reason is on the record: a policy that
629
+ # refused is evidence, not silence.
630
+ def withhold!(assistant, policy, reason, request: nil)
631
+ event = record_transition!(:assistant_withheld, actor: assistant) do
632
+ { "assistant" => assistant.key, "reason" => reason.to_s, "policy" => policy.to_h }
633
+ end
634
+ stamp_assistant_action!
635
+ publish_transition(event, :assistant_withheld, assistant, request)
636
+ SupportDesk.emit_after_commit(:assistant_withheld, self, assistant, reason: reason, policy: policy)
637
+ Outcome.new(action: :withheld, reason: reason, policy: policy, turn: assistant_turn)
638
+ end
639
+
640
+ # Say it to the requester. The caller holds the lock and has already
641
+ # decided she may.
642
+ def speak!(body, assistant, policy:, turn:, files:, confidence:, sources:, metadata:, request:)
643
+ notice = (post_disclosure_notice!(assistant) if assistant.notice? && !disclosure_posted?(assistant))
644
+ apply_reply_policy!(assistant, request: request)
645
+
646
+ stamped = assistant_message_metadata(assistant, policy: policy, turn: turn, confidence: confidence,
647
+ sources: sources, host: metadata)
648
+
649
+ # `author: nil` in the nameless modes, so chats prints no signature
650
+ # and the requester sees the desk — while the metadata above still
651
+ # says exactly what wrote it, for staff, for export and for audit.
652
+ posted = post_agent_message!(body, files: files, by: (assistant if assistant.signs?), metadata: stamped)
653
+ pin_before!(notice, posted) if notice
654
+ record_registration!(posted)
655
+ update_columns(assistant_turns_count: assistant_turns_count.to_i + 1)
656
+ stamp_assistant_action!
657
+ posted
658
+ end
659
+
660
+ # What every machine-written message carries: who wrote it, under what
661
+ # disclosure, holding which turn, and the whole policy that allowed it.
662
+ #
663
+ # The host's own metadata is NESTED under "host" rather than merged:
664
+ # the "support_desk" key is the gem's evidence, and a host writing
665
+ # `policy` into it would be rewriting it.
666
+ def assistant_message_metadata(assistant, policy:, turn:, confidence: nil, sources: [], host: {})
667
+ stamped = { "support_desk" => {
668
+ "assistant" => assistant.key,
669
+ "kind" => "ai",
670
+ "name" => assistant.name,
671
+ "display_name" => assistant.disclosed_name,
672
+ "disclosure" => assistant.disclosure.to_s,
673
+ "signed" => assistant.signs?,
674
+ "turn" => turn.to_s,
675
+ "confidence" => confidence,
676
+ "sources" => sources.presence,
677
+ "policy" => policy.to_h
678
+ }.compact }
679
+ stamped["host"] = host if host.present?
680
+ stamped
681
+ end
682
+
683
+ # The desk's first word, when it is hers. Called from inside the
684
+ # transaction that created the case (see Ticket.post_opening!), where
685
+ # she is already seated and there is no earlier turn for anybody to
686
+ # have held — so the first turn is the one this message consumes.
687
+ def post_assistant_opening!(body, files:, assistant:)
688
+ policy = assistant_policy(assistant)
689
+ stamped = assistant_message_metadata(assistant, policy: policy, turn: assistant_turn)
690
+ posted = post_agent_message!(body, files: files, by: (assistant if assistant.signs?), metadata: stamped)
691
+ update_columns(assistant_turns_count: 1)
692
+ posted
693
+ end
694
+
695
+ # Write the proposal. Validated BEFORE the pending one is superseded:
696
+ # a draft that can't be saved must not take the previous one with it.
697
+ def propose_draft!(body, assistant, policy:, files: [], confidence: nil, sources: [], metadata: {})
698
+ candidate = drafts.new(author: assistant, proposed_turn: assistant_turn, body: body,
699
+ confidence: confidence, sources: sources || [],
700
+ metadata: { "policy" => policy.to_h, "host" => metadata.presence }.compact)
701
+ candidate.files = files if files.present? && candidate.respond_to?(:files=)
702
+ candidate.validate!
703
+
704
+ # One pending proposal per case. The row lock serialises this, so
705
+ # there is no unique-violation to rescue: supersede, then insert.
706
+ drafts.pending.each(&:supersede!)
707
+ bump_assistant_revision!
708
+ # The turn this proposal LEAVES the case at, not the one it answered
709
+ # — a proposal is itself a change, so stamping the turn it consumed
710
+ # would make every draft stale the moment it was written.
711
+ candidate.proposed_turn = assistant_turn
712
+ candidate.save!
713
+ reset_draft_associations!
714
+ stamp_assistant_action!
715
+ broadcast_change
716
+ SupportDesk.emit_after_commit(:draft_proposed, self, candidate)
717
+ candidate
718
+ end
719
+
720
+ # The notice a `:notice` mode opens with, posted once per disclosure
721
+ # MODE: a host that changes mode discloses again, and one that doesn't
722
+ # never repeats itself.
723
+ def post_disclosure_notice!(assistant)
724
+ line = assistant.config.line_for(:disclosure_line, self)
725
+ if line.blank?
726
+ raise ConfigurationError,
727
+ "assistant #{assistant.key} discloses with a notice but has no disclosure_line — " \
728
+ "an undisclosed autonomous message is never posted silently"
729
+ end
730
+
731
+ notice = conversation.post_system_message!(line)
732
+ disclosed = (metadata["assistant_disclosed"] || {}).merge(assistant.key.to_s => assistant.disclosure.to_s)
733
+ update!(metadata: metadata.merge("assistant_disclosed" => disclosed))
734
+ notice
735
+ end
736
+
737
+ def disclosure_posted?(assistant)
738
+ metadata.dig("assistant_disclosed", assistant.key.to_s) == assistant.disclosure.to_s
739
+ end
740
+
741
+ # chats orders a transcript by (created_at, id), and two inserts in one
742
+ # transaction can share a timestamp — so a notice is pinned one
743
+ # database tick before the message it introduces, exactly as the
744
+ # opening line is.
745
+ def pin_before!(notice, message)
746
+ return if notice.nil? || message.nil?
747
+
748
+ notice.update_columns(created_at: message.created_at - self.class.send(:ordering_tick))
749
+ end
750
+
751
+ # The one write behind both hand-offs: release her seat, record who
752
+ # asked and why, raise the priority, and say so in the thread.
753
+ # Returns [event, previous assignee] — nil event when it was a no-op.
754
+ def flag_human_required!(actor:, kind:, reason:, summary:, line:, request:)
755
+ from = nil
756
+ event = write_transition!(kind, actor: actor, request: request) do
757
+ raise InvalidTransition, "can't #{kind} a closed case — reopen it first" if closed?
758
+ next false if human_required?
759
+
760
+ from = assignee
761
+ release_assistant_seat!(reason: :escalated)
762
+ update!(human_required_at: Time.current, human_required_reason: reason.to_s,
763
+ priority: [ priority.to_i, Topic::PRIORITIES[:high] ].max)
764
+ { "reason" => reason.to_s, "summary" => summary.presence,
765
+ "from" => SupportDesk.actor_key(from) }.compact
766
+ end
767
+ return [ nil, nil ] unless event
768
+
769
+ post_assistant_line!(line) unless requester_unavailable?
770
+ [ event, from ]
771
+ end
772
+
773
+ # Her seat, and only hers: a human who holds the case keeps it. A case
774
+ # that waited long enough for a person still gets one, seat or no seat.
775
+ def release_assistant_seat!(reason:)
776
+ return unless held_by_assistant?
777
+
778
+ assignments.open.each { |assignment| assignment.release!(reason: reason) }
779
+ update!(assignee: nil)
780
+ end
781
+
782
+ # One of the assistant's system lines, in the thread. A blank line
783
+ # posts nothing, and a rendering error is reported rather than raised:
784
+ # a missing translation must not roll back a hand-off.
785
+ def post_assistant_line!(setting)
786
+ assistant = self.assistant
787
+ return if assistant.nil? || conversation.nil?
788
+
789
+ line = assistant.config.line_for(setting, self)
790
+ return if line.blank?
791
+
792
+ conversation.post_system_message!(line)
793
+ rescue StandardError => e
794
+ SupportDesk.report_error(e, context: { hook: setting, ticket: id })
795
+ end
796
+
797
+ # The host's "somebody typed 'quiero hablar con una persona'" hook, run
798
+ # on every requester message under the registration lock.
799
+ #
800
+ # It FAILS CLOSED (v2 R28): true hands the case over, false and nil do
801
+ # nothing, and anything else — a String, a raise — is reported AND
802
+ # hands the case over. A hook nobody can read the answer of is a hook
803
+ # that has already failed, and the safe direction is a person.
804
+ def evaluate_hand_off_phrase!(message)
805
+ assistant = self.assistant
806
+ return if assistant.nil? || human_required? || !assistant_in_play?
807
+
808
+ block = assistant.config.hand_off_when
809
+ return if block.nil?
810
+
811
+ result = begin
812
+ block.call(self, message)
813
+ rescue StandardError => e
814
+ SupportDesk.report_error(e, context: { hook: :hand_off_when, ticket: id })
815
+ :error
816
+ end
817
+
818
+ case result
819
+ when true
820
+ flag_human_required!(actor: requester, kind: :human_requested, reason: "phrase", summary: nil,
821
+ line: :human_requested_line, request: nil)
822
+ SupportDesk.emit_after_commit(:human_requested, self, by: requester, reason: :phrase)
823
+ when false, nil
824
+ nil
825
+ else
826
+ unless result == :error
827
+ SupportDesk.report_error(
828
+ ArgumentError.new("hand_off_when must return true, false or nil, got #{result.inspect}"),
829
+ context: { hook: :hand_off_when, ticket: id }
830
+ )
831
+ end
832
+ flag_human_required!(actor: requester, kind: :human_requested, reason: "hand_off_when_error",
833
+ summary: nil, line: :human_requested_line, request: nil)
834
+ SupportDesk.emit_after_commit(:human_requested, self, by: requester, reason: :hand_off_when_error)
835
+ end
836
+ end
837
+
838
+ # "There is something to answer here." The only event a harness
839
+ # subscribes to, and the only place the gem asks anybody to do work.
840
+ #
841
+ # It carries the turn, so a job can check `ticket.assistant_turn ==
842
+ # turn` before spending money. A hook that raises is reported: a
843
+ # broken subscriber must not roll back the message that triggered it.
844
+ def emit_assistant_turn(message = nil)
845
+ return unless open? && awaiting_reply?
846
+
847
+ assistant = self.assistant
848
+ return if assistant.nil?
849
+ return unless assistant_policy(assistant).may_observe?
850
+
851
+ SupportDesk.emit_after_commit(:assistant_turn, self, assistant, message, turn: assistant_turn)
852
+ rescue StandardError => e
853
+ SupportDesk.report_error(e, context: { hook: :assistant_turn, ticket: id })
854
+ end
855
+
856
+ # A human transition that lowers the effective level below :reply while
857
+ # she holds the case takes her seat — a case she may no longer answer
858
+ # is not a case she can go on holding.
859
+ def release_assistant_if_unfit!
860
+ return false unless held_by_assistant?
861
+
862
+ holder = assignee
863
+ return false if assistant_policy(holder).may_reply?
864
+
865
+ assignments.open.each { |assignment| assignment.release!(reason: :released) }
866
+ update!(assignee: nil)
867
+ true
868
+ end
869
+
870
+ # Every pending proposal on this case is out of date. `except:` is the
871
+ # draft being sent right now, which is about to become "sent".
872
+ def supersede_pending_drafts!(except: nil)
873
+ scope = drafts.pending
874
+ scope = scope.where.not(id: except) if except.present?
875
+ scope.each(&:supersede!)
876
+ reset_draft_associations!
877
+ end
878
+
879
+ # A closed case has nothing pending. Returns how many it expired, for
880
+ # the close event's payload.
881
+ def expire_pending_drafts!
882
+ pending = drafts.pending.to_a
883
+ pending.each(&:expire!)
884
+ reset_draft_associations!
885
+ pending.size
886
+ end
887
+
888
+ # The proposals moved, so what this instance remembers about them is
889
+ # a lie. Everything that changes a draft's status calls this, so
890
+ # `ticket.pending_draft` and `actions_for` are right inside the SAME
891
+ # operation — a console that had to `reload` to see its own write is a
892
+ # console that renders a button nobody can press.
893
+ def reset_draft_associations!
894
+ association(:pending_draft).reset
895
+ association(:drafts).reset
896
+ end
897
+ end
898
+ end
899
+ end