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,675 @@
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
+ module Assistance
35
+ extend ActiveSupport::Concern
36
+
37
+ included do
38
+ has_many :drafts, class_name: "SupportDesk::Draft", inverse_of: :ticket, dependent: :destroy
39
+ has_one :pending_draft, -> { pending }, class_name: "SupportDesk::Draft", inverse_of: :ticket
40
+
41
+ # --- Scopes -------------------------------------------------------------
42
+
43
+ scope :held_by_assistants, lambda {
44
+ where(assignee_type: SupportDesk::Assistant.polymorphic_name)
45
+ }
46
+ scope :held_by_humans, lambda {
47
+ assigned.where.not(assignee_type: SupportDesk::Assistant.polymorphic_name)
48
+ }
49
+ # The tab that exists so a case a machine could not finish is never
50
+ # the one nobody looks at.
51
+ scope :needs_human, -> { where.not(human_required_at: nil) }
52
+ scope :assistant_paused, -> { where.not(assistant_paused_at: nil) }
53
+ scope :assistant_capped, -> { where.not(assistant_cap: nil) }
54
+ scope :resolved_by_assistant, lambda {
55
+ closed.where(closed_by_type: SupportDesk::Assistant.polymorphic_name)
56
+ }
57
+ scope :with_pending_draft, -> { where(id: SupportDesk::Draft.pending.select(:ticket_id)) }
58
+ # Waiting on the desk since before +time+, with nothing from the
59
+ # assistant since the customer wrote: a harness that is down, or one
60
+ # that keeps deciding to do nothing.
61
+ scope :assistant_idle_since, lambda { |time|
62
+ awaiting_reply.where(last_requester_message_at: ..time)
63
+ .where("assistant_acted_at IS NULL OR assistant_acted_at < last_requester_message_at")
64
+ }
65
+ end
66
+
67
+ # --- Readers ------------------------------------------------------------------
68
+
69
+ # The assistant who works this case's desk, or nil.
70
+ def assistant = desk&.assistant
71
+
72
+ # What she may do here, right now, and why. See AssistantPolicy.
73
+ def assistant_policy(assistant = self.assistant, hand_back: false)
74
+ AssistantPolicy.for(self, assistant, hand_back: hand_back)
75
+ end
76
+
77
+ # The token every assistant action has to hold. Never nil: a case that
78
+ # has never been touched still has a turn, which is what makes "the
79
+ # turn you read" a complete answer rather than a special case.
80
+ def assistant_turn = "t#{id}-r#{assistant_revision.to_i}"
81
+
82
+ # Whether this case has anything to do with an assistant — configured
83
+ # on the desk now, or spoken in by one at any point. History counts:
84
+ # the door to a person must not vanish because somebody edited an
85
+ # initializer after she answered.
86
+ def assistant_in_play?
87
+ assistant.present? || assistant_turns_count.to_i.positive?
88
+ end
89
+
90
+ def held_by_assistant?
91
+ assigned? && assignee_type == SupportDesk::Assistant.polymorphic_name
92
+ end
93
+
94
+ # Whether a person has been asked for on this case — by the assistant,
95
+ # by the requester, or by the silent sweep.
96
+ def human_required? = human_required_at.present?
97
+
98
+ # Whether a human has switched her off on this case.
99
+ def assistant_paused? = assistant_paused_at.present?
100
+
101
+ # How many times she may still speak here; nil when unlimited.
102
+ def assistant_turns_left
103
+ max = assistant&.max_turns
104
+ return nil if max.nil?
105
+
106
+ [ max - assistant_turns_count.to_i, 0 ].max
107
+ end
108
+
109
+ # The conversation as ordered turns — the readable half of what a
110
+ # harness works from. `limit:` keeps the LAST n, which is the part
111
+ # still being answered. See SupportDesk::Transcript.
112
+ def transcript(limit: nil)
113
+ Transcript.new(self, limit: limit)
114
+ end
115
+
116
+ # Everything a machine needs to answer this case, as data: the desk,
117
+ # the policy, the case, the requester and the transcript.
118
+ # `include_internal: true` adds the desk's own notes and proposals —
119
+ # off by default, because it leaves the building. See
120
+ # SupportDesk::Brief.
121
+ def brief(include_internal: false, transcript_limit: 50)
122
+ Brief.new(self, include_internal: include_internal, transcript_limit: transcript_limit)
123
+ end
124
+
125
+ # Whether this message came from an assistant — by authorship when she
126
+ # signs, by the provenance stamp when she doesn't. Both shapes, because
127
+ # a host that changes disclosure mode must not rewrite old bubbles.
128
+ def assistant_message?(message)
129
+ return false if message.nil?
130
+
131
+ return true if message.author.is_a?(SupportDesk::Assistant)
132
+
133
+ stamp = message.try(:metadata)
134
+ stamp.is_a?(Hash) && stamp.dig("support_desk", "assistant").present?
135
+ end
136
+
137
+ # --- Her two verbs ------------------------------------------------------------
138
+
139
+ # Answer, and let policy decide what that means: sent to the requester,
140
+ # proposed as a draft for a person to send, or withheld. The ONE verb a
141
+ # harness needs — it never has to encode the rules it is working under.
142
+ #
143
+ # Returns a SupportDesk::Outcome. Raises StaleTurn when the case moved
144
+ # on, Locked when there is nobody to write to, NotAnAssistant when
145
+ # `by:` isn't this desk's assistant.
146
+ def respond!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
147
+ assistant = resolve_assistant!(by)
148
+ raise ArgumentError, "respond! needs something to say" if body.blank? && files.blank?
149
+
150
+ with_lock(requires_new: true) do
151
+ reconcile_unregistered_messages!
152
+ ensure_current_turn!(turn)
153
+ ensure_writable!
154
+ policy = assistant_policy(assistant)
155
+ next withhold!(assistant, policy, :policy, request: request) unless policy.may_draft?
156
+ next withhold!(assistant, policy, :not_your_turn, request: request) unless awaiting_reply?
157
+
158
+ left = assistant_turns_left
159
+ if policy.may_reply? && (left.nil? || left.positive?)
160
+ message = speak!(body, assistant, policy: policy, turn: turn, files: files, confidence: confidence,
161
+ sources: sources, metadata: metadata, request: request)
162
+ Outcome.new(action: :sent, message: message, policy: policy, turn: assistant_turn)
163
+ else
164
+ draft = propose_draft!(body, assistant, policy: policy, files: files, confidence: confidence,
165
+ sources: sources, metadata: metadata)
166
+ reason = nil
167
+ # Out of budget at a level that could otherwise have answered:
168
+ # the draft stays, and so does a person — a conversation that
169
+ # ran out of turns is one somebody has to finish.
170
+ if policy.may_reply? && left&.zero?
171
+ flag_human_required!(actor: assistant, kind: :escalated, reason: "max_turns",
172
+ summary: metadata[:summary] || metadata["summary"],
173
+ line: :hand_off_line, request: request)
174
+ reason = :max_turns
175
+ end
176
+ Outcome.new(action: :drafted, draft: draft, policy: policy, reason: reason, turn: assistant_turn)
177
+ end
178
+ end
179
+ end
180
+
181
+ # Propose a reply for a person to send, whatever the level allows.
182
+ # `respond!` is what a harness should call; this is for a host that has
183
+ # already decided it wants a draft. Returns the SupportDesk::Draft.
184
+ def draft!(body = nil, by:, turn:, files: [], confidence: nil, sources: [], metadata: {}, request: nil)
185
+ assistant = resolve_assistant!(by)
186
+ raise ArgumentError, "draft! needs something to say" if body.blank? && files.blank?
187
+
188
+ with_lock(requires_new: true) do
189
+ reconcile_unregistered_messages!
190
+ ensure_current_turn!(turn)
191
+ ensure_writable!
192
+ policy = assistant_policy(assistant)
193
+ raise AssistantNotAllowed.new(policy, verb: :draft) unless policy.may_draft?
194
+
195
+ propose_draft!(body, assistant, policy: policy, files: files, confidence: confidence,
196
+ sources: sources, metadata: metadata)
197
+ end
198
+ end
199
+
200
+ # --- The two exits ------------------------------------------------------------
201
+
202
+ # Hand the case to a person: the assistant's own way out, and what the
203
+ # silent sweep calls as `:system`. Releases her seat, records the
204
+ # reason, raises the priority and tells the requester.
205
+ #
206
+ # `turn:` is required when `by:` is the assistant — escalating is an
207
+ # action like any other, and a stale one must not speak.
208
+ def escalate!(by: nil, reason:, summary: nil, turn: nil, request: nil)
209
+ actor = resolve_actor(by)
210
+ ensure_agent!(actor)
211
+ assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
212
+ raise ArgumentError, "escalate! needs a reason" if reason.blank?
213
+
214
+ event = nil
215
+ from = nil
216
+ with_lock(requires_new: true) do
217
+ if assistant
218
+ ensure_current_turn!(turn)
219
+ policy = assistant_policy(assistant)
220
+ raise AssistantNotAllowed.new(policy, verb: :escalate) unless policy.may_observe?
221
+ end
222
+
223
+ event, from = flag_human_required!(actor: actor, kind: :escalated, reason: reason, summary: summary,
224
+ line: :hand_off_line, request: request)
225
+ end
226
+ return self unless event
227
+
228
+ stamp_assistant_action! if assistant
229
+ SupportDesk.emit_after_commit(:ticket_escalated, self, from: from, reason: reason.to_sym, by: actor)
230
+ self
231
+ end
232
+
233
+ # "Prefiero hablar con una persona." The requester's own door, and the
234
+ # one thing on this case they can always do: it reopens a closed case
235
+ # where the desk allows it, flags the case, and says so in the thread.
236
+ # Idempotent — pressing it twice writes once.
237
+ def request_human!(by: nil, request: nil)
238
+ actor = resolve_actor(by)
239
+ unless self.class.same_actor?(actor, requester)
240
+ raise NotAllowed,
241
+ "#{describe_actor(actor)} can't ask for a person on #{reference} — it isn't their case"
242
+ end
243
+
244
+ event = nil
245
+ with_lock(requires_new: true) do
246
+ reopen!(by: actor, request: request) if closed? && desk_config.closed_tickets == :reopen_on_reply
247
+ ensure_writable!
248
+ event, = flag_human_required!(actor: actor, kind: :human_requested, reason: "requester_request",
249
+ summary: nil, line: :human_requested_line, request: request)
250
+ end
251
+ return self unless event
252
+
253
+ SupportDesk.emit_after_commit(:human_requested, self, by: actor, reason: :requester_request)
254
+ self
255
+ end
256
+
257
+ # --- The human override -------------------------------------------------------
258
+
259
+ # Switch the assistant off on THIS case: a delicate conversation, a
260
+ # customer who has had enough, a thread somebody wants to handle
261
+ # themselves. Releases her seat, throws away her pending proposal, and
262
+ # floors her policy at :off until somebody resumes her.
263
+ def pause_assistant!(by: nil, reason: nil, request: nil)
264
+ actor = resolve_actor(by)
265
+ if actor.is_a?(Symbol) || SupportDesk.ai_actor?(actor)
266
+ raise NotAllowed, "only a person can pause the assistant on a case"
267
+ end
268
+ ensure_agent!(actor)
269
+
270
+ event = write_transition!(:assistant_paused, actor: actor, request: request) do
271
+ raise InvalidTransition, "can't pause the assistant on a closed case" if closed?
272
+ next false if assistant_paused?
273
+
274
+ release_assistant_seat!(reason: :released)
275
+ supersede_pending_drafts!
276
+ update!(assistant_paused_at: Time.current, assistant_paused_reason: reason.presence&.to_s)
277
+ { "reason" => reason.presence&.to_s }
278
+ end
279
+ return self unless event
280
+
281
+ SupportDesk.emit_after_commit(:assistant_paused, self, by: actor)
282
+ self
283
+ end
284
+
285
+ # Let her back in on this case. Clears the pause and NOTHING ELSE: a
286
+ # case cap and a request for a person are different decisions, made by
287
+ # different people, and only an explicit hand-back lifts those.
288
+ def resume_assistant!(by: nil, request: nil)
289
+ actor = resolve_actor(by)
290
+ if actor.is_a?(Symbol) || SupportDesk.ai_actor?(actor)
291
+ raise NotAllowed, "only a person can resume the assistant on a case"
292
+ end
293
+ ensure_agent!(actor)
294
+
295
+ event = write_transition!(:assistant_resumed, actor: actor, request: request) do
296
+ next false unless assistant_paused?
297
+
298
+ update!(assistant_paused_at: nil, assistant_paused_reason: nil)
299
+ {}
300
+ end
301
+ return self unless event
302
+
303
+ SupportDesk.emit_after_commit(:assistant_resumed, self, by: actor)
304
+ emit_assistant_turn if awaiting_reply?
305
+ self
306
+ end
307
+
308
+ # --- Internals ----------------------------------------------------------------
309
+
310
+ # The verbs the assistant may press on this case right now — the
311
+ # machine's half of `actions_for`, and the same decision the
312
+ # transitions make, so a harness reading `may` from a brief is reading
313
+ # the authorization and not a hint.
314
+ def assistant_actions_for(assistant) # :nodoc:
315
+ return [] unless assistant.is_a?(SupportDesk::Assistant)
316
+ return [] unless self.class.same_actor?(assistant, self.assistant)
317
+
318
+ verbs = assistant_policy(assistant).allowed_verbs.dup
319
+ verbs -= %i[escalate] if closed?
320
+ verbs -= %i[reply draft take] if requester_unavailable?
321
+ verbs -= %i[take] unless unassigned?
322
+ verbs -= %i[release] unless assigned_to?(assistant)
323
+ verbs -= %i[reply] unless awaiting_reply?
324
+ verbs -= %i[close] unless assigned_to?(assistant) && awaiting_requester? && !human_required?
325
+ verbs
326
+ end
327
+
328
+ # Bump the case's revision — the caller holds the lock. `update_columns`
329
+ # on purpose: inside the transaction, no callbacks, no validations, and
330
+ # the in-memory value moves with the row, so the turn a caller reads
331
+ # next is the one the database has.
332
+ def bump_assistant_revision! # :nodoc:
333
+ update_columns(assistant_revision: assistant_revision.to_i + 1)
334
+ end
335
+
336
+ # When the assistant last did anything here — what the idle-turn check
337
+ # and the redispatch task read to tell "she decided not to speak" from
338
+ # "nothing is running".
339
+ def stamp_assistant_action! # :nodoc:
340
+ update_columns(assistant_acted_at: Time.current)
341
+ end
342
+
343
+ private
344
+
345
+ # The actor, as this desk's assistant — or a refusal that names which
346
+ # of the three things went wrong. Every AI-kind actor comes through
347
+ # here: a host model declared `kind: :ai` is not an assistant, and
348
+ # another desk's assistant is not this desk's (I2).
349
+ def resolve_assistant!(actor)
350
+ unless actor.is_a?(SupportDesk::Assistant)
351
+ raise NotAnAssistant,
352
+ "#{describe_actor(actor)} is an AI agent but not a SupportDesk::Assistant — only the desk's " \
353
+ "configured assistant may act on a case"
354
+ end
355
+ unless self.class.same_actor?(actor, assistant)
356
+ raise NotAnAssistant,
357
+ "#{actor.key} is not desk #{desk.key}'s assistant"
358
+ end
359
+
360
+ # Fresh from the row: `active` is a cross-process kill switch, and a
361
+ # record loaded a minute ago is not evidence about now.
362
+ self.class.ensure_agent_record!(actor)
363
+ actor
364
+ end
365
+
366
+ # The turn check, under the lock, from the revision the row holds.
367
+ def ensure_current_turn!(turn)
368
+ raise ArgumentError, "turn: is required for an assistant — pass the turn you read" if turn.nil?
369
+ return if turn.to_s == assistant_turn
370
+
371
+ raise StaleTurn,
372
+ "#{turn} is stale on #{reference}: the case has changed (now #{assistant_turn})"
373
+ end
374
+
375
+ # Fold in any requester message chats has committed but the gem hasn't
376
+ # folded in yet, under the lock, BEFORE the turn is checked (I5).
377
+ #
378
+ # The subscriber runs after commit and the assistant's job runs on
379
+ # another connection: without this, a message the customer sent a
380
+ # millisecond ago would be invisible to the turn, and she would answer
381
+ # around it. Folding it in here makes the turn stale instead, which is
382
+ # exactly what should happen.
383
+ def reconcile_unregistered_messages!
384
+ return if conversation.nil?
385
+
386
+ scope = conversation.messages.where(kind: "text", sender_type: requester_type, sender_id: requester_id)
387
+ if last_requester_message_at.present?
388
+ scope = if last_requester_message_id.present?
389
+ # Everything after the clock, plus anything sharing its instant
390
+ # that ISN'T the message the clock was set from — two messages
391
+ # can land on one timestamp, and the second one is real.
392
+ scope.where(
393
+ "chats_messages.created_at > :at OR (chats_messages.created_at = :at AND chats_messages.id <> :id)",
394
+ at: last_requester_message_at, id: last_requester_message_id
395
+ )
396
+ else
397
+ # No pointer to compare against (a 0.2 row whose backfill found
398
+ # nothing): the clock alone, rather than a comparison against an
399
+ # empty string that some adapters refuse outright.
400
+ scope.where("chats_messages.created_at > ?", last_requester_message_at)
401
+ end
402
+ end
403
+
404
+ scope.oldest_first.each { |message| record_registration!(message) }
405
+ end
406
+
407
+ # Nothing was written, and the reason is on the record: a policy that
408
+ # refused is evidence, not silence.
409
+ def withhold!(assistant, policy, reason, request: nil)
410
+ event = record_transition!(:assistant_withheld, actor: assistant) do
411
+ { "assistant" => assistant.key, "reason" => reason.to_s, "policy" => policy.to_h }
412
+ end
413
+ stamp_assistant_action!
414
+ publish_transition(event, :assistant_withheld, assistant, request)
415
+ SupportDesk.emit_after_commit(:assistant_withheld, self, assistant, reason: reason, policy: policy)
416
+ Outcome.new(action: :withheld, reason: reason, policy: policy, turn: assistant_turn)
417
+ end
418
+
419
+ # Say it to the requester. The caller holds the lock and has already
420
+ # decided she may.
421
+ def speak!(body, assistant, policy:, turn:, files:, confidence:, sources:, metadata:, request:)
422
+ notice = (post_disclosure_notice!(assistant) if assistant.notice? && !disclosure_posted?(assistant))
423
+ apply_reply_policy!(assistant, request: request)
424
+
425
+ stamped = assistant_message_metadata(assistant, policy: policy, turn: turn, confidence: confidence,
426
+ sources: sources, host: metadata)
427
+
428
+ # `author: nil` in the nameless modes, so chats prints no signature
429
+ # and the requester sees the desk — while the metadata above still
430
+ # says exactly what wrote it, for staff, for export and for audit.
431
+ posted = post_agent_message!(body, files: files, by: (assistant if assistant.signs?), metadata: stamped)
432
+ pin_before!(notice, posted) if notice
433
+ record_registration!(posted)
434
+ update_columns(assistant_turns_count: assistant_turns_count.to_i + 1)
435
+ stamp_assistant_action!
436
+ posted
437
+ end
438
+
439
+ # What every machine-written message carries: who wrote it, under what
440
+ # disclosure, holding which turn, and the whole policy that allowed it.
441
+ #
442
+ # The host's own metadata is NESTED under "host" rather than merged:
443
+ # the "support_desk" key is the gem's evidence, and a host writing
444
+ # `policy` into it would be rewriting it.
445
+ def assistant_message_metadata(assistant, policy:, turn:, confidence: nil, sources: [], host: {})
446
+ stamped = { "support_desk" => {
447
+ "assistant" => assistant.key,
448
+ "kind" => "ai",
449
+ "display_name" => assistant.disclosed_name,
450
+ "disclosure" => assistant.disclosure.to_s,
451
+ "signed" => assistant.signs?,
452
+ "turn" => turn.to_s,
453
+ "confidence" => confidence,
454
+ "sources" => sources.presence,
455
+ "policy" => policy.to_h
456
+ }.compact }
457
+ stamped["host"] = host if host.present?
458
+ stamped
459
+ end
460
+
461
+ # The desk's first word, when it is hers. Called from inside the
462
+ # transaction that created the case (see Ticket.post_opening!), where
463
+ # she is already seated and there is no earlier turn for anybody to
464
+ # have held — so the first turn is the one this message consumes.
465
+ def post_assistant_opening!(body, files:, assistant:)
466
+ policy = assistant_policy(assistant)
467
+ stamped = assistant_message_metadata(assistant, policy: policy, turn: assistant_turn)
468
+ posted = post_agent_message!(body, files: files, by: (assistant if assistant.signs?), metadata: stamped)
469
+ update_columns(assistant_turns_count: 1)
470
+ posted
471
+ end
472
+
473
+ # Write the proposal. Validated BEFORE the pending one is superseded:
474
+ # a draft that can't be saved must not take the previous one with it.
475
+ def propose_draft!(body, assistant, policy:, files: [], confidence: nil, sources: [], metadata: {})
476
+ candidate = drafts.new(author: assistant, proposed_turn: assistant_turn, body: body,
477
+ confidence: confidence, sources: sources || [],
478
+ metadata: { "policy" => policy.to_h, "host" => metadata.presence }.compact)
479
+ candidate.files = files if files.present? && candidate.respond_to?(:files=)
480
+ candidate.validate!
481
+
482
+ # One pending proposal per case. The row lock serialises this, so
483
+ # there is no unique-violation to rescue: supersede, then insert.
484
+ drafts.pending.each(&:supersede!)
485
+ bump_assistant_revision!
486
+ # The turn this proposal LEAVES the case at, not the one it answered
487
+ # — a proposal is itself a change, so stamping the turn it consumed
488
+ # would make every draft stale the moment it was written.
489
+ candidate.proposed_turn = assistant_turn
490
+ candidate.save!
491
+ reset_draft_associations!
492
+ stamp_assistant_action!
493
+ broadcast_change
494
+ SupportDesk.emit_after_commit(:draft_proposed, self, candidate)
495
+ candidate
496
+ end
497
+
498
+ # The notice a `:notice` mode opens with, posted once per disclosure
499
+ # MODE: a host that changes mode discloses again, and one that doesn't
500
+ # never repeats itself.
501
+ def post_disclosure_notice!(assistant)
502
+ line = assistant.config.line_for(:disclosure_line, self)
503
+ if line.blank?
504
+ raise ConfigurationError,
505
+ "assistant #{assistant.key} discloses with a notice but has no disclosure_line — " \
506
+ "an undisclosed autonomous message is never posted silently"
507
+ end
508
+
509
+ notice = conversation.post_system_message!(line)
510
+ disclosed = (metadata["assistant_disclosed"] || {}).merge(assistant.key.to_s => assistant.disclosure.to_s)
511
+ update!(metadata: metadata.merge("assistant_disclosed" => disclosed))
512
+ notice
513
+ end
514
+
515
+ def disclosure_posted?(assistant)
516
+ metadata.dig("assistant_disclosed", assistant.key.to_s) == assistant.disclosure.to_s
517
+ end
518
+
519
+ # chats orders a transcript by (created_at, id), and two inserts in one
520
+ # transaction can share a timestamp — so a notice is pinned one
521
+ # database tick before the message it introduces, exactly as the
522
+ # opening line is.
523
+ def pin_before!(notice, message)
524
+ return if notice.nil? || message.nil?
525
+
526
+ notice.update_columns(created_at: message.created_at - self.class.send(:ordering_tick))
527
+ end
528
+
529
+ # The one write behind both hand-offs: release her seat, record who
530
+ # asked and why, raise the priority, and say so in the thread.
531
+ # Returns [event, previous assignee] — nil event when it was a no-op.
532
+ def flag_human_required!(actor:, kind:, reason:, summary:, line:, request:)
533
+ from = nil
534
+ event = write_transition!(kind, actor: actor, request: request) do
535
+ raise InvalidTransition, "can't #{kind} a closed case — reopen it first" if closed?
536
+ next false if human_required?
537
+
538
+ from = assignee
539
+ release_assistant_seat!(reason: :escalated)
540
+ update!(human_required_at: Time.current, human_required_reason: reason.to_s,
541
+ priority: [ priority.to_i, Topic::PRIORITIES[:high] ].max)
542
+ { "reason" => reason.to_s, "summary" => summary.presence,
543
+ "from" => SupportDesk.actor_key(from) }.compact
544
+ end
545
+ return [ nil, nil ] unless event
546
+
547
+ post_assistant_line!(line) unless requester_unavailable?
548
+ [ event, from ]
549
+ end
550
+
551
+ # Her seat, and only hers: a human who holds the case keeps it. A case
552
+ # that waited long enough for a person still gets one, seat or no seat.
553
+ def release_assistant_seat!(reason:)
554
+ return unless held_by_assistant?
555
+
556
+ assignments.open.each { |assignment| assignment.release!(reason: reason) }
557
+ update!(assignee: nil)
558
+ end
559
+
560
+ # One of the assistant's system lines, in the thread. A blank line
561
+ # posts nothing, and a rendering error is reported rather than raised:
562
+ # a missing translation must not roll back a hand-off.
563
+ def post_assistant_line!(setting)
564
+ assistant = self.assistant
565
+ return if assistant.nil? || conversation.nil?
566
+
567
+ line = assistant.config.line_for(setting, self)
568
+ return if line.blank?
569
+
570
+ conversation.post_system_message!(line)
571
+ rescue StandardError => e
572
+ SupportDesk.report_error(e, context: { hook: setting, ticket: id })
573
+ end
574
+
575
+ # The host's "somebody typed 'quiero hablar con una persona'" hook, run
576
+ # on every requester message under the registration lock.
577
+ #
578
+ # It FAILS CLOSED (v2 R28): true hands the case over, false and nil do
579
+ # nothing, and anything else — a String, a raise — is reported AND
580
+ # hands the case over. A hook nobody can read the answer of is a hook
581
+ # that has already failed, and the safe direction is a person.
582
+ def evaluate_hand_off_phrase!(message)
583
+ assistant = self.assistant
584
+ return if assistant.nil? || human_required? || !assistant_in_play?
585
+
586
+ block = assistant.config.hand_off_when
587
+ return if block.nil?
588
+
589
+ result = begin
590
+ block.call(self, message)
591
+ rescue StandardError => e
592
+ SupportDesk.report_error(e, context: { hook: :hand_off_when, ticket: id })
593
+ :error
594
+ end
595
+
596
+ case result
597
+ when true
598
+ flag_human_required!(actor: requester, kind: :human_requested, reason: "phrase", summary: nil,
599
+ line: :human_requested_line, request: nil)
600
+ SupportDesk.emit_after_commit(:human_requested, self, by: requester, reason: :phrase)
601
+ when false, nil
602
+ nil
603
+ else
604
+ unless result == :error
605
+ SupportDesk.report_error(
606
+ ArgumentError.new("hand_off_when must return true, false or nil, got #{result.inspect}"),
607
+ context: { hook: :hand_off_when, ticket: id }
608
+ )
609
+ end
610
+ flag_human_required!(actor: requester, kind: :human_requested, reason: "hand_off_when_error",
611
+ summary: nil, line: :human_requested_line, request: nil)
612
+ SupportDesk.emit_after_commit(:human_requested, self, by: requester, reason: :hand_off_when_error)
613
+ end
614
+ end
615
+
616
+ # "There is something to answer here." The only event a harness
617
+ # subscribes to, and the only place the gem asks anybody to do work.
618
+ #
619
+ # It carries the turn, so a job can check `ticket.assistant_turn ==
620
+ # turn` before spending money. A hook that raises is reported: a
621
+ # broken subscriber must not roll back the message that triggered it.
622
+ def emit_assistant_turn(message = nil)
623
+ assistant = self.assistant
624
+ return if assistant.nil?
625
+ return unless assistant_policy(assistant).may_observe?
626
+
627
+ SupportDesk.emit_after_commit(:assistant_turn, self, assistant, message, turn: assistant_turn)
628
+ rescue StandardError => e
629
+ SupportDesk.report_error(e, context: { hook: :assistant_turn, ticket: id })
630
+ end
631
+
632
+ # A human transition that lowers the effective level below :reply while
633
+ # she holds the case takes her seat — a case she may no longer answer
634
+ # is not a case she can go on holding.
635
+ def release_assistant_if_unfit!
636
+ return false unless held_by_assistant?
637
+
638
+ holder = assignee
639
+ return false if assistant_policy(holder).may_reply?
640
+
641
+ assignments.open.each { |assignment| assignment.release!(reason: :released) }
642
+ update!(assignee: nil)
643
+ true
644
+ end
645
+
646
+ # Every pending proposal on this case is out of date. `except:` is the
647
+ # draft being sent right now, which is about to become "sent".
648
+ def supersede_pending_drafts!(except: nil)
649
+ scope = drafts.pending
650
+ scope = scope.where.not(id: except) if except.present?
651
+ scope.each(&:supersede!)
652
+ reset_draft_associations!
653
+ end
654
+
655
+ # A closed case has nothing pending. Returns how many it expired, for
656
+ # the close event's payload.
657
+ def expire_pending_drafts!
658
+ pending = drafts.pending.to_a
659
+ pending.each(&:expire!)
660
+ reset_draft_associations!
661
+ pending.size
662
+ end
663
+
664
+ # The proposals moved, so what this instance remembers about them is
665
+ # a lie. Everything that changes a draft's status calls this, so
666
+ # `ticket.pending_draft` and `actions_for` are right inside the SAME
667
+ # operation — a console that had to `reload` to see its own write is a
668
+ # console that renders a button nobody can press.
669
+ def reset_draft_associations!
670
+ association(:pending_draft).reset
671
+ association(:drafts).reset
672
+ end
673
+ end
674
+ end
675
+ end